| author | |
| committer | |
| log | 33118d9ab263904e4180b3bf2cbf0a36fe0695e2 |
| tree | 4f157a735c5f821c58d4ef82181ed4506eab3c5b |
| parent | 47ace94962a714a561d7cfbdbaa4c721ef6b0598 |
| signature | Signed by PGP key B5690EEEBB952194 |
19 files changed, 206 insertions(+), 20 deletions(-)
apps/desktop/scripts/electron-launcher.mjs+1| ... | ... | @@ -117,6 +117,7 @@ export function makeDevelopmentEnvironmentScript(environment) { |
| 117 | 117 | ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL], |
| 118 | 118 | ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS], |
| 119 | 119 | ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS], |
| 120 | ["T3CODE_OTLP_PROTOCOL", environment.T3CODE_OTLP_PROTOCOL], | |
| 120 | 121 | ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], |
| 121 | 122 | ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); |
| 122 | 123 | return [ |
apps/desktop/scripts/electron-launcher.test.mjs+5| ... | ... | @@ -21,12 +21,17 @@ describe("electron development launcher", () => { |
| 21 | 21 | VITE_DEV_SERVER_URL: "http://127.0.0.1:8526", |
| 22 | 22 | T3CODE_PORT: "16566", |
| 23 | 23 | T3CODE_HOME: "/tmp/t3", |
| 24 | T3CODE_OTLP_PROTOCOL: "http/protobuf", | |
| 24 | 25 | }); |
| 25 | 26 | |
| 26 | 27 | assert.include( |
| 27 | 28 | environmentScript, |
| 28 | 29 | "if [ -z \"${VITE_DEV_SERVER_URL:-}\" ]; then export VITE_DEV_SERVER_URL='http://127.0.0.1:8526'; fi", |
| 29 | 30 | ); |
| 31 | assert.include( | |
| 32 | environmentScript, | |
| 33 | "if [ -z \"${T3CODE_OTLP_PROTOCOL:-}\" ]; then export T3CODE_OTLP_PROTOCOL='http/protobuf'; fi", | |
| 34 | ); | |
| 30 | 35 | assert.notInclude(environmentScript, "\nexport VITE_DEV_SERVER_URL="); |
| 31 | 36 | }); |
| 32 | 37 |
apps/desktop/src/app/DesktopConfig.ts+4-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { OtlpHeadersFromString } from "@t3tools/shared/observability"; | |
| 1 | import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; | |
| 2 | 2 | import * as Config from "effect/Config"; |
| 3 | 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| ... | ... | @@ -50,6 +50,9 @@ export const DesktopConfig = Config.all({ |
| 50 | 50 | Config.withDefault(10_000), |
| 51 | 51 | ), |
| 52 | 52 | otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option), |
| 53 | otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( | |
| 54 | Config.withDefault("http/json"), | |
| 55 | ), | |
| 53 | 56 | appImagePath: trimmedString("APPIMAGE"), |
| 54 | 57 | disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), |
| 55 | 58 | mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), |
apps/desktop/src/app/DesktopEnvironment.test.ts+3| ... | ... | @@ -53,6 +53,7 @@ describe("DesktopEnvironment", () => { |
| 53 | 53 | T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", |
| 54 | 54 | T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", |
| 55 | 55 | T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", |
| 56 | T3CODE_OTLP_PROTOCOL: "http/protobuf", | |
| 56 | 57 | }, |
| 57 | 58 | ); |
| 58 | 59 | |
| ... | ... | @@ -93,6 +94,7 @@ describe("DesktopEnvironment", () => { |
| 93 | 94 | "x-tenant": "t3", |
| 94 | 95 | }), |
| 95 | 96 | ); |
| 97 | assert.equal(environment.otlpProtocol, "http/protobuf"); | |
| 96 | 98 | }), |
| 97 | 99 | ); |
| 98 | 100 | |
| ... | ... | @@ -110,6 +112,7 @@ describe("DesktopEnvironment", () => { |
| 110 | 112 | assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); |
| 111 | 113 | assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); |
| 112 | 114 | assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); |
| 115 | assert.equal(environment.otlpProtocol, "http/json"); | |
| 113 | 116 | }), |
| 114 | 117 | ); |
| 115 | 118 |
apps/desktop/src/app/DesktopEnvironment.ts+3| ... | ... | @@ -16,6 +16,7 @@ import * as DesktopConfig from "./DesktopConfig.ts"; |
| 16 | 16 | import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; |
| 17 | 17 | import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; |
| 18 | 18 | import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; |
| 19 | import type { OtlpProtocol } from "@t3tools/shared/observability"; | |
| 19 | 20 | |
| 20 | 21 | export interface MakeDesktopEnvironmentInput { |
| 21 | 22 | readonly dirname: string; |
| ... | ... | @@ -73,6 +74,7 @@ export class DesktopEnvironment extends Context.Service< |
| 73 | 74 | readonly otlpTracesUrl: Option.Option<string>; |
| 74 | 75 | readonly otlpExportIntervalMs: number; |
| 75 | 76 | readonly otlpHeaders: Option.Option<Record<string, string>>; |
| 77 | readonly otlpProtocol: OtlpProtocol; | |
| 76 | 78 | readonly branding: DesktopAppBranding; |
| 77 | 79 | readonly displayName: string; |
| 78 | 80 | readonly appUserModelId: string; |
| ... | ... | @@ -227,6 +229,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( |
| 227 | 229 | otlpTracesUrl: config.otlpTracesUrl, |
| 228 | 230 | otlpExportIntervalMs: config.otlpExportIntervalMs, |
| 229 | 231 | otlpHeaders: config.otlpHeaders, |
| 232 | otlpProtocol: config.otlpProtocol, | |
| 230 | 233 | branding, |
| 231 | 234 | displayName, |
| 232 | 235 | appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => |
apps/desktop/src/app/DesktopObservability.ts+8-4| ... | ... | @@ -1,5 +1,9 @@ |
| 1 | 1 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; |
| 2 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; | |
| 2 | import { | |
| 3 | makeLocalFileTracer, | |
| 4 | makeTraceSink, | |
| 5 | otlpSerializationLayer, | |
| 6 | } from "@t3tools/shared/observability"; | |
| 3 | 7 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 4 | 8 | import * as Context from "effect/Context"; |
| 5 | 9 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -17,7 +21,7 @@ import * as Scope from "effect/Scope"; |
| 17 | 21 | import * as Semaphore from "effect/Semaphore"; |
| 18 | 22 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 19 | 23 | import * as Tracer from "effect/Tracer"; |
| 20 | import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | |
| 24 | import { OtlpExporter, OtlpTracer } from "effect/unstable/observability"; | |
| 21 | 25 | |
| 22 | 26 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 23 | 27 | |
| ... | ... | @@ -592,7 +596,7 @@ const tracerLayer = Layer.unwrap( |
| 592 | 596 | "service.mode": environment.isDevelopment ? "development" : "packaged", |
| 593 | 597 | }, |
| 594 | 598 | }, |
| 595 | }); | |
| 599 | }).pipe(Effect.provide(otlpSerializationLayer(environment.otlpProtocol))); | |
| 596 | 600 | const tracer = yield* makeLocalFileTracer({ |
| 597 | 601 | filePath: tracePath, |
| 598 | 602 | maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, |
| ... | ... | @@ -604,7 +608,7 @@ const tracerLayer = Layer.unwrap( |
| 604 | 608 | |
| 605 | 609 | return Layer.succeed(Tracer.Tracer, tracer); |
| 606 | 610 | }), |
| 607 | ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(OtlpSerialization.layerJson)); | |
| 611 | ).pipe(Layer.provide(OtlpExporter.layerFlusher)); | |
| 608 | 612 | |
| 609 | 613 | export const layer = Layer.mergeAll( |
| 610 | 614 | backendOutputLogFactoryLayer, |
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+5-1| ... | ... | @@ -854,11 +854,13 @@ describe("DesktopBackendConfiguration", () => { |
| 854 | 854 | const previousOpenAiKey = process.env.OPENAI_API_KEY; |
| 855 | 855 | const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; |
| 856 | 856 | const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; |
| 857 | const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; | |
| 857 | 858 | try { |
| 858 | 859 | process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; |
| 859 | 860 | process.env.OPENAI_API_KEY = "openai-key"; |
| 860 | 861 | process.env.ANTHROPIC_API_KEY = "anthropic-key"; |
| 861 | 862 | process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"'; |
| 863 | process.env.T3CODE_OTLP_PROTOCOL = "http/protobuf"; | |
| 862 | 864 | |
| 863 | 865 | yield* Effect.gen(function* () { |
| 864 | 866 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| ... | ... | @@ -878,13 +880,14 @@ describe("DesktopBackendConfiguration", () => { |
| 878 | 880 | assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); |
| 879 | 881 | assert.equal(config.env.OPENAI_API_KEY, "openai-key"); |
| 880 | 882 | assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); |
| 883 | assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); | |
| 881 | 884 | // The existing WSLENV is preserved byte-for-byte (note the empty |
| 882 | 885 | // "::" segment survives — WSL ignores it, so we don't normalize |
| 883 | 886 | // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is |
| 884 | 887 | // already declared, so it isn't forwarded twice. |
| 885 | 888 | assert.equal( |
| 886 | 889 | config.env.WSLENV, |
| 887 | "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS", | |
| 890 | "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", | |
| 888 | 891 | ); |
| 889 | 892 | }).pipe( |
| 890 | 893 | Effect.provide( |
| ... | ... | @@ -908,6 +911,7 @@ describe("DesktopBackendConfiguration", () => { |
| 908 | 911 | restoreEnv("OPENAI_API_KEY", previousOpenAiKey); |
| 909 | 912 | restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); |
| 910 | 913 | restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); |
| 914 | restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); | |
| 911 | 915 | } |
| 912 | 916 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 913 | 917 | ); |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+4-3| ... | ... | @@ -87,14 +87,15 @@ const DESKTOP_BACKEND_ENV_NAMES = [ |
| 87 | 87 | "T3CODE_TAILSCALE_SERVE_PORT", |
| 88 | 88 | ] as const; |
| 89 | 89 | |
| 90 | // Sensitive env vars that the WSL backend needs but Windows process.env won't | |
| 91 | // forward across the wsl.exe boundary without WSLENV. The dev-server URL is | |
| 92 | // handled separately via a `--dev-url` CLI flag because WSLENV translation of | |
| 90 | // Env vars that the WSL backend needs but Windows process.env won't forward | |
| 91 | // across the wsl.exe boundary without WSLENV. The dev-server URL is handled | |
| 92 | // separately via a `--dev-url` CLI flag because WSLENV translation of | |
| 93 | 93 | // URL-shaped values (colons / slashes) is unreliable. |
| 94 | 94 | const WSL_FORWARDED_ENV_NAMES = [ |
| 95 | 95 | "OPENAI_API_KEY", |
| 96 | 96 | "ANTHROPIC_API_KEY", |
| 97 | 97 | "T3CODE_OTLP_HEADERS", |
| 98 | "T3CODE_OTLP_PROTOCOL", | |
| 98 | 99 | ] as const; |
| 99 | 100 | |
| 100 | 101 | const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; |
apps/server/src/bin.test.ts+1| ... | ... | @@ -102,6 +102,7 @@ const makeCliTestServerConfig = (baseDir: string) => |
| 102 | 102 | otlpExportIntervalMs: 10_000, |
| 103 | 103 | otlpServiceName: "t3-server", |
| 104 | 104 | otlpHeaders: undefined, |
| 105 | otlpProtocol: "http/json", | |
| 105 | 106 | mode: "web", |
| 106 | 107 | port: 0, |
| 107 | 108 | host: "127.0.0.1", |
apps/server/src/cli/config.test.ts+37| ... | ... | @@ -53,6 +53,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 53 | 53 | otlpExportIntervalMs: 10_000, |
| 54 | 54 | otlpServiceName: "t3-server", |
| 55 | 55 | otlpHeaders: undefined, |
| 56 | otlpProtocol: "http/json", | |
| 56 | 57 | devAllowedOrigins: [], |
| 57 | 58 | } as const; |
| 58 | 59 | |
| ... | ... | @@ -808,4 +809,40 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 808 | 809 | expect(resolved.otlpTracesUrl).toBe("http://collector.internal:4318"); |
| 809 | 810 | }), |
| 810 | 811 | ); |
| 812 | ||
| 813 | it.effect("reads the OTLP protocol from env", () => | |
| 814 | Effect.gen(function* () { | |
| 815 | const { join } = yield* Path.Path; | |
| 816 | const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-protocol-base"); | |
| 817 | ||
| 818 | const resolved = yield* resolveServerConfig( | |
| 819 | { | |
| 820 | mode: Option.some("web"), | |
| 821 | port: Option.some(3773), | |
| 822 | host: Option.none(), | |
| 823 | baseDir: Option.some(baseDir), | |
| 824 | cwd: Option.none(), | |
| 825 | devUrl: Option.none(), | |
| 826 | noBrowser: Option.none(), | |
| 827 | bootstrapFd: Option.none(), | |
| 828 | autoBootstrapProjectFromCwd: Option.none(), | |
| 829 | logWebSocketEvents: Option.none(), | |
| 830 | tailscaleServeEnabled: Option.none(), | |
| 831 | tailscaleServePort: Option.none(), | |
| 832 | }, | |
| 833 | Option.none(), | |
| 834 | ).pipe( | |
| 835 | Effect.provide( | |
| 836 | Layer.mergeAll( | |
| 837 | ConfigProvider.layer( | |
| 838 | ConfigProvider.fromEnv({ env: { T3CODE_OTLP_PROTOCOL: "http/protobuf" } }), | |
| 839 | ), | |
| 840 | NetService.layer, | |
| 841 | ), | |
| 842 | ), | |
| 843 | ); | |
| 844 | ||
| 845 | expect(resolved.otlpProtocol).toBe("http/protobuf"); | |
| 846 | }), | |
| 847 | ); | |
| 811 | 848 | }); |
apps/server/src/cli/config.ts+5-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import * as NetService from "@t3tools/shared/Net"; |
| 2 | import { OtlpHeadersFromString } from "@t3tools/shared/observability"; | |
| 2 | import { OtlpHeadersFromString, OtlpProtocol } from "@t3tools/shared/observability"; | |
| 3 | 3 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 4 | 4 | import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; |
| 5 | 5 | import * as Config from "effect/Config"; |
| ... | ... | @@ -104,6 +104,9 @@ const EnvServerConfig = Config.all({ |
| 104 | 104 | Config.option, |
| 105 | 105 | Config.map(Option.getOrUndefined), |
| 106 | 106 | ), |
| 107 | otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( | |
| 108 | Config.withDefault("http/json"), | |
| 109 | ), | |
| 107 | 110 | mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe( |
| 108 | 111 | Config.option, |
| 109 | 112 | Config.map(Option.getOrUndefined), |
| ... | ... | @@ -393,6 +396,7 @@ export const resolveServerConfig = ( |
| 393 | 396 | otlpExportIntervalMs: env.otlpExportIntervalMs, |
| 394 | 397 | otlpServiceName: env.otlpServiceName, |
| 395 | 398 | otlpHeaders: env.otlpHeaders, |
| 399 | otlpProtocol: env.otlpProtocol, | |
| 396 | 400 | mode, |
| 397 | 401 | port, |
| 398 | 402 | cwd, |
apps/server/src/cli/pair.ts+1| ... | ... | @@ -323,6 +323,7 @@ const makePairServerConfig = Effect.fn(function* (input: { |
| 323 | 323 | otlpExportIntervalMs: 10_000, |
| 324 | 324 | otlpServiceName: "t3-server", |
| 325 | 325 | otlpHeaders: undefined, |
| 326 | otlpProtocol: "http/json", | |
| 326 | 327 | mode: "web", |
| 327 | 328 | port: state.port, |
| 328 | 329 | host: state.host, |
apps/server/src/config.ts+3| ... | ... | @@ -17,6 +17,7 @@ import type * as Redacted from "effect/Redacted"; |
| 17 | 17 | import * as Schema from "effect/Schema"; |
| 18 | 18 | |
| 19 | 19 | import { sweepStalePendingAttachments } from "./attachmentStore.ts"; |
| 20 | import { OtlpProtocol } from "@t3tools/shared/observability"; | |
| 20 | 21 | |
| 21 | 22 | export const DEFAULT_PORT = 3773; |
| 22 | 23 | |
| ... | ... | @@ -74,6 +75,7 @@ export class ServerConfig extends Context.Service< |
| 74 | 75 | readonly otlpExportIntervalMs: number; |
| 75 | 76 | readonly otlpServiceName: string; |
| 76 | 77 | readonly otlpHeaders: Readonly<Record<string, string>> | undefined; |
| 78 | readonly otlpProtocol: OtlpProtocol; | |
| 77 | 79 | readonly mode: RuntimeMode; |
| 78 | 80 | readonly port: number; |
| 79 | 81 | readonly host: string | undefined; |
| ... | ... | @@ -199,6 +201,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( |
| 199 | 201 | otlpExportIntervalMs: 10_000, |
| 200 | 202 | otlpServiceName: "t3-server", |
| 201 | 203 | otlpHeaders: undefined, |
| 204 | otlpProtocol: "http/json", | |
| 202 | 205 | cwd, |
| 203 | 206 | baseDir, |
| 204 | 207 | ...derivedPaths, |
apps/server/src/environment/ServerEnvironment.test.ts+1| ... | ... | @@ -55,6 +55,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { |
| 55 | 55 | otlpExportIntervalMs: 10_000, |
| 56 | 56 | otlpServiceName: "t3-server", |
| 57 | 57 | otlpHeaders: undefined, |
| 58 | otlpProtocol: "http/json", | |
| 58 | 59 | cwd: process.cwd(), |
| 59 | 60 | baseDir, |
| 60 | 61 | mode: "web", |
apps/server/src/http.ts+3-3| ... | ... | @@ -16,7 +16,6 @@ import * as Schema from "effect/Schema"; |
| 16 | 16 | import * as Stream from "effect/Stream"; |
| 17 | 17 | import { cast } from "effect/Function"; |
| 18 | 18 | import { |
| 19 | HttpBody, | |
| 20 | 19 | HttpClient, |
| 21 | 20 | HttpClientResponse, |
| 22 | 21 | HttpMiddleware, |
| ... | ... | @@ -26,7 +25,7 @@ import { |
| 26 | 25 | HttpServerRespondable, |
| 27 | 26 | } from "effect/unstable/http"; |
| 28 | 27 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 29 | import { OtlpTracer } from "effect/unstable/observability"; | |
| 28 | import { OtlpTracer, OtlpSerialization } from "effect/unstable/observability"; | |
| 30 | 29 | |
| 31 | 30 | import * as ServerConfig from "./config.ts"; |
| 32 | 31 | import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; |
| ... | ... | @@ -322,6 +321,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 322 | 321 | const otlpHeaders = config.otlpHeaders; |
| 323 | 322 | const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; |
| 324 | 323 | const httpClient = yield* HttpClient.HttpClient; |
| 324 | const serialization = yield* OtlpSerialization.OtlpSerialization; | |
| 325 | 325 | const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json); |
| 326 | 326 | |
| 327 | 327 | yield* Effect.try({ |
| ... | ... | @@ -343,7 +343,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 343 | 343 | |
| 344 | 344 | return yield* httpClient |
| 345 | 345 | .post(otlpTracesUrl, { |
| 346 | body: HttpBody.jsonUnsafe(bodyJson), | |
| 346 | body: serialization.traces(bodyJson), | |
| 347 | 347 | headers: otlpHeaders, |
| 348 | 348 | }) |
| 349 | 349 | .pipe( |
apps/server/src/observability/Layers/Observability.ts+8-6| ... | ... | @@ -1,12 +1,15 @@ |
| 1 | 1 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; |
| 2 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; | |
| 2 | import { | |
| 3 | makeLocalFileTracer, | |
| 4 | makeTraceSink, | |
| 5 | otlpSerializationLayer, | |
| 6 | } from "@t3tools/shared/observability"; | |
| 3 | 7 | import * as Effect from "effect/Effect"; |
| 4 | 8 | import * as Layer from "effect/Layer"; |
| 5 | 9 | import * as References from "effect/References"; |
| 6 | 10 | import * as Tracer from "effect/Tracer"; |
| 7 | 11 | import * as OtlpExporter from "effect/unstable/observability/OtlpExporter"; |
| 8 | 12 | import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics"; |
| 9 | import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization"; | |
| 10 | 13 | import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; |
| 11 | 14 | |
| 12 | 15 | import * as ServerConfig from "../../config.ts"; |
| ... | ... | @@ -14,11 +17,10 @@ import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttributio |
| 14 | 17 | import { ServerLoggerLive } from "../../serverLogger.ts"; |
| 15 | 18 | import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; |
| 16 | 19 | |
| 17 | const otlpSerializationLayer = OtlpSerialization.layerJson; | |
| 18 | ||
| 19 | 20 | export const ObservabilityLive = Layer.unwrap( |
| 20 | 21 | Effect.gen(function* () { |
| 21 | 22 | const config = yield* ServerConfig.ServerConfig; |
| 23 | const serializationLayer = otlpSerializationLayer(config.otlpProtocol); | |
| 22 | 24 | const attribution = yield* ResourceAttribution.ResourceAttribution; |
| 23 | 25 | |
| 24 | 26 | const traceReferencesLayer = Layer.mergeAll( |
| ... | ... | @@ -73,7 +75,7 @@ export const ObservabilityLive = Layer.unwrap( |
| 73 | 75 | BrowserTraceCollector.layer(sink), |
| 74 | 76 | ); |
| 75 | 77 | }), |
| 76 | ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(otlpSerializationLayer)); | |
| 78 | ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(serializationLayer)); | |
| 77 | 79 | |
| 78 | 80 | const metricsLayer = |
| 79 | 81 | config.otlpMetricsUrl === undefined |
| ... | ... | @@ -89,7 +91,7 @@ export const ObservabilityLive = Layer.unwrap( |
| 89 | 91 | "service.mode": config.mode, |
| 90 | 92 | }, |
| 91 | 93 | }, |
| 92 | }).pipe(Layer.provideMerge(otlpSerializationLayer)); | |
| 94 | }).pipe(Layer.provideMerge(serializationLayer)); | |
| 93 | 95 | |
| 94 | 96 | return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); |
| 95 | 97 | }), |
apps/server/src/server.test.ts+107| ... | ... | @@ -216,6 +216,7 @@ import { |
| 216 | 216 | transferBudgetViolations, |
| 217 | 217 | } from "../integration/TransferBudgetReport.integration.ts"; |
| 218 | 218 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 219 | import { otlpSerializationLayer } from "@t3tools/shared/observability"; | |
| 219 | 220 | |
| 220 | 221 | const defaultProjectId = ProjectId.make("project-default"); |
| 221 | 222 | const defaultThreadId = ThreadId.make("thread-default"); |
| ... | ... | @@ -577,6 +578,7 @@ const buildAppUnderTest = (options?: { |
| 577 | 578 | otlpExportIntervalMs: 10_000, |
| 578 | 579 | otlpServiceName: "t3-server", |
| 579 | 580 | otlpHeaders: undefined, |
| 581 | otlpProtocol: "http/json", | |
| 580 | 582 | mode: "desktop", |
| 581 | 583 | port: 0, |
| 582 | 584 | host: "127.0.0.1", |
| ... | ... | @@ -1060,6 +1062,7 @@ const buildAppUnderTest = (options?: { |
| 1060 | 1062 | ...options?.layers?.browserTraceCollector, |
| 1061 | 1063 | }), |
| 1062 | 1064 | ), |
| 1065 | Layer.provide(otlpSerializationLayer(config.otlpProtocol)), | |
| 1063 | 1066 | Layer.provide( |
| 1064 | 1067 | Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({ |
| 1065 | 1068 | publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), |
| ... | ... | @@ -5216,6 +5219,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5216 | 5219 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 5217 | 5220 | ); |
| 5218 | 5221 | |
| 5222 | it.effect("forwards browser OTLP traces as protobuf when the protocol is http/protobuf", () => | |
| 5223 | Effect.gen(function* () { | |
| 5224 | const upstreamRequests: Array<{ | |
| 5225 | readonly body: string; | |
| 5226 | readonly contentType: string | null; | |
| 5227 | }> = []; | |
| 5228 | const localTraceRecords: Array<unknown> = []; | |
| 5229 | // Produced by effect's own tracer, so enum fields are numeric and the | |
| 5230 | // protobuf encoder accepts them. The hand-written payload in the JSON | |
| 5231 | // test uses enum names, which only the JSON path tolerates. | |
| 5232 | const payload = yield* makeBrowserOtlpPayload("client.protobuf.test"); | |
| 5233 | ||
| 5234 | const collector = yield* Effect.acquireRelease( | |
| 5235 | Effect.promise(async () => { | |
| 5236 | const NodeHttp = await import("node:http"); | |
| 5237 | ||
| 5238 | return await new Promise<{ | |
| 5239 | readonly close: () => Promise<void>; | |
| 5240 | readonly url: string; | |
| 5241 | }>((resolve, reject) => { | |
| 5242 | const server = NodeHttp.createServer((request, response) => { | |
| 5243 | const chunks: Buffer[] = []; | |
| 5244 | request.on("data", (chunk) => { | |
| 5245 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | |
| 5246 | }); | |
| 5247 | request.on("end", () => { | |
| 5248 | upstreamRequests.push({ | |
| 5249 | body: Buffer.concat(chunks).toString("utf8"), | |
| 5250 | contentType: request.headers["content-type"] ?? null, | |
| 5251 | }); | |
| 5252 | response.statusCode = 204; | |
| 5253 | response.end(); | |
| 5254 | }); | |
| 5255 | }); | |
| 5256 | ||
| 5257 | server.on("error", reject); | |
| 5258 | server.listen(0, "127.0.0.1", () => { | |
| 5259 | const address = server.address(); | |
| 5260 | if (!address || typeof address === "string") { | |
| 5261 | reject(new Error("Expected TCP collector address")); | |
| 5262 | return; | |
| 5263 | } | |
| 5264 | ||
| 5265 | resolve({ | |
| 5266 | url: `http://127.0.0.1:${address.port}/v1/traces`, | |
| 5267 | close: () => | |
| 5268 | new Promise<void>((resolveClose, rejectClose) => { | |
| 5269 | server.close((error) => { | |
| 5270 | if (error) { | |
| 5271 | rejectClose(error); | |
| 5272 | return; | |
| 5273 | } | |
| 5274 | resolveClose(); | |
| 5275 | }); | |
| 5276 | }), | |
| 5277 | }); | |
| 5278 | }); | |
| 5279 | }); | |
| 5280 | }), | |
| 5281 | ({ close }) => Effect.promise(close), | |
| 5282 | ); | |
| 5283 | ||
| 5284 | yield* buildAppUnderTest({ | |
| 5285 | config: { | |
| 5286 | otlpTracesUrl: collector.url, | |
| 5287 | otlpProtocol: "http/protobuf", | |
| 5288 | }, | |
| 5289 | layers: { | |
| 5290 | browserTraceCollector: { | |
| 5291 | record: (records) => | |
| 5292 | Effect.sync(() => { | |
| 5293 | localTraceRecords.push(...records); | |
| 5294 | }), | |
| 5295 | }, | |
| 5296 | }, | |
| 5297 | }); | |
| 5298 | ||
| 5299 | const response = yield* HttpClient.post("/api/observability/v1/traces", { | |
| 5300 | headers: { | |
| 5301 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 5302 | "content-type": "application/json", | |
| 5303 | }, | |
| 5304 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 5305 | body: HttpBody.text(JSON.stringify(payload), "application/json"), | |
| 5306 | }); | |
| 5307 | ||
| 5308 | assert.equal(response.status, 204); | |
| 5309 | // The local collector still decodes the browser's JSON before forwarding. | |
| 5310 | assert.equal(localTraceRecords.length, 1); | |
| 5311 | assert.equal(upstreamRequests.length, 1); | |
| 5312 | const forwarded = upstreamRequests[0]; | |
| 5313 | assert.notEqual(forwarded, undefined); | |
| 5314 | if (!forwarded) { | |
| 5315 | return; | |
| 5316 | } | |
| 5317 | assert.equal(forwarded.contentType, "application/x-protobuf"); | |
| 5318 | // Protobuf strings are raw UTF-8, so the span and service names survive | |
| 5319 | // the stub's utf8 decode even though the surrounding bytes don't. | |
| 5320 | assert.notEqual(forwarded.body[0], "{"); | |
| 5321 | assert.include(forwarded.body, "client.protobuf.test"); | |
| 5322 | assert.include(forwarded.body, "t3-web"); | |
| 5323 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 5324 | ); | |
| 5325 | ||
| 5219 | 5326 | it.effect("responds to browser OTLP trace preflight requests with CORS headers", () => |
| 5220 | 5327 | Effect.gen(function* () { |
| 5221 | 5328 | yield* buildAppUnderTest(); |
docs/operations/observability.md+1| ... | ... | @@ -530,6 +530,7 @@ OTLP export: |
| 530 | 530 | - `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` |
| 531 | 531 | - `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as |
| 532 | 532 | `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. |
| 533 | - `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf` | |
| 533 | 534 | |
| 534 | 535 | If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. |
| 535 | 536 |
packages/shared/src/observability.ts+6-1| ... | ... | @@ -7,10 +7,15 @@ import * as Schema from "effect/Schema"; |
| 7 | 7 | import * as SchemaIssue from "effect/SchemaIssue"; |
| 8 | 8 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 9 | 9 | import * as Tracer from "effect/Tracer"; |
| 10 | import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; | |
| 10 | import { OtlpResource, OtlpTracer, OtlpSerialization } from "effect/unstable/observability"; | |
| 11 | 11 | |
| 12 | 12 | import { RotatingFileSink } from "./logging.ts"; |
| 13 | 13 | |
| 14 | export const OtlpProtocol = Schema.Literals(["http/json", "http/protobuf"]); | |
| 15 | export type OtlpProtocol = typeof OtlpProtocol.Type; | |
| 16 | export const otlpSerializationLayer = (protocol: OtlpProtocol) => | |
| 17 | protocol === "http/protobuf" ? OtlpSerialization.layerProtobuf : OtlpSerialization.layerJson; | |
| 18 | ||
| 14 | 19 | const FLUSH_BUFFER_THRESHOLD = 256; |
| 15 | 20 | const textEncoder = new TextEncoder(); |
| 16 | 21 |