From 9f656e9a664fadffe1c7292ffc9b14322f0fbfa2 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 24 Oct 2026 00:02:18 -0400 Subject: [PATCH 1/4] fix(mobile): preview builds report the staging environment and release channel Signed-off-by: Yordis Prieto --- .../features/observability/tracing.test.ts | 58 +++++++++++++++---- .../src/features/observability/tracing.ts | 17 +++++- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts index 36ef1cf2a369..54df65b17d5d 100644 --- a/apps/mobile/src/features/observability/tracing.test.ts +++ b/apps/mobile/src/features/observability/tracing.test.ts @@ -17,7 +17,7 @@ vi.mock("expo-constants", () => ({ }, })); -it.effect("labels exported spans with the mobile surface, environment, and instance", () => { +const exportedResourceBody = (appVariant: string) => { const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); const layerTracing = Tracing.layerFromConfig( { @@ -26,7 +26,7 @@ it.effect("labels exported spans with the mobile surface, environment, and insta tracesToken: "public-ingest-token", }, { - appVariant: "test", + appVariant, serviceInstanceId: "test-instance", serviceVersion: "1.2.3", }, @@ -40,18 +40,52 @@ it.effect("labels exported spans with the mobile surface, environment, and insta Effect.andThen( Effect.sync(() => { expect(fetchFn).toHaveBeenCalledOnce(); - const body = new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array); - 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"}', - ); + return new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array); }), ), ); -}); +}; + +const stringAttribute = (key: string, value: string) => + `"key":"${key}","value":{"stringValue":"${value}"}`; + +it.effect("labels exported spans with the mobile surface and instance", () => + exportedResourceBody("production").pipe( + Effect.andThen((body) => + Effect.sync(() => { + expect(body).toContain(stringAttribute("t3code.client.surface", "mobile")); + expect(body).toContain(stringAttribute("service.instance.id", "test-instance")); + expect(body).toContain(stringAttribute("service.version", "1.2.3")); + }), + ), + ), +); + +it.effect.each([ + ["development", "development", "dev"], + ["preview", "staging", "preview"], + ["production", "production", "stable"], +] as const)("reports %s builds as %s on the %s channel", ([appVariant, environment, channel]) => + exportedResourceBody(appVariant).pipe( + Effect.andThen((body) => + Effect.sync(() => { + expect(body).toContain(stringAttribute("deployment.environment.name", environment)); + expect(body).toContain(stringAttribute("t3code.release.channel", channel)); + }), + ), + ), +); + +it.effect("omits environment and channel for an unknown variant", () => + exportedResourceBody("unknown").pipe( + Effect.andThen((body) => + Effect.sync(() => { + expect(body).not.toContain("deployment.environment.name"); + expect(body).not.toContain("t3code.release.channel"); + }), + ), + ), +); it.effect("does not let OTLP serialization failures alter application effects", () => { const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); @@ -62,7 +96,7 @@ it.effect("does not let OTLP serialization failures alter application effects", tracesToken: "public-ingest-token", }, { - appVariant: "test", + appVariant: "production", serviceInstanceId: "test-instance", serviceVersion: "1.2.3", }, diff --git a/apps/mobile/src/features/observability/tracing.ts b/apps/mobile/src/features/observability/tracing.ts index 458565c2332b..b0abb75c5ed5 100644 --- a/apps/mobile/src/features/observability/tracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -1,4 +1,5 @@ import Constants from "expo-constants"; +import type { ReleaseChannel } from "@t3tools/shared/releaseResource"; import * as RelayTracing from "@t3tools/shared/relayTracing"; import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; @@ -15,6 +16,14 @@ export interface TracingResource { readonly appVariant: string; } +const RELEASE_IDENTITY_BY_VARIANT: Readonly< + Record +> = { + development: { environment: "development", channel: "dev" }, + preview: { environment: "staging", channel: "preview" }, + production: { environment: "production", channel: "stable" }, +}; + export function resolveTracingConfig(): TracingConfig | null { const config = resolveCloudPublicConfig(); if (!hasTracingPublicConfig(config)) { @@ -25,6 +34,9 @@ export function resolveTracingConfig(): TracingConfig | null { } export function layerFromConfig(config: TracingConfig | null, resource: TracingResource) { + const releaseIdentity = Object.hasOwn(RELEASE_IDENTITY_BY_VARIANT, resource.appVariant) + ? RELEASE_IDENTITY_BY_VARIANT[resource.appVariant] + : undefined; return RelayTracing.layer(config, { serviceName: "t3code-mobile", serviceVersion: resource.serviceVersion, @@ -32,8 +44,9 @@ export function layerFromConfig(config: TracingConfig | null, resource: TracingR client: "mobile", attributes: { "process.runtime.name": "react-native", - ...(resource.appVariant !== "unknown" && { - "deployment.environment.name": resource.appVariant, + ...(releaseIdentity && { + "deployment.environment.name": releaseIdentity.environment, + "t3code.release.channel": releaseIdentity.channel, }), }, }); From a4af473ed9722ab09715d2f33094e94b346f31b2 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 24 Oct 2026 00:02:24 -0400 Subject: [PATCH 2/4] fix(observability): every app reports its own environment and release channel Signed-off-by: Yordis Prieto --- .github/workflows/web-preview.yml | 3 +- .../src/app/DesktopObservability.test.ts | 31 +++++++++ apps/desktop/src/app/DesktopObservability.ts | 8 ++- apps/server/src/config.test.ts | 19 +++++ apps/server/src/config.ts | 1 + .../src/observability/clientResource.test.ts | 46 +++++++++++++ apps/web/src/observability/clientResource.ts | 69 +++++++++++++++++++ apps/web/src/observability/clientTracing.ts | 49 +++---------- apps/web/src/vite-env.d.ts | 1 + apps/web/vite.config.ts | 2 + .../0026-telemetry-says-which-app-sent-it.md | 21 ++++-- docs/operations/observability.md | 22 ++++-- packages/shared/package.json | 4 ++ packages/shared/src/observability.test.ts | 19 ++++- packages/shared/src/observability.ts | 6 +- packages/shared/src/releaseResource.test.ts | 63 +++++++++++++++++ packages/shared/src/releaseResource.ts | 37 ++++++++++ 17 files changed, 338 insertions(+), 63 deletions(-) create mode 100644 apps/web/src/observability/clientResource.test.ts create mode 100644 apps/web/src/observability/clientResource.ts create mode 100644 packages/shared/src/releaseResource.test.ts create mode 100644 packages/shared/src/releaseResource.ts diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index 406fe913fe09..799ecd6ee50e 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -82,7 +82,8 @@ jobs: --archive=tgz \ --yes \ --token "$VERCEL_TOKEN" \ - --scope "$vercel_scope" + --scope "$vercel_scope" \ + --build-env "VITE_RELEASE_CHANNEL=preview" )" printf '%s\n' "$deployment_output" # Vercel can print lockfile warnings to stdout before the deployment URL. diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 34270c383f31..7e7ab63a253c 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -302,6 +302,7 @@ describe("DesktopObservability", () => { const body = requests[0]?.body ?? ""; assert.include(body, '"stringValue":"t3code-desktop"'); assert.include(body, '"key":"deployment.environment.name","value":{"stringValue":"staging"}'); + assert.include(body, '"key":"t3code.release.channel","value":{"stringValue":"dev"}'); assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); assert.include(body, '"key":"process.runtime.name","value":{"stringValue":"nodejs"}'); assert.notInclude(body, "renamed"); @@ -358,6 +359,36 @@ describe("DesktopObservability", () => { ); }); + it.effect("reports the app's own release identity for a non-development run", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const layerEnvironment = layerEnvironmentFor(baseDir, false); + yield* writeObservabilitySettings(layerEnvironment, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop identity under test").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), + ), + ); + + const body = requests.map((request) => request.body).join("\n"); + assert.include( + body, + '"key":"deployment.environment.name","value":{"stringValue":"production"}', + ); + assert.include(body, '"key":"t3code.release.channel","value":{"stringValue":"stable"}'); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), + ); + }); + it.effect("reports the app's own version for an unpackaged desktop run", () => { const requests: Array = []; return Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 4a1018c2c135..e8240993e5be 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -598,13 +598,17 @@ const layerTelemetry = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; + // Unpackaged runs launch a script, so Electron reports its own version. + const serviceVersion = environment.isPackaged + ? environment.appVersion + : desktopPackageJson.version; const resource = { serviceName: "t3code-desktop", - // Unpackaged runs launch a script, so Electron reports its own version. - serviceVersion: environment.isPackaged ? environment.appVersion : desktopPackageJson.version, + serviceVersion, attributes: { "service.namespace": "t3code", ...nodeProcessResourceAttributes({ + version: serviceVersion, operatorAttributes: endpoints.resourceAttributes, isDevelopment: environment.isDevelopment, }), diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index ab1cb46972b2..53b399925d7f 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -1,6 +1,7 @@ import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { assert, describe, it } from "@effect/vitest"; +import packageJson from "../package.json" with { type: "json" }; import * as ServerConfig from "./config.ts"; describe("otlpResource", () => { @@ -18,6 +19,24 @@ describe("otlpResource", () => { "box-1", ); + it("reports the server's own version, release channel and environment", () => { + const attributes: Readonly> = resource({}).attributes; + assert.equal(resource({}).serviceVersion, packageJson.version); + assert.equal(attributes["t3code.release.channel"], "stable"); + assert.equal(attributes["deployment.environment.name"], "production"); + + const dev: Readonly> = resource( + {}, + { devUrl: new URL("http://localhost:5733") }, + ).attributes; + assert.equal(dev["t3code.release.channel"], "dev"); + + const operator: Readonly> = resource({ + "t3code.release.channel": "nightly", + }).attributes; + assert.equal(operator["t3code.release.channel"], "nightly"); + }); + it("reports who manages the server and its deployment environment", () => { const standalone: Readonly> = resource({}).attributes; assert.equal(standalone["t3code.server.managed_by"], "standalone"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d70b21baf185..61c1817188fc 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -116,6 +116,7 @@ export const otlpResource = ( "service.namespace": "t3code", "t3code.server.managed_by": config.mode === "desktop" ? "desktop" : "standalone", ...nodeProcessResourceAttributes({ + version: packageJson.version, operatorAttributes: config.otelEnvironment.resourceAttributes, isDevelopment: config.devUrl !== undefined, }), diff --git a/apps/web/src/observability/clientResource.test.ts b/apps/web/src/observability/clientResource.test.ts new file mode 100644 index 000000000000..4b1bf9cf1f32 --- /dev/null +++ b/apps/web/src/observability/clientResource.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { clientTracingResource } from "./clientResource"; + +const resource = (overrides: Partial[0]> = {}) => + clientTracingResource({ + version: "1.2.3", + isDevelopment: false, + releaseChannel: undefined, + surface: "web", + serviceInstanceId: "instance-1", + navigator: undefined, + ...overrides, + }); + +describe("clientTracingResource", () => { + it.each([ + ["a dev build", { isDevelopment: true }, "development", "dev"], + ["a stable build", {}, "production", "stable"], + ["an unrecognized channel", { releaseChannel: "unrecognized" }, "production", "stable"], + ["a nightly build", { version: "1.2.3-nightly.20261023.4" }, "production", "nightly"], + ["a PR preview", { releaseChannel: "preview" }, "staging", "preview"], + ["a preview version", { version: "1.2.3-preview.20261023.1" }, "staging", "preview"], + ] as const)("reports %s as %s on the %s channel", (_name, overrides, environment, channel) => { + const { attributes } = resource(overrides); + expect(attributes["deployment.environment.name"]).toBe(environment); + expect(attributes["t3code.release.channel"]).toBe(channel); + }); + + it("keeps the version untouched so the server version comparison still works", () => { + expect(resource({ releaseChannel: "preview" }).serviceVersion).toBe("1.2.3"); + }); + + it("names the desktop window and the browser as separate surfaces", () => { + expect(resource({ surface: "desktop" }).attributes["t3code.client.surface"]).toBe("desktop"); + expect(resource().attributes["t3code.client.surface"]).toBe("web"); + }); + + it("describes the browser when one is present", () => { + const { attributes } = resource({ + navigator: { userAgent: "UA", language: "en-US" }, + }); + expect(attributes["user_agent.original"]).toBe("UA"); + expect(attributes["browser.language"]).toBe("en-US"); + }); +}); diff --git a/apps/web/src/observability/clientResource.ts b/apps/web/src/observability/clientResource.ts new file mode 100644 index 000000000000..63a44f92ef6e --- /dev/null +++ b/apps/web/src/observability/clientResource.ts @@ -0,0 +1,69 @@ +import { releaseResourceAttributes } from "@t3tools/shared/releaseResource"; + +interface NavigatorUserAgentData { + readonly platform: string; + readonly mobile: boolean; + readonly brands: ReadonlyArray<{ readonly brand: string; readonly version: string }>; +} + +type BrowserNavigator = Pick & { + readonly userAgentData?: NavigatorUserAgentData; +}; + +/** + * The same `browser.*` and `user_agent.original` attributes the OpenTelemetry + * 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 = ( + navigator: BrowserNavigator | undefined, +): Record => { + if (navigator === undefined) return {}; + const { userAgentData } = navigator; + return { + "user_agent.original": navigator.userAgent, + "process.runtime.name": "browser", + "process.runtime.version": navigator.userAgent, + "browser.language": navigator.language, + ...(userAgentData && { + "browser.platform": userAgentData.platform, + "browser.mobile": userAgentData.mobile, + "browser.brands": userAgentData.brands.map(({ brand, version }) => `${brand} ${version}`), + }), + }; +}; + +/** + * The UI is a build of its own, so it reports its own release identity rather + * than the server's. Vercel PR previews cannot carry it in `version` without + * breaking the version-skew comparison against the server, so they mark + * themselves with `VITE_RELEASE_CHANNEL=preview`. + */ +export const clientTracingResource = (input: { + readonly version: string; + readonly isDevelopment: boolean; + readonly releaseChannel: string | undefined; + readonly surface: "desktop" | "web"; + readonly serviceInstanceId: string; + readonly navigator: BrowserNavigator | undefined; +}): { + readonly serviceName: string; + readonly serviceVersion: string; + readonly attributes: Record; +} => ({ + serviceName: "t3code-web", + serviceVersion: input.version, + attributes: { + "service.namespace": "t3code", + "service.instance.id": input.serviceInstanceId, + ...releaseResourceAttributes({ + version: input.version, + isDevelopment: input.isDevelopment, + operatorAttributes: {}, + channel: input.releaseChannel?.trim().toLowerCase() === "preview" ? "preview" : undefined, + }), + "t3code.client.surface": input.surface, + ...browserResourceAttributes(input.navigator), + }, +}); diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 07046811f992..46c6aa5bcc76 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -9,52 +9,21 @@ 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 { clientTracingResource } from "./clientResource"; import { serviceInstanceId } from "./serviceInstance"; import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; 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, 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 {}; - const userAgentData = (navigator as Navigator & { userAgentData?: NavigatorUserAgentData }) - .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, - "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.instance.id": serviceInstanceId, - "deployment.environment.name": import.meta.env.DEV ? "development" : "production", - "t3code.client.surface": isElectron ? "desktop" : "web", - ...browserResourceAttributes(), - }, -}; +const CLIENT_TRACING_RESOURCE = clientTracingResource({ + version: APP_VERSION, + isDevelopment: import.meta.env.DEV, + releaseChannel: import.meta.env.VITE_RELEASE_CHANNEL, + surface: isElectron ? "desktop" : "web", + serviceInstanceId, + navigator: typeof navigator === "undefined" ? undefined : navigator, +}); const layerDelegateRuntime = Layer.mergeAll( PrimaryEnvironmentHttpLayer.layer, diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index acc73a399a3c..37a133afafee 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -7,6 +7,7 @@ interface ImportMetaEnv { readonly VITE_WS_URL: string; readonly VITE_HOSTED_APP_URL: string; readonly VITE_HOSTED_APP_CHANNEL: string; + readonly VITE_RELEASE_CHANNEL: string; readonly VITE_CLERK_PUBLISHABLE_KEY: string; readonly VITE_CLERK_JWT_TEMPLATE: string; readonly VITE_CLERK_CLI_OAUTH_CLIENT_ID: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d039a5428c57..abef99cd9cd1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -42,6 +42,7 @@ const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || ""; const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; +const configuredReleaseChannel = process.env.VITE_RELEASE_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; const configuredDesktopUpdateRepository = process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || @@ -251,6 +252,7 @@ export default defineConfig(() => { "import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY": JSON.stringify( configuredDesktopUpdateRepository, ), + "import.meta.env.VITE_RELEASE_CHANNEL": JSON.stringify(configuredReleaseChannel), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, resolve: { 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 96ba82edf6de..23b017c60eef 100644 --- a/docs/fork/0026-telemetry-says-which-app-sent-it.md +++ b/docs/fork/0026-telemetry-says-which-app-sent-it.md @@ -11,12 +11,16 @@ `t3code.server.managed_by`. - Query every T3 Code signal with the resource attributes a collector, dashboard, or vendor already understands: `service.version`, - `service.instance.id`, `deployment.environment.name`, `process.runtime.*`, + `service.instance.id`, `deployment.environment.name`, + `t3code.release.channel`, `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. +- Set `deployment.environment.name`, `t3code.release.channel`, or + `service.instance.id` through `OTEL_RESOURCE_ATTRIBUTES` and have the server + and desktop app keep it. +- Tell a hosted UI build, a PR preview, and the server it talks to apart, + since each reports the environment and release channel of its own build. ## Why @@ -40,13 +44,16 @@ Upstream has taken the `t3code.`-prefixed keys (`t3code.client.surface`, `t3code.server.managed_by`, `t3code.component`) and the web UI's `deployment.environment.name`. What the fork still carries is dropping `service.runtime`, plus `service.instance.id`, the server and desktop -`deployment.environment.name`, and the web UI's `process.runtime.*`, -`user_agent.original`, and `browser.*`. Dashboards or +`deployment.environment.name`, the web UI's `process.runtime.*`, +`user_agent.original`, and `browser.*`, and the derivation of +`deployment.environment.name` from the build's release channel, with the new +`t3code.release.channel` on every process. The web UI's value no longer follows +only the Vite dev server, and preview builds report `staging`. Dashboards or saved queries that filter on `service.runtime` 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 build variant as `deployment.environment.name` and `t3code.release.channel`, +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 3a1fb6a5d3e4..6204f4460fe7 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -626,16 +626,24 @@ 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 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 +`OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=staging`. Every process reports the identity +of its own build, never that of the process it talks to: the server, the desktop main process, the +UI, and the mobile app each carry their own `service.version`, `t3code.release.channel`, and +`deployment.environment.name`. The channel is `dev` under a development run, and otherwise `stable`, +`nightly`, or `preview`, read from the version (`X-nightly.YYYYMMDD.N`, `X-preview.YYYYMMDD.N`). +The environment follows it: `development`, `production`, and `staging` for `preview`, which is an +internal build with no update feed. The server and the desktop main process also report a random +`service.instance.id` per process. A value for any of these in `OTEL_RESOURCE_ATTRIBUTES` replaces it. Attributes specific to T3 Code use the `t3code.` prefix. 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 -full user agent. Its `deployment.environment.name` follows the build: `development` under the Vite dev -server and `production` otherwise. The server's `t3code.server.managed_by` is `desktop` when the -desktop app launched it and `standalone` otherwise. +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 full user agent. The hosted +UI derives its channel from `APP_VERSION`, and Vercel PR previews mark themselves with +`VITE_RELEASE_CHANNEL=preview` so they report `staging` without changing the version the UI +compares against the server's. The mobile app takes both values from its build variant: +`development`, `preview` (`staging`), or `production`. The server's `t3code.server.managed_by` is +`desktop` when the desktop app launched it and `standalone` otherwise. Every process T3 Code launches for a provider, such as Claude Code or Codex, inherits `t3code.launched=true` in `OTEL_RESOURCE_ATTRIBUTES`, added after any attributes already there. diff --git a/packages/shared/package.json b/packages/shared/package.json index 5fa05fda74bb..62a8f775a4c5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -67,6 +67,10 @@ "types": "./src/logging.ts", "import": "./src/logging.ts" }, + "./releaseResource": { + "types": "./src/releaseResource.ts", + "import": "./src/releaseResource.ts" + }, "./observability": { "types": "./src/observability.ts", "import": "./src/observability.ts" diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 2d74fef8db27..f0f0ddd24926 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -28,26 +28,39 @@ import { } 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 }); + it("shares one instance id per process and reports the release identity of the build", () => { + const first = nodeProcessResourceAttributes({ + version: "1.2.3", + operatorAttributes: {}, + isDevelopment: true, + }); + const second = nodeProcessResourceAttributes({ + version: "1.2.3", + operatorAttributes: {}, + isDevelopment: false, + }); expect(first["service.instance.id"]).toBe(second["service.instance.id"]); expect(first["deployment.environment.name"]).toBe("development"); + expect(first["t3code.release.channel"]).toBe("dev"); expect(second["deployment.environment.name"]).toBe("production"); + expect(second["t3code.release.channel"]).toBe("stable"); }); it("keeps values an operator set in OTEL_RESOURCE_ATTRIBUTES", () => { const attributes = nodeProcessResourceAttributes({ + version: "1.2.3", operatorAttributes: { "service.instance.id": "pod-7", "deployment.environment.name": "staging", + "t3code.release.channel": "nightly", }, isDevelopment: true, }); expect(attributes["service.instance.id"]).toBe("pod-7"); expect(attributes["deployment.environment.name"]).toBe("staging"); + expect(attributes["t3code.release.channel"]).toBe("nightly"); }); }); diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 14e0b1cd38c2..77a32b041306 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -15,6 +15,7 @@ import * as Tracer from "effect/Tracer"; import { OtlpResource, OtlpTracer, OtlpSerialization } from "effect/observability"; import { makeAsyncRotatingFileSink } from "./logging.ts"; +import { releaseResourceAttributes } from "./releaseResource.ts"; export const OtlpProtocol = Schema.Literals(["http/json", "http/protobuf"]); export type OtlpProtocol = typeof OtlpProtocol.Type; @@ -56,14 +57,13 @@ export const nodeProcessRuntimeAttributes = (): Record => ({ * operator set there are carried over by hand to keep winning. */ export const nodeProcessResourceAttributes = (input: { + readonly version: string; 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"), + ...releaseResourceAttributes(input), ...nodeProcessRuntimeAttributes(), }); diff --git a/packages/shared/src/releaseResource.test.ts b/packages/shared/src/releaseResource.test.ts new file mode 100644 index 000000000000..7243fcd7a2e7 --- /dev/null +++ b/packages/shared/src/releaseResource.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { releaseResourceAttributes } from "./releaseResource.ts"; + +describe("releaseResourceAttributes", () => { + it.each([ + ["1.2.3", true, "dev", "development"], + ["1.2.3-nightly.20261023.4", true, "dev", "development"], + ["1.2.3-preview.20261023.1", true, "dev", "development"], + ["1.2.3", false, "stable", "production"], + ["1.2.3-nightly.20261023.4", false, "nightly", "production"], + ["1.2.3-preview.20261023.1", false, "preview", "staging"], + ["1.2.3-rc.1", false, "stable", "production"], + ] as const)( + "derives %s (development: %s) as %s in %s", + (version, isDevelopment, channel, env) => { + expect(releaseResourceAttributes({ version, isDevelopment, operatorAttributes: {} })).toEqual( + { + "t3code.release.channel": channel, + "deployment.environment.name": env, + }, + ); + }, + ); + + it("lets an operator value win for each key independently", () => { + expect( + releaseResourceAttributes({ + version: "1.2.3", + isDevelopment: false, + operatorAttributes: { "deployment.environment.name": "staging" }, + }), + ).toEqual({ "t3code.release.channel": "stable", "deployment.environment.name": "staging" }); + expect( + releaseResourceAttributes({ + version: "1.2.3", + isDevelopment: false, + operatorAttributes: { "t3code.release.channel": "nightly" }, + }), + ).toEqual({ "t3code.release.channel": "nightly", "deployment.environment.name": "production" }); + }); + + it("ignores empty or non-string operator values", () => { + expect( + releaseResourceAttributes({ + version: "1.2.3", + isDevelopment: false, + operatorAttributes: { "deployment.environment.name": "", "t3code.release.channel": 3 }, + }), + ).toEqual({ "t3code.release.channel": "stable", "deployment.environment.name": "production" }); + }); + + it("lets a build mark itself as a preview without changing its version", () => { + expect( + releaseResourceAttributes({ + version: "1.2.3", + isDevelopment: false, + operatorAttributes: {}, + channel: "preview", + }), + ).toEqual({ "t3code.release.channel": "preview", "deployment.environment.name": "staging" }); + }); +}); diff --git a/packages/shared/src/releaseResource.ts b/packages/shared/src/releaseResource.ts new file mode 100644 index 000000000000..4fd91da32a48 --- /dev/null +++ b/packages/shared/src/releaseResource.ts @@ -0,0 +1,37 @@ +import { type CliReleaseChannel, cliReleaseChannelOf } from "./cliRelease.ts"; + +export type ReleaseChannel = "dev" | CliReleaseChannel; + +/** + * The release identity of the build that produced a signal. `preview` builds + * are internal and have no update feed, so they report `staging`. An operator + * value in `OTEL_RESOURCE_ATTRIBUTES` wins for either key, and `channel` + * replaces the one the version implies for builds that cannot carry it there. + */ +export const releaseResourceAttributes = (input: { + readonly version: string; + readonly isDevelopment: boolean; + readonly operatorAttributes: Readonly>; + readonly channel?: CliReleaseChannel | undefined; +}): { + readonly "t3code.release.channel": string; + readonly "deployment.environment.name": string; +} => { + const releaseChannel: ReleaseChannel = input.isDevelopment + ? "dev" + : (input.channel ?? cliReleaseChannelOf(input.version)); + const environment = + releaseChannel === "dev" + ? "development" + : releaseChannel === "preview" + ? "staging" + : "production"; + const operatorValue = (key: string) => { + const value = input.operatorAttributes[key]; + return typeof value === "string" && value !== "" ? value : undefined; + }; + return { + "t3code.release.channel": operatorValue("t3code.release.channel") ?? releaseChannel, + "deployment.environment.name": operatorValue("deployment.environment.name") ?? environment, + }; +}; From 0001ee76f490f2c0f62f53216310082574306eb4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 24 Oct 2026 00:38:10 -0400 Subject: [PATCH 3/4] fix(desktop): WSL servers keep the resource attributes an operator set Signed-off-by: Yordis Prieto --- .../src/backend/DesktopBackendConfiguration.test.ts | 7 ++++++- apps/desktop/src/backend/DesktopBackendConfiguration.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 8d70ab4c4791..7464d8fbe9e2 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -678,7 +678,7 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolveWsl carries the kill switch into the distro", () => + it.effect("resolveWsl carries the kill switch and resource attributes into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -688,10 +688,12 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousDisabled = process.env.OTEL_SDK_DISABLED; const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; + const previousResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; try { delete process.env.WSLENV; process.env.OTEL_SDK_DISABLED = "true"; process.env.T3CODE_TELEMETRY_ENABLED = "false"; + process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=staging"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -701,6 +703,8 @@ describe("DesktopBackendConfiguration", () => { assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); + assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); }).pipe( Effect.provide( layerBackendConfiguration().pipe( @@ -722,6 +726,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OTEL_SDK_DISABLED", previousDisabled); restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); + restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index dadbe8acc743..2a5f50779af6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -113,6 +113,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_TRACES_EXPORTER", "OTEL_METRICS_EXPORTER", "OTEL_LOGS_EXPORTER", + "OTEL_RESOURCE_ATTRIBUTES", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; From cd0d685aecdb3aa6742fc1e34398bd9e6372f846 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 24 Oct 2026 00:49:47 -0400 Subject: [PATCH 4/4] docs(observability): resource attribute overrides only reach Node processes Signed-off-by: Yordis Prieto --- docs/fork/0026-telemetry-says-which-app-sent-it.md | 9 ++++++--- docs/operations/observability.md | 10 ++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) 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 23b017c60eef..078d533aafaf 100644 --- a/docs/fork/0026-telemetry-says-which-app-sent-it.md +++ b/docs/fork/0026-telemetry-says-which-app-sent-it.md @@ -51,9 +51,12 @@ Upstream has taken the `t3code.`-prefixed keys (`t3code.client.surface`, only the Vite dev server, and preview builds report `staging`. Dashboards or saved queries that filter on `service.runtime` 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 as `deployment.environment.name` and `t3code.release.channel`, -so a query on `mobile-production` becomes two filters. +Mobile relay traces now report `t3code.client.surface` as `mobile` and map the +build variant onto `deployment.environment.name` and `t3code.release.channel`: +`development` becomes `development`/`dev`, `preview` becomes `staging`/`preview`, +and `production` becomes `production`/`stable`. A query on `mobile-production` +becomes `deployment.environment.name=production` plus +`t3code.release.channel=stable`. 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 6204f4460fe7..93e39eb25703 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -633,16 +633,18 @@ UI, and the mobile app each carry their own `service.version`, `t3code.release.c `nightly`, or `preview`, read from the version (`X-nightly.YYYYMMDD.N`, `X-preview.YYYYMMDD.N`). The environment follows it: `development`, `production`, and `staging` for `preview`, which is an internal build with no update feed. The server and the desktop main process also report a random -`service.instance.id` per process. A value for any of these in `OTEL_RESOURCE_ATTRIBUTES` replaces -it. +`service.instance.id` per process. On the server and the desktop main process, a value for any of +these in `OTEL_RESOURCE_ATTRIBUTES` replaces it. The UI and the mobile app never read that variable; +their values come from build inputs. Attributes specific to T3 Code use the `t3code.` prefix. 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 full user agent. The hosted UI derives its channel from `APP_VERSION`, and Vercel PR previews mark themselves with `VITE_RELEASE_CHANNEL=preview` so they report `staging` without changing the version the UI -compares against the server's. The mobile app takes both values from its build variant: -`development`, `preview` (`staging`), or `production`. The server's `t3code.server.managed_by` is +compares against the server's. The mobile app takes both values from its build variant: the +`development` variant reports `development` and `dev`, `preview` reports `staging` and `preview`, +and `production` reports `production` and `stable`. The server's `t3code.server.managed_by` is `desktop` when the desktop app launched it and `standalone` otherwise. Every process T3 Code launches for a provider, such as Claude Code or Codex, inherits