Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/desktop/src/app/DesktopObservability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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",
},
}),
),
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/app/DesktopObservability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
import {
makeLocalFileTracer,
makeTraceSink,
nodeProcessResourceAttributes,
otlpSerializationLayer,
type SignalExport,
} from "@t3tools/shared/observability";
Expand Down Expand Up @@ -627,10 +628,13 @@ 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",
...nodeProcessResourceAttributes({
operatorAttributes: endpoints.resourceAttributes,
isDevelopment: environment.isDevelopment,
}),
},
};

Expand Down
12 changes: 11 additions & 1 deletion apps/mobile/src/features/observability/tracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand All @@ -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"}',
);
}),
),
);
Expand All @@ -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)));
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/features/observability/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface TracingConfig {

export interface TracingResource {
readonly serviceVersion?: string;
readonly serviceInstanceId: string;
readonly appVariant: string;
}

Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/cloud/relayTracing.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
nodeProcessRuntimeAttributes,
processServiceInstanceId,
} from "@t3tools/shared/observability";
import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";

import { resolveRelayClientTracingConfig } from "./publicConfig.ts";
Expand All @@ -8,14 +12,16 @@ export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer(
relayClientTracingConfig,
{
serviceName: "t3code-server",
runtime: "node",
serviceInstanceId: processServiceInstanceId(),
attributes: nodeProcessRuntimeAttributes(),
client: "headless-cli",
},
);

export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, {
serviceName: "t3code-server",
runtime: "node",
serviceInstanceId: processServiceInstanceId(),
attributes: nodeProcessRuntimeAttributes(),
client: "environment-server",
component: "relay-broker",
});
15 changes: 12 additions & 3 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
nodeProcessResourceAttributes,
type SignalExport,
} from "@t3tools/shared/observability";
import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";

export const DEFAULT_PORT = 3773;
Expand Down Expand Up @@ -118,10 +123,14 @@ 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,
"t3code.server.managed_by": config.mode === "desktop" ? "desktop" : "standalone",
...nodeProcessResourceAttributes({
operatorAttributes: config.otelEnvironment.resourceAttributes,
isDevelopment: config.devUrl !== undefined,
}),
},
});

Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/serverLogger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ 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 ?? "", "t3code.server.managed_by");
assert.include(request?.body ?? "", "process.runtime.name");
assert.include(request?.body ?? "", "service.instance.id");
}),
);

Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/lib/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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));

Expand Down
39 changes: 35 additions & 4 deletions apps/web/src/observability/clientTracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,51 @@ 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";

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<string, unknown> => {
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.runtime": "t3-web",
"service.mode": isElectron ? "electron" : "browser",
"service.version": APP_VERSION,
"service.instance.id": serviceInstanceId,
"t3code.client.surface": isElectron ? "desktop" : "web",
...browserResourceAttributes(),
},
} as const;
};

const delegateRuntimeLayer = Layer.mergeAll(
primaryEnvironmentHttpLayer,
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/observability/serviceInstance.ts
Original file line number Diff line number Diff line change
@@ -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();
51 changes: 51 additions & 0 deletions docs/fork/0026-telemetry-says-which-app-sent-it.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 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
`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
`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.*`,
`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

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 `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`, `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
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.
2 changes: 2 additions & 0 deletions docs/fork/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
17 changes: 13 additions & 4 deletions docs/operations/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -587,10 +587,19 @@ 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 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
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
on stdout only.
Expand Down
16 changes: 14 additions & 2 deletions infra/relay/src/observability.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -212,6 +214,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 ??= NodeCrypto.randomUUID());

export const makeRelayTraceLayer = (input: {
readonly tracesEndpoint: string;
readonly tracesDatasetName: string;
Expand All @@ -225,8 +235,10 @@ export const makeRelayTraceLayer = (input: {
serviceName: "t3code-relay",
attributes: {
"service.namespace": "t3code",
"service.runtime": "cloudflare-worker",
"service.component": "relay",
"service.instance.id": isolateServiceInstanceId(),
"cloud.provider": "cloudflare",
"cloud.platform": "cloudflare.workers",
"t3code.component": "relay",
},
},
headers: {
Expand Down
Loading
Loading