From 6e83b57b3b975479828c2bbb3888708f50950f37 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 28 Sep 2026 11:13:05 -0400 Subject: [PATCH 1/6] fix(observability): resource attributes follow OTel semantic conventions Signed-off-by: Yordis Prieto --- .../src/app/DesktopObservability.test.ts | 7 +-- apps/desktop/src/app/DesktopObservability.ts | 10 ++++- apps/server/src/cloud/relayTracing.ts | 4 +- apps/server/src/config.ts | 12 +++-- apps/server/src/serverLogger.test.ts | 3 +- apps/web/src/observability/clientTracing.ts | 34 ++++++++++++-- .../0026-telemetry-says-which-app-sent-it.md | 44 +++++++++++++++++++ docs/fork/README.md | 2 + docs/operations/observability.md | 16 +++++-- packages/shared/src/observability.ts | 13 ++++++ packages/shared/src/relayTracing.ts | 4 +- 11 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 docs/fork/0026-telemetry-says-which-app-sent-it.md diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index e23d78aa2161..16de927e106b 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -412,7 +412,8 @@ describe("DesktopObservability", () => { const [request] = requests; assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); assert.include(request?.body ?? "", "desktop log export"); - assert.include(request?.body ?? "", "service.runtime"); + assert.include(request?.body ?? "", "deployment.environment.name"); + assert.include(request?.body ?? "", "process.runtime.name"); assert.strictEqual(request?.headers["x-scope"], "desktop"); // The log record is the export now, so the same message must not also @@ -497,7 +498,7 @@ describe("DesktopObservability", () => { assert.lengthOf(requests, 1); const body = requests[0]?.body ?? ""; assert.include(body, '"stringValue":"t3code-desktop"'); - assert.include(body, "deployment.environment.name"); + assert.include(body, '"key":"deployment.environment.name","value":{"stringValue":"staging"}'); assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); assert.notInclude(body, "renamed"); }).pipe( @@ -511,7 +512,7 @@ describe("DesktopObservability", () => { env: { OTEL_SERVICE_NAME: "renamed", OTEL_RESOURCE_ATTRIBUTES: - "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", + "service.name=renamed,service.namespace=renamed,deployment.environment.name=staging", }, }), ), diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index ce8233e56612..cfde3b322ddb 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -2,6 +2,7 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink, + nodeProcessRuntimeAttributes, otlpSerializationLayer, type SignalExport, } from "@t3tools/shared/observability"; @@ -627,10 +628,15 @@ const telemetryLayer = Layer.unwrap( const endpoints = yield* resolveOtlpEndpoints; const resource = { serviceName: "t3code-desktop", + serviceVersion: environment.appVersion, attributes: { "service.namespace": "t3code", - "service.runtime": "desktop", - "service.mode": environment.isDevelopment ? "development" : "packaged", + // Effect lets explicit attributes beat `OTEL_RESOURCE_ATTRIBUTES`, so an + // operator's tier has to be carried over by hand to keep winning. + "deployment.environment.name": + endpoints.resourceAttributes["deployment.environment.name"] ?? + (environment.isDevelopment ? "development" : "production"), + ...nodeProcessRuntimeAttributes(), }, }; diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index eeea28a2b68f..3e6a2576c2fe 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -8,14 +8,14 @@ export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer( relayClientTracingConfig, { serviceName: "t3code-server", - runtime: "node", + runtime: "nodejs", client: "headless-cli", }, ); export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, { serviceName: "t3code-server", - runtime: "node", + runtime: "nodejs", client: "environment-server", component: "relay-broker", }); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 7fbe95bb21ab..a39eae7eb52b 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -16,8 +16,13 @@ import * as Path from "effect/Path"; import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; +import packageJson from "../package.json" with { type: "json" }; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; -import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; +import { + DEFAULT_SIGNAL_EXPORT, + nodeProcessRuntimeAttributes, + type SignalExport, +} from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; export const DEFAULT_PORT = 3773; @@ -118,10 +123,11 @@ export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config) */ export const otlpResource = (config: ServerConfig["Service"]) => ({ serviceName: "t3code-server", + serviceVersion: packageJson.version, attributes: { "service.namespace": "t3code", - "service.runtime": "t3-server", - "service.mode": config.mode, + "t3.server.managed_by": config.mode === "desktop" ? "desktop" : "standalone", + ...nodeProcessRuntimeAttributes(), }, }); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 43843b249eea..9246b7f48207 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -147,7 +147,8 @@ describe("ServerLoggerLive", () => { assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); assert.include(request?.body ?? "", "server logger under test"); assert.include(request?.body ?? "", "t3code-server"); - assert.include(request?.body ?? "", "service.runtime"); + assert.include(request?.body ?? "", "t3.server.managed_by"); + assert.include(request?.body ?? "", "process.runtime.name"); }), ); diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index e8ccd4173d35..9aa99c8655b8 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -14,15 +14,41 @@ import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; const DEFAULT_EXPORT_INTERVAL_MS = 1_000; +interface NavigatorUserAgentData { + readonly platform: string; + readonly mobile: boolean; + readonly brands: ReadonlyArray<{ readonly brand: string; readonly version: string }>; +} + +/** + * The same `browser.*` and `user_agent.original` attributes the OpenTelemetry + * browser resource detector reports. `userAgentData` exists only in Chromium, + * which includes the desktop app. + */ +const browserResourceAttributes = (): Record => { + if (typeof navigator === "undefined") return {}; + const userAgentData = (navigator as Navigator & { userAgentData?: NavigatorUserAgentData }) + .userAgentData; + return { + "user_agent.original": navigator.userAgent, + "browser.language": navigator.language, + ...(userAgentData && { + "browser.platform": userAgentData.platform, + "browser.mobile": userAgentData.mobile, + "browser.brands": userAgentData.brands.map(({ brand, version }) => `${brand} ${version}`), + }), + }; +}; + const CLIENT_TRACING_RESOURCE = { serviceName: "t3code-web", + serviceVersion: APP_VERSION, attributes: { "service.namespace": "t3code", - "service.runtime": "t3-web", - "service.mode": isElectron ? "electron" : "browser", - "service.version": APP_VERSION, + "t3.client.surface": isElectron ? "desktop" : "web", + ...browserResourceAttributes(), }, -} as const; +}; const delegateRuntimeLayer = Layer.mergeAll( primaryEnvironmentHttpLayer, diff --git a/docs/fork/0026-telemetry-says-which-app-sent-it.md b/docs/fork/0026-telemetry-says-which-app-sent-it.md new file mode 100644 index 000000000000..f83a74d93f5e --- /dev/null +++ b/docs/fork/0026-telemetry-says-which-app-sent-it.md @@ -0,0 +1,44 @@ +# 0026: Telemetry says which app sent it + +- PR: [TrogonStack/t3code#68](https://github.com/TrogonStack/t3code/pull/68) +- Status: active + +## What you can do now + +- Filter UI traces to the desktop window or to a browser tab with + `t3.client.surface`, instead of guessing from a key named `service.mode`. +- Tell a server the desktop app launched from one you started yourself with + `t3.server.managed_by`. +- Query every T3 Code signal with the resource attributes a collector, + dashboard, or vendor already understands: `service.version`, + `deployment.environment.name`, `process.runtime.*`, `user_agent.original`, + and `browser.*`. +- Set `deployment.environment.name` through `OTEL_RESOURCE_ATTRIBUTES` and + have the desktop app keep it. + +## Why + +The desktop window runs the web UI, so its traces arrive as `t3code-web`, and +the only thing separating them from a browser tab was `service.mode`. That +key sat in the `service.*` namespace, which OpenTelemetry reserves for its +own attributes, and it meant something different in each service: where the +UI runs in one, the build type in another, and who launched the process in +the third. Nobody reading a trace could know that without reading the code. + +Following the semantic conventions puts every attribute where tooling +already looks for it, and keeps the one question the conventions have no +answer for, which surface of the product sent this, under the `t3.` prefix +the relay tracing already used. + +## Upstream considerations + +A plausible upstream submission. The attributes came from upstream, and the +change carries no fork-specific intent. Dashboards or saved queries that +filter on `service.mode`, `service.runtime`, or `service.component` have to +move to the new keys, which is the part upstream would want to weigh. + +The server's own `mode` values (`web` for any standalone launch) are left +alone, since renaming them touches the CLI flag, `T3CODE_MODE`, and persisted +session data. Telemetry maps `mode` to `t3.server.managed_by` instead. A +sync that takes upstream's copy of any resource definition brings the old +keys back without any test going red outside the ones changed here. diff --git a/docs/fork/README.md b/docs/fork/README.md index b532fcc65958..d23ab558a332 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -43,5 +43,7 @@ Each entry uses these sections: active, [#38](https://github.com/TrogonStack/t3code/pull/38) - **0025** [A test run leaves no processes behind](./0025-a-test-run-leaves-no-processes-behind.md) active, [#57](https://github.com/TrogonStack/t3code/pull/57) +- **0026** [Telemetry says which app sent it](./0026-telemetry-says-which-app-sent-it.md) + active, [#68](https://github.com/TrogonStack/t3code/pull/68) - **0027** [Symlinked settings stay linked](./0027-symlinked-settings-stay-linked.md) active, [#73](https://github.com/TrogonStack/t3code/pull/73), [#74](https://github.com/TrogonStack/t3code/pull/74) diff --git a/docs/operations/observability.md b/docs/operations/observability.md index b83a9015abb2..0685654c8328 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -587,10 +587,18 @@ an `http` or `https` URL, a protocol other than `http/protobuf` or `http/json` s headers that are not `key=value` pairs with percent-encoded values turn that signal's export off with a startup warning, rather than sending it to the Settings endpoint. -Service names are fixed: `t3code-server` for the backend and `t3code-desktop` for the desktop main -process, both in `service.namespace` `t3code`. `OTEL_SERVICE_NAME` and a `service.name` or -`service.namespace` in `OTEL_RESOURCE_ATTRIBUTES` are ignored. Tell installations apart with other -resource attributes, such as `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development`. +Service names are fixed: `t3code-server` for the backend, `t3code-desktop` for the desktop main +process, and `t3code-web` for the UI, all in `service.namespace` `t3code`. `OTEL_SERVICE_NAME` and a +`service.name` or `service.namespace` in `OTEL_RESOURCE_ATTRIBUTES` are ignored. Tell installations +apart with other resource attributes, such as +`OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=staging`. The desktop main process reports +`deployment.environment.name` as `development` or `production` on its own, and a value from +`OTEL_RESOURCE_ATTRIBUTES` replaces it. + +The UI runs as `t3code-web` in both a browser and the desktop window, since it is the same code. Its +`t3.client.surface` resource attribute is `desktop` or `web`, and `user_agent.original` carries the +full user agent. The server's `t3.server.managed_by` is `desktop` when the desktop app launched it +and `standalone` otherwise. If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index db3f4a89c022..6e96a376d3bc 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -28,6 +28,19 @@ export interface SignalExport { readonly exportIntervalMs: number; } +/** + * `process.runtime.*` resource attributes for a Node process, matching the + * OpenTelemetry Node process detector. Electron embeds Node, so an Electron + * process reports Node and names Electron in the description. + */ +export const nodeProcessRuntimeAttributes = (): Record => ({ + "process.runtime.name": "nodejs", + "process.runtime.version": process.versions.node, + "process.runtime.description": process.versions.electron + ? `Electron ${process.versions.electron}` + : "Node.js", +}); + /** What T3 Code exports with when nothing configured a signal. */ export const DEFAULT_SIGNAL_EXPORT: SignalExport = { protocol: "http/json", diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 76954558eb07..89f91d67a485 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -143,8 +143,8 @@ export function makeRelayClientTracingLayer( serviceVersion: resource.serviceVersion, attributes: { "service.namespace": "t3code", - "service.runtime": resource.runtime, - "service.component": resource.component ?? "relay-client", + "process.runtime.name": resource.runtime, + "t3.component": resource.component ?? "relay-client", "t3.client.surface": resource.client, }, }, From b3a1b88b09c3667bf9361d30fdba81d5985006d5 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 30 Sep 2026 21:31:46 -0400 Subject: [PATCH 2/6] fix(web): UI traces name the browser runtime Signed-off-by: Yordis Prieto --- apps/web/src/observability/clientTracing.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 9aa99c8655b8..85826f76c908 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -22,8 +22,9 @@ interface NavigatorUserAgentData { /** * The same `browser.*` and `user_agent.original` attributes the OpenTelemetry - * browser resource detector reports. `userAgentData` exists only in Chromium, - * which includes the desktop app. + * browser resource detector reports, plus the `process.runtime.*` values the + * semantic conventions give a web browser. `userAgentData` exists only in + * Chromium, which includes the desktop app. */ const browserResourceAttributes = (): Record => { if (typeof navigator === "undefined") return {}; @@ -31,6 +32,8 @@ const browserResourceAttributes = (): Record => { .userAgentData; return { "user_agent.original": navigator.userAgent, + "process.runtime.name": "browser", + "process.runtime.version": navigator.userAgent, "browser.language": navigator.language, ...(userAgentData && { "browser.platform": userAgentData.platform, From b84e429357f3c1de3374f1de544bfcd0241eef17 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 30 Sep 2026 21:40:02 -0400 Subject: [PATCH 3/6] fix(relay): relay traces use the same resource keys Signed-off-by: Yordis Prieto --- infra/relay/src/observability.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index 2325d7e0432a..119f3e499814 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -225,8 +225,8 @@ export const makeRelayTraceLayer = (input: { serviceName: "t3code-relay", attributes: { "service.namespace": "t3code", - "service.runtime": "cloudflare-worker", - "service.component": "relay", + "process.runtime.name": "cloudflare-worker", + "t3.component": "relay", }, }, headers: { From 3411e2392e1797f617306cd9a398af1454de9a72 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 30 Sep 2026 21:56:26 -0400 Subject: [PATCH 4/6] fix(observability): custom attributes use the t3code prefix Signed-off-by: Yordis Prieto --- apps/server/src/config.ts | 2 +- apps/server/src/serverLogger.test.ts | 2 +- apps/web/src/observability/clientTracing.ts | 2 +- .../0026-telemetry-says-which-app-sent-it.md | 16 +++++++++------- docs/operations/observability.md | 4 ++-- infra/relay/src/observability.ts | 2 +- packages/shared/src/relayTracing.ts | 4 ++-- 7 files changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index a39eae7eb52b..b65dbdbd5ba7 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -126,7 +126,7 @@ export const otlpResource = (config: ServerConfig["Service"]) => ({ serviceVersion: packageJson.version, attributes: { "service.namespace": "t3code", - "t3.server.managed_by": config.mode === "desktop" ? "desktop" : "standalone", + "t3code.server.managed_by": config.mode === "desktop" ? "desktop" : "standalone", ...nodeProcessRuntimeAttributes(), }, }); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 9246b7f48207..cff7e8a249f3 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -147,7 +147,7 @@ describe("ServerLoggerLive", () => { assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); assert.include(request?.body ?? "", "server logger under test"); assert.include(request?.body ?? "", "t3code-server"); - assert.include(request?.body ?? "", "t3.server.managed_by"); + assert.include(request?.body ?? "", "t3code.server.managed_by"); assert.include(request?.body ?? "", "process.runtime.name"); }), ); diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 85826f76c908..406ae8cc2d90 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -48,7 +48,7 @@ const CLIENT_TRACING_RESOURCE = { serviceVersion: APP_VERSION, attributes: { "service.namespace": "t3code", - "t3.client.surface": isElectron ? "desktop" : "web", + "t3code.client.surface": isElectron ? "desktop" : "web", ...browserResourceAttributes(), }, }; diff --git a/docs/fork/0026-telemetry-says-which-app-sent-it.md b/docs/fork/0026-telemetry-says-which-app-sent-it.md index f83a74d93f5e..91839bb6db7c 100644 --- a/docs/fork/0026-telemetry-says-which-app-sent-it.md +++ b/docs/fork/0026-telemetry-says-which-app-sent-it.md @@ -6,9 +6,9 @@ ## What you can do now - Filter UI traces to the desktop window or to a browser tab with - `t3.client.surface`, instead of guessing from a key named `service.mode`. + `t3code.client.surface`, instead of guessing from a key named `service.mode`. - Tell a server the desktop app launched from one you started yourself with - `t3.server.managed_by`. + `t3code.server.managed_by`. - Query every T3 Code signal with the resource attributes a collector, dashboard, or vendor already understands: `service.version`, `deployment.environment.name`, `process.runtime.*`, `user_agent.original`, @@ -27,18 +27,20 @@ the third. Nobody reading a trace could know that without reading the code. Following the semantic conventions puts every attribute where tooling already looks for it, and keeps the one question the conventions have no -answer for, which surface of the product sent this, under the `t3.` prefix -the relay tracing already used. +answer for, which surface of the product sent this, under the `t3code.` +prefix. That is the application's own name, which the conventions recommend +for custom attributes, and unlike `t3` it cannot be mistaken for anything +else. ## Upstream considerations A plausible upstream submission. The attributes came from upstream, and the change carries no fork-specific intent. Dashboards or saved queries that -filter on `service.mode`, `service.runtime`, or `service.component` have to -move to the new keys, which is the part upstream would want to weigh. +filter on `service.mode`, `service.runtime`, `service.component`, or the +relay's `t3.client.surface` have to move to the new keys, which is the part upstream would want to weigh. The server's own `mode` values (`web` for any standalone launch) are left alone, since renaming them touches the CLI flag, `T3CODE_MODE`, and persisted -session data. Telemetry maps `mode` to `t3.server.managed_by` instead. A +session data. Telemetry maps `mode` to `t3code.server.managed_by` instead. A sync that takes upstream's copy of any resource definition brings the old keys back without any test going red outside the ones changed here. diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 0685654c8328..de7554c2021a 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -596,8 +596,8 @@ apart with other resource attributes, such as `OTEL_RESOURCE_ATTRIBUTES` replaces it. The UI runs as `t3code-web` in both a browser and the desktop window, since it is the same code. Its -`t3.client.surface` resource attribute is `desktop` or `web`, and `user_agent.original` carries the -full user agent. The server's `t3.server.managed_by` is `desktop` when the desktop app launched it +`t3code.client.surface` resource attribute is `desktop` or `web`, and `user_agent.original` carries the +full user agent. The server's `t3code.server.managed_by` is `desktop` when the desktop app launched it and `standalone` otherwise. If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index 119f3e499814..4b18c87b5bf7 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -226,7 +226,7 @@ export const makeRelayTraceLayer = (input: { attributes: { "service.namespace": "t3code", "process.runtime.name": "cloudflare-worker", - "t3.component": "relay", + "t3code.component": "relay", }, }, headers: { diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 89f91d67a485..4bc1d54ea077 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -144,8 +144,8 @@ export function makeRelayClientTracingLayer( attributes: { "service.namespace": "t3code", "process.runtime.name": resource.runtime, - "t3.component": resource.component ?? "relay-client", - "t3.client.surface": resource.client, + "t3code.component": resource.component ?? "relay-client", + "t3code.client.surface": resource.client, }, }, }).pipe(Layer.provide(OtlpSerialization.layerJson)); From 81079e5bb288895bc07c1409f290f64ef84bacfc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 3 Oct 2026 02:06:56 -0400 Subject: [PATCH 5/6] fix(observability): every resource names its service instance Signed-off-by: Yordis Prieto --- apps/desktop/src/app/DesktopObservability.ts | 12 ++++---- .../features/observability/tracing.test.ts | 12 +++++++- .../src/features/observability/tracing.ts | 12 ++++++-- apps/server/src/cloud/relayTracing.ts | 10 +++++-- apps/server/src/config.ts | 7 +++-- apps/server/src/serverLogger.test.ts | 1 + apps/web/src/lib/runtime.ts | 4 ++- apps/web/src/observability/clientTracing.ts | 2 ++ apps/web/src/observability/serviceInstance.ts | 4 +++ .../0026-telemetry-says-which-app-sent-it.md | 13 ++++++--- docs/operations/observability.md | 7 +++-- infra/relay/src/observability.ts | 12 +++++++- packages/shared/src/observability.test.ts | 25 ++++++++++++++++ packages/shared/src/observability.ts | 29 +++++++++++++++++++ packages/shared/src/relayTracing.test.ts | 2 +- packages/shared/src/relayTracing.ts | 7 +++-- 16 files changed, 133 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/observability/serviceInstance.ts diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index cfde3b322ddb..a2c8be588c69 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -2,7 +2,7 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink, - nodeProcessRuntimeAttributes, + nodeProcessResourceAttributes, otlpSerializationLayer, type SignalExport, } from "@t3tools/shared/observability"; @@ -631,12 +631,10 @@ const telemetryLayer = Layer.unwrap( serviceVersion: environment.appVersion, attributes: { "service.namespace": "t3code", - // Effect lets explicit attributes beat `OTEL_RESOURCE_ATTRIBUTES`, so an - // operator's tier has to be carried over by hand to keep winning. - "deployment.environment.name": - endpoints.resourceAttributes["deployment.environment.name"] ?? - (environment.isDevelopment ? "development" : "production"), - ...nodeProcessRuntimeAttributes(), + ...nodeProcessResourceAttributes({ + operatorAttributes: endpoints.resourceAttributes, + isDevelopment: environment.isDevelopment, + }), }, }; diff --git a/apps/mobile/src/features/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts index b0deb15be8cb..9981e13e5f81 100644 --- a/apps/mobile/src/features/observability/tracing.test.ts +++ b/apps/mobile/src/features/observability/tracing.test.ts @@ -27,6 +27,7 @@ it.effect("exports spans through the scoped mobile OTLP layer", () => { }, { appVariant: "test", + serviceInstanceId: "test-instance", serviceVersion: "1.2.3", }, ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); @@ -47,7 +48,15 @@ it.effect("exports spans through the scoped mobile OTLP layer", () => { expect(String(url)).toBe("https://api.axiom.test/v1/traces"); expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); - expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); + const body = new TextDecoder().decode(init?.body as Uint8Array); + expect(body).toContain("mobile.test.span"); + expect(body).toContain('"key":"t3code.client.surface","value":{"stringValue":"mobile"}'); + expect(body).toContain( + '"key":"deployment.environment.name","value":{"stringValue":"test"}', + ); + expect(body).toContain( + '"key":"service.instance.id","value":{"stringValue":"test-instance"}', + ); }), ), ); @@ -63,6 +72,7 @@ it.effect("does not let OTLP serialization failures alter application effects", }, { appVariant: "test", + serviceInstanceId: "test-instance", serviceVersion: "1.2.3", }, ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); diff --git a/apps/mobile/src/features/observability/tracing.ts b/apps/mobile/src/features/observability/tracing.ts index ae204413e777..759067a1129e 100644 --- a/apps/mobile/src/features/observability/tracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -11,6 +11,7 @@ export interface TracingConfig { export interface TracingResource { readonly serviceVersion?: string; + readonly serviceInstanceId: string; readonly appVariant: string; } @@ -27,13 +28,20 @@ export function makeTracingLayer(config: TracingConfig | null, resource: Tracing return makeRelayClientTracingLayer(config, { serviceName: "t3code-mobile", serviceVersion: resource.serviceVersion, - runtime: "react-native", - client: `mobile-${resource.appVariant}`, + serviceInstanceId: resource.serviceInstanceId, + client: "mobile", + attributes: { + "process.runtime.name": "react-native", + ...(resource.appVariant !== "unknown" && { + "deployment.environment.name": resource.appVariant, + }), + }, }); } export const tracingLayer = makeTracingLayer(resolveTracingConfig(), { serviceVersion: Constants.expoConfig?.version, + serviceInstanceId: Constants.sessionId, appVariant: typeof Constants.expoConfig?.extra?.appVariant === "string" ? Constants.expoConfig.extra.appVariant diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index 3e6a2576c2fe..f5576e919749 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -1,3 +1,7 @@ +import { + nodeProcessRuntimeAttributes, + processServiceInstanceId, +} from "@t3tools/shared/observability"; import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; import { resolveRelayClientTracingConfig } from "./publicConfig.ts"; @@ -8,14 +12,16 @@ export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer( relayClientTracingConfig, { serviceName: "t3code-server", - runtime: "nodejs", + serviceInstanceId: processServiceInstanceId(), + attributes: nodeProcessRuntimeAttributes(), client: "headless-cli", }, ); export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, { serviceName: "t3code-server", - runtime: "nodejs", + serviceInstanceId: processServiceInstanceId(), + attributes: nodeProcessRuntimeAttributes(), client: "environment-server", component: "relay-broker", }); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b65dbdbd5ba7..25ab7f7182e4 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -20,7 +20,7 @@ import packageJson from "../package.json" with { type: "json" }; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; import { DEFAULT_SIGNAL_EXPORT, - nodeProcessRuntimeAttributes, + nodeProcessResourceAttributes, type SignalExport, } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; @@ -127,7 +127,10 @@ export const otlpResource = (config: ServerConfig["Service"]) => ({ attributes: { "service.namespace": "t3code", "t3code.server.managed_by": config.mode === "desktop" ? "desktop" : "standalone", - ...nodeProcessRuntimeAttributes(), + ...nodeProcessResourceAttributes({ + operatorAttributes: config.otelEnvironment.resourceAttributes, + isDevelopment: config.devUrl !== undefined, + }), }, }); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index cff7e8a249f3..6bbb8f4531d2 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -149,6 +149,7 @@ describe("ServerLoggerLive", () => { assert.include(request?.body ?? "", "t3code-server"); assert.include(request?.body ?? "", "t3code.server.managed_by"); assert.include(request?.body ?? "", "process.runtime.name"); + assert.include(request?.body ?? "", "service.instance.id"); }), ); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index fd4f2b194096..687ba787fafd 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -12,6 +12,7 @@ import { browserCryptoLayer } from "../cloud/dpop"; import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; import * as ClientTracer from "../observability/clientTracer"; +import { serviceInstanceId } from "../observability/serviceInstance"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; @@ -21,7 +22,8 @@ const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch( const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { serviceName: "t3code-web", serviceVersion: import.meta.env.APP_VERSION, - runtime: "browser", + serviceInstanceId, + attributes: { "process.runtime.name": "browser" }, client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", }).pipe(Layer.provide(httpClientLayer)); diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 406ae8cc2d90..8f0d1945d44a 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -9,6 +9,7 @@ import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-run import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; import * as ClientTracer from "./clientTracer"; +import { serviceInstanceId } from "./serviceInstance"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; @@ -48,6 +49,7 @@ const CLIENT_TRACING_RESOURCE = { serviceVersion: APP_VERSION, attributes: { "service.namespace": "t3code", + "service.instance.id": serviceInstanceId, "t3code.client.surface": isElectron ? "desktop" : "web", ...browserResourceAttributes(), }, diff --git a/apps/web/src/observability/serviceInstance.ts b/apps/web/src/observability/serviceInstance.ts new file mode 100644 index 000000000000..cd9c76723c6e --- /dev/null +++ b/apps/web/src/observability/serviceInstance.ts @@ -0,0 +1,4 @@ +import { randomUUID } from "../lib/utils"; + +/** `service.instance.id` for this page load, shared by every resource the UI exports. */ +export const serviceInstanceId = randomUUID(); diff --git a/docs/fork/0026-telemetry-says-which-app-sent-it.md b/docs/fork/0026-telemetry-says-which-app-sent-it.md index 91839bb6db7c..fd6cafdf73d1 100644 --- a/docs/fork/0026-telemetry-says-which-app-sent-it.md +++ b/docs/fork/0026-telemetry-says-which-app-sent-it.md @@ -11,10 +11,12 @@ `t3code.server.managed_by`. - Query every T3 Code signal with the resource attributes a collector, dashboard, or vendor already understands: `service.version`, - `deployment.environment.name`, `process.runtime.*`, `user_agent.original`, - and `browser.*`. -- Set `deployment.environment.name` through `OTEL_RESOURCE_ATTRIBUTES` and - have the desktop app keep it. + `service.instance.id`, `deployment.environment.name`, `process.runtime.*`, + `user_agent.original`, and `browser.*`. +- Tell two copies of the same service apart, such as two browser tabs or a + desktop-managed server next to a standalone one, by `service.instance.id`. +- Set `deployment.environment.name` or `service.instance.id` through + `OTEL_RESOURCE_ATTRIBUTES` and have the server and desktop app keep it. ## Why @@ -38,6 +40,9 @@ A plausible upstream submission. The attributes came from upstream, and the change carries no fork-specific intent. Dashboards or saved queries that filter on `service.mode`, `service.runtime`, `service.component`, or the relay's `t3.client.surface` have to move to the new keys, which is the part upstream would want to weigh. +Mobile relay traces now report `t3code.client.surface` as `mobile` and carry +the build variant in `deployment.environment.name`, so a query on +`mobile-production` becomes two filters. The server's own `mode` values (`web` for any standalone launch) are left alone, since renaming them touches the CLI flag, `T3CODE_MODE`, and persisted diff --git a/docs/operations/observability.md b/docs/operations/observability.md index de7554c2021a..7f1af645b835 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -591,9 +591,10 @@ Service names are fixed: `t3code-server` for the backend, `t3code-desktop` for t process, and `t3code-web` for the UI, all in `service.namespace` `t3code`. `OTEL_SERVICE_NAME` and a `service.name` or `service.namespace` in `OTEL_RESOURCE_ATTRIBUTES` are ignored. Tell installations apart with other resource attributes, such as -`OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=staging`. The desktop main process reports -`deployment.environment.name` as `development` or `production` on its own, and a value from -`OTEL_RESOURCE_ATTRIBUTES` replaces it. +`OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=staging`. The server and the desktop main +process report `deployment.environment.name` as `development` or `production` on their own, and a +random `service.instance.id` per process. A value for either in `OTEL_RESOURCE_ATTRIBUTES` replaces +it. The UI runs as `t3code-web` in both a browser and the desktop window, since it is the same code. Its `t3code.client.surface` resource attribute is `desktop` or `web`, and `user_agent.original` carries the diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index 4b18c87b5bf7..d2d331c409cf 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -212,6 +212,14 @@ const withSchemaErrorAttributes = (delegate: Tracer.Tracer): Tracer.Tracer => ...(delegate.context ? { context: delegate.context } : {}), }); +let serviceInstanceId: string | undefined; + +/** + * One `service.instance.id` per isolate, made on first use because Workers + * refuse to generate random values in global scope. + */ +const isolateServiceInstanceId = (): string => (serviceInstanceId ??= crypto.randomUUID()); + export const makeRelayTraceLayer = (input: { readonly tracesEndpoint: string; readonly tracesDatasetName: string; @@ -225,7 +233,9 @@ export const makeRelayTraceLayer = (input: { serviceName: "t3code-relay", attributes: { "service.namespace": "t3code", - "process.runtime.name": "cloudflare-worker", + "service.instance.id": isolateServiceInstanceId(), + "cloud.provider": "cloudflare", + "cloud.platform": "cloudflare.workers", "t3code.component": "relay", }, }, diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index a9081a831fa6..291aefbbc642 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -26,8 +26,33 @@ import { type TraceSinkFlushStats, OtlpHeadersFromString, truncateTraceAttributes, + nodeProcessResourceAttributes, } from "./observability.ts"; +describe("nodeProcessResourceAttributes", () => { + it("shares one instance id per process and defaults the tier from the build", () => { + const first = nodeProcessResourceAttributes({ operatorAttributes: {}, isDevelopment: true }); + const second = nodeProcessResourceAttributes({ operatorAttributes: {}, isDevelopment: false }); + + expect(first["service.instance.id"]).toBe(second["service.instance.id"]); + expect(first["deployment.environment.name"]).toBe("development"); + expect(second["deployment.environment.name"]).toBe("production"); + }); + + it("keeps values an operator set in OTEL_RESOURCE_ATTRIBUTES", () => { + const attributes = nodeProcessResourceAttributes({ + operatorAttributes: { + "service.instance.id": "pod-7", + "deployment.environment.name": "staging", + }, + isDevelopment: true, + }); + + expect(attributes["service.instance.id"]).toBe("pod-7"); + expect(attributes["deployment.environment.name"]).toBe("staging"); + }); +}); + describe("errorTag", () => { it("reports structural tags without retaining arbitrary values", () => { assert.equal(errorTag({ _tag: "AcpRequestError" }), "AcpRequestError"); diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 6e96a376d3bc..c8a3b99f0f86 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -1,3 +1,5 @@ +import * as NodeCrypto from "node:crypto"; + import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import type * as Exit from "effect/Exit"; @@ -28,6 +30,16 @@ export interface SignalExport { readonly exportIntervalMs: number; } +let serviceInstanceId: string | undefined; + +/** + * `service.instance.id` for this process. Every resource the process exports + * shares it, so a restart reads as a new instance and two resources of one + * process can be joined. + */ +export const processServiceInstanceId = (): string => + (serviceInstanceId ??= NodeCrypto.randomUUID()); + /** * `process.runtime.*` resource attributes for a Node process, matching the * OpenTelemetry Node process detector. Electron embeds Node, so an Electron @@ -41,6 +53,23 @@ export const nodeProcessRuntimeAttributes = (): Record => ({ : "Node.js", }); +/** + * Resource attributes a Node process reports besides its service identity. + * Effect lets explicit attributes beat `OTEL_RESOURCE_ATTRIBUTES`, so values an + * operator set there are carried over by hand to keep winning. + */ +export const nodeProcessResourceAttributes = (input: { + readonly operatorAttributes: Readonly>; + readonly isDevelopment: boolean; +}): Record => ({ + "service.instance.id": + input.operatorAttributes["service.instance.id"] ?? processServiceInstanceId(), + "deployment.environment.name": + input.operatorAttributes["deployment.environment.name"] ?? + (input.isDevelopment ? "development" : "production"), + ...nodeProcessRuntimeAttributes(), +}); + /** What T3 Code exports with when nothing configured a signal. */ export const DEFAULT_SIGNAL_EXPORT: SignalExport = { protocol: "http/json", diff --git a/packages/shared/src/relayTracing.test.ts b/packages/shared/src/relayTracing.test.ts index f96dfea8bb3b..7a3d24db7b1c 100644 --- a/packages/shared/src/relayTracing.test.ts +++ b/packages/shared/src/relayTracing.test.ts @@ -75,7 +75,7 @@ describe("withRelayClientTracing", () => { }, { serviceName: "relay-test", - runtime: "test", + serviceInstanceId: "test-instance", client: "test", }, ).pipe(Layer.provide(httpClientLayer)); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 4bc1d54ea077..ff2b2563ae28 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -17,9 +17,11 @@ export interface RelayClientTracingConfig { export interface RelayClientTracingResource { readonly serviceName: string; readonly serviceVersion?: string; - readonly runtime: string; + readonly serviceInstanceId: string; readonly client: string; readonly component?: string; + /** Semantic convention attributes the caller's runtime can report, such as `process.runtime.*`. */ + readonly attributes?: Readonly>; } export class RelayClientTracer extends Context.Reference( @@ -142,8 +144,9 @@ export function makeRelayClientTracingLayer( serviceName: resource.serviceName, serviceVersion: resource.serviceVersion, attributes: { + ...resource.attributes, "service.namespace": "t3code", - "process.runtime.name": resource.runtime, + "service.instance.id": resource.serviceInstanceId, "t3code.component": resource.component ?? "relay-client", "t3code.client.surface": resource.client, }, From 7dfbc2bbc3c7c47f32c330a8b0aeff38119307f4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 3 Oct 2026 02:11:40 -0400 Subject: [PATCH 6/6] fix(relay): instance id passes the Effect randomness lint Signed-off-by: Yordis Prieto --- infra/relay/src/observability.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index d2d331c409cf..134c1cbf320d 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -1,3 +1,5 @@ +import * as NodeCrypto from "node:crypto"; + import * as Alchemy from "alchemy"; import * as Axiom from "alchemy/Axiom"; import * as Output from "alchemy/Output"; @@ -218,7 +220,7 @@ let serviceInstanceId: string | undefined; * One `service.instance.id` per isolate, made on first use because Workers * refuse to generate random values in global scope. */ -const isolateServiceInstanceId = (): string => (serviceInstanceId ??= crypto.randomUUID()); +const isolateServiceInstanceId = (): string => (serviceInstanceId ??= NodeCrypto.randomUUID()); export const makeRelayTraceLayer = (input: { readonly tracesEndpoint: string;