From 88878cc98e3c6021b7a73fec076d29d3b64377c5 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 28 Sep 2026 11:13:05 -0400 Subject: [PATCH] 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/server.test.ts | 3 +- 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 +- 12 files changed, 129 insertions(+), 23 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 d8dad5ae4d24..c9c0b51c132c 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/server.test.ts b/apps/server/src/server.test.ts index dc9e63d8010e..1cd8456fbc0a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -496,8 +496,7 @@ const makeBrowserOtlpPayload = (spanName: string) => resource: { serviceName: "t3code-web", attributes: { - "service.runtime": "t3-web", - "service.mode": "browser", + "t3.client.surface": "web", "service.version": "test", }, }, 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 df2d9d4f76c3..920688eb97f3 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -55,3 +55,5 @@ 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) diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 910bd018bcc7..6baa6702c8f7 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -611,10 +611,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 b4dbdf88651a..9daa12f5027b 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, }, },