authorclover caruso <git@paperclover.net> 2026-10-16 12:18:30-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log70f13f54049d9837fe63d489015fd50cd55b98cb
treec887d358a89f02a3dc16c8a058384b2c725d0c83
parent7caaa8c6b763db8a1e8934de235f956e13ff789d
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

fix: remove product analytics and hosted trace exports

Clover wants the fork to contact T3 services only for explicitly used T3 Connect features. Remove server PostHog analytics, account identity collection, hosted relay tracing, analytics-only client metadata, and Clerk telemetry across desktop, web, and mobile. Delete unused exporters and configuration instead of retaining inert services. Preserve connection authentication, registration, tunnels, and provider traffic, along with local diagnostics and explicitly configured OTLP exports. Keep ordinary authenticated requests on the local tracer. Assisted-by: gpt-6.1-sol

78 files changed, 246 insertions(+), 3519 deletions(-)

apps/desktop/src/backend/DesktopBackendConfiguration.test.ts-5
......@@ -996,11 +996,9 @@ describe("DesktopBackendConfiguration", () => {
996996
997997 const previousWslEnv = process.env.WSLENV;
998998 const previousDisabled = process.env.OTEL_SDK_DISABLED;
999 const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED;
1000999 try {
10011000 delete process.env.WSLENV;
10021001 process.env.OTEL_SDK_DISABLED = "true";
1003 process.env.T3CODE_TELEMETRY_ENABLED = "false";
10041002
10051003 yield* Effect.gen(function* () {
10061004 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
......@@ -1008,8 +1006,6 @@ describe("DesktopBackendConfiguration", () => {
10081006
10091007 assert.equal(config.env.OTEL_SDK_DISABLED, "true");
10101008 assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED");
1011 assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false");
1012 assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED");
10131009 }).pipe(
10141010 Effect.provide(
10151011 DesktopBackendConfiguration.layer.pipe(
......@@ -1030,7 +1026,6 @@ describe("DesktopBackendConfiguration", () => {
10301026 } finally {
10311027 restoreEnv("WSLENV", previousWslEnv);
10321028 restoreEnv("OTEL_SDK_DISABLED", previousDisabled);
1033 restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry);
10341029 }
10351030 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
10361031 );
apps/desktop/src/backend/DesktopBackendConfiguration.ts-1
......@@ -102,7 +102,6 @@ const DESKTOP_BACKEND_ENV_NAMES = [
102102const WSL_FORWARDED_ENV_NAMES = [
103103 "OPENAI_API_KEY",
104104 "ANTHROPIC_API_KEY",
105 "T3CODE_TELEMETRY_ENABLED",
106105 // Otherwise the WSL server keeps exporting to endpoints from the bootstrap.
107106 "T3CODE_OTEL_SDK_DISABLED",
108107 "OTEL_SDK_DISABLED",
apps/desktop/src/shell/DesktopShellEnvironment.test.ts-2
......@@ -357,13 +357,11 @@ describe("DesktopShellEnvironment", () => {
357357 envOutput({
358358 PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
359359 SSH_AUTH_SOCK: "/tmp/secretive.sock",
360 T3CODE_TELEMETRY_ENABLED: "false",
361360 }),
362361 });
363362
364363 assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin");
365364 assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
366 assert.equal(env.T3CODE_TELEMETRY_ENABLED, "false");
367365 }),
368366 );
369367
apps/desktop/src/shell/DesktopShellEnvironment.ts-3
......@@ -89,7 +89,6 @@ const LOGIN_SHELL_ENV_NAMES = [
8989 "SSL_CERT_FILE",
9090 "SSL_CERT_DIR",
9191 "NODE_EXTRA_CA_CERTS",
92 "T3CODE_TELEMETRY_ENABLED",
9392 "__MISE_DIFF",
9493] as const;
9594const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
......@@ -463,8 +462,6 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
463462 "SSL_CERT_FILE",
464463 "SSL_CERT_DIR",
465464 "NODE_EXTRA_CA_CERTS",
466 // The telemetry opt-out is documented as a shell variable; GUI launches never see it.
467 "T3CODE_TELEMETRY_ENABLED",
468465 ] as const) {
469466 if (!config.env[name] && shellEnvironment[name]) {
470467 config.env[name] = shellEnvironment[name];
apps/mobile/app.config.ts-5
......@@ -485,11 +485,6 @@ const config: ExpoConfig = {
485485 EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID,
486486 EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID,
487487 EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME,
488 observability: {
489 tracesUrl: repoEnv.EXPO_PUBLIC_OTLP_TRACES_URL ?? "https://api.axiom.co/v1/traces",
490 tracesDataset: repoEnv.EXPO_PUBLIC_OTLP_TRACES_DATASET ?? null,
491 tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null,
492 },
493488 eas: {
494489 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
495490 },
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+1-1
......@@ -229,7 +229,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) {
229229 }
230230
231231 return (
232 <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
232 <ClerkProvider telemetry={false} publishableKey={publishableKey} tokenCache={tokenCache}>
233233 <CloudAuthAvailableContext.Provider value>
234234 <CloudAuthBridge>{props.children}</CloudAuthBridge>
235235 </CloudAuthAvailableContext.Provider>
apps/mobile/src/features/cloud/publicConfig.test.ts-62
......@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vite-plus/test";
22
33import {
44 CloudPublicConfigMissingError,
5 hasTracingPublicConfig,
65 resolveCloudPublicConfig,
76 resolveRelayClerkTokenOptions,
87} from "./publicConfig";
......@@ -31,11 +30,6 @@ describe("resolveCloudPublicConfig", () => {
3130 relay: {
3231 url: null,
3332 },
34 observability: {
35 tracesUrl: null,
36 tracesDataset: null,
37 tracesToken: null,
38 },
3933 });
4034 });
4135
......@@ -44,11 +38,6 @@ describe("resolveCloudPublicConfig", () => {
4438 resolveCloudPublicConfig({
4539 clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " },
4640 relay: { url: " https://relay.example.test/// " },
47 observability: {
48 tracesUrl: " https://api.axiom.co/v1/traces ",
49 tracesDataset: " mobile-traces ",
50 tracesToken: " public-ingest-token ",
51 },
5241 }),
5342 ).toEqual({
5443 clerk: {
......@@ -58,11 +47,6 @@ describe("resolveCloudPublicConfig", () => {
5847 relay: {
5948 url: "https://relay.example.test",
6049 },
61 observability: {
62 tracesUrl: "https://api.axiom.co/v1/traces",
63 tracesDataset: "mobile-traces",
64 tracesToken: "public-ingest-token",
65 },
6650 });
6751 });
6852
......@@ -80,52 +64,6 @@ describe("resolveCloudPublicConfig", () => {
8064 relay: {
8165 url: null,
8266 },
83 observability: {
84 tracesUrl: null,
85 tracesDataset: null,
86 tracesToken: null,
87 },
8867 });
8968 });
90
91 it("rejects an insecure traces URL", () => {
92 expect(
93 resolveCloudPublicConfig({
94 observability: {
95 tracesUrl: "http://api.axiom.co/v1/traces",
96 tracesDataset: "mobile-traces",
97 tracesToken: "public-ingest-token",
98 },
99 }).observability,
100 ).toEqual({
101 tracesUrl: null,
102 tracesDataset: "mobile-traces",
103 tracesToken: "public-ingest-token",
104 });
105 });
106
107 it("keeps tracing disabled unless every public tracing value is configured", () => {
108 expect(hasTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false);
109 expect(
110 hasTracingPublicConfig(
111 resolveCloudPublicConfig({
112 observability: {
113 tracesUrl: "https://api.axiom.co/v1/traces",
114 tracesDataset: "mobile-traces",
115 },
116 }),
117 ),
118 ).toBe(false);
119 expect(
120 hasTracingPublicConfig(
121 resolveCloudPublicConfig({
122 observability: {
123 tracesUrl: "https://api.axiom.co/v1/traces",
124 tracesDataset: "mobile-traces",
125 tracesToken: "public-ingest-token",
126 },
127 }),
128 ),
129 ).toBe(true);
130 });
13169});
apps/mobile/src/features/cloud/publicConfig.ts-41
......@@ -22,11 +22,6 @@ export interface CloudPublicConfig {
2222 readonly relay: {
2323 readonly url: string | null;
2424 };
25 readonly observability: {
26 readonly tracesUrl: string | null;
27 readonly tracesDataset: string | null;
28 readonly tracesToken: string | null;
29 };
3025}
3126
3227type UntrustedSection<T> = {
......@@ -43,19 +38,6 @@ function trimNonEmpty(value: unknown): string | null {
4338 return typeof value === "string" && value.trim() ? value.trim() : null;
4439}
4540
46function normalizeSecureUrl(value: unknown): string | null {
47 const raw = trimNonEmpty(value);
48 if (raw === null) {
49 return null;
50 }
51 try {
52 const url = new URL(raw);
53 return url.protocol === "https:" ? url.toString() : null;
54 } catch {
55 return null;
56 }
57}
58
5941export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) {
6042 return {
6143 clerk: {
......@@ -65,11 +47,6 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig
6547 relay: {
6648 url: normalizeSecureRelayUrl(trimNonEmpty(extra?.relay?.url) ?? ""),
6749 },
68 observability: {
69 tracesUrl: normalizeSecureUrl(extra?.observability?.tracesUrl),
70 tracesDataset: trimNonEmpty(extra?.observability?.tracesDataset),
71 tracesToken: trimNonEmpty(extra?.observability?.tracesToken),
72 },
7350 } satisfies CloudPublicConfig;
7451}
7552
......@@ -78,24 +55,6 @@ export function hasCloudPublicConfig(): boolean {
7855 return Boolean(config.clerk.publishableKey && config.clerk.jwtTemplate && config.relay.url);
7956}
8057
81type Configured<T> = {
82 readonly [Key in keyof T]: NonNullable<T[Key]>;
83};
84
85type TracingPublicConfig = Omit<CloudPublicConfig, "observability"> & {
86 readonly observability: Configured<CloudPublicConfig["observability"]>;
87};
88
89export function hasTracingPublicConfig(
90 config: CloudPublicConfig = resolveCloudPublicConfig(),
91): config is TracingPublicConfig {
92 return Boolean(
93 config.observability.tracesUrl &&
94 config.observability.tracesDataset &&
95 config.observability.tracesToken,
96 );
97}
98
9958export function resolveRelayClerkTokenOptions() {
10059 const { jwtTemplate } = resolveCloudPublicConfig().clerk;
10160 if (!jwtTemplate) {
apps/mobile/src/features/observability/tracing.test.ts deleted-97
......@@ -1,97 +0,0 @@
1import { expect, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { vi } from "vite-plus/test";
6
7import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";
8import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
9
10import * as Tracing from "./tracing";
11
12vi.mock("expo-constants", () => ({
13 default: {
14 expoConfig: {
15 extra: {},
16 },
17 },
18}));
19
20it.effect("exports spans through the scoped mobile OTLP layer", () => {
21 const fetchFn = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 }));
22 const layerTracing = Tracing.layerFromConfig(
23 {
24 tracesUrl: "https://api.axiom.test/v1/traces",
25 tracesDataset: "mobile-traces",
26 tracesToken: "public-ingest-token",
27 },
28 {
29 appVariant: "test",
30 serviceVersion: "1.2.3",
31 },
32 ).pipe(Layer.provide(layerRemoteHttpClient(fetchFn)));
33 const layerTracedApplication = Layer.effectDiscard(
34 Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing),
35 ).pipe(Layer.provide(layerTracing));
36
37 return Effect.gen(function* () {
38 yield* Layer.build(layerTracedApplication);
39
40 expect(fetchFn).not.toHaveBeenCalled();
41 }).pipe(
42 Effect.scoped,
43 Effect.andThen(
44 Effect.sync(() => {
45 expect(fetchFn).toHaveBeenCalledOnce();
46 const [url, init] = fetchFn.mock.calls[0]!;
47 expect(String(url)).toBe("https://api.axiom.test/v1/traces");
48 expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token");
49 expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces");
50 expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span");
51 }),
52 ),
53 );
54});
55
56it.effect("does not let OTLP serialization failures alter application effects", () => {
57 const fetchFn = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 }));
58 const layerTracing = Tracing.layerFromConfig(
59 {
60 tracesUrl: "https://api.axiom.test/v1/traces",
61 tracesDataset: "mobile-traces",
62 tracesToken: "public-ingest-token",
63 },
64 {
65 appVariant: "test",
66 serviceVersion: "1.2.3",
67 },
68 ).pipe(Layer.provide(layerRemoteHttpClient(fetchFn)));
69 const failure = { durationNanos: 1n };
70 const layerTracedApplication = Layer.effectDiscard(
71 Effect.fail(failure).pipe(
72 Effect.withSpan("mobile.test.failed-span"),
73 withRelayClientTracing,
74 Effect.exit,
75 Effect.flatMap((exit) => {
76 const reason = exit._tag === "Failure" ? exit.cause.reasons[0] : undefined;
77 return reason && Cause.isFailReason(reason)
78 ? Effect.sync(() => {
79 expect(reason.error).toBe(failure);
80 })
81 : Effect.die(new Error("Expected the original typed failure."));
82 }),
83 ),
84 ).pipe(Layer.provide(layerTracing));
85
86 return Layer.build(layerTracedApplication).pipe(
87 Effect.scoped,
88 Effect.andThen(
89 Effect.sync(() => {
90 expect(fetchFn).toHaveBeenCalledOnce();
91 expect(new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array)).toContain(
92 "mobile.test.failed-span",
93 );
94 }),
95 ),
96 );
97});
apps/mobile/src/features/observability/tracing.ts deleted-41
......@@ -1,41 +0,0 @@
1import Constants from "expo-constants";
2import * as RelayTracing from "@t3tools/shared/relayTracing";
3
4import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig";
5
6export interface TracingConfig {
7 readonly tracesUrl: string;
8 readonly tracesDataset: string;
9 readonly tracesToken: string;
10}
11
12export interface TracingResource {
13 readonly serviceVersion?: string;
14 readonly appVariant: string;
15}
16
17export function resolveTracingConfig(): TracingConfig | null {
18 const config = resolveCloudPublicConfig();
19 if (!hasTracingPublicConfig(config)) {
20 return null;
21 }
22 const { tracesUrl, tracesDataset, tracesToken } = config.observability;
23 return { tracesUrl, tracesDataset, tracesToken };
24}
25
26export function layerFromConfig(config: TracingConfig | null, resource: TracingResource) {
27 return RelayTracing.layer(config, {
28 serviceName: "t3code-mobile",
29 serviceVersion: resource.serviceVersion,
30 runtime: "react-native",
31 client: `mobile-${resource.appVariant}`,
32 });
33}
34
35export const layer = layerFromConfig(resolveTracingConfig(), {
36 serviceVersion: Constants.expoConfig?.version,
37 appVariant:
38 typeof Constants.expoConfig?.extra?.appVariant === "string"
39 ? Constants.expoConfig.extra.appVariant
40 : "unknown",
41});
apps/mobile/src/lib/authClientMetadata.ts-5
......@@ -3,9 +3,6 @@ import * as Device from "expo-device";
33import { Platform } from "react-native";
44
55export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata {
6 const osMajorVersion = Number.parseInt(Device.osVersion?.split(".")[0] ?? "", 10);
7 const deviceModel = Device.modelName?.trim();
8
96 return {
107 label: "The Knife Mobile",
118 deviceType:
......@@ -15,8 +12,6 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM
1512 ? "mobile"
1613 : "unknown",
1714 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
18 ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}),
19 ...(deviceModel ? { deviceModel } : {}),
2015 surface: "mobile",
2116 ...(appVersion ? { appVersion } : {}),
2217 };
apps/mobile/src/lib/connection.test.ts+1-13
......@@ -14,8 +14,6 @@ const mobileDevice = vi.hoisted(() => ({
1414 DESKTOP: 3,
1515 TV: 4,
1616 },
17 osVersion: "18.4.1",
18 modelName: "iPhone 15 Pro",
1917}));
2018
2119vi.mock("./runtime", () => ({
......@@ -34,8 +32,6 @@ describe("mobile remote connection records", () => {
3432 afterEach(() => {
3533 mobilePlatform.OS = "ios";
3634 mobileDevice.deviceType = mobileDevice.DeviceType.PHONE;
37 mobileDevice.osVersion = "18.4.1";
38 mobileDevice.modelName = "iPhone 15 Pro";
3935 });
4036
4137 it("identifies mobile token exchanges for authorized-client presentation", () => {
......@@ -43,32 +39,24 @@ describe("mobile remote connection records", () => {
4339 label: "The Knife Mobile",
4440 deviceType: "mobile",
4541 os: "iOS",
46 osMajorVersion: 18,
47 deviceModel: "iPhone 15 Pro",
4842 surface: "mobile",
4943 });
5044 });
5145
52 it("includes only the Android major version and hardware model", () => {
46 it("identifies Android clients for authorized-client presentation", () => {
5347 mobilePlatform.OS = "android";
54 mobileDevice.osVersion = "15.2.1";
55 mobileDevice.modelName = "Pixel 9";
5648
5749 expect(authClientMetadata()).toMatchObject({
5850 os: "Android",
59 osMajorVersion: 15,
60 deviceModel: "Pixel 9",
6151 });
6252 });
6353
6454 it("identifies native tablets separately from phones", () => {
6555 mobileDevice.deviceType = mobileDevice.DeviceType.TABLET;
66 mobileDevice.modelName = "iPad Pro 13-inch";
6756
6857 expect(authClientMetadata()).toMatchObject({
6958 deviceType: "tablet",
7059 os: "iOS",
71 deviceModel: "iPad Pro 13-inch",
7260 });
7361 });
7462
apps/mobile/src/lib/runtime.ts+1-4
......@@ -7,7 +7,6 @@ import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";
77import * as Dpop from "../features/cloud/dpop";
88import * as ManagedRelayLayer from "../features/cloud/managedRelayLayer";
99import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
10import * as Tracing from "../features/observability/tracing";
1110import * as Persistence from "../persistence/layer";
1211import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh";
1312
......@@ -24,8 +23,7 @@ type RuntimeLayerSource =
2423 | typeof Socket.layerWebSocketConstructorGlobal
2524 | typeof Dpop.layer
2625 | typeof layerHttpClient
27 | typeof Persistence.layer
28 | typeof Tracing.layer;
26 | typeof Persistence.layer;
2927
3028const layerRuntime = Layer.merge(
3129 ManagedRelayLayer.layer(configuredRelayUrl()),
......@@ -33,7 +31,6 @@ const layerRuntime = Layer.merge(
3331).pipe(
3432 Layer.provideMerge(Dpop.layer),
3533 Layer.provideMerge(layerHttpClient),
36 Layer.provideMerge(Tracing.layer.pipe(Layer.provide(layerHttpClient))),
3734 Layer.provideMerge(Persistence.layer),
3835);
3936
apps/server/src/auth/http.test.ts+2-14
......@@ -8,13 +8,11 @@ import {
88 EnvironmentAuthenticatedAuth,
99 EnvironmentHttpApi,
1010} from "@t3tools/contracts";
11import { RelayClientTracer } from "@t3tools/shared/relayTracing";
1211import { expect, it } from "@effect/vitest";
1312import * as Context from "effect/Context";
1413import * as Crypto from "effect/Crypto";
1514import * as Effect from "effect/Effect";
1615import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
1816import * as Redacted from "effect/Redacted";
1917import * as Schema from "effect/Schema";
2018import * as Tracer from "effect/Tracer";
......@@ -165,9 +163,8 @@ it.effect("sets the selected browser session cookies through the HTTP route", ()
165163 }).pipe(Effect.provide(NodeServices.layer)),
166164);
167165
168it.effect("exports only verified T3 Connect requests", () =>
166it.effect("keeps authenticated connection requests on the local tracer", () =>
169167 Effect.gen(function* () {
170 const productSpans: Array<string> = [];
171168 const localSpans: Array<string> = [];
172169 const collect = (into: Array<string>) =>
173170 Tracer.make({
......@@ -216,27 +213,18 @@ it.effect("exports only verified T3 Connect requests", () =>
216213 }),
217214 ),
218215 ),
219 Effect.provideService(RelayClientTracer, Option.some(collect(productSpans))),
220216 Effect.withTracer(collect(localSpans)),
221217 );
222218
223219 yield* handle("DPoP connect");
224 expect(productSpans).toEqual([
225 "environment.relay.request",
226 "EnvironmentAuth.authenticateHttpRequest",
227 "environment.handler",
228 ]);
229 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]);
220 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]);
230221
231 productSpans.length = 0;
232222 localSpans.length = 0;
233223 yield* handle("DPoP forged");
234 expect(productSpans).toEqual([]);
235224 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]);
236225
237226 localSpans.length = 0;
238227 yield* handle("Bearer access-token");
239 expect(productSpans).toEqual([]);
240228 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]);
241229 }).pipe(Effect.scoped),
242230);
apps/server/src/auth/http.ts+1-10
......@@ -21,11 +21,9 @@ import {
2121import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";
2222import { parseOAuthScope } from "@t3tools/shared/oauthScope";
2323import { causeErrorTag } from "@t3tools/shared/observability";
24import * as Clock from "effect/Clock";
2524import * as DateTime from "effect/DateTime";
2625import * as Effect from "effect/Effect";
2726import * as Schema from "effect/Schema";
28import { identity } from "effect/Function";
2927import * as Layer from "effect/Layer";
3028import * as Cookies from "effect/http/Cookies";
3129import * as HttpEffect from "effect/http/HttpEffect";
......@@ -34,7 +32,6 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
3432
3533import * as EnvironmentAuth from "./EnvironmentAuth.ts";
3634import * as SessionStore from "./SessionStore.ts";
37import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts";
3835import { deriveAuthClientMetadata } from "./utils.ts";
3936import { verifyRequestDpopProof } from "./dpop.ts";
4037
......@@ -233,7 +230,6 @@ export const layerAuthenticatedAuth = Layer.effect(
233230 return (httpEffect) =>
234231 Effect.gen(function* () {
235232 const request = yield* HttpServerRequest.HttpServerRequest;
236 const startTime = yield* Clock.currentTimeNanos;
237233 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
238234 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
239235 failEnvironmentAuthInvalid(
......@@ -245,16 +241,12 @@ export const layerAuthenticatedAuth = Layer.effect(
245241 failEnvironmentInternal("internal_error", error),
246242 ),
247243 );
248 const endTime = yield* Clock.currentTimeNanos;
249 const handler = httpEffect.pipe(
244 return yield* httpEffect.pipe(
250245 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
251246 ...session,
252247 scopes: new Set(session.scopes),
253248 }),
254249 );
255 return yield* session.subject === "cloud-connect"
256 ? traceAuthenticatedRelayRequest(handler, { startTime, endTime })
257 : handler;
258250 }).pipe(Effect.catchTags({ EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized }));
259251 }),
260252);
......@@ -397,7 +389,6 @@ export const layer = HttpApiBuilder.group(
397389 proofKeyThumbprint ? { proofKeyThumbprint } : undefined,
398390 );
399391 },
400 traceRelayRequest,
401392 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
402393 failEnvironmentAuthInvalid(
403394 EnvironmentAuth.serverAuthCredentialReason(error),
apps/server/src/cli/connect.ts-4
......@@ -7,7 +7,6 @@ import {
77import { RelayOkResponse } from "@t3tools/contracts/relay";
88import * as HostProcess from "@t3tools/shared/HostProcess";
99import * as RelayClient from "@t3tools/shared/relayClient";
10import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1110import * as Cause from "effect/Cause";
1211import * as Config from "effect/Config";
1312import * as Console from "effect/Console";
......@@ -38,7 +37,6 @@ import {
3837 RELAY_URL_SECRET,
3938} from "../cloud/config.ts";
4039import { relayUrlConfig } from "../cloud/publicConfig.ts";
41import * as RelayTracing from "../cloud/relayTracing.ts";
4240import * as ServerConfig from "../config.ts";
4341import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
4442import * as ExternalLauncher from "../process/externalLauncher.ts";
......@@ -346,7 +344,6 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f
346344 httpClient.execute,
347345 Effect.flatMap(filterRelayResponse),
348346 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
349 withRelayClientTracing,
350347 );
351348 return response.ok
352349 ? ({ status: "revoked" } satisfies RelayUnlinkResult)
......@@ -448,7 +445,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
448445 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
449446 EnvironmentAuth.layerRuntime,
450447 CliService.layer(config),
451 RelayTracing.layerHeadlessRelayClient,
452448 ).pipe(
453449 Layer.provideMerge(FetchHttpClient.layer),
454450 Layer.provideMerge(ServerConfig.layer(config)),
apps/server/src/cloud/CloudLink.ts-3
......@@ -34,7 +34,6 @@ import {
3434 type RelayManagedEndpointRuntimeConfig,
3535 RelayOkResponse,
3636} from "@t3tools/contracts/relay";
37import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3837import {
3938 normalizeRelayIssuer,
4039 RELAY_HEALTH_REQUEST_TYP,
......@@ -885,7 +884,6 @@ const make = Effect.gen(function* () {
885884 Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)),
886885 Effect.timeout(input.timeout ?? "10 seconds"),
887886 Effect.mapError(relayRequestError),
888 withRelayClientTracing,
889887 );
890888
891889 const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")(
......@@ -1333,7 +1331,6 @@ const make = Effect.gen(function* () {
13331331 httpClient.execute,
13341332 Effect.flatMap(filterRelayResponse),
13351333 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
1336 withRelayClientTracing,
13371334 );
13381335 // ok:false means the relay skipped deletion because a concurrent provision
13391336 // owns the recorded tunnel now — leave the stored config alone.
apps/server/src/cloud/http.ts+2-5
......@@ -18,7 +18,6 @@ import { requireEnvironmentScope } from "../auth/http.ts";
1818import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
1919import * as CloudLink from "./CloudLink.ts";
2020import type { RelayRequestError } from "./relayResponse.ts";
21import { traceRelayRequest } from "./traceRelayRequest.ts";
2221
2322const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
2423 "cache-control": "no-store",
......@@ -151,10 +150,8 @@ export const layer = HttpApiBuilder.group(
151150 ),
152151 )
153152 .handle("t3MintCredential", ({ payload }) =>
154 traceRelayRequest(
155 toHttpError(cloudLink.mintCredential(payload)).pipe(
156 Effect.tap(() => appendCloudCredentialResponseHeaders),
157 ),
153 toHttpError(cloudLink.mintCredential(payload)).pipe(
154 Effect.tap(() => appendCloudCredentialResponseHeaders),
158155 ),
159156 );
160157 }),
apps/server/src/cloud/publicConfig.test.ts+1-42
......@@ -3,12 +3,7 @@ import * as ConfigProvider from "effect/ConfigProvider";
33import * as Effect from "effect/Effect";
44import * as Result from "effect/Result";
55
6import {
7 hostedAppUrlConfig,
8 makeCloudCliOAuthConfig,
9 makeRelayUrlConfig,
10 resolveRelayClientTracingConfig,
11} from "./publicConfig.ts";
6import { hostedAppUrlConfig, makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts";
127
138const provideEnv = (env: Readonly<Record<string, string>>) =>
149 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })));
......@@ -144,39 +139,3 @@ it.effect("reports malformed Clerk publishable keys as typed configuration failu
144139 }
145140 }),
146141);
147
148it("resolves relay client tracing from runtime config with build-time fallback", () => {
149 const fallback = {
150 tracesUrl: "https://embedded.example.test/v1/traces",
151 tracesDataset: "embedded-dataset",
152 tracesToken: "embedded-token",
153 };
154
155 assert.deepEqual(resolveRelayClientTracingConfig({}, fallback), fallback);
156 assert.deepEqual(
157 resolveRelayClientTracingConfig(
158 {
159 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://runtime.example.test/v1/traces",
160 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset",
161 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token",
162 },
163 fallback,
164 ),
165 {
166 tracesUrl: "https://runtime.example.test/v1/traces",
167 tracesDataset: "runtime-dataset",
168 tracesToken: "runtime-token",
169 },
170 );
171 assert.equal(
172 resolveRelayClientTracingConfig(
173 {
174 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "http://insecure.example.test/v1/traces",
175 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset",
176 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token",
177 },
178 fallback,
179 ),
180 null,
181 );
182});
apps/server/src/cloud/publicConfig.ts-44
......@@ -14,9 +14,6 @@ import * as SchemaIssue from "effect/SchemaIssue";
1414declare const __T3CODE_BUILD_RELAY_URL__: string | undefined;
1515declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
1616declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined;
17declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined;
18declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined;
19declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined;
2017
2118const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338;
2219const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES;
......@@ -40,15 +37,6 @@ function readBuildTimeValue(value: string | undefined): string {
4037 return typeof value === "undefined" ? "" : value.trim();
4138}
4239
43function normalizeSecureUrl(value: string): string | null {
44 try {
45 const url = new URL(value);
46 return url.protocol === "https:" ? url.toString() : null;
47 } catch {
48 return null;
49 }
50}
51
5240const buildTimeRelayUrl =
5341 typeof __T3CODE_BUILD_RELAY_URL__ === "undefined"
5442 ? ""
......@@ -63,38 +51,6 @@ const buildTimeClerkCliOAuthClientId = readBuildTimeValue(
6351 ? undefined
6452 : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__,
6553);
66const buildTimeRelayClientTracing = {
67 tracesUrl: readBuildTimeValue(
68 typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__ === "undefined"
69 ? undefined
70 : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__,
71 ),
72 tracesDataset: readBuildTimeValue(
73 typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__ === "undefined"
74 ? undefined
75 : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__,
76 ),
77 tracesToken: readBuildTimeValue(
78 typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__ === "undefined"
79 ? undefined
80 : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__,
81 ),
82} as const;
83
84export function resolveRelayClientTracingConfig(
85 env: Readonly<Record<string, string | undefined>> = process.env,
86 fallback = buildTimeRelayClientTracing,
87) {
88 const tracesUrl = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() || fallback.tracesUrl;
89 const tracesDataset =
90 env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() || fallback.tracesDataset;
91 const tracesToken = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() || fallback.tracesToken;
92 const normalizedTracesUrl = normalizeSecureUrl(tracesUrl);
93 return normalizedTracesUrl && tracesDataset && tracesToken
94 ? { tracesUrl: normalizedTracesUrl, tracesDataset, tracesToken }
95 : null;
96}
97
9854export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) {
9955 const runtimeConfig = Config.NonEmptyString("T3CODE_RELAY_URL");
10056 return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe(
apps/server/src/cloud/relayTracing.ts deleted-18
......@@ -1,18 +0,0 @@
1import * as RelayTracing from "@t3tools/shared/relayTracing";
2
3import { resolveRelayClientTracingConfig } from "./publicConfig.ts";
4
5const relayClientTracingConfig = resolveRelayClientTracingConfig();
6
7export const layerHeadlessRelayClient = RelayTracing.layer(relayClientTracingConfig, {
8 serviceName: "t3code-server",
9 runtime: "node",
10 client: "headless-cli",
11});
12
13export const layerServerRelayBroker = RelayTracing.layer(relayClientTracingConfig, {
14 serviceName: "t3code-server",
15 runtime: "node",
16 client: "environment-server",
17 component: "relay-broker",
18});
apps/server/src/cloud/traceRelayRequest.test.ts deleted-127
......@@ -1,127 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Tracer from "effect/Tracer";
5import { HttpServerRequest } from "effect/http";
6import { RelayClientTracer } from "@t3tools/shared/relayTracing";
7
8import {
9 traceAuthenticatedRelayRequest,
10 traceLocalHandlerWork,
11 traceRelayRequest,
12} from "./traceRelayRequest.ts";
13
14const AUTHENTICATION = { startTime: 1_000n, endTime: 2_000n };
15
16describe("relay request tracing", () => {
17 it.effect("does not accept an unauthenticated request trace parent", () =>
18 Effect.gen(function* () {
19 const spans: Array<Tracer.Span> = [];
20 const productTracer = Tracer.make({
21 span: (options) => {
22 const span = new Tracer.NativeSpan(options);
23 spans.push(span);
24 return span;
25 },
26 });
27 const request = HttpServerRequest.fromWeb(
28 new Request("https://environment.example.test/api/t3-cloud/mint-credential", {
29 headers: {
30 traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01",
31 },
32 }),
33 );
34
35 yield* traceRelayRequest(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe(
36 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
37 Effect.provideService(RelayClientTracer, Option.some(productTracer)),
38 );
39
40 expect(spans).toHaveLength(1);
41 const span = spans[0]!;
42 expect(span.traceId).not.toBe("0123456789abcdef0123456789abcdef");
43 expect(Option.isNone(span.parent)).toBe(true);
44 }),
45 );
46
47 it.effect("continues an authenticated relay trace with the product tracer", () =>
48 Effect.gen(function* () {
49 const spans: Array<Tracer.Span> = [];
50 const productTracer = Tracer.make({
51 span: (options) => {
52 const span = new Tracer.NativeSpan(options);
53 spans.push(span);
54 return span;
55 },
56 });
57 const request = HttpServerRequest.fromWeb(
58 new Request("https://environment.example.test/api/t3-cloud/mint-credential", {
59 headers: {
60 traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01",
61 },
62 }),
63 );
64
65 yield* traceAuthenticatedRelayRequest(
66 Effect.void.pipe(Effect.withSpan("relay.mint.handler")),
67 AUTHENTICATION,
68 ).pipe(
69 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
70 Effect.provideService(RelayClientTracer, Option.some(productTracer)),
71 );
72
73 expect(spans.map((span) => span.name)).toEqual([
74 "environment.relay.request",
75 "EnvironmentAuth.authenticateHttpRequest",
76 "relay.mint.handler",
77 ]);
78 const [relaySpan, authentication, handler] = spans;
79 expect(Option.getOrUndefined(authentication!.parent)?.spanId).toBe(relaySpan!.spanId);
80 expect(relaySpan!.traceId).toBe("0123456789abcdef0123456789abcdef");
81 expect(Option.getOrUndefined(relaySpan!.parent)?.spanId).toBe("0123456789abcdef");
82 expect(Option.getOrUndefined(handler!.parent)?.spanId).toBe(relaySpan!.spanId);
83 }),
84 );
85});
86
87describe("relay request tracing boundary", () => {
88 it.effect("exports a T3 Connect handler span but not its local work", () =>
89 Effect.gen(function* () {
90 const productSpans: Array<string> = [];
91 const localSpans: Array<string> = [];
92 const collect = (into: Array<string>) =>
93 Tracer.make({
94 span: (options) => {
95 into.push(options.name);
96 return new Tracer.NativeSpan(options);
97 },
98 });
99 const request = HttpServerRequest.fromWeb(
100 new Request("https://environment.example.test/api/orchestration/threads/thread-1"),
101 );
102
103 // Shaped like a real handler: an Effect.fn span whose service call runs
104 // on the local tracer.
105 const handler = Effect.fn("environment.orchestration.threadSnapshot")(function* () {
106 yield* Effect.void.pipe(
107 Effect.withSpan("sql.execute"),
108 Effect.withSpan("ServerSecretStore.get"),
109 traceLocalHandlerWork,
110 );
111 });
112
113 yield* traceAuthenticatedRelayRequest(handler(), AUTHENTICATION).pipe(
114 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
115 Effect.provideService(RelayClientTracer, Option.some(collect(productSpans))),
116 Effect.withTracer(collect(localSpans)),
117 );
118
119 expect(productSpans).toEqual([
120 "environment.relay.request",
121 "EnvironmentAuth.authenticateHttpRequest",
122 "environment.orchestration.threadSnapshot",
123 ]);
124 expect(localSpans).toEqual(["ServerSecretStore.get", "sql.execute"]);
125 }),
126 );
127});
apps/server/src/cloud/traceRelayRequest.ts deleted-77
......@@ -1,77 +0,0 @@
1import {
2 RelayClientTracer,
3 withLocalTracing,
4 withRelayClientTracing,
5} from "@t3tools/shared/relayTracing";
6import * as Clock from "effect/Clock";
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as Exit from "effect/Exit";
10import * as Option from "effect/Option";
11import * as Tracer from "effect/Tracer";
12import { HttpServerRequest, HttpTraceContext } from "effect/http";
13
14/**
15 * Exports every span of a handler that is itself T3 Connect work (the token
16 * exchange, the environment descriptor, credential minting), so the whole
17 * connection path can be measured.
18 */
19export const traceRelayRequest = <A, E, R>(
20 effect: Effect.Effect<A, E, R>,
21): Effect.Effect<A, E, R> => effect.pipe(withRelayClientTracing);
22
23/**
24 * Traces a request that arrived over T3 Connect, continuing the client's
25 * trace. The request's span and its authentication timing are exported, so
26 * their latency and errors are visible; what the handler then does on the
27 * user's machine (database reads, project indexing, processes) is not, unless
28 * the handler is connection work and opts back in with {@link traceRelayRequest}.
29 *
30 * Call it only after the session is verified as T3 Connect: an unverified
31 * request must not add spans to a trace it names. Authentication therefore
32 * runs on the local tracer, and its timing is recorded here afterwards.
33 */
34export const traceAuthenticatedRelayRequest = <A, E, R>(
35 effect: Effect.Effect<A, E, R>,
36 authentication: { readonly startTime: bigint; readonly endTime: bigint },
37): Effect.Effect<A, E, R | HttpServerRequest.HttpServerRequest> =>
38 Effect.all([RelayClientTracer, HttpServerRequest.HttpServerRequest]).pipe(
39 Effect.flatMap(([tracer, request]): Effect.Effect<A, E, R> => {
40 if (Option.isNone(tracer)) return effect;
41 const parent = HttpTraceContext.fromHeaders(request.headers);
42 const sampled = Option.isNone(parent) || parent.value.sampled;
43 const startSpan = (
44 name: string,
45 kind: Tracer.SpanKind,
46 parent: Option.Option<Tracer.AnySpan>,
47 ) =>
48 tracer.value.span({
49 name,
50 parent,
51 annotations: Context.empty(),
52 links: [],
53 startTime: authentication.startTime,
54 kind,
55 root: Option.isNone(parent),
56 sampled,
57 });
58 const span = startSpan("environment.relay.request", "server", parent);
59 startSpan("EnvironmentAuth.authenticateHttpRequest", "internal", Option.some(span)).end(
60 authentication.endTime,
61 Exit.void,
62 );
63 return effect.pipe(
64 Effect.withParentSpan(span),
65 Effect.onExit((exit) =>
66 Clock.currentTimeNanos.pipe(Effect.map((endTime) => span.end(endTime, exit))),
67 ),
68 withRelayClientTracing,
69 );
70 }),
71 );
72
73/**
74 * Keeps the work inside a relay-traced handler on the local tracer. The
75 * handler's span still records the timing and outcome.
76 */
77export const traceLocalHandlerWork = withLocalTracing;
apps/server/src/config.ts-3
......@@ -48,7 +48,6 @@ export interface ServerDerivedPaths {
4848 readonly providerLogsDir: string;
4949 readonly providerEventLogPath: string;
5050 readonly terminalLogsDir: string;
51 readonly anonymousIdPath: string;
5251 readonly environmentIdPath: string;
5352 readonly serverRuntimeStatePath: string;
5453 readonly secretsDir: string;
......@@ -163,7 +162,6 @@ export const deriveServerPaths = Effect.fn(function* (
163162 providerLogsDir,
164163 providerEventLogPath: join(providerLogsDir, "events.log"),
165164 terminalLogsDir: join(logsDir, "terminals"),
166 anonymousIdPath: join(stateDir, "anonymous-id"),
167165 environmentIdPath: join(stateDir, "environment-id"),
168166 serverRuntimeStatePath: join(stateDir, "server-runtime.json"),
169167 secretsDir: join(stateDir, "secrets"),
......@@ -185,7 +183,6 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server
185183 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),
186184 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
187185 fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }),
188 fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),
189186 fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }),
190187 ],
191188 { concurrency: "unbounded" },
apps/server/src/http.ts+1-2
......@@ -57,7 +57,6 @@ import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts
5757import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
5858import * as SharedAccounts from "./provider/SharedAccounts.ts";
5959import * as SharedProfiles from "./provider/SharedProfiles.ts";
60import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
6160import {
6261 annotateEnvironmentRequest,
6362 failEnvironmentScopeRequired,
......@@ -534,7 +533,7 @@ export const layerServerEnvironmentHttpApi = HttpApiBuilder.group(
534533 Effect.fn("environment.metadata.descriptor")(function* (args) {
535534 yield* annotateEnvironmentRequest(args.endpoint.name);
536535 return yield* serverEnvironment.getDescriptor;
537 }, traceRelayRequest),
536 }),
538537 );
539538 }),
540539);
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts-1
......@@ -173,7 +173,6 @@ export function makeReplayServerConfig(
173173 providerLogsDir,
174174 providerEventLogPath: path.join(providerLogsDir, "events.log"),
175175 terminalLogsDir,
176 anonymousIdPath: path.join(stateDir, "anonymous-id"),
177176 environmentIdPath: path.join(stateDir, "environment-id"),
178177 serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"),
179178 secretsDir: path.join(stateDir, "secrets"),
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts-100
......@@ -143,106 +143,6 @@ function threadCreatedEvent(
143143
144144const layer = it.layer(layerTest);
145145
146it.effect("records accepted billed turn usage once without billing the context window", () => {
147 const recorded: Array<Readonly<Record<string, unknown>>> = [];
148 const layerAnalytics = Layer.succeed(ProviderEventIngestor.ProviderTurnAnalytics, {
149 record: (properties: Readonly<Record<string, unknown>>) =>
150 Effect.sync(() => {
151 recorded.push(properties);
152 }),
153 });
154 return Effect.gen(function* () {
155 const now = yield* DateTime.now;
156 const eventSink = yield* EventSink.EventSinkV2;
157 const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2;
158 const idAllocator = yield* IdAllocator.IdAllocatorV2;
159 const threadEvent = yield* threadCreatedEvent(now);
160 yield* eventSink.write({ events: [threadEvent] });
161 const providerSessionId = yield* idAllocator.allocate.providerSession({
162 providerInstanceId: modelSelection.instanceId,
163 threadId: threadEvent.threadId,
164 });
165 const providerThreadId = idAllocator.derive.providerThread({
166 driver: CODEX_DRIVER,
167 nativeThreadId: "billing-thread",
168 });
169 const providerTurn = {
170 id: idAllocator.derive.providerTurn({ driver: CODEX_DRIVER, nativeTurnId: "billing-turn" }),
171 providerThreadId,
172 nodeId: NodeId.make("node:billing-turn"),
173 runAttemptId: null,
174 nativeTurnRef: null,
175 ordinal: 1,
176 status: "completed" as const,
177 startedAt: now,
178 completedAt: DateTime.makeUnsafe(DateTime.toEpochMillis(now) + 120),
179 tokenUsage: {
180 inputTokens: 4000,
181 cachedInputTokens: 3000,
182 outputTokens: 100,
183 usedTokens: 4100,
184 updatedAt: DateTime.formatIso(now),
185 },
186 turnTokenUsage: {
187 usageStatus: "complete" as const,
188 usageScope: "main_agent" as const,
189 hasSubagents: false,
190 inputTokens: 40,
191 cachedInputTokens: 30,
192 outputTokens: 10,
193 },
194 };
195 const input = {
196 providerSessionId,
197 providerInstanceId: modelSelection.instanceId,
198 threadId: threadEvent.threadId,
199 analyticsContext: {
200 modelSelection,
201 runtimeMode: "full-access" as const,
202 interactionMode: "default" as const,
203 },
204 event: { type: "provider_turn.updated" as const, driver: CODEX_DRIVER, providerTurn },
205 };
206 yield* ingestor.ingestNormalized(input);
207 yield* ingestor.ingestNormalized(input);
208 const ignored = yield* ingestor.ingestNormalized({
209 ...input,
210 event: {
211 ...input.event,
212 providerTurn: {
213 ...providerTurn,
214 id: idAllocator.derive.providerTurn({
215 driver: CODEX_DRIVER,
216 nativeTurnId: "stale-billing-turn",
217 }),
218 },
219 },
220 writeIfRunCurrent: {
221 runId: RunId.make("missing-run"),
222 activeAttemptId: RunAttemptId.make("stale-attempt"),
223 expectedStatus: "running",
224 },
225 });
226 assert.isEmpty(ignored);
227 assert.lengthOf(recorded, 1);
228 assert.deepEqual(recorded[0], {
229 provider: CODEX_DRIVER,
230 terminalStatus: "completed",
231 usageStatus: "complete",
232 usageScope: "main_agent",
233 hasSubagents: false,
234 inputTokens: 40,
235 cachedInputTokens: 30,
236 outputTokens: 10,
237 model: modelSelection.model,
238 mixedModels: false,
239 runtimeMode: "full-access",
240 interactionMode: "default",
241 durationMs: 120,
242 });
243 }).pipe(Effect.provide(layerTest.pipe(Layer.provide(layerAnalytics))));
244});
245
246146layer("ProviderEventIngestorV2", (it) => {
247147 it.effect("normalizes provider events through the real event log and projection store", () =>
248148 Effect.gen(function* () {
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+2-103
......@@ -5,12 +5,9 @@ import {
55 OrchestrationV2StoredEvent,
66 type OrchestrationV2PlanArtifact,
77 type OrchestrationV2Run,
8 type OrchestrationV2ProviderTurn,
8 type ModelSelection,
99 type OrchestrationV2Subagent,
1010 type OrchestrationV2ThreadProjection,
11 type ModelSelection,
12 type RuntimeMode,
13 type ProviderInteractionMode,
1411 ProviderInstanceId,
1512 ProviderSessionId,
1613 ProviderThreadId,
......@@ -27,8 +24,7 @@ import * as Layer from "effect/Layer";
2724import * as Schema from "effect/Schema";
2825
2926import { AgentScope } from "@t3tools/shared/AgentScope";
30import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
31import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
27import { modelSelectionsEqual } from "@t3tools/shared/model";
3228import * as EventSink from "./EventSink.ts";
3329import * as ProjectionStore from "./ProjectionStore.ts";
3430import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
......@@ -71,71 +67,6 @@ export const ProviderEventIngestorV2Error = Schema.Union([
7167]);
7268export type ProviderEventIngestorV2Error = typeof ProviderEventIngestorV2Error.Type;
7369
74export interface ProviderTurnAnalyticsContext {
75 readonly modelSelection: ModelSelection;
76 readonly runtimeMode?: RuntimeMode;
77 readonly interactionMode?: ProviderInteractionMode;
78}
79
80export class ProviderTurnAnalytics extends Context.Reference<{
81 readonly record: (properties: Readonly<Record<string, unknown>>) => Effect.Effect<void>;
82}>("t3/orchestration-v2/ProviderTurnAnalytics", {
83 defaultValue: () => ({ record: () => Effect.void }),
84}) {}
85
86export const layerAnalytics = Layer.effect(
87 ProviderTurnAnalytics,
88 Effect.gen(function* () {
89 const analytics = yield* AnalyticsService.AnalyticsService;
90 return {
91 record: (properties: Readonly<Record<string, unknown>>) =>
92 analytics.record("provider.turn.completed", properties),
93 };
94 }),
95);
96
97function providerTurnAnalyticsProperties(input: {
98 readonly driver: ProviderAdapter.ProviderAdapterV2Event["driver"];
99 readonly providerTurn: OrchestrationV2ProviderTurn;
100 readonly context?: ProviderTurnAnalyticsContext;
101}): Readonly<Record<string, unknown>> {
102 const usage = input.providerTurn.turnTokenUsage;
103 const modelSelection = input.context?.modelSelection;
104 const effort = modelSelection
105 ? (getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
106 getModelSelectionStringOptionValue(modelSelection, "effort"))
107 : undefined;
108 return {
109 provider: input.driver,
110 terminalStatus: input.providerTurn.status,
111 usageStatus: usage?.usageStatus ?? "unavailable",
112 usageScope: usage?.usageScope ?? "main_agent",
113 ...(usage ? { hasSubagents: usage.hasSubagents } : {}),
114 ...(usage?.inputTokens === undefined ? {} : { inputTokens: usage.inputTokens }),
115 ...(usage?.cachedInputTokens === undefined
116 ? {}
117 : { cachedInputTokens: usage.cachedInputTokens }),
118 ...(usage?.cacheCreationTokens === undefined
119 ? {}
120 : { cacheCreationTokens: usage.cacheCreationTokens }),
121 ...(usage?.outputTokens === undefined ? {} : { outputTokens: usage.outputTokens }),
122 ...(usage?.reasoningTokens === undefined ? {} : { reasoningTokens: usage.reasoningTokens }),
123 ...(modelSelection ? { model: modelSelection.model, mixedModels: false } : {}),
124 ...(effort ? { effort } : {}),
125 ...(input.context?.runtimeMode ? { runtimeMode: input.context.runtimeMode } : {}),
126 ...(input.context?.interactionMode ? { interactionMode: input.context.interactionMode } : {}),
127 ...(input.providerTurn.startedAt && input.providerTurn.completedAt
128 ? {
129 durationMs: Math.max(
130 0,
131 DateTime.toEpochMillis(input.providerTurn.completedAt) -
132 DateTime.toEpochMillis(input.providerTurn.startedAt),
133 ),
134 }
135 : {}),
136 };
137}
138
13970type TodoListPlan = Extract<OrchestrationV2PlanArtifact, { readonly kind: "todo_list" }>;
14071
14172function withPlanStepDurations(
......@@ -260,7 +191,6 @@ export interface ProviderEventIngestInput {
260191 readonly nodeId?: NodeId;
261192 readonly rawEventId?: RawEventId;
262193 readonly event: ProviderAdapter.ProviderAdapterV2Event;
263 readonly analyticsContext?: ProviderTurnAnalyticsContext;
264194}
265195
266196export interface ProviderEventIngestorV2Shape {
......@@ -317,9 +247,7 @@ export const layer: Layer.Layer<
317247 const projections = yield* ProjectionStore.ProjectionStoreV2;
318248 const idAllocator = yield* IdAllocator.IdAllocatorV2;
319249 const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;
320 const analytics = yield* ProviderTurnAnalytics;
321250 const agentScope = yield* AgentScope;
322 const completedTurnAnalytics = new Set<string>();
323251
324252 const makeDomainEvent = (
325253 input: ProviderEventIngestInput,
......@@ -795,35 +723,6 @@ export const layer: Layer.Layer<
795723 ),
796724 ),
797725 ),
798 Effect.tap((storedEvents) =>
799 Effect.gen(function* () {
800 if (storedEvents.length === 0 || input.event.type !== "provider_turn.updated") return;
801 const providerTurn = input.event.providerTurn;
802 if (
803 providerTurn.status !== "completed" &&
804 providerTurn.status !== "failed" &&
805 providerTurn.status !== "interrupted" &&
806 providerTurn.status !== "cancelled"
807 )
808 return;
809 const key = `${input.providerInstanceId}:${providerTurn.id}`;
810 if (completedTurnAnalytics.has(key)) return;
811 completedTurnAnalytics.add(key);
812 if (completedTurnAnalytics.size > 4096) {
813 const oldest = completedTurnAnalytics.values().next().value;
814 if (oldest !== undefined) completedTurnAnalytics.delete(oldest);
815 }
816 yield* analytics.record(
817 providerTurnAnalyticsProperties({
818 driver: input.event.driver,
819 providerTurn,
820 ...(input.analyticsContext === undefined
821 ? {}
822 : { context: input.analyticsContext }),
823 }),
824 );
825 }),
826 ),
827726 (effect) =>
828727 input.event.type === "app_thread.created"
829728 ? threadCommands.withLock(input.event.appThread.id, effect)
apps/server/src/orchestration-v2/RunExecutionService.ts-5
......@@ -1246,11 +1246,6 @@ export const layer: Layer.Layer<
12461246 event.type === "provider_thread.updated" &&
12471247 event.providerThread.id === input.providerThread.id;
12481248 const storedEvents = yield* providerEventIngestor.ingestNormalized({
1249 analyticsContext: {
1250 modelSelection: input.modelSelection,
1251 runtimeMode: input.runtimePolicy.runtimeMode,
1252 interactionMode: input.runtimePolicy.interactionMode,
1253 },
12541249 providerSessionId: input.providerSessionId,
12551250 providerInstanceId: input.run.providerInstanceId,
12561251 threadId: input.run.threadId,
apps/server/src/orchestration-v2/http.ts+2-8
......@@ -17,7 +17,6 @@ import {
1717 requireEnvironmentScope,
1818} from "../auth/http.ts";
1919import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
20import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts";
2120import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
2221import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
2322import {
......@@ -162,7 +161,6 @@ export const layer = HttpApiBuilder.group(
162161 yield* annotateEnvironmentRequest(args.endpoint.name);
163162 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
164163 return yield* loadShellSnapshot().pipe(
165 traceLocalHandlerWork,
166164 Effect.catch((cause) =>
167165 failEnvironmentInternal("orchestration_snapshot_failed", cause),
168166 ),
......@@ -177,7 +175,7 @@ export const layer = HttpApiBuilder.group(
177175 const snapshot = yield* loadThreadSnapshot(
178176 args.params.threadId,
179177 "orchestration_thread_snapshot_failed",
180 ).pipe(traceLocalHandlerWork);
178 );
181179 return {
182180 snapshotSequence: snapshot.snapshotSequence,
183181 projection: snapshot.projection,
......@@ -191,7 +189,6 @@ export const layer = HttpApiBuilder.group(
191189 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
192190 return yield* threadManagement.getThreadTranscript(args.params.threadId).pipe(
193191 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment),
194 traceLocalHandlerWork,
195192 Effect.catch(
196193 Effect.fnUntraced(function* (error) {
197194 if (isThreadTranscriptTooLargeError(error)) {
......@@ -214,9 +211,7 @@ export const layer = HttpApiBuilder.group(
214211 Effect.fn("environment.orchestration.threadBoundedSnapshot")(function* (args) {
215212 yield* annotateEnvironmentRequest(args.endpoint.name);
216213 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
217 const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId).pipe(
218 traceLocalHandlerWork,
219 );
214 const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId);
220215 const bounded = buildBoundedThreadProjection({
221216 projection: snapshot.projection,
222217 snapshotSequence: snapshot.snapshotSequence,
......@@ -250,7 +245,6 @@ export const layer = HttpApiBuilder.group(
250245 args.query.view === "conversation",
251246 )
252247 .pipe(
253 traceLocalHandlerWork,
254248 Effect.catch(
255249 Effect.fnUntraced(function* (cause) {
256250 if (isThreadNotFound(cause))
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts-1
......@@ -146,7 +146,6 @@ export function makeReplayServerConfig(
146146 providerLogsDir,
147147 providerEventLogPath: path.join(providerLogsDir, "events.log"),
148148 terminalLogsDir,
149 anonymousIdPath: path.join(stateDir, "anonymous-id"),
150149 environmentIdPath: path.join(stateDir, "environment-id"),
151150 serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"),
152151 secretsDir: path.join(stateDir, "secrets"),
apps/server/src/project/http.ts+1-5
......@@ -12,7 +12,6 @@ import {
1212 failEnvironmentInvalidRequest,
1313 requireEnvironmentScope,
1414} from "../auth/http.ts";
15import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts";
1615import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
1716import * as ProjectService from "./ProjectService.ts";
1817import { projectMutationOperation } from "./ProjectMutation.ts";
......@@ -44,7 +43,6 @@ export const layer = HttpApiBuilder.group(
4443 yield* annotateEnvironmentRequest(args.endpoint.name);
4544 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
4645 return yield* projects.snapshot.pipe(
47 traceLocalHandlerWork,
4846 Effect.catch((cause) => failEnvironmentInternal("project_snapshot_failed", cause)),
4947 );
5048 }),
......@@ -57,9 +55,7 @@ export const layer = HttpApiBuilder.group(
5755 yield* requireEnvironmentScope(scope);
5856 }
5957 const operation = projectMutationOperation(projects, args.payload);
60 return yield* startup
61 .enqueueCommand(operation)
62 .pipe(traceLocalHandlerWork, Effect.catch(failProjectMutation));
58 return yield* startup.enqueueCommand(operation).pipe(Effect.catch(failProjectMutation));
6359 }),
6460 );
6561 }),
apps/server/src/provider/CodexChatGptAuth.test.ts+2-240
......@@ -17,8 +17,6 @@ import { exportJWK, generateKeyPair, SignJWT } from "jose";
1717import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1818import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
1919import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
20import * as ServerSettings from "../serverSettings.ts";
21import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
2220import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts";
2321
2422const assertSameCallback = (actual: string | null, expected: string | null) => {
......@@ -33,7 +31,6 @@ const instanceId = ProviderInstanceId.make("managed-codex-test");
3331const makeHarnessFor = Effect.fnUntraced(function* (
3432 instanceId: ProviderInstanceId,
3533 bytes: Map<string, Uint8Array> = new Map(),
36 failAnalytics = false,
3734) {
3835 const environmentId = environmentIds.get(bytes) ?? EnvironmentId.make(NodeCrypto.randomUUID());
3936 environmentIds.set(bytes, environmentId);
......@@ -208,25 +205,11 @@ const makeHarnessFor = Effect.fnUntraced(function* (
208205 const address = server.address();
209206 if (!address || typeof address === "string") throw new Error("mock address");
210207 origin = `http://127.0.0.1:${address.port}`;
211 const analyticsEvents: {
212 event: string;
213 properties: Readonly<Record<string, unknown>> | undefined;
214 }[] = [];
215 const analytics = AnalyticsService.AnalyticsService.of({
216 record: (event, properties) =>
217 failAnalytics
218 ? Effect.die("analytics unavailable")
219 : Effect.sync(() => {
220 analyticsEvents.push({ event, properties });
221 }),
222 flush: Effect.void,
223 });
224208 const auth = yield* makeCodexChatGptAuth({
225209 instanceId,
226210 discoveryUrl: `${origin}/discovery`,
227211 resource: `${origin}/v1`,
228212 }).pipe(
229 Effect.provideService(AnalyticsService.AnalyticsService, analytics),
230213 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
231214 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment),
232215 );
......@@ -278,7 +261,6 @@ const makeHarnessFor = Effect.fnUntraced(function* (
278261 });
279262 return {
280263 auth,
281 analyticsEvents,
282264 secrets,
283265 environmentId,
284266 bytes,
......@@ -293,7 +275,7 @@ const makeHarnessFor = Effect.fnUntraced(function* (
293275 },
294276 "owner",
295277 { discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1` },
296 ).pipe(Stream.provideService(AnalyticsService.AnalyticsService, analytics)),
278 ),
297279 finishCallback: (state: { authorizationUrl: string | null }) =>
298280 Effect.promise(() => fetch(prepareCallback(state))),
299281 destination: Effect.gen(function* () {
......@@ -315,7 +297,6 @@ const makeHarnessFor = Effect.fnUntraced(function* (
315297 discoveryUrl: `${origin}/discovery`,
316298 resource: `${origin}/v1`,
317299 }).pipe(
318 Effect.provideService(AnalyticsService.AnalyticsService, analytics),
319300 Effect.provideService(ServerSecretStore.ServerSecretStore, destinationStore),
320301 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment),
321302 );
......@@ -1700,24 +1681,6 @@ it.effect("primary completes OAuth and destination imports and owns the refresh
17001681 );
17011682 assert.isTrue(stopped);
17021683 assert.strictEqual(imported.phase, "succeeded");
1703 assert.deepStrictEqual(
1704 h.analyticsEvents.map(({ event }) => event),
1705 [
1706 "chatgpt.auth.started",
1707 "chatgpt.auth.completed",
1708 "chatgpt.transfer.started",
1709 "chatgpt.transfer.completed",
1710 ],
1711 );
1712 assert.isTrue(
1713 h.analyticsEvents.every(({ properties }) => properties?.flow === "primary_handoff"),
1714 );
1715 assert.strictEqual(h.analyticsEvents[1]?.properties?.outcome, "succeeded");
1716 assert.strictEqual(h.analyticsEvents[3]?.properties?.outcome, "succeeded");
1717 assert.notProperty(h.analyticsEvents[1]?.properties ?? {}, "connectedAccountCount");
1718 assert.strictEqual(h.analyticsEvents[1]?.properties?.intent, "different_account");
1719 assert.strictEqual(h.analyticsEvents[3]?.properties?.connectedAccountCount, 1);
1720 assert.strictEqual(h.analyticsEvents[3]?.properties?.savedConnectionCount, 1);
17211684 const saved = Option.getOrThrow(yield* destination.auth.read);
17221685 assert.strictEqual(saved.clientId, result.profile.registration.clientId);
17231686 assert.strictEqual(saved.refreshToken, "initial-refresh");
......@@ -1839,214 +1802,13 @@ it.effect(
18391802 ),
18401803);
18411804
1842it.effect("records one anonymous auth outcome per success, failure, or cancellation", () =>
1843 provision(
1844 Effect.gen(function* () {
1845 const h = yield* makeHarness;
1846 yield* h.signIn;
1847 yield* h.phase("succeeded");
1848 h.setInvalidNonce();
1849 yield* h.signIn;
1850 yield* h.phase("failed");
1851 const { waiting } = yield* h.startRemote();
1852 yield* h.auth.controller.cancel("owner", waiting.flowId!);
1853 yield* h.phase("cancelled");
1854 const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed");
1855 assert.deepStrictEqual(
1856 completed.map(({ properties }) => properties?.outcome),
1857 ["succeeded", "failed", "cancelled"],
1858 );
1859 assert.strictEqual(
1860 h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.started").length,
1861 3,
1862 );
1863 assert.strictEqual(completed[1]?.properties?.failureStage, "verify");
1864 for (const { properties } of completed) {
1865 assert.isNumber(properties?.durationMs);
1866 assert.strictEqual(properties?.flow, "direct");
1867 assert.deepStrictEqual(
1868 Object.keys(properties!).sort(),
1869 [
1870 ...(properties?.outcome === "succeeded"
1871 ? [
1872 "accountCountScope",
1873 "connectedAccountCount",
1874 "connectedConnectionCount",
1875 "savedConnectionCount",
1876 "unidentifiedConnectedConnectionCount",
1877 ]
1878 : []),
1879 "callbackMode",
1880 "durationMs",
1881 ...(properties?.failureStage ? ["failureStage"] : []),
1882 "flow",
1883 "intent",
1884 "outcome",
1885 ].sort(),
1886 );
1887 }
1888 const serialized = JSON.stringify(h.analyticsEvents);
1889 for (const secret of [
1890 "hidden@example.test",
1891 "oaiapp_test",
1892 "user-test",
1893 "access_token",
1894 "refresh_token",
1895 ]) {
1896 assert.notInclude(serialized, secret);
1897 }
1898 }),
1899 ),
1900);
1901
1902it.effect("telemetry failures do not fail a verified ChatGPT sign-in", () =>
1903 provision(
1904 Effect.gen(function* () {
1905 const h = yield* makeHarnessFor(instanceId, new Map(), true);
1906 yield* h.signIn;
1907 const state = yield* h.phase("succeeded");
1908 assert.strictEqual(state.phase, "succeeded");
1909 assert.isTrue(Option.isSome(yield* h.auth.read));
1910 }),
1911 ),
1912);
1913
1914it.effect("counts accounts separately from saved connections across additions and reconnects", () =>
1915 provision(
1916 Effect.gen(function* () {
1917 const h = yield* makeHarness;
1918 yield* h.signIn;
1919 yield* h.phase("succeeded");
1920 yield* h.signIn;
1921 yield* h.phase("succeeded");
1922 h.setCallbackClientId("oaiapp_same_account");
1923 h.setIdentity("another-client-subject", "HIDDEN@example.test");
1924 yield* h.changeAccount;
1925 yield* h.phase("succeeded");
1926 h.setCallbackClientId("oaiapp_other_account");
1927 h.setIdentity("other-user", "other@example.test");
1928 yield* h.changeAccount;
1929 yield* h.phase("succeeded");
1930 yield* h.auth.controller.logout(Effect.void);
1931 h.setCallbackClientId("oaiapp_test");
1932 h.setIdentity("user-test", "hidden@example.test");
1933 yield* h.reconnectProfile("oaiapp_test");
1934 yield* h.phase("succeeded");
1935 const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed");
1936 assert.deepStrictEqual(
1937 completed.map(({ properties }) => [
1938 properties?.connectedAccountCount,
1939 properties?.connectedConnectionCount,
1940 properties?.savedConnectionCount,
1941 ]),
1942 [
1943 [1, 1, 1],
1944 [1, 1, 1],
1945 [1, 2, 2],
1946 [2, 3, 3],
1947 [1, 2, 3],
1948 ],
1949 );
1950 assert.isTrue(
1951 completed.every(({ properties }) => properties?.unidentifiedConnectedConnectionCount === 0),
1952 );
1953 assert.notInclude(JSON.stringify(h.analyticsEvents), "example.test");
1954 }),
1955 ),
1956);
1957
1958it.effect("includes accounts from other Codex instances in the environment", () =>
1959 provision(
1960 Effect.gen(function* () {
1961 const bytes = new Map<string, Uint8Array>();
1962 const personal = yield* makeHarnessFor(instanceId, bytes);
1963 const work = yield* makeHarnessFor(ProviderInstanceId.make("managed-work"), bytes);
1964 yield* personal.signIn;
1965 yield* personal.phase("succeeded");
1966 work.setCallbackClientId("oaiapp_work");
1967 work.setIdentity("work-user", "work@example.test");
1968 yield* work.signIn;
1969 yield* work.phase("succeeded");
1970 const completed = work.analyticsEvents.find(
1971 ({ event }) => event === "chatgpt.auth.completed",
1972 );
1973 assert.strictEqual(completed?.properties?.accountCountScope, "environment");
1974 assert.strictEqual(completed?.properties?.connectedAccountCount, 2);
1975 assert.strictEqual(completed?.properties?.connectedConnectionCount, 2);
1976 assert.strictEqual(completed?.properties?.savedConnectionCount, 2);
1977 }).pipe(
1978 Effect.provide(
1979 ServerSettings.layerTest({
1980 providerInstances: {
1981 [instanceId]: { driver: "codex", enabled: true },
1982 [ProviderInstanceId.make("managed-work")]: { driver: "codex", enabled: true },
1983 },
1984 }),
1985 ),
1986 ),
1987 ),
1988);
1989
1990it.effect("an unreadable unrelated profile omits counts without failing sign-in", () =>
1991 provision(
1992 Effect.gen(function* () {
1993 const h = yield* makeHarness;
1994 const unrelated = yield* ProviderCredentialStore.make("codex-chatgpt", "codex").pipe(
1995 Effect.provideService(ServerSecretStore.ServerSecretStore, h.secrets),
1996 );
1997 // The harness owns its store; seed the corresponding binding in that store.
1998 h.bytes.set(unrelated.binding.key, new TextEncoder().encode("invalid"));
1999 yield* h.signIn;
2000 yield* h.phase("succeeded");
2001 const completed = h.analyticsEvents.find(({ event }) => event === "chatgpt.auth.completed");
2002 assert.strictEqual(completed?.properties?.outcome, "succeeded");
2003 assert.notProperty(completed?.properties ?? {}, "connectedAccountCount");
2004 }).pipe(Effect.provide(ServerSettings.layerTest())),
2005 ),
2006);
2007
2008it.effect("reports connections without an email separately from identifiable accounts", () =>
2009 provision(
2010 Effect.gen(function* () {
2011 const h = yield* makeHarness;
2012 yield* h.signIn;
2013 yield* h.phase("succeeded");
2014 const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe(
2015 Effect.provideService(ServerSecretStore.ServerSecretStore, h.secrets),
2016 );
2017 yield* store.set(
2018 new TextEncoder().encode(
2019 JSON.stringify({
2020 activeClientId: "oaiapp_test",
2021 sessions: [{ ...Option.getOrThrow(yield* h.auth.read), email: null }],
2022 }),
2023 ),
2024 );
2025 h.setCallbackClientId("oaiapp_other_account");
2026 h.setIdentity("other-user", "other@example.test");
2027 yield* h.changeAccount;
2028 yield* h.phase("succeeded");
2029 const completed = h.analyticsEvents.findLast(
2030 ({ event }) => event === "chatgpt.auth.completed",
2031 );
2032 assert.strictEqual(completed?.properties?.connectedAccountCount, 1);
2033 assert.strictEqual(completed?.properties?.connectedConnectionCount, 2);
2034 assert.strictEqual(completed?.properties?.unidentifiedConnectedConnectionCount, 1);
2035 }),
2036 ),
2037);
2038
2039it.effect("records an expired auth outcome when sign-in reaches its deadline", () =>
1805it.effect("expires sign-in when it reaches its deadline", () =>
20401806 provision(
20411807 Effect.gen(function* () {
20421808 const h = yield* makeHarness;
20431809 yield* h.startRemote();
20441810 yield* TestClock.adjust(300_001);
20451811 assert.include((yield* h.phase("failed")).message ?? "", "expired");
2046 const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed");
2047 assert.strictEqual(completed.length, 1);
2048 assert.strictEqual(completed[0]?.properties?.outcome, "expired");
2049 assert.isAtLeast(completed[0]?.properties?.durationMs as number, 300_000);
20501812 }),
20511813 ),
20521814);
apps/server/src/provider/CodexChatGptAuth.ts+3-128
......@@ -10,9 +10,6 @@ import {
1010} from "@t3tools/contracts";
1111import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff";
1212import * as Clock from "effect/Clock";
13import * as Cause from "effect/Cause";
14import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
15import * as Crypto from "effect/Crypto";
1613import * as Exit from "effect/Exit";
1714import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts";
1815import * as Effect from "effect/Effect";
......@@ -25,10 +22,8 @@ import { HttpClient, HttpClientRequest } from "effect/http";
2522import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
2623import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts";
2724import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
28import * as ServerSettings from "../serverSettings.ts";
2925import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
3026
31const isSetupError = Schema.is(ProviderSetupError);
3227const RESOURCE = "https://api.openai.com/v1";
3328const REQUIRED_SCOPE = "chatgpt.tokens.use.direct";
3429const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration";
......@@ -105,53 +100,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
105100 readonly resource?: string;
106101 readonly defaultReturnUrl?: string;
107102 readonly reconnectProfile?: ChatGptReconnectProfile | null;
108 readonly telemetryFlow?: "direct" | "primary_handoff";
109103}) {
110 const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService);
111 const settings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService);
112 const track = <A, E extends ProviderSetupError, R>(
113 event: "auth" | "transfer",
114 properties: Readonly<Record<string, string>>,
115 task: Effect.Effect<A, E, R>,
116 expiresAt?: number,
117 ) =>
118 Effect.gen(function* () {
119 if (Option.isNone(analytics)) return yield* task;
120 const record = (name: string, properties: Readonly<Record<string, unknown>>) =>
121 analytics.value.record(name, properties).pipe(Effect.ignoreCause);
122 const startedAt = yield* Clock.currentTimeMillis;
123 yield* record(`chatgpt.${event}.started`, properties);
124 return yield* task.pipe(
125 Effect.onExit((result) =>
126 Effect.gen(function* () {
127 const endedAt = yield* Clock.currentTimeMillis;
128 const error = Exit.isFailure(result)
129 ? Cause.findErrorOption(result.cause)
130 : Option.none();
131 const counts =
132 Exit.isSuccess(result) &&
133 (event === "transfer" || options.telemetryFlow !== "primary_handoff")
134 ? yield* accountCounts.pipe(Effect.catchCause(() => Effect.succeed({})))
135 : {};
136 yield* record(`chatgpt.${event}.completed`, {
137 ...counts,
138 ...properties,
139 outcome: Exit.isSuccess(result)
140 ? "succeeded"
141 : Cause.hasInterruptsOnly(result.cause)
142 ? expiresAt !== undefined && endedAt >= expiresAt
143 ? "expired"
144 : "cancelled"
145 : "failed",
146 durationMs: Math.max(0, endedAt - startedAt),
147 ...(Option.isSome(error) && isSetupError(error.value)
148 ? { failureStage: error.value.operation }
149 : {}),
150 });
151 }),
152 ),
153 );
154 });
155104 const http = yield* HttpClient.HttpClient;
156105 const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId);
157106 const registrationStore = yield* ProviderCredentialStore.make(
......@@ -159,7 +108,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
159108 options.instanceId,
160109 );
161110 const secrets = yield* ServerSecretStore.ServerSecretStore;
162 const crypto = yield* Crypto.Crypto;
163111 const environmentLocks = sessionLocks.get(secrets) ?? new Map<string, Semaphore.Semaphore>();
164112 sessionLocks.set(secrets, environmentLocks);
165113 const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1));
......@@ -264,54 +212,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
264212 ),
265213 ),
266214 );
267 const accountCounts = Effect.gen(function* () {
268 const instanceIds = new Set<string>([options.instanceId]);
269 if (Option.isSome(settings)) {
270 const current = yield* settings.value.getSettings;
271 // Include the legacy default instance as well as explicitly configured ones.
272 instanceIds.add("codex");
273 for (const [id, instance] of Object.entries(current.providerInstances)) {
274 if (instance.driver === "codex") instanceIds.add(id);
275 }
276 }
277 const accounts = new Set<string>();
278 const connections = new Set<string>();
279 const savedConnections = new Set<string>();
280 let unidentifiedConnectedConnectionCount = 0;
281 for (const id of instanceIds) {
282 const registrations = yield* ProviderCredentialStore.make("codex-chatgpt-registration", id);
283 const registrationBytes = yield* registrations.get;
284 if (Option.isSome(registrationBytes)) {
285 const saved = yield* decodeRegistration(new TextDecoder().decode(registrationBytes.value));
286 for (const profile of "profiles" in saved ? saved.profiles : [saved]) {
287 savedConnections.add(profile.clientId);
288 }
289 }
290 const credentials = yield* ProviderCredentialStore.make("codex-chatgpt", id);
291 const credentialBytes = yield* credentials.get;
292 if (Option.isNone(credentialBytes)) continue;
293 const saved = yield* decodeSessions(new TextDecoder().decode(credentialBytes.value));
294 for (const session of "sessions" in saved ? saved.sessions : [saved]) {
295 if (!session.scopes.includes(REQUIRED_SCOPE) || connections.has(session.clientId)) continue;
296 connections.add(session.clientId);
297 // Subjects are client-scoped. Use verified email only for counting locally,
298 // never export it or merge the underlying profiles.
299 const email = session.email?.trim().toLowerCase();
300 if (email) accounts.add(email);
301 else unidentifiedConnectedConnectionCount++;
302 }
303 }
304 return {
305 accountCountScope: Option.isSome(settings) ? "environment" : "provider_instance",
306 connectedAccountCount: accounts.size,
307 connectedConnectionCount: connections.size,
308 savedConnectionCount: savedConnections.size,
309 unidentifiedConnectedConnectionCount,
310 };
311 }).pipe(
312 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
313 Effect.provideService(Crypto.Crypto, crypto),
314 );
315215 const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) {
316216 const saved = yield* readRegistrations;
317217 profile = {
......@@ -861,28 +761,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
861761 })),
862762 ];
863763 }),
864 authenticate: (method, context) =>
865 lock.withPermit(
866 track(
867 "auth",
868 {
869 flow: options.telemetryFlow ?? "direct",
870 intent:
871 options.telemetryFlow === "primary_handoff"
872 ? options.reconnectProfile
873 ? "saved_profile"
874 : "different_account"
875 : method === "chatgpt-change-account"
876 ? "different_account"
877 : method.startsWith("chatgpt-profile:")
878 ? "saved_profile"
879 : "default",
880 callbackMode: context.callbackMode ?? "server",
881 },
882 authenticate(method, context),
883 context.expiresAt,
884 ),
885 ),
764 authenticate: (method, context) => lock.withPermit(authenticate(method, context)),
886765 logout: lock.withPermit(withSessionLock(logout)),
887766 });
888767 const reconnectProfile = Effect.fnUntraced(function* (methodId: string) {
......@@ -971,12 +850,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
971850 yield* save(profile.credentials, true);
972851 });
973852 return lock.withPermit(
974 track(
975 "transfer",
976 { flow: "primary_handoff" },
977 validate.pipe(
978 Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)),
979 ),
853 validate.pipe(
854 Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)),
980855 ),
981856 );
982857 };
apps/server/src/provider/CodexChatGptHandoff.ts-1
......@@ -35,7 +35,6 @@ export function subscribeChatGptHandoff(
3535 ...endpoints,
3636 instanceId: input.instanceId,
3737 reconnectProfile: input.profile,
38 telemetryFlow: "primary_handoff",
3938 defaultReturnUrl: input.returnUrl,
4039 }).pipe(
4140 Effect.provideService(ServerSecretStore.ServerSecretStore, store),
apps/server/src/pullRequest/http.ts+1-2
......@@ -3,7 +3,6 @@ import * as Effect from "effect/Effect";
33import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
44
55import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts";
6import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts";
76import * as PullRequestService from "./PullRequestService.ts";
87
98/** The patch is often the largest PR payload and benefits from HTTP compression and flow control. */
......@@ -17,7 +16,7 @@ export const layer = HttpApiBuilder.group(
1716 Effect.fn("environment.pullRequests.diff")(function* (args) {
1817 yield* annotateEnvironmentRequest(args.endpoint.name);
1918 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
20 return yield* pullRequests.diff(args.payload).pipe(traceLocalHandlerWork);
19 return yield* pullRequests.diff(args.payload);
2120 }),
2221 );
2322 }),
apps/server/src/relay/AgentAwarenessRelay.ts+1-2
......@@ -14,7 +14,6 @@ import {
1414import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness";
1515import { turnItemUpdateCanEndBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
1616import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
17import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1817import {
1918 normalizeRelayIssuer,
2019 RELAY_ACTIVITY_PUBLISH_TYP,
......@@ -521,7 +520,7 @@ export const make = Effect.gen(function* () {
521520 ok: response.ok,
522521 deliveries: deliveryStats(response.deliveries),
523522 });
524 }).pipe(Effect.withSpan("relay.agent_activity.publish"), withRelayClientTracing);
523 }).pipe(Effect.withSpan("relay.agent_activity.publish"));
525524
526525 // Per-thread shell read: this publish runs for every activity-relevant
527526 // domain event, so materializing the full shell here would make the cost
apps/server/src/scheduledTasks/webhookRoute.ts+1-3
......@@ -7,7 +7,6 @@ import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage";
77import type * as HttpServerRequest from "effect/http/HttpServerRequest";
88import * as HttpServerResponse from "effect/http/HttpServerResponse";
99import * as HttpTraceContext from "effect/http/HttpTraceContext";
10import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1110import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
1211
1312import * as Metrics from "../observability/Metrics.ts";
......@@ -100,8 +99,7 @@ export const layer = HttpApiBuilder.group(
10099 })
101100 .pipe(
102101 Option.isSome(relayParent)
103 ? (effect) =>
104 effect.pipe(Effect.withParentSpan(relayParent.value), withRelayClientTracing)
102 ? (effect) => effect.pipe(Effect.withParentSpan(relayParent.value))
105103 : (effect) => effect,
106104 // Defects too, so the sender only ever sees the fixed error body.
107105 Effect.catchCause((cause) =>
apps/server/src/server.ts-6
......@@ -67,8 +67,6 @@ import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts
6767import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts";
6868import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts";
6969import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
70import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
71import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts";
7270import * as ModelManifest from "./provider/ModelManifest.ts";
7371import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts";
7472import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts";
......@@ -162,7 +160,6 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
162160import * as CloudHttp from "./cloud/http.ts";
163161import * as CloudLink from "./cloud/CloudLink.ts";
164162import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts";
165import * as RelayTracing from "./cloud/relayTracing.ts";
166163import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
167164import {
168165 MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER,
......@@ -510,7 +507,6 @@ const layerScheduledTaskWebhookOrigin = Layer.effect(
510507
511508const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe(
512509 Layer.provide(layerScheduledTaskWebhookOrigin),
513 Layer.provide(ProviderEventIngestor.layerAnalytics),
514510 Layer.provide(layerCheckpointStore),
515511 Layer.provide(layerGitWorkflow),
516512 Layer.provide(ResourceCleanupService.layer),
......@@ -769,7 +765,6 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe(
769765 Layer.provideMerge(layerBackground),
770766 Layer.provideMerge(layerResourceDiagnostics),
771767 Layer.provideMerge(TraceDiagnostics.layer),
772 Layer.provideMerge(AnalyticsService.layer),
773768 Layer.provideMerge(ExternalLauncher.layer),
774769 Layer.provideMerge(RemoteOpenTargets.layer),
775770 Layer.provideMerge(DirectEndpoints.layer),
......@@ -1206,7 +1201,6 @@ const layerMakeServer = Layer.unwrap(
12061201 ),
12071202 ),
12081203 Layer.provide(layerActivation),
1209 Layer.provideMerge(RelayTracing.layerServerRelayBroker),
12101204 Layer.provideMerge(layerHttpServer),
12111205 Layer.provide(layerApplicationObservability),
12121206 Layer.provideMerge(FetchHttpClient.layer),
apps/server/src/serverRuntimeStartup.autoPull.test.ts-2
......@@ -30,7 +30,6 @@ import { ServerActivation } from "./serverActivation.ts";
3030import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
3131import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
3232import * as ServerSettings from "./serverSettings.ts";
33import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
3433import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
3534
3635it.effect("parks automatic pull until activation without delaying command readiness", () =>
......@@ -162,7 +161,6 @@ it.effect("parks automatic pull until activation without delaying command readin
162161 Effect.onInterrupt(() => Deferred.succeed(statusInterrupted, undefined)),
163162 ),
164163 }),
165 Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void }),
166164 NodeCrypto.layer,
167165 Layer.mock(EnvironmentAuth.EnvironmentAuth)({}),
168166 Layer.mock(ExternalLauncher.ExternalLauncher)({}),
apps/server/src/serverRuntimeStartup.ts-41
......@@ -44,7 +44,6 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
4444import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
4545import * as ServerSettings from "./serverSettings.ts";
4646import { forkParked, forkParkedFiber } from "./serverActivation.ts";
47import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
4847import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
4948import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
5049import {
......@@ -143,37 +142,6 @@ export const makeCommandGate = Effect.gen(function* () {
143142 } satisfies CommandGate;
144143});
145144
146const recordStartupHeartbeat = Effect.gen(function* () {
147 const analytics = yield* AnalyticsService.AnalyticsService;
148 const projects = yield* ProjectService.ProjectService;
149 const threads = yield* ThreadManagement.ThreadManagementService;
150
151 const { threadCount, projectCount } = yield* Effect.all({
152 projects: projects.snapshot,
153 threads: threads.getShellSnapshot(),
154 }).pipe(
155 Effect.map(({ projects: projectSnapshot, threads: shellSnapshot }) => ({
156 projectCount: projectSnapshot.projects.length,
157 threadCount: shellSnapshot.threads.length + shellSnapshot.archivedThreads.length,
158 })),
159 Effect.catch((cause) =>
160 Effect.logWarning("failed to gather V2 startup counts for telemetry", {
161 cause,
162 }).pipe(
163 Effect.as({
164 threadCount: 0,
165 projectCount: 0,
166 }),
167 ),
168 ),
169 );
170
171 yield* analytics.record("server.boot.heartbeat", {
172 threadCount,
173 projectCount,
174 });
175});
176
177145export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({
178146 instanceId: ProviderInstanceId.make("codex"),
179147 model: DEFAULT_MODEL,
......@@ -581,13 +549,6 @@ const make = (options?: StartupOptions) =>
581549
582550 yield* forkParked(
583551 Effect.gen(function* () {
584 yield* Effect.logDebug("startup phase: recording startup heartbeat");
585 yield* recordStartupHeartbeat.pipe(
586 Effect.withSpan("server.startup.heartbeat.record", {
587 attributes: { "startup.phase": "heartbeat.record" },
588 }),
589 Effect.ignoreCause({ log: true }),
590 );
591552 if (serverConfig.startupPresentation === "headless") {
592553 yield* Effect.logDebug("startup phase: headless access info");
593554 const accessInfo = yield* issueHeadlessServeAccessInfo();
......@@ -696,5 +657,3 @@ const make = (options?: StartupOptions) =>
696657
697658export const layerWithOptions = (options?: StartupOptions) =>
698659 Layer.effect(ServerRuntimeStartup, make(options));
699
700const layer = layerWithOptions();
apps/server/src/telemetry/AnalyticsService.test.ts deleted-277
......@@ -1,277 +0,0 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8import * as TestClock from "effect/testing/TestClock";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientError from "effect/http/HttpClientError";
11import * as HttpServer from "effect/http/HttpServer";
12import * as HttpServerRequest from "effect/http/HttpServerRequest";
13import * as HttpServerResponse from "effect/http/HttpServerResponse";
14import * as HostProcess from "@t3tools/shared/HostProcess";
15
16import * as ServerConfig from "../config.ts";
17import { getTelemetryIdentifier } from "./Identify.ts";
18import * as AnalyticsService from "./AnalyticsService.ts";
19
20interface RecordedBatchRequest {
21 readonly path: string;
22 readonly body: {
23 readonly batch?: ReadonlyArray<{
24 readonly event?: string;
25 readonly properties?: {
26 readonly index?: number;
27 readonly clientType?: string;
28 readonly serverOs?: string;
29 readonly serverArch?: string;
30 readonly serverAppVersion?: string;
31 readonly serverMode?: string;
32 readonly t3CodeVersion?: string;
33 };
34 }>;
35 } | null;
36}
37
38interface RecordedBatchBody {
39 readonly batch: ReadonlyArray<{
40 readonly event?: string;
41 readonly properties?: {
42 readonly index?: number;
43 readonly clientType?: string;
44 readonly serverOs?: string;
45 readonly serverArch?: string;
46 readonly serverAppVersion?: string;
47 readonly serverMode?: string;
48 readonly t3CodeVersion?: string;
49 };
50 }>;
51}
52
53const SentBatch = Schema.fromJsonString(
54 Schema.Struct({
55 batch: Schema.Array(Schema.Struct({ uuid: Schema.String })),
56 }),
57);
58
59/**
60 * HTTP client that reads each batch, then fails as if the connection dropped
61 * before the response arrived. PostHog stores these batches, so the server
62 * must not send them forever.
63 */
64const layerAcceptThenFailClient = (batches: Array<ReadonlyArray<{ readonly uuid: string }>>) =>
65 Layer.succeed(
66 HttpClient.HttpClient,
67 HttpClient.make((request) =>
68 Effect.gen(function* () {
69 if (request.body._tag === "Uint8Array") {
70 const body = yield* Schema.decodeEffect(SentBatch)(
71 new TextDecoder().decode(request.body.body),
72 ).pipe(Effect.orDie);
73 batches.push(body.batch);
74 }
75 return yield* new HttpClientError.HttpClientError({
76 reason: new HttpClientError.TransportError({ request, cause: "connection reset" }),
77 });
78 }),
79 ),
80 );
81
82it("retryDelayMs doubles from 2s and stays under the 5 minute cap", () => {
83 assert.equal(AnalyticsService.retryDelayMs(1, 0), 1_000);
84 assert.equal(AnalyticsService.retryDelayMs(2, 0.999_999), 4_000);
85 assert.equal(AnalyticsService.retryDelayMs(30, 0), 150_000);
86 assert.equal(AnalyticsService.retryDelayMs(30, 0.999_999), 300_000);
87});
88
89it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
90 it.effect("a batch that keeps failing is retried with backoff, then dropped", () =>
91 Effect.gen(function* () {
92 const batches: Array<ReadonlyArray<{ readonly uuid: string }>> = [];
93 const layerRuntime = AnalyticsService.layer.pipe(
94 Layer.provideMerge(
95 ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-retry-" }),
96 ),
97 Layer.provide(
98 ConfigProvider.layer(
99 ConfigProvider.fromUnknown({
100 T3CODE_TELEMETRY_ENABLED: true,
101 T3CODE_POSTHOG_KEY: "phc_test_key",
102 T3CODE_POSTHOG_HOST: "http://localhost",
103 }),
104 ),
105 ),
106 Layer.provide(
107 Layer.mergeAll(
108 Layer.succeed(HostProcess.Platform, "win32"),
109 Layer.succeed(HostProcess.Architecture, "x64"),
110 layerAcceptThenFailClient(batches),
111 ),
112 ),
113 );
114
115 yield* Effect.gen(function* () {
116 const analytics = yield* AnalyticsService.AnalyticsService;
117 for (let index = 0; index < 20; index += 1) {
118 yield* analytics.record("test.retry", { index });
119 }
120 // Before the fix this loop sent the batch about once a second.
121 for (let second = 0; second < 600; second += 1) {
122 yield* TestClock.adjust("1 second");
123 }
124 }).pipe(Effect.provide(layerRuntime));
125
126 assert.equal(batches.length, 5);
127 const uuids = batches.map((batch) => batch.map((event) => event.uuid).join(","));
128 assert.equal(new Set(uuids).size, 1, "every retry carries the same uuids");
129 assert.equal(new Set(batches[0]?.map((event) => event.uuid)).size, 20);
130 }),
131 );
132
133 it.effect("flush drains all buffered events across multiple batches", () =>
134 Effect.gen(function* () {
135 const capturedRequests: Array<RecordedBatchRequest> = [];
136 const layerServerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), {
137 prefix: "t3-telemetry-base-",
138 });
139
140 const layerTelemetry = AnalyticsService.layer.pipe(Layer.provideMerge(layerServerConfig));
141 const layerConfig = ConfigProvider.layer(
142 ConfigProvider.fromUnknown({
143 T3CODE_TELEMETRY_ENABLED: true,
144 T3CODE_POSTHOG_KEY: "phc_test_key",
145 T3CODE_POSTHOG_HOST: "http://localhost",
146 T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20,
147 }),
148 );
149 const layerBatchServer = HttpServer.serve(
150 Effect.gen(function* () {
151 const request = yield* HttpServerRequest.HttpServerRequest;
152 if (request.method !== "POST") {
153 return HttpServerResponse.empty({ status: 404 });
154 }
155
156 const payload = yield* request.json.pipe(
157 Effect.map((body) => body as RecordedBatchRequest["body"]),
158 Effect.orElseSucceed(() => null),
159 );
160
161 capturedRequests.push({ path: request.url, body: payload });
162
163 return HttpServerResponse.jsonUnsafe({});
164 }),
165 );
166 const layerRuntime = layerTelemetry.pipe(
167 Layer.provide(layerConfig),
168 Layer.provide(
169 Layer.mergeAll(
170 Layer.succeed(HostProcess.Platform, "linux"),
171 Layer.succeed(HostProcess.Architecture, "arm64"),
172 ),
173 ),
174 Layer.provideMerge(NodeHttpServer.layerTest),
175 );
176
177 yield* Effect.gen(function* () {
178 yield* Layer.launch(layerBatchServer).pipe(Effect.forkScoped);
179 const telemetryIdentifier = yield* getTelemetryIdentifier;
180 assert.equal(telemetryIdentifier !== null, true);
181 const analytics = yield* AnalyticsService.AnalyticsService;
182
183 for (let index = 0; index < 45; index += 1) {
184 yield* analytics.record("test.flush.drain", { index });
185 }
186
187 yield* analytics.flush;
188 }).pipe(Effect.provide(layerRuntime));
189
190 const batchRequests = capturedRequests.filter(
191 (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } =>
192 Array.isArray(request.body?.batch),
193 );
194 assert.equal(batchRequests.length, 3);
195 assert.equal(
196 batchRequests.every(
197 (request) => request.path.endsWith("/batch/") || request.path.endsWith("/batch"),
198 ),
199 true,
200 );
201 const deliveredIndexes = batchRequests.flatMap((request) =>
202 request.body.batch
203 .filter((event) => event.event === "test.flush.drain")
204 .map((event) => event.properties?.index)
205 .filter((index): index is number => typeof index === "number"),
206 );
207
208 const sorted = deliveredIndexes.toSorted((a, b) => a - b);
209 assert.equal(sorted.length, 45);
210 assert.deepEqual(
211 sorted,
212 Array.from({ length: 45 }, (_, index) => index),
213 );
214 assert.equal(
215 batchRequests.every((request) =>
216 request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"),
217 ),
218 true,
219 );
220 assert.equal(
221 batchRequests.every((request) =>
222 request.body.batch.every(
223 (event) =>
224 event.properties?.serverOs === "Linux" &&
225 event.properties.serverArch === "arm64" &&
226 event.properties.serverAppVersion === event.properties.t3CodeVersion &&
227 event.properties.serverMode === "web",
228 ),
229 ),
230 true,
231 );
232 }),
233 );
234
235 it.effect("does not send batch requests when telemetry is disabled", () =>
236 Effect.gen(function* () {
237 const capturedPaths: Array<string> = [];
238 const layerServerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), {
239 prefix: "t3-telemetry-disabled-",
240 });
241 const layerTelemetry = AnalyticsService.layer.pipe(Layer.provideMerge(layerServerConfig));
242 const layerConfig = ConfigProvider.layer(
243 ConfigProvider.fromUnknown({
244 T3CODE_TELEMETRY_ENABLED: false,
245 T3CODE_POSTHOG_KEY: "phc_test_key",
246 T3CODE_POSTHOG_HOST: "http://localhost",
247 }),
248 );
249 const layerBatchServer = HttpServer.serve(
250 Effect.gen(function* () {
251 const request = yield* HttpServerRequest.HttpServerRequest;
252 capturedPaths.push(request.url);
253 return HttpServerResponse.jsonUnsafe({});
254 }),
255 );
256 const layerRuntime = layerTelemetry.pipe(
257 Layer.provide(layerConfig),
258 Layer.provide(
259 Layer.mergeAll(
260 Layer.succeed(HostProcess.Platform, "linux"),
261 Layer.succeed(HostProcess.Architecture, "arm64"),
262 ),
263 ),
264 Layer.provideMerge(NodeHttpServer.layerTest),
265 );
266
267 yield* Effect.gen(function* () {
268 yield* Layer.launch(layerBatchServer).pipe(Effect.forkScoped);
269 const analytics = yield* AnalyticsService.AnalyticsService;
270 yield* analytics.record("test.disabled", { index: 1 });
271 yield* analytics.flush;
272 }).pipe(Effect.provide(layerRuntime));
273
274 assert.deepEqual(capturedPaths, []);
275 }),
276 );
277});
apps/server/src/telemetry/AnalyticsService.ts deleted-288
......@@ -1,288 +0,0 @@
1import { SERVER_VERSION } from "../version.ts";
2/**
3 * Anonymous PostHog telemetry service.
4 *
5 * Persists an installation-scoped anonymous identifier, buffers events in
6 * memory, and flushes batches over Effect's HTTP client. A failed batch is
7 * retried with backoff and dropped after a few tries. Each event carries a
8 * uuid, so PostHog can tell a retried copy from a new event.
9 *
10 * @module AnalyticsService
11 */
12import * as HostProcess from "@t3tools/shared/HostProcess";
13import type { ClientOs } from "@t3tools/contracts";
14import * as Clock from "effect/Clock";
15import * as Config from "effect/Config";
16import * as Context from "effect/Context";
17import * as Crypto from "effect/Crypto";
18import * as DateTime from "effect/DateTime";
19import * as Effect from "effect/Effect";
20import * as Exit from "effect/Exit";
21import * as Layer from "effect/Layer";
22import * as Option from "effect/Option";
23import * as Random from "effect/Random";
24import * as Ref from "effect/Ref";
25import * as Result from "effect/Result";
26import * as Semaphore from "effect/Semaphore";
27import * as HttpClient from "effect/http/HttpClient";
28import * as HttpClientRequest from "effect/http/HttpClientRequest";
29import * as HttpClientResponse from "effect/http/HttpClientResponse";
30
31import * as ServerConfig from "../config.ts";
32import { getTelemetryIdentifier } from "./Identify.ts";
33
34interface BufferedAnalyticsEvent {
35 readonly uuid: string;
36 readonly event: string;
37 readonly properties?: Readonly<Record<string, unknown>>;
38 readonly capturedAt: string;
39}
40
41interface DeliveryState {
42 /** Batch that failed last. It is sent again before newer events. */
43 readonly failedBatch: ReadonlyArray<BufferedAnalyticsEvent>;
44 /** Failed sends of `failedBatch`. */
45 readonly batchAttempts: number;
46 /** Failed sends since the last success. Sets the backoff delay. */
47 readonly failures: number;
48 /** The background flush does not send before this time (epoch ms). */
49 readonly retryAt: number;
50}
51
52const FLUSH_INTERVAL_MS = 1_000;
53// A hung send would hold the flush lock, and with it the shutdown flush.
54const SEND_TIMEOUT = "10 seconds";
55const MAX_BATCH_ATTEMPTS = 5;
56const RETRY_BASE_DELAY_MS = 2_000;
57const RETRY_MAX_DELAY_MS = 300_000;
58
59/**
60 * Delay before the next send after `failures` consecutive failed sends. The
61 * ceiling doubles from 2s up to 5 minutes, and the delay is a random point in
62 * its upper half. `random` is in [0, 1).
63 */
64export function retryDelayMs(failures: number, random: number): number {
65 const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1));
66 return Math.round(ceiling / 2 + (ceiling / 2) * random);
67}
68
69const TelemetryEnvConfig = Config.all({
70 posthogKey: Config.String("T3CODE_POSTHOG_KEY").pipe(
71 Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"),
72 ),
73 posthogHost: Config.String("T3CODE_POSTHOG_HOST").pipe(
74 Config.withDefault("https://us.i.posthog.com"),
75 ),
76 enabled: Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)),
77 flushBatchSize: Config.Number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)),
78 maxBufferedEvents: Config.Number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe(
79 Config.withDefault(1_000),
80 ),
81 wslDistroName: Config.String("WSL_DISTRO_NAME").pipe(Config.option),
82});
83
84export class AnalyticsService extends Context.Service<
85 AnalyticsService,
86 {
87 /** Record an anonymous event for best-effort buffered delivery. */
88 readonly record: (
89 event: string,
90 properties?: Readonly<Record<string, unknown>>,
91 ) => Effect.Effect<void>;
92
93 /** Flush all currently queued telemetry events. */
94 readonly flush: Effect.Effect<void>;
95 }
96>()("t3/telemetry/AnalyticsService") {
97 /** No-op layer for callers that intentionally disable telemetry. */
98 static readonly layerTest = Layer.succeed(
99 AnalyticsService,
100 AnalyticsService.of({
101 record: () => Effect.void,
102 flush: Effect.void,
103 }),
104 );
105}
106
107function serverOsFromNodePlatform(platform: string): ClientOs {
108 switch (platform) {
109 case "darwin":
110 return "macOS";
111 case "win32":
112 return "Windows";
113 case "linux":
114 return "Linux";
115 case "android":
116 return "Android";
117 default:
118 return "other";
119 }
120}
121
122/** @public Service construction is part of the canonical Effect module API. */
123export const make = Effect.gen(function* () {
124 const telemetryConfig = yield* TelemetryEnvConfig;
125 const httpClient = yield* HttpClient.HttpClient;
126 const serverConfig = yield* ServerConfig.ServerConfig;
127 const identifier = yield* getTelemetryIdentifier;
128 const crypto = yield* Crypto.Crypto;
129 const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);
130 const deliveryRef = yield* Ref.make<DeliveryState>({
131 failedBatch: [],
132 batchAttempts: 0,
133 failures: 0,
134 retryAt: 0,
135 });
136 // The background flush and the shutdown flush must not send the same batch at once.
137 const flushLock = yield* Semaphore.make(1);
138 const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client";
139 const hostPlatform = yield* HostProcess.Platform;
140 const hostArchitecture = yield* HostProcess.Architecture;
141
142 const enqueueBufferedEvent = (
143 uuid: string,
144 event: string,
145 properties?: Readonly<Record<string, unknown>>,
146 ) =>
147 Effect.flatMap(DateTime.now, (now) =>
148 Ref.modify(bufferRef, (current) => {
149 const appended = [
150 ...current,
151 {
152 uuid,
153 event,
154 ...(properties ? { properties } : {}),
155 capturedAt: DateTime.formatIso(now),
156 } satisfies BufferedAnalyticsEvent,
157 ];
158
159 const next =
160 appended.length > telemetryConfig.maxBufferedEvents
161 ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents)
162 : appended;
163
164 return [
165 {
166 size: next.length,
167 dropped: next.length !== appended.length,
168 } as const,
169 next,
170 ] as const;
171 }),
172 );
173
174 const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* (
175 events: ReadonlyArray<BufferedAnalyticsEvent>,
176 ) {
177 if (!telemetryConfig.enabled || !identifier) return;
178
179 const payload = {
180 api_key: telemetryConfig.posthogKey,
181 batch: events.map((event) => ({
182 uuid: event.uuid,
183 event: event.event,
184 distinct_id: identifier,
185 properties: {
186 ...event.properties,
187 $process_person_profile: false,
188 platform: hostPlatform,
189 wsl: Option.getOrUndefined(telemetryConfig.wslDistroName),
190 arch: hostArchitecture,
191 t3CodeVersion: SERVER_VERSION,
192 clientType,
193 serverOs: serverOsFromNodePlatform(hostPlatform),
194 serverArch: hostArchitecture,
195 serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName),
196 serverAppVersion: SERVER_VERSION,
197 serverMode: serverConfig.mode,
198 },
199 timestamp: event.capturedAt,
200 })),
201 };
202
203 yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe(
204 HttpClientRequest.bodyJson(payload),
205 Effect.flatMap(httpClient.execute),
206 Effect.flatMap(HttpClientResponse.filterStatusOk),
207 Effect.timeout(SEND_TIMEOUT),
208 );
209 });
210
211 const takeBatch = Ref.modify(bufferRef, (current) => {
212 const nextBatch = current.slice(0, telemetryConfig.flushBatchSize);
213 return [nextBatch, current.slice(nextBatch.length)] as const;
214 });
215
216 // Sends batches until the buffer is empty or a send fails. A failed batch is
217 // kept for the next flush, and dropped after MAX_BATCH_ATTEMPTS failed sends.
218 const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () {
219 while (true) {
220 const delivery = yield* Ref.get(deliveryRef);
221 const batch = delivery.failedBatch.length > 0 ? delivery.failedBatch : yield* takeBatch;
222 if (batch.length === 0) {
223 return;
224 }
225
226 const sent = yield* Effect.result(sendBatch(batch));
227 if (Result.isSuccess(sent)) {
228 yield* Ref.set(deliveryRef, { failedBatch: [], batchAttempts: 0, failures: 0, retryAt: 0 });
229 continue;
230 }
231
232 const failures = delivery.failures + 1;
233 const batchAttempts = delivery.batchAttempts + 1;
234 const retryAt = (yield* Clock.currentTimeMillis) + retryDelayMs(failures, yield* Random.next);
235 if (batchAttempts < MAX_BATCH_ATTEMPTS) {
236 yield* Ref.set(deliveryRef, { failedBatch: batch, batchAttempts, failures, retryAt });
237 yield* Effect.logDebug("Failed to send telemetry batch; will retry", {
238 attempt: batchAttempts,
239 cause: sent.failure,
240 });
241 return;
242 }
243 yield* Ref.set(deliveryRef, { failedBatch: [], batchAttempts: 0, failures, retryAt });
244 yield* Effect.logWarning("Dropped telemetry batch after repeated send failures", {
245 events: batch.length,
246 attempts: batchAttempts,
247 cause: sent.failure,
248 });
249 return;
250 }
251 }).pipe(flushLock.withPermit);
252
253 const flushWhenDue = Effect.gen(function* () {
254 const { retryAt } = yield* Ref.get(deliveryRef);
255 if ((yield* Clock.currentTimeMillis) >= retryAt) {
256 yield* flush;
257 }
258 });
259
260 const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")(
261 function* (event, properties) {
262 if (!telemetryConfig.enabled || !identifier) return;
263
264 // Telemetry is best effort: an event without a uuid is not sent. The
265 // Node implementation throws (a defect) rather than failing, so catch both.
266 const uuid = yield* Effect.exit(crypto.randomUUIDv7());
267 if (Exit.isFailure(uuid)) return;
268
269 const enqueueResult = yield* enqueueBufferedEvent(uuid.value, event, properties);
270 if (enqueueResult.dropped) {
271 yield* Effect.logDebug("analytics buffer full; dropping oldest event", {
272 size: enqueueResult.size,
273 event,
274 });
275 }
276 },
277 );
278
279 yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), {
280 disableYield: true,
281 }).pipe(Effect.forkScoped);
282
283 yield* Effect.addFinalizer(() => flush);
284
285 return AnalyticsService.of({ record, flush });
286});
287
288export const layer = Layer.effect(AnalyticsService, make);
apps/server/src/telemetry/Identify.test.ts deleted-253
......@@ -1,253 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";
5import * as Hex from "effect/encoding/Hex";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Logger from "effect/Logger";
9import * as Path from "effect/Path";
10import * as PlatformError from "effect/PlatformError";
11import * as References from "effect/References";
12
13import * as ServerConfig from "../config.ts";
14import * as Identify from "./Identify.ts";
15
16interface CapturedLog {
17 readonly message: unknown;
18 readonly annotations: Readonly<Record<string, unknown>>;
19}
20
21const sha256 = Effect.fn("test.sha256")(function* (value: string) {
22 const crypto = yield* Crypto.Crypto;
23 return Hex.encode(
24 yield* crypto.digest("SHA-256", new TextEncoder().encode(value)).pipe(Effect.orDie),
25 );
26});
27
28const makeCaptureLogger = (logs: CapturedLog[]) =>
29 Logger.make(({ fiber, message }) => {
30 logs.push({
31 message,
32 annotations: fiber.getRef(References.CurrentLogAnnotations),
33 });
34 });
35
36const findIdentityLog = (
37 logs: ReadonlyArray<CapturedLog>,
38 source: Identify.TelemetryIdentitySource,
39 errorTag: string,
40) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag);
41
42it.layer(NodeServices.layer)("telemetry identity", (it) => {
43 it.effect("uses the persisted anonymous id when provider identities are absent", () =>
44 Effect.gen(function* () {
45 const config = yield* ServerConfig.ServerConfig;
46 const fileSystem = yield* FileSystem.FileSystem;
47 const path = yield* Path.Path;
48 const anonymousId = "persisted-anonymous-id";
49
50 yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId);
51
52 const identifier = yield* Identify.getTelemetryIdentifierForHome(
53 path.join(config.baseDir, "home"),
54 );
55
56 assert.equal(identifier, yield* sha256(anonymousId));
57 }).pipe(
58 Effect.provide(
59 ServerConfig.layerTest(process.cwd(), {
60 prefix: "t3-telemetry-identify-anonymous-",
61 }),
62 ),
63 ),
64 );
65
66 it.effect("leaves no torn anonymous id behind when its write fails midway", () =>
67 Effect.gen(function* () {
68 const config = yield* ServerConfig.ServerConfig;
69 const fileSystem = yield* FileSystem.FileSystem;
70 const path = yield* Path.Path;
71 const tornWrites = FileSystem.FileSystem.of({
72 ...fileSystem,
73 writeFileString: (filePath, data, options) =>
74 fileSystem
75 .writeFileString(filePath, data.slice(0, Math.floor(data.length / 2)), options)
76 .pipe(
77 Effect.andThen(
78 Effect.fail(
79 PlatformError.systemError({
80 _tag: "WriteZero",
81 module: "FileSystem",
82 method: "writeFileString",
83 pathOrDescriptor: filePath,
84 }),
85 ),
86 ),
87 ),
88 });
89
90 const identifier = yield* Identify.getTelemetryIdentifierForHome(
91 path.join(config.baseDir, "home"),
92 ).pipe(Effect.provideService(FileSystem.FileSystem, tornWrites));
93
94 assert.isNull(identifier);
95 assert.isFalse(yield* fileSystem.exists(config.anonymousIdPath));
96 }).pipe(
97 Effect.provide(
98 ServerConfig.layerTest(process.cwd(), {
99 prefix: "t3-telemetry-identify-torn-",
100 }),
101 ),
102 ),
103 );
104
105 it.effect.each([
106 {
107 login: "an API key",
108 secret: "sk-private-openai-api-key",
109 authJson: (secret: string) => `{"auth_mode":"apikey","OPENAI_API_KEY":"${secret}"}`,
110 },
111 {
112 login: "an agent identity",
113 secret: "private-agent-identity-jwt",
114 authJson: (secret: string) =>
115 `{"auth_mode":"agentIdentity","OPENAI_API_KEY":null,"agent_identity":"${secret}"}`,
116 },
117 {
118 login: "a personal access token",
119 secret: "private-personal-access-token",
120 authJson: (secret: string) => `{"OPENAI_API_KEY":null,"personal_access_token":"${secret}"}`,
121 },
122 ])("falls back quietly when Codex authenticates with $login", ({ secret, authJson }) => {
123 const logs: CapturedLog[] = [];
124 const logger = makeCaptureLogger(logs);
125
126 return Effect.gen(function* () {
127 const config = yield* ServerConfig.ServerConfig;
128 const fileSystem = yield* FileSystem.FileSystem;
129 const path = yield* Path.Path;
130 const homeDirectory = path.join(config.baseDir, "home");
131 const codexAuthPath = path.join(homeDirectory, ".codex", "auth.json");
132 const anonymousId = "tokenless-codex-anonymous-id";
133
134 yield* fileSystem.makeDirectory(path.dirname(codexAuthPath), { recursive: true });
135 yield* fileSystem.writeFileString(codexAuthPath, authJson(secret));
136 yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId);
137
138 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);
139
140 assert.equal(identifier, yield* sha256(anonymousId));
141 assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"));
142 assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityReadError"));
143 const allLogs = logs
144 .map((log) =>
145 [String(log.message), ...Object.values(log.annotations).map(String)].join("\n"),
146 )
147 .join("\n");
148 assert.notInclude(allLogs, secret);
149 }).pipe(
150 Effect.provide(
151 Layer.merge(
152 ServerConfig.layerTest(process.cwd(), {
153 prefix: "t3-telemetry-identify-tokenless-",
154 }),
155 Logger.layer([logger], { mergeWithExisting: false }),
156 ),
157 ),
158 );
159 });
160
161 it.effect("logs structured decode context and falls back from malformed Codex auth", () => {
162 const logs: CapturedLog[] = [];
163 const logger = makeCaptureLogger(logs);
164
165 return Effect.gen(function* () {
166 const config = yield* ServerConfig.ServerConfig;
167 const fileSystem = yield* FileSystem.FileSystem;
168 const path = yield* Path.Path;
169 const homeDirectory = path.join(config.baseDir, "home");
170 const codexAuthPath = path.join(homeDirectory, ".codex", "auth.json");
171 const anonymousId = "decode-fallback-anonymous-id";
172 const privateAccessToken = "private-codex-access-token";
173
174 yield* fileSystem.makeDirectory(path.dirname(codexAuthPath), { recursive: true });
175 yield* fileSystem.writeFileString(
176 codexAuthPath,
177 `{"tokens":{"access_token":"${privateAccessToken}"}}`,
178 );
179 yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId);
180
181 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);
182
183 assert.equal(identifier, yield* sha256(anonymousId));
184 const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError");
185 assert.isDefined(decodeLog);
186 assert.equal(
187 decodeLog?.message,
188 `Failed to decode codex telemetry identity at '${codexAuthPath}'.`,
189 );
190
191 assert.equal(decodeLog?.annotations.filePath, codexAuthPath);
192 assert.equal(decodeLog?.annotations.causeKind, "schema");
193 assert.notProperty(decodeLog?.annotations ?? {}, "cause");
194 const errorStack = decodeLog?.annotations.errorStack;
195 assert.isString(errorStack);
196 assert.include(errorStack, "Failed to decode codex telemetry identity");
197 const annotations = Object.values(decodeLog?.annotations ?? {})
198 .map(String)
199 .join("\n");
200 assert.notInclude(annotations, privateAccessToken);
201 }).pipe(
202 Effect.provide(
203 Layer.merge(
204 ServerConfig.layerTest(process.cwd(), {
205 prefix: "t3-telemetry-identify-decode-",
206 }),
207 Logger.layer([logger], { mergeWithExisting: false }),
208 ),
209 ),
210 );
211 });
212
213 it.effect("does not overwrite the anonymous id path after a non-NotFound read failure", () => {
214 const logs: CapturedLog[] = [];
215 const logger = makeCaptureLogger(logs);
216
217 return Effect.gen(function* () {
218 const config = yield* ServerConfig.ServerConfig;
219 const fileSystem = yield* FileSystem.FileSystem;
220 const path = yield* Path.Path;
221 const homeDirectory = path.join(config.baseDir, "home");
222
223 yield* fileSystem.makeDirectory(config.anonymousIdPath);
224
225 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);
226
227 assert.isNull(identifier);
228 assert.deepEqual(yield* fileSystem.readDirectory(config.anonymousIdPath), []);
229
230 const readLog = findIdentityLog(logs, "anonymous", "TelemetryIdentityReadError");
231 assert.isDefined(readLog);
232 assert.equal(readLog?.annotations.filePath, config.anonymousIdPath);
233 assert.equal(readLog?.annotations.causeKind, "platform");
234 assert.notEqual(readLog?.annotations.platformReason, "NotFound");
235 assert.notProperty(readLog?.annotations ?? {}, "cause");
236 const errorStack = readLog?.annotations.errorStack;
237 assert.isString(errorStack);
238 assert.include(errorStack, "Failed to read anonymous telemetry identity");
239 assert.isUndefined(
240 findIdentityLog(logs, "anonymous", "TelemetryAnonymousIdPersistenceError"),
241 );
242 }).pipe(
243 Effect.provide(
244 Layer.merge(
245 ServerConfig.layerTest(process.cwd(), {
246 prefix: "t3-telemetry-identify-read-",
247 }),
248 Logger.layer([logger], { mergeWithExisting: false }),
249 ),
250 ),
251 );
252 });
253});
apps/server/src/telemetry/Identify.ts deleted-317
......@@ -1,317 +0,0 @@
1import * as Crypto from "effect/Crypto";
2import * as Effect from "effect/Effect";
3import * as Hex from "effect/encoding/Hex";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";
8import * as Schema from "effect/Schema";
9
10import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
11import * as ServerConfig from "../config.ts";
12import * as HostProcess from "@t3tools/shared/HostProcess";
13
14/**
15 * Codex writes `tokens` only for ChatGPT logins and omits the key for API-key,
16 * agent-identity, and personal-access-token logins, so its absence is a
17 * supported install and not a malformed file.
18 */
19const CodexAuthJsonSchema = Schema.Struct({
20 tokens: Schema.optionalKey(
21 Schema.Struct({
22 account_id: Schema.String,
23 }),
24 ),
25});
26
27const ClaudeJsonSchema = Schema.Struct({
28 userID: Schema.String,
29});
30
31export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]);
32export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type;
33
34class TelemetryIdentityReadError extends Schema.TaggedError<TelemetryIdentityReadError>()(
35 "TelemetryIdentityReadError",
36 {
37 source: TelemetryIdentitySource,
38 filePath: Schema.String,
39 cause: Schema.Defect(),
40 },
41) {
42 override get message(): string {
43 return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`;
44 }
45}
46
47class TelemetryIdentityDecodeError extends Schema.TaggedError<TelemetryIdentityDecodeError>()(
48 "TelemetryIdentityDecodeError",
49 {
50 source: Schema.Literals(["codex", "claude"]),
51 filePath: Schema.String,
52 cause: Schema.Defect(),
53 },
54) {
55 override get message(): string {
56 return `Failed to decode ${this.source} telemetry identity at '${this.filePath}'.`;
57 }
58}
59
60export class TelemetryAnonymousIdGenerationError extends Schema.TaggedError<TelemetryAnonymousIdGenerationError>()(
61 "TelemetryAnonymousIdGenerationError",
62 {
63 source: Schema.Literal("anonymous"),
64 filePath: Schema.String,
65 cause: Schema.Defect(),
66 },
67) {
68 override get message(): string {
69 return `Failed to generate anonymous telemetry identity for '${this.filePath}'.`;
70 }
71}
72
73export class TelemetryAnonymousIdPersistenceError extends Schema.TaggedError<TelemetryAnonymousIdPersistenceError>()(
74 "TelemetryAnonymousIdPersistenceError",
75 {
76 source: Schema.Literal("anonymous"),
77 filePath: Schema.String,
78 cause: Schema.Defect(),
79 },
80) {
81 override get message(): string {
82 return `Failed to persist anonymous telemetry identity at '${this.filePath}'.`;
83 }
84}
85
86export class TelemetryIdentityHashError extends Schema.TaggedError<TelemetryIdentityHashError>()(
87 "TelemetryIdentityHashError",
88 {
89 source: TelemetryIdentitySource,
90 algorithm: Schema.Literal("SHA-256"),
91 cause: Schema.Defect(),
92 },
93) {
94 override get message(): string {
95 return `Failed to hash ${this.source} telemetry identity with ${this.algorithm}.`;
96 }
97}
98
99type TelemetryIdentityError =
100 | TelemetryIdentityReadError
101 | TelemetryIdentityDecodeError
102 | TelemetryAnonymousIdGenerationError
103 | TelemetryAnonymousIdPersistenceError
104 | TelemetryIdentityHashError;
105
106const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema));
107const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema));
108
109function isNotFoundError(error: PlatformError.PlatformError): boolean {
110 return error.reason._tag === "NotFound";
111}
112
113const getTelemetryIdentityCauseAnnotations = (cause: unknown) => {
114 if (cause instanceof PlatformError.PlatformError) {
115 return {
116 causeKind: "platform",
117 platformReason: cause.reason._tag,
118 };
119 }
120 if (cause instanceof Schema.SchemaError) {
121 return { causeKind: "schema" };
122 }
123 return { causeKind: "other" };
124};
125
126const logTelemetryIdentityError = (error: TelemetryIdentityError) =>
127 Effect.logWarning(error.message).pipe(
128 Effect.annotateLogs({
129 errorTag: error._tag,
130 source: error.source,
131 ...("filePath" in error ? { filePath: error.filePath } : {}),
132 ...getTelemetryIdentityCauseAnnotations(error.cause),
133 ...(error.stack === undefined ? {} : { errorStack: error.stack }),
134 }),
135 );
136
137const readIdentityFile = (
138 fileSystem: FileSystem.FileSystem,
139 source: TelemetryIdentitySource,
140 filePath: string,
141) =>
142 fileSystem.readFileString(filePath).pipe(
143 Effect.asSome,
144 Effect.catchTags({
145 PlatformError: (cause) =>
146 isNotFoundError(cause)
147 ? Effect.succeed(Option.none<string>())
148 : Effect.fail(
149 new TelemetryIdentityReadError({
150 source,
151 filePath,
152 cause,
153 }),
154 ),
155 }),
156 );
157
158const hash = (source: TelemetryIdentitySource, value: string) =>
159 Crypto.Crypto.pipe(
160 Effect.flatMap((crypto) => crypto.digest("SHA-256", new TextEncoder().encode(value))),
161 Effect.map(Hex.encode),
162 Effect.mapError(
163 (cause) =>
164 new TelemetryIdentityHashError({
165 source,
166 algorithm: "SHA-256",
167 cause,
168 }),
169 ),
170 );
171
172const getCodexAccountId = Effect.fn("TelemetryIdentity.getCodexAccountId")(function* (
173 homeDirectory: string,
174) {
175 const fileSystem = yield* FileSystem.FileSystem;
176 const path = yield* Path.Path;
177
178 const authJsonPath = path.join(homeDirectory, ".codex", "auth.json");
179 const encoded = yield* readIdentityFile(fileSystem, "codex", authJsonPath);
180 if (Option.isNone(encoded)) {
181 return Option.none<string>();
182 }
183 const authJson = yield* decodeCodexAuthJson(encoded.value).pipe(
184 Effect.mapError(
185 (cause) =>
186 new TelemetryIdentityDecodeError({
187 source: "codex",
188 filePath: authJsonPath,
189 cause,
190 }),
191 ),
192 );
193
194 return authJson.tokens === undefined
195 ? Option.none<string>()
196 : Option.some(authJson.tokens.account_id);
197});
198
199const getClaudeUserId = Effect.fn("TelemetryIdentity.getClaudeUserId")(function* (
200 homeDirectory: string,
201) {
202 const fileSystem = yield* FileSystem.FileSystem;
203 const path = yield* Path.Path;
204
205 const claudeJsonPath = path.join(homeDirectory, ".claude.json");
206 const encoded = yield* readIdentityFile(fileSystem, "claude", claudeJsonPath);
207 if (Option.isNone(encoded)) {
208 return Option.none<string>();
209 }
210 const claudeJson = yield* decodeClaudeJson(encoded.value).pipe(
211 Effect.mapError(
212 (cause) =>
213 new TelemetryIdentityDecodeError({
214 source: "claude",
215 filePath: claudeJsonPath,
216 cause,
217 }),
218 ),
219 );
220
221 return Option.some(claudeJson.userID);
222});
223
224const upsertAnonymousId = Effect.gen(function* () {
225 const fileSystem = yield* FileSystem.FileSystem;
226 const { anonymousIdPath } = yield* ServerConfig.ServerConfig;
227
228 const existing = yield* readIdentityFile(fileSystem, "anonymous", anonymousIdPath);
229 if (Option.isSome(existing)) {
230 return existing.value;
231 }
232
233 const anonymousId = yield* Crypto.Crypto.pipe(
234 Effect.flatMap((crypto) => crypto.randomUUIDv4()),
235 Effect.mapError(
236 (cause) =>
237 new TelemetryAnonymousIdGenerationError({
238 source: "anonymous",
239 filePath: anonymousIdPath,
240 cause,
241 }),
242 ),
243 );
244 yield* writeFileStringAtomically({ filePath: anonymousIdPath, contents: anonymousId }).pipe(
245 Effect.mapError(
246 (cause) =>
247 new TelemetryAnonymousIdPersistenceError({
248 source: "anonymous",
249 filePath: anonymousIdPath,
250 cause,
251 }),
252 ),
253 );
254
255 return anonymousId;
256});
257
258/**
259 * getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value.
260 * 1. ~/.codex/auth.json tokens.account_id
261 * 2. ~/.claude.json userID
262 * 3. ~/.t3/telemetry/anonymous-id
263 *
264 * A missing file or an API-key-only Codex auth.json falls through quietly. Only
265 * unreadable or malformed files warn.
266 */
267export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")(
268 function* (homeDirectory: string) {
269 const codexAccountId = yield* getCodexAccountId(homeDirectory).pipe(
270 Effect.catchTags({
271 TelemetryIdentityReadError: (error) =>
272 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
273 TelemetryIdentityDecodeError: (error) =>
274 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
275 }),
276 );
277 if (Option.isSome(codexAccountId)) {
278 return yield* hash("codex", codexAccountId.value);
279 }
280
281 const claudeUserId = yield* getClaudeUserId(homeDirectory).pipe(
282 Effect.catchTags({
283 TelemetryIdentityReadError: (error) =>
284 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
285 TelemetryIdentityDecodeError: (error) =>
286 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
287 }),
288 );
289 if (Option.isSome(claudeUserId)) {
290 return yield* hash("claude", claudeUserId.value);
291 }
292
293 const anonymousId = yield* upsertAnonymousId.pipe(
294 Effect.asSome,
295 Effect.catchTags({
296 TelemetryIdentityReadError: (error) =>
297 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
298 TelemetryAnonymousIdGenerationError: (error) =>
299 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
300 TelemetryAnonymousIdPersistenceError: (error) =>
301 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
302 }),
303 );
304 if (Option.isSome(anonymousId)) {
305 return yield* hash("anonymous", anonymousId.value);
306 }
307
308 return null;
309 },
310 Effect.tapError(logTelemetryIdentityError),
311 Effect.orElseSucceed(() => null),
312);
313
314export const getTelemetryIdentifier = Effect.flatMap(
315 HostProcess.HomeDirectory,
316 getTelemetryIdentifierForHome,
317);
apps/server/src/ws.ts+1-95
......@@ -33,16 +33,11 @@ import {
3333 type AuthEnvironmentScope,
3434 type ScheduledTaskListResult,
3535 AuthSessionId,
36 ClientConnectionMethod,
37 ClientDeviceType,
38 ClientOs,
3936 ClientSurface,
40 ClientWebDeployment,
4137 type DiscoveredLocalServerList,
4238 type EditorId,
4339 type FileManagerRevealKind,
4440 type OrchestrationClientOrigin,
45 type OrchestrationV2Command,
4641 type GitActionProgressEvent,
4742 type GitManagerServiceError,
4843 type MessageId,
......@@ -224,7 +219,6 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
224219import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
225220import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
226221import * as HostResources from "./resourceTelemetry/HostResources.ts";
227import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
228222import * as UsageService from "./usage/UsageService.ts";
229223import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
230224import * as PullRequestService from "./pullRequest/PullRequestService.ts";
......@@ -602,13 +596,7 @@ function toAuthAccessStreamEvent(
602596}
603597
604598const isClientSurface = Schema.is(ClientSurface);
605const isClientConnectionMethod = Schema.is(ClientConnectionMethod);
606const isClientDeviceType = Schema.is(ClientDeviceType);
607const isClientOs = Schema.is(ClientOs);
608const isClientWebDeployment = Schema.is(ClientWebDeployment);
609599const MAX_CLIENT_APP_VERSION_LENGTH = 64;
610const MAX_CLIENT_BROWSER_LENGTH = 64;
611const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80;
612600
613601export function hasCompatibleOrchestrationProtocol(url: URL): boolean {
614602 return (
......@@ -643,52 +631,6 @@ function readClientConnectionOrigin(
643631 };
644632}
645633
646// Client telemetry stays in this socket's RPC layer. It must not become a
647// server-global "current client" because several client types can connect at once.
648function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) {
649 const url = HttpServerRequest.toURL(request);
650 if (Option.isNone(url)) {
651 return {};
652 }
653
654 const surface = url.value.searchParams.get("clientSurface");
655 const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? "";
656 const deviceType = url.value.searchParams.get("clientDeviceType");
657 const os = url.value.searchParams.get("clientOs");
658 const webDeployment = url.value.searchParams.get("clientWebDeployment");
659 const browser = url.value.searchParams.get("clientBrowser")?.trim() ?? "";
660 const connectionMethod = url.value.searchParams.get("connectionMethod");
661 const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? "";
662 const osMajorVersion = Number(rawOsMajorVersion);
663 const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? "";
664 const isMobile = surface === "mobile";
665 const hasOsMajorVersion =
666 isMobile && rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0;
667 const hasDeviceModel =
668 isMobile && deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH;
669
670 return {
671 ...(isClientSurface(surface) ? { surface } : {}),
672 ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH
673 ? { appVersion, clientAppVersion: appVersion }
674 : {}),
675 ...(isClientOs(os)
676 ? {
677 clientOs: os,
678 ...(isMobile && (os === "iOS" || os === "Android") ? { os } : {}),
679 }
680 : {}),
681 ...(isClientDeviceType(deviceType) ? { clientDeviceType: deviceType } : {}),
682 ...(surface === "web" && isClientWebDeployment(webDeployment) ? { webDeployment } : {}),
683 ...(surface === "web" && browser !== "" && browser.length <= MAX_CLIENT_BROWSER_LENGTH
684 ? { clientBrowser: browser }
685 : {}),
686 ...(hasOsMajorVersion ? { osMajorVersion, clientOsMajorVersion: osMajorVersion } : {}),
687 ...(hasDeviceModel ? { deviceModel, clientDeviceModel: deviceModel } : {}),
688 ...(isClientConnectionMethod(connectionMethod) ? { connectionMethod } : {}),
689 };
690}
691
692634const canReplayPersistedRange = Effect.fnUntraced(function* (
693635 afterSequence: number,
694636 headSequence: number,
......@@ -1200,7 +1142,6 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc
12001142const layerWsRpc = (
12011143 currentSession: EnvironmentAuth.AuthenticatedSession,
12021144 clientOrigin: OrchestrationClientOrigin,
1203 clientAnalyticsProps: Readonly<Record<string, unknown>>,
12041145 previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],
12051146 serverBrowser: ServerBrowser.ServerBrowser["Service"],
12061147) =>
......@@ -1224,19 +1165,6 @@ const layerWsRpc = (
12241165
12251166 const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2;
12261167 const mcpAppRequests = yield* McpAppRequests.McpAppRequests;
1227 const analytics = yield* AnalyticsService.AnalyticsService;
1228 // Client-origin attribution (#7774): every thread/turn the connecting
1229 // client starts is credited to its surface + app version. Best-effort:
1230 // attribution must never fail the user's command.
1231 const originProps = clientAnalyticsProps;
1232 const recordClientCommandAnalytics = (command: OrchestrationV2Command) => {
1233 switch (command.type) {
1234 case "message.dispatch":
1235 return analytics.record("client.turn.requested", originProps).pipe(Effect.ignore);
1236 default:
1237 return Effect.void;
1238 }
1239 };
12401168 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;
12411169 const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2;
12421170 const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService;
......@@ -1869,7 +1797,6 @@ const layerWsRpc = (
18691797 ).pipe(Effect.provide(intakeContext)),
18701798 )
18711799 .pipe(
1872 Effect.tap(() => recordClientCommandAnalytics(command)),
18731800 Effect.map((result) => ({ sequence: result.sequence })),
18741801 Effect.mapError((cause) => {
18751802 const detail = userFacingDispatchErrorMessage(cause);
......@@ -2009,18 +1936,6 @@ const layerWsRpc = (
20091936 }).pipe(Effect.provide(intakeContext)),
20101937 )
20111938 .pipe(
2012 Effect.tap(() =>
2013 analytics
2014 .record("client.thread.started", originProps)
2015 .pipe(
2016 Effect.andThen(
2017 input.initialMessage === undefined
2018 ? Effect.void
2019 : analytics.record("client.turn.requested", originProps),
2020 ),
2021 Effect.ignore,
2022 ),
2023 ),
20241939 Effect.map((result) => ({
20251940 ...result,
20261941 projection: projectThreadProjectionForWire(result.projection),
......@@ -3178,7 +3093,6 @@ export const layer = Layer.unwrap(
31783093 }
31793094 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
31803095 const sessions = yield* SessionStore.SessionStore;
3181 const analytics = yield* AnalyticsService.AnalyticsService;
31823096 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
31833097 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
31843098 failEnvironmentAuthInvalid(
......@@ -3191,9 +3105,7 @@ export const layer = Layer.unwrap(
31913105 ),
31923106 );
31933107 const clientOrigin = readClientConnectionOrigin(request);
3194 const clientAnalyticsProps = readClientAnalyticsProps(request);
31953108 yield* sessions.recordClientConnection(session.sessionId, clientOrigin);
3196 yield* analytics.record("client.connected", clientAnalyticsProps);
31973109 const rpcWebSocketHttpEffect = yield* Effect.gen(function* () {
31983110 const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket;
31993111 yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe(
......@@ -3207,13 +3119,7 @@ export const layer = Layer.unwrap(
32073119 return httpEffect;
32083120 }).pipe(
32093121 Effect.provide(
3210 layerWsRpc(
3211 session,
3212 clientOrigin,
3213 clientAnalyticsProps,
3214 previewAutomationBroker,
3215 serverBrowser,
3216 ).pipe(
3122 layerWsRpc(session, clientOrigin, previewAutomationBroker, serverBrowser).pipe(
32173123 Layer.provideMerge(RpcSerialization.layerJson),
32183124 // Request fibers run in the handlers' context, so this reporter sees
32193125 // their defects, not the rest of the server's.
apps/server/vite.config.ts-9
......@@ -119,15 +119,6 @@ export default mergeConfig(
119119 __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify(
120120 repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "",
121121 ),
122 __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: JSON.stringify(
123 repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() ?? "",
124 ),
125 __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: JSON.stringify(
126 repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() ?? "",
127 ),
128 __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: JSON.stringify(
129 repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() ?? "",
130 ),
131122 },
132123 },
133124 test: {
apps/web/src/cloud/publicConfig.ts-34
......@@ -17,26 +17,12 @@ export interface CloudPublicConfig {
1717 readonly clerkPublishableKey: string | null;
1818 readonly clerkJwtTemplate: string | null;
1919 readonly relayUrl: string | null;
20 readonly relayTracing: {
21 readonly tracesUrl: string | null;
22 readonly tracesDataset: string | null;
23 readonly tracesToken: string | null;
24 };
2520}
2621
2722export function trimNonEmpty(value: string | undefined): string | null {
2823 return value?.trim() || null;
2924}
3025
31function normalizeSecureUrl(value: string): string | null {
32 try {
33 const url = new URL(value);
34 return url.protocol === "https:" ? url.toString() : null;
35 } catch {
36 return null;
37 }
38}
39
4026export function resolveCloudPublicConfig(): CloudPublicConfig {
4127 return {
4228 clerkPublishableKey: trimNonEmpty(
......@@ -46,29 +32,9 @@ export function resolveCloudPublicConfig(): CloudPublicConfig {
4632 relayUrl: normalizeSecureRelayUrl(
4733 (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "",
4834 ),
49 relayTracing: {
50 tracesUrl: normalizeSecureUrl(
51 (import.meta.env.VITE_RELAY_OTLP_TRACES_URL as string | undefined) ?? "",
52 ),
53 tracesDataset: trimNonEmpty(
54 import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET as string | undefined,
55 ),
56 tracesToken: trimNonEmpty(import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN as string | undefined),
57 },
5835 };
5936}
6037
61export function resolveRelayTracingConfig() {
62 const { relayTracing } = resolveCloudPublicConfig();
63 return relayTracing.tracesUrl && relayTracing.tracesDataset && relayTracing.tracesToken
64 ? {
65 tracesUrl: relayTracing.tracesUrl,
66 tracesDataset: relayTracing.tracesDataset,
67 tracesToken: relayTracing.tracesToken,
68 }
69 : null;
70}
71
7238export function hasCloudPublicConfig(): boolean {
7339 const config = resolveCloudPublicConfig();
7440 return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl);
apps/web/src/components/clerk/BrowserManagedAuthShell.tsx+1-1
......@@ -11,7 +11,7 @@ export default function BrowserManagedAuthShell({
1111 readonly children: ReactNode;
1212}) {
1313 return (
14 <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey}>
14 <ClerkProvider telemetry={false} appearance={clerkAppearance} publishableKey={publishableKey}>
1515 {children}
1616 </ClerkProvider>
1717 );
apps/web/src/components/clerk/ElectronManagedAuthShell.tsx+6-1
......@@ -13,7 +13,12 @@ export default function ElectronManagedAuthShell({
1313 readonly children: ReactNode;
1414}) {
1515 return (
16 <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey} passkeys={passkeys}>
16 <ClerkProvider
17 telemetry={false}
18 appearance={clerkAppearance}
19 publishableKey={publishableKey}
20 passkeys={passkeys}
21 >
1722 {children}
1823 </ClerkProvider>
1924 );
apps/web/src/connection/clientMetadata.test.ts+4-15
......@@ -1,11 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import {
4 browserClientOs,
5 browserDeviceType,
6 browserFamily,
7 clientPresentationMetadata,
8} from "./clientMetadata";
3import { browserClientOs, browserDeviceType, clientPresentationMetadata } from "./clientMetadata";
94
105const desktopChrome = {
116 userAgent:
......@@ -14,32 +9,28 @@ const desktopChrome = {
149 maxTouchPoints: 0,
1510};
1611
17describe("client telemetry metadata", () => {
18 it("distinguishes hosted web from server-served web", () => {
12describe("client presentation metadata", () => {
13 it("identifies web clients and omits the development app version", () => {
1914 expect(
2015 clientPresentationMetadata({
2116 appVersion: "1.2.3",
22 hosted: true,
2317 identity: desktopChrome,
2418 desktopBridge: undefined,
2519 }),
2620 ).toMatchObject({
2721 surface: "web",
28 webDeployment: "hosted",
2922 deviceType: "desktop",
3023 os: "Windows",
31 browser: "Chrome",
3224 appVersion: "1.2.3",
3325 });
3426
3527 expect(
3628 clientPresentationMetadata({
3729 appVersion: "0.0.0",
38 hosted: false,
3930 identity: desktopChrome,
4031 desktopBridge: undefined,
4132 }),
42 ).toMatchObject({ surface: "web", webDeployment: "server" });
33 ).not.toHaveProperty("appVersion");
4334 });
4435
4536 it("identifies phone and tablet browsers", () => {
......@@ -64,7 +55,6 @@ describe("client telemetry metadata", () => {
6455
6556 expect(browserDeviceType(iphone)).toBe("mobile");
6657 expect(browserClientOs(iphone)).toBe("iOS");
67 expect(browserFamily(iphone.userAgent)).toBe("Safari");
6858 expect(browserDeviceType(androidTablet)).toBe("tablet");
6959 expect(browserClientOs(androidTablet)).toBe("Android");
7060 expect(browserDeviceType(ipadosDesktopUa)).toBe("tablet");
......@@ -75,7 +65,6 @@ describe("client telemetry metadata", () => {
7565 expect(
7666 clientPresentationMetadata({
7767 appVersion: "1.2.3",
78 hosted: false,
7968 identity: desktopChrome,
8069 desktopBridge: { getClientPlatform: () => "darwin" },
8170 }),
apps/web/src/connection/clientMetadata.ts-14
......@@ -44,17 +44,6 @@ export function browserClientOs(identity: BrowserIdentity): ClientOs {
4444 return "other";
4545}
4646
47export function browserFamily(userAgent: string): string {
48 if (userAgent.trim() === "") return "unknown";
49 if (/edg(?:e|a|ios)?\//i.test(userAgent)) return "Edge";
50 if (/opr\/|opios\//i.test(userAgent)) return "Opera";
51 if (/samsungbrowser\//i.test(userAgent)) return "Samsung Internet";
52 if (/firefox\/|fxios\//i.test(userAgent)) return "Firefox";
53 if (/chrome\/|crios\//i.test(userAgent)) return "Chrome";
54 if (/safari\//i.test(userAgent)) return "Safari";
55 return "other";
56}
57
5847export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadataDeviceType {
5948 const userAgent = identity.userAgent;
6049 if (userAgent.trim() === "") return "unknown";
......@@ -71,7 +60,6 @@ export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadata
7160
7261export function clientPresentationMetadata(input: {
7362 readonly appVersion: string;
74 readonly hosted: boolean;
7563 readonly identity: BrowserIdentity;
7664 readonly desktopBridge: Pick<DesktopBridge, "getClientPlatform"> | undefined;
7765}): AuthClientPresentationMetadata {
......@@ -90,8 +78,6 @@ export function clientPresentationMetadata(input: {
9078 deviceType: browserDeviceType(input.identity),
9179 os: browserClientOs(input.identity),
9280 surface: "web",
93 webDeployment: input.hosted ? "hosted" : "server",
94 browser: browserFamily(input.identity.userAgent),
9581 ...(input.appVersion === "0.0.0" ? {} : { appVersion: input.appVersion }),
9682 };
9783}
apps/web/src/connection/platform.ts-1
......@@ -170,7 +170,6 @@ const layerWakeups = Wakeups.layer({
170170function clientMetadata() {
171171 return clientPresentationMetadata({
172172 appVersion: APP_VERSION,
173 hosted: isHostedStaticApp(),
174173 identity: {
175174 userAgent: navigator.userAgent,
176175 platform: navigator.platform,
apps/web/src/lib/runtime.ts+1-11
......@@ -4,13 +4,12 @@ import * as Layer from "effect/Layer";
44import * as Socket from "effect/socket/Socket";
55
66import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";
7import * as RelayTracing from "@t3tools/shared/relayTracing";
87import * as PrimaryEnvironmentHttpClient from "../environments/primary/httpClient";
98import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";
109
1110import * as Dpop from "../cloud/dpop";
1211import * as ManagedRelayLayer from "../cloud/managedRelayLayer";
13import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig";
12import { resolveCloudPublicConfig } from "../cloud/publicConfig";
1413import * as ClientTracer from "../observability/clientTracer";
1514
1615function configuredRelayUrl(): string {
......@@ -18,18 +17,10 @@ function configuredRelayUrl(): string {
1817}
1918
2019const layerHttpClient = layerRemoteHttpClient((input, init) => globalThis.fetch(input, init));
21const layerRelayTracing = RelayTracing.layer(resolveRelayTracingConfig(), {
22 serviceName: "t3code-web",
23 serviceVersion: import.meta.env.APP_VERSION,
24 runtime: "browser",
25 client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web",
26}).pipe(Layer.provide(layerHttpClient));
27
2820type RuntimeLayerSource =
2921 | typeof layerHttpClient
3022 | typeof Dpop.layer
3123 | typeof Socket.layerWebSocketConstructorGlobal
32 | typeof layerRelayTracing
3324 | typeof ClientTracer.layer
3425 | ReturnType<typeof ManagedRelayLayer.layer>;
3526
......@@ -59,7 +50,6 @@ const layerRuntime = Layer.mergeAll(
5950 Dpop.layer,
6051 Socket.layerWebSocketConstructorGlobal,
6152 ClientTracer.layer,
62 layerRelayTracing,
6353 ManagedRelayLayer.layer(configuredRelayUrl()).pipe(
6454 Layer.provide(Layer.mergeAll(layerHttpClient, Dpop.layer)),
6555 ),
apps/web/src/vite-env.d.ts-3
......@@ -10,9 +10,6 @@ interface ImportMetaEnv {
1010 readonly VITE_CLERK_PUBLISHABLE_KEY: string;
1111 readonly VITE_CLERK_JWT_TEMPLATE: string;
1212 readonly VITE_CLERK_CLI_OAUTH_CLIENT_ID: string;
13 readonly VITE_RELAY_OTLP_TRACES_URL: string;
14 readonly VITE_RELAY_OTLP_TRACES_DATASET: string;
15 readonly VITE_RELAY_OTLP_TRACES_TOKEN: string;
1613 readonly APP_VERSION: string;
1714}
1815
apps/web/vite.config.ts-8
......@@ -39,9 +39,6 @@ const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
3939const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
4040const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
4141const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || "";
42const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || "";
43const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || "";
44const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || "";
4542const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
4643const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
4744const configuredHostedAppUrl = (() => {
......@@ -211,11 +208,6 @@ export default defineConfig(() => {
211208 "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify(
212209 configuredClerkCliOAuthClientId,
213210 ),
214 "import.meta.env.VITE_RELAY_OTLP_TRACES_URL": JSON.stringify(configuredRelayTracingUrl),
215 "import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET": JSON.stringify(
216 configuredRelayTracingDataset,
217 ),
218 "import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN": JSON.stringify(configuredRelayTracingToken),
219211 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
220212 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
221213 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),
docs/README.md+1-2
......@@ -17,7 +17,7 @@
1717- [Use T3 Code as your default browser](./user/default-browser.md)
1818- [Devices](./user/devices.md)
1919- [Usage and limits](./user/usage.md)
20- [Product usage data](./user/telemetry.md)
20- [Diagnostics and data sharing](./user/telemetry.md)
2121- [Remote access](./user/remote-access.md)
2222- [Outside agents (MCP)](./user/outside-agents.md)
2323- [Running in the background](./user/background-service.md)
......@@ -45,7 +45,6 @@ source alone does not explain. Most code changes do not need an internal documen
4545- [Remote environments](./internals/remote.md)
4646- [Server updates](./internals/server-updates.md)
4747- [Resource telemetry](./internals/resource-telemetry.md)
48- [Product analytics](./internals/product-analytics.md)
4948- [Environment auth](./internals/environment-auth.md)
5049- [T3 Connect](./internals/t3-connect.md)
5150- [Assistant citations](./internals/assistant-citations.md)
docs/internals/product-analytics.md deleted-56
......@@ -1,56 +0,0 @@
1# Product analytics
2
3The server owns PostHog delivery, opt-out, and identity for every connected client.
4[Identity selection](../../apps/server/src/telemetry/Identify.ts) hashes an available
5provider account ID, falling back to an installation-scoped ID. This identity can
6span several clients; it does not identify a browser session. Clients do not load
7the PostHog browser SDK. Client-use events require an authenticated connection, so
8visiting the hosted app without connecting does not count as product use.
9
10## Attribution boundaries
11
12Client dimensions belong to the event's WebSocket connection. A server-global
13"current client" would misattribute simultaneous web, desktop, and mobile use.
14Provider execution has its own events because a turn can outlive the requesting
15connection.
16
17Keep client and server dimensions separate. A desktop host can serve a phone or a
18remote browser, and a direct connection can cross a network. Older clients omit
19metadata. Missing client values must stay unknown rather than being backfilled
20from server properties. The legacy `clientType` property describes how the server
21runs; use `surface` for the connected client.
22
23## Interpreting events
24
25Use `client.turn.requested` for active-use reports. `client.connected` counts
26reconnects, so network behavior can inflate it. One identity can appear in several
27client groups during a period; adding those groups double-counts users.
28
29Provider send and completion counts need not match. Providers can emit synthetic
30turns without a send request. Collection is best effort, with no scan or backfill
31of provider history.
32
33Token totals cover the main agent. Child agents and model rerouting prevent a turn
34from representing one provider/model combination's full cost. For provider
35comparisons, require complete usage, no observed subagents, and no mixed models;
36compare matching model, effort, interaction mode, and terminal status. Aggregate
37output/input ratios should divide the summed totals. Averaging per-turn ratios
38lets small-input turns dominate.
39
40Unknown counts stay absent. Partial usage contains valid observed counts but
41cannot establish a whole-turn total. Keep these distinctions when changing token
42normalization or building reports.
43
44## Delivery
45
46A send can fail after PostHog has stored the batch, so every retry is a copy.
47[Delivery](../../apps/server/src/telemetry/AnalyticsService.ts) gives each event a
48uuid when it is recorded, backs off after a failed send, and drops a batch after a
49few tries. Without these limits, one stuck batch was sent every second for days.
50
51## Collection boundary
52
53Keep analytics payloads to product metadata and normalized measurements. Do not
54send prompts, authentication material, raw provider payloads, user-assigned device
55names, or conversation identifiers. Client metadata is best effort; invalid values
56must not reject a connection. PostHog person profiles remain disabled.
docs/operations/observability.md+3-2
......@@ -418,8 +418,9 @@ Webhooks have their own families:
418418
419419`ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run
420420started from a delivery is its own `ScheduledTaskService.runWebhookDelivery` trace. For a request
421the relay forwarded, the span also goes to the T3 Connect trace export as a child of the relay's
422span; requests that reach the environment directly never join a sender's trace.
421the relay forwarded, the local span retains the relay's parent span for correlation; The Knife
422does not export it to T3 Connect. Requests that reach the environment directly never join a
423sender's trace.
423424
424425Use metrics when the question is:
425426
docs/operations/relay-observability.md+6-5
......@@ -2,7 +2,9 @@
22
33> For maintainers. Using T3 Code? See [docs/user](../user/).
44
5The relay Alchemy stack owns a shared Axiom trace setup:
5The relay Alchemy stack owns a shared Axiom trace setup. The Knife clients and environment
6servers do not export to it; local diagnostics use the configured local or user-owned receiver.
7The resources below belong to the upstream relay deployment:
68
79- `t3-code-relay-traces-prod`, the OpenTelemetry trace dataset shared by the Worker, mobile app, and
810 first-party relay clients
......@@ -21,8 +23,7 @@ vp run deploy
2123```
2224
2325Alchemy resolves account-level Axiom deployment credentials through its provider. At runtime, the
24Worker receives only its scoped ingest token. Mobile and relay clients use their own separately
25provisioned scoped ingest tokens.
26Worker receives only its scoped ingest token. The Knife does not consume the provisioned mobile or relay-client ingest tokens.
2627
2728The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans.
2829Effect's OpenTelemetry exporter stores semantic HTTP attributes below the `attributes.` prefix.
......@@ -70,8 +71,8 @@ managed endpoint, and with it the environment. On a forward, `relay.hook.upstrea
7071`x-t3-hook-outcome` response header. A held request's delivery records the same on its
7172`relay.inbox.deliver` span. The relay sends its own `traceparent` with each forward and drops any a
7273sender supplied, and signs each forward (`x-t3-relay-delivery`) so the environment trusts the
73relay's delivery id, receive time and trace context only from the relay, so on environments that export to T3 Connect, the environment's
74`ScheduledTaskService.triggerWebhook` span lands in the same trace. `relay.hook.rate_limit` says which
74relay's delivery id, receive time and trace context only from the relay. The environment records
75its `ScheduledTaskService.triggerWebhook` span locally. `relay.hook.rate_limit` says which
7576budget ran out: `endpoint` or `hook`. `relay.hook.rate_limiter_failed_open` is set when the
7677Cloudflare rate limiter was unavailable and the request went through unlimited.
7778
docs/user/telemetry.md+4-13
......@@ -1,17 +1,8 @@
1# Product usage data
1# Diagnostics and data sharing
22
3The T3 Code server sends product usage events to PostHog, associated with a hashed account or
4installation identifier. Events include the provider, model, reasoning effort, permission mode,
5turn result, duration, and main-agent token totals when available.
6
7Events do not include prompts, responses, file contents, authentication tokens, conversation IDs,
8raw provider events, or child-agent output. Child-agent token use is excluded from the totals.
9
10To disable collection, set `T3CODE_TELEMETRY_ENABLED=false` in the server's environment before
11starting it. This stops product events from being recorded or sent.
12
13The desktop app reads the variable from your shell profile (for example `~/.zshrc`) on macOS and
14Linux, so export it there and restart the app. On Windows, set it as a user environment variable.
3The Knife does not collect or send product analytics. T3 Connect contacts its account and relay
4services only when you use that connection option. Local diagnostics stay on your machine unless
5you configure an export endpoint.
156
167## Export diagnostics to your own receiver
178
packages/client-runtime/src/authorization/remote.test.ts+2-5
......@@ -304,7 +304,7 @@ describe("remote environment authorization", () => {
304304 deviceType: "desktop",
305305 os: "unknown",
306306 });
307 expect(websocketUrl.searchParams.get("clientOs")).toBe("unknown");
307 expect(websocketUrl.searchParams.get("clientOs")).toBeNull();
308308 }),
309309 );
310310
......@@ -532,14 +532,11 @@ describe("remote environment authorization", () => {
532532 appVersion: "1.2.3",
533533 deviceType: "mobile",
534534 os: "Android",
535 osMajorVersion: 15,
536 deviceModel: "Pixel 9",
537535 },
538 connectionMethod: "relay",
539536 }).pipe(provideRemoteHttp(fetch.fetchFn));
540537
541538 expect(url).toBe(
542 "wss://remote.example.com/ws?wsTicket=ws-ticket&clientSurface=mobile&clientAppVersion=1.2.3&clientDeviceType=phone&clientOs=Android&clientOsMajorVersion=15&clientDeviceModel=Pixel+9&connectionMethod=relay",
539 "wss://remote.example.com/ws?wsTicket=ws-ticket&clientSurface=mobile&clientAppVersion=1.2.3",
543540 );
544541 }),
545542 );
packages/client-runtime/src/authorization/remote.ts+2-37
......@@ -3,7 +3,6 @@ import {
33 type AuthClientPresentationMetadata,
44 AuthEnvironmentBootstrapTokenType,
55 AuthTokenExchangeGrantType,
6 type ClientConnectionMethod,
76 type AuthEnvironmentScope,
87} from "@t3tools/contracts";
98import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
......@@ -42,7 +41,6 @@ const clientMetadataTokenExchangeFields = (
4241export const appendClientConnectionParams = (
4342 url: URL,
4443 clientMetadata: AuthClientPresentationMetadata | undefined,
45 connectionMethod?: ClientConnectionMethod,
4644): void => {
4745 if (clientMetadata?.surface) {
4846 url.searchParams.set("clientSurface", clientMetadata.surface);
......@@ -50,37 +48,6 @@ export const appendClientConnectionParams = (
5048 if (clientMetadata?.appVersion) {
5149 url.searchParams.set("clientAppVersion", clientMetadata.appVersion);
5250 }
53 if (clientMetadata?.deviceType) {
54 const deviceType =
55 clientMetadata.deviceType === "mobile"
56 ? "phone"
57 : clientMetadata.deviceType === "desktop" || clientMetadata.deviceType === "tablet"
58 ? clientMetadata.deviceType
59 : "unknown";
60 url.searchParams.set("clientDeviceType", deviceType);
61 }
62 if (clientMetadata?.os) {
63 url.searchParams.set("clientOs", clientMetadata.os);
64 }
65 if (clientMetadata?.surface === "web") {
66 if (clientMetadata.webDeployment) {
67 url.searchParams.set("clientWebDeployment", clientMetadata.webDeployment);
68 }
69 if (clientMetadata.browser) {
70 url.searchParams.set("clientBrowser", clientMetadata.browser);
71 }
72 }
73 if (clientMetadata?.surface === "mobile") {
74 if (clientMetadata.osMajorVersion !== undefined) {
75 url.searchParams.set("clientOsMajorVersion", String(clientMetadata.osMajorVersion));
76 }
77 if (clientMetadata.deviceModel) {
78 url.searchParams.set("clientDeviceModel", clientMetadata.deviceModel);
79 }
80 }
81 if (connectionMethod) {
82 url.searchParams.set("connectionMethod", connectionMethod);
83 }
8451};
8552
8653export const exchangeRemoteDpopAccessToken = Effect.fn(
......@@ -205,7 +172,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
205172 readonly httpBaseUrl: string;
206173 readonly bearerToken: string;
207174 readonly clientMetadata?: AuthClientPresentationMetadata;
208 readonly connectionMethod?: ClientConnectionMethod;
209175 readonly timeoutMs?: number;
210176}) {
211177 const issued = yield* issueRemoteWebSocketTicket({
......@@ -216,7 +182,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
216182
217183 const url = new URL(environmentWebSocketUrl(input.wsBaseUrl));
218184 url.searchParams.set("wsTicket", issued.ticket);
219 appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod);
185 appendClientConnectionParams(url, input.clientMetadata);
220186 return url.toString();
221187});
222188
......@@ -228,7 +194,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(
228194 readonly accessToken: string;
229195 readonly dpopProof: string;
230196 readonly clientMetadata?: AuthClientPresentationMetadata;
231 readonly connectionMethod?: ClientConnectionMethod;
232197 readonly timeoutMs?: number;
233198}) {
234199 const issued = yield* issueRemoteDpopWebSocketTicket({
......@@ -239,6 +204,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(
239204 });
240205 const url = new URL(environmentWebSocketUrl(input.wsBaseUrl));
241206 url.searchParams.set("wsTicket", issued.ticket);
242 appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod);
207 appendClientConnectionParams(url, input.clientMetadata);
243208 return url.toString();
244209});
packages/client-runtime/src/authorization/service.ts-4
......@@ -4,7 +4,6 @@ import {
44 type ExecutionEnvironmentDescriptor,
55} from "@t3tools/contracts";
66import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
7import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
87import {
98 exchangeRemoteDpopAccessToken,
109 type RemoteEnvironmentAuthError,
......@@ -197,7 +196,6 @@ export const make = Effect.gen(function* () {
197196 httpBaseUrl: input.httpBaseUrl,
198197 bearerToken: input.bearerToken,
199198 clientMetadata: presentation.metadata,
200 connectionMethod: input.connectionMethod,
201199 }).pipe(
202200 Effect.mapError(mapRemoteEnvironmentError),
203201 Effect.provideService(HttpClient.HttpClient, httpClient),
......@@ -242,7 +240,6 @@ export const make = Effect.gen(function* () {
242240 accessToken: token.accessToken,
243241 dpopProof: ticketProof,
244242 clientMetadata: presentation.metadata,
245 connectionMethod: "relay",
246243 ...(timeoutMs === undefined ? {} : { timeoutMs }),
247244 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
248245 },
......@@ -551,7 +548,6 @@ export const make = Effect.gen(function* () {
551548 getDpopToken(input).pipe(
552549 Effect.map(({ token }) => httpAuthorization(token)),
553550 Effect.withSpan("environment.authorization.http"),
554 withRelayClientTracing,
555551 ),
556552 });
557553});
packages/client-runtime/src/connection/resolver.test.ts+5-12
......@@ -3,7 +3,6 @@ import {
33 ORCHESTRATION_PROTOCOL_VERSION,
44 type DesktopSshEnvironmentTarget,
55} from "@t3tools/contracts";
6import { RelayClientTracer } from "@t3tools/shared/relayTracing";
76import { describe, expect, it } from "@effect/vitest";
87import * as Effect from "effect/Effect";
98import * as Layer from "effect/Layer";
......@@ -269,8 +268,7 @@ describe("ConnectionResolver", () => {
269268 environmentId: ENVIRONMENT_ID,
270269 label: "Primary",
271270 httpBaseUrl: "http://127.0.0.1:3777",
272 socketUrl:
273 "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2",
271 socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web&orchestrationProtocol=2",
274272 httpAuthorization: null,
275273 target,
276274 });
......@@ -383,10 +381,9 @@ describe("ConnectionResolver", () => {
383381 }),
384382 );
385383
386 it.effect("exports the complete relay authorization flow through the product tracer", () =>
384 it.effect("records the complete relay authorization flow in local tracing", () =>
387385 Effect.gen(function* () {
388386 const userSpans: Array<string> = [];
389 const productSpans: Array<string> = [];
390387 const target = new RelayConnectionTarget({
391388 environmentId: ENVIRONMENT_ID,
392389 label: "Cloud",
......@@ -409,15 +406,11 @@ describe("ConnectionResolver", () => {
409406
410407 yield* broker
411408 .prepare(catalogEntry(target))
412 .pipe(
413 Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))),
414 Effect.withTracer(collectingTracer(userSpans)),
415 );
409 .pipe(Effect.withTracer(collectingTracer(userSpans)));
416410
417 expect(productSpans).toContain("clientRuntime.connection.broker.relay");
418 expect(productSpans).toContain("test.remote.authorizeDpop");
411 expect(userSpans).toContain("clientRuntime.connection.broker.relay");
412 expect(userSpans).toContain("test.remote.authorizeDpop");
419413 expect(userSpans).toContain("clientRuntime.connection.broker.prepare");
420 expect(userSpans).not.toContain("test.remote.authorizeDpop");
421414 }),
422415 );
423416
packages/client-runtime/src/connection/resolver.ts+14-19
......@@ -2,7 +2,6 @@ import type {
22 AuthClientPresentationMetadata,
33 ExecutionEnvironmentDescriptor,
44} from "@t3tools/contracts";
5import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
65import * as Context from "effect/Context";
76import * as Effect from "effect/Effect";
87import * as Layer from "effect/Layer";
......@@ -77,7 +76,7 @@ function primarySocketUrl(
7776 clientMetadata: AuthClientPresentationMetadata | undefined,
7877): string {
7978 const url = new URL(environmentWebSocketUrl(target.wsBaseUrl));
80 appendClientConnectionParams(url, clientMetadata, "direct");
79 appendClientConnectionParams(url, clientMetadata);
8180 return url.toString();
8281}
8382
......@@ -224,23 +223,19 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
224223const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () {
225224 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
226225
227 return Effect.fnUntraced(
228 function* (target: RelayConnectionTarget) {
229 const authorized = yield* remote.authorizeDpop({
230 expectedEnvironmentId: target.environmentId,
231 });
232 return {
233 environmentId: authorized.environmentId,
234 label: authorized.label,
235 httpBaseUrl: authorized.httpBaseUrl,
236 socketUrl: authorized.socketUrl,
237 httpAuthorization: authorized.httpAuthorization,
238 target,
239 } satisfies PreparedConnection;
240 },
241 Effect.withSpan("clientRuntime.connection.broker.relay"),
242 withRelayClientTracing,
243 );
226 return Effect.fnUntraced(function* (target: RelayConnectionTarget) {
227 const authorized = yield* remote.authorizeDpop({
228 expectedEnvironmentId: target.environmentId,
229 });
230 return {
231 environmentId: authorized.environmentId,
232 label: authorized.label,
233 httpBaseUrl: authorized.httpBaseUrl,
234 socketUrl: authorized.socketUrl,
235 httpAuthorization: authorized.httpAuthorization,
236 target,
237 } satisfies PreparedConnection;
238 }, Effect.withSpan("clientRuntime.connection.broker.relay"));
244239});
245240
246241const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () {
packages/client-runtime/src/connection/supervisor.test.ts+2-6
......@@ -1,5 +1,4 @@
11import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts";
2import { RelayClientTracer } from "@t3tools/shared/relayTracing";
32import { describe, expect, it } from "@effect/vitest";
43import * as Deferred from "effect/Deferred";
54import * as Effect from "effect/Effect";
......@@ -295,7 +294,7 @@ describe("retryDelayMs", () => {
295294});
296295
297296describe("EnvironmentSupervisor", () => {
298 it.effect("exports each relay setup as a standalone linked trace that ends at readiness", () =>
297 it.effect("records each relay setup as a local linked trace that ends at readiness", () =>
299298 Effect.gen(function* () {
300299 const spans: Array<Tracer.NativeSpan> = [];
301300 const tracer = Tracer.make({
......@@ -315,10 +314,7 @@ describe("EnvironmentSupervisor", () => {
315314 });
316315 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
317316 initiallyDesired: true,
318 }).pipe(
319 Effect.provide(harness.dependencies),
320 Effect.provideService(RelayClientTracer, Option.some(tracer)),
321 );
317 }).pipe(Effect.provide(harness.dependencies), Effect.withTracer(tracer));
322318
323319 yield* awaitState(
324320 supervisor.state,
packages/client-runtime/src/connection/supervisor.ts+1-2
......@@ -1,4 +1,3 @@
1import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
21import * as Cause from "effect/Cause";
32import * as Clock from "effect/Clock";
43import * as Context from "effect/Context";
......@@ -484,7 +483,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
484483 "connection.retry.reason": retry.reason,
485484 }),
486485 ),
487 }).pipe(withRelayClientTracing);
486 });
488487 };
489488
490489 const establishTracedConnection = Effect.fnUntraced(function* (
packages/client-runtime/src/relay/managedRelay.ts+169-215
......@@ -31,7 +31,6 @@ import {
3131} from "@t3tools/contracts/relay";
3232import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
3333import { decodeRelayJwt } from "@t3tools/shared/relayJwt";
34import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3534import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
3635import * as Clock from "effect/Clock";
3736import * as Context from "effect/Context";
......@@ -725,228 +724,183 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
725724
726725 return ManagedRelayClient.of({
727726 relayUrl,
728 listEnvironments: Effect.fnUntraced(
729 function* (input) {
730 return yield* client.client
731 .listEnvironments({ headers: bearerHeaders(input.clerkToken) })
732 .pipe(
733 Effect.map((response) => response.environments),
734 Effect.mapError(relayRequestError("list relay-managed environments")),
735 timeoutRelayRequest("Relay environment listing"),
736 );
737 },
738 Effect.withSpan("clientRuntime.managedRelay.listEnvironments"),
739 withRelayClientTracing,
740 ),
741 listDevices: Effect.fnUntraced(
742 function* (input) {
743 return yield* client.client
744 .listDevicesV2({
745 headers: bearerHeaders(input.clerkToken),
746 })
747 .pipe(
748 Effect.map((response) => response.devices),
749 Effect.mapError(relayRequestError("list relay client devices")),
750 timeoutRelayRequest("Relay client device listing"),
751 );
752 },
753 Effect.withSpan("clientRuntime.managedRelay.listDevices"),
754 withRelayClientTracing,
755 ),
756 createEnvironmentLinkChallenge: Effect.fnUntraced(
757 function* (input) {
758 return yield* client.client
759 .createEnvironmentLinkChallenge({
760 headers: bearerHeaders(input.clerkToken),
761 payload: input.payload,
762 })
763 .pipe(
764 Effect.mapError(relayRequestError("create relay environment link challenge")),
765 timeoutRelayRequest("Relay environment link challenge"),
766 );
767 },
768 Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"),
769 withRelayClientTracing,
770 ),
771 linkEnvironment: Effect.fnUntraced(
772 function* (input) {
773 return yield* client.client
774 .linkEnvironment({
775 headers: bearerHeaders(input.clerkToken),
776 payload: input.payload,
777 })
778 .pipe(
779 Effect.mapError(relayRequestError("link relay environment")),
780 timeoutRelayRequest("Relay environment linking"),
781 );
782 },
783 Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"),
784 withRelayClientTracing,
785 ),
786 unlinkEnvironment: Effect.fnUntraced(
787 function* (input) {
788 return yield* client.client
789 .unlinkEnvironment({
790 headers: bearerHeaders(input.clerkToken),
791 params: { environmentId: input.environmentId },
792 })
793 .pipe(
794 Effect.mapError(relayRequestError("unlink relay environment")),
795 timeoutRelayRequest("Relay environment unlinking"),
796 );
797 },
798 Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"),
799 withRelayClientTracing,
800 ),
801 getEnvironmentStatus: Effect.fnUntraced(
802 function* (input) {
803 yield* Effect.annotateCurrentSpan({
804 "environment.id": input.environmentId,
805 });
806 return yield* runDpopRequest(
807 {
808 clerkToken: input.clerkToken,
809 scopes: input.scopes,
810 target: dpopProofTargets.getEnvironmentStatus(input.environmentId),
811 },
812 (authorization) =>
813 client.dpopClient
814 .getEnvironmentStatus({
815 headers: dpopHeaders(authorization),
816 params: { environmentId: input.environmentId },
817 })
818 .pipe(
819 Effect.mapError(relayRequestError("get relay environment status")),
820 timeoutRelayRequest("Relay environment status request"),
821 ),
822 );
823 },
824 Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"),
825 withRelayClientTracing,
826 ),
827 connectEnvironment: Effect.fnUntraced(
828 function* (input) {
829 yield* Effect.annotateCurrentSpan({
830 "environment.id": input.environmentId,
831 });
832 return yield* runDpopRequest(
833 {
834 clerkToken: input.clerkToken,
835 scopes: input.scopes,
836 target: dpopProofTargets.connectEnvironment(input.environmentId),
837 },
838 (authorization) => {
839 const payload: RelayEnvironmentConnectRequest = {
840 ...(input.deviceId ? { deviceId: input.deviceId } : {}),
841 clientKeyThumbprint: authorization.thumbprint,
842 };
843 return client.dpopClient
844 .connectEnvironment({
845 headers: dpopHeaders(authorization),
846 params: { environmentId: input.environmentId },
847 payload,
848 })
849 .pipe(
850 Effect.mapError(relayRequestError("connect relay environment")),
851 timeoutRelayRequest("Relay environment connection"),
852 );
853 },
727 listEnvironments: Effect.fnUntraced(function* (input) {
728 return yield* client.client
729 .listEnvironments({ headers: bearerHeaders(input.clerkToken) })
730 .pipe(
731 Effect.map((response) => response.environments),
732 Effect.mapError(relayRequestError("list relay-managed environments")),
733 timeoutRelayRequest("Relay environment listing"),
854734 );
855 },
856 Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"),
857 withRelayClientTracing,
858 ),
859 registerDevice: Effect.fnUntraced(
860 function* (input) {
861 return yield* mobileRegistrationRequest(
862 {
863 clerkToken: input.clerkToken,
864 target: dpopProofTargets.registerDevice(),
865 },
866 (authorization) =>
867 client.mobile
868 .registerDevice({
869 headers: dpopHeaders(authorization),
870 payload: input.payload,
871 })
872 .pipe(
873 Effect.mapError(relayRequestError("register relay mobile device")),
874 timeoutRelayRequest("Relay mobile device registration"),
875 ),
735 }, Effect.withSpan("clientRuntime.managedRelay.listEnvironments")),
736 listDevices: Effect.fnUntraced(function* (input) {
737 return yield* client.client
738 .listDevicesV2({
739 headers: bearerHeaders(input.clerkToken),
740 })
741 .pipe(
742 Effect.map((response) => response.devices),
743 Effect.mapError(relayRequestError("list relay client devices")),
744 timeoutRelayRequest("Relay client device listing"),
876745 );
877 },
878 Effect.withSpan("clientRuntime.managedRelay.registerDevice"),
879 withRelayClientTracing,
880 ),
881 unregisterDevice: Effect.fnUntraced(
882 function* (input) {
883 return yield* mobileRegistrationRequest(
884 {
885 clerkToken: input.clerkToken,
886 target: dpopProofTargets.unregisterDevice(input.deviceId),
887 },
888 (authorization) =>
889 client.mobile
890 .unregisterDevice({
891 headers: dpopHeaders(authorization),
892 params: { deviceId: input.deviceId },
893 })
894 .pipe(
895 Effect.mapError(relayRequestError("unregister relay mobile device")),
896 timeoutRelayRequest("Relay mobile device unregistration"),
897 ),
746 }, Effect.withSpan("clientRuntime.managedRelay.listDevices")),
747 createEnvironmentLinkChallenge: Effect.fnUntraced(function* (input) {
748 return yield* client.client
749 .createEnvironmentLinkChallenge({
750 headers: bearerHeaders(input.clerkToken),
751 payload: input.payload,
752 })
753 .pipe(
754 Effect.mapError(relayRequestError("create relay environment link challenge")),
755 timeoutRelayRequest("Relay environment link challenge"),
898756 );
899 },
900 Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"),
901 withRelayClientTracing,
902 ),
903 getAgentActivitySnapshot: Effect.fnUntraced(
904 function* (input) {
905 return yield* mobileRegistrationRequest(
906 {
907 clerkToken: input.clerkToken,
908 target: dpopProofTargets.getAgentActivitySnapshot(),
909 },
910 (authorization) =>
911 client.mobile
912 .getAgentActivitySnapshot({
913 headers: dpopHeaders(authorization),
914 })
915 .pipe(
916 Effect.mapError(relayRequestError("read relay agent activity snapshot")),
917 timeoutRelayRequest("Relay agent activity snapshot"),
918 ),
757 }, Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge")),
758 linkEnvironment: Effect.fnUntraced(function* (input) {
759 return yield* client.client
760 .linkEnvironment({
761 headers: bearerHeaders(input.clerkToken),
762 payload: input.payload,
763 })
764 .pipe(
765 Effect.mapError(relayRequestError("link relay environment")),
766 timeoutRelayRequest("Relay environment linking"),
919767 );
920 },
921 Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot"),
922 withRelayClientTracing,
923 ),
924 registerLiveActivity: Effect.fnUntraced(
925 function* (input) {
926 return yield* mobileRegistrationRequest(
927 {
928 clerkToken: input.clerkToken,
929 target: dpopProofTargets.registerLiveActivity(),
930 },
931 (authorization) =>
932 client.mobile
933 .registerLiveActivity({
934 headers: dpopHeaders(authorization),
935 payload: input.payload,
936 })
937 .pipe(
938 Effect.mapError(relayRequestError("register relay live activity")),
939 timeoutRelayRequest("Relay Live Activity registration"),
940 ),
768 }, Effect.withSpan("clientRuntime.managedRelay.linkEnvironment")),
769 unlinkEnvironment: Effect.fnUntraced(function* (input) {
770 return yield* client.client
771 .unlinkEnvironment({
772 headers: bearerHeaders(input.clerkToken),
773 params: { environmentId: input.environmentId },
774 })
775 .pipe(
776 Effect.mapError(relayRequestError("unlink relay environment")),
777 timeoutRelayRequest("Relay environment unlinking"),
941778 );
942 },
943 Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"),
944 withRelayClientTracing,
945 ),
779 }, Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment")),
780 getEnvironmentStatus: Effect.fnUntraced(function* (input) {
781 yield* Effect.annotateCurrentSpan({
782 "environment.id": input.environmentId,
783 });
784 return yield* runDpopRequest(
785 {
786 clerkToken: input.clerkToken,
787 scopes: input.scopes,
788 target: dpopProofTargets.getEnvironmentStatus(input.environmentId),
789 },
790 (authorization) =>
791 client.dpopClient
792 .getEnvironmentStatus({
793 headers: dpopHeaders(authorization),
794 params: { environmentId: input.environmentId },
795 })
796 .pipe(
797 Effect.mapError(relayRequestError("get relay environment status")),
798 timeoutRelayRequest("Relay environment status request"),
799 ),
800 );
801 }, Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus")),
802 connectEnvironment: Effect.fnUntraced(function* (input) {
803 yield* Effect.annotateCurrentSpan({
804 "environment.id": input.environmentId,
805 });
806 return yield* runDpopRequest(
807 {
808 clerkToken: input.clerkToken,
809 scopes: input.scopes,
810 target: dpopProofTargets.connectEnvironment(input.environmentId),
811 },
812 (authorization) => {
813 const payload: RelayEnvironmentConnectRequest = {
814 ...(input.deviceId ? { deviceId: input.deviceId } : {}),
815 clientKeyThumbprint: authorization.thumbprint,
816 };
817 return client.dpopClient
818 .connectEnvironment({
819 headers: dpopHeaders(authorization),
820 params: { environmentId: input.environmentId },
821 payload,
822 })
823 .pipe(
824 Effect.mapError(relayRequestError("connect relay environment")),
825 timeoutRelayRequest("Relay environment connection"),
826 );
827 },
828 );
829 }, Effect.withSpan("clientRuntime.managedRelay.connectEnvironment")),
830 registerDevice: Effect.fnUntraced(function* (input) {
831 return yield* mobileRegistrationRequest(
832 {
833 clerkToken: input.clerkToken,
834 target: dpopProofTargets.registerDevice(),
835 },
836 (authorization) =>
837 client.mobile
838 .registerDevice({
839 headers: dpopHeaders(authorization),
840 payload: input.payload,
841 })
842 .pipe(
843 Effect.mapError(relayRequestError("register relay mobile device")),
844 timeoutRelayRequest("Relay mobile device registration"),
845 ),
846 );
847 }, Effect.withSpan("clientRuntime.managedRelay.registerDevice")),
848 unregisterDevice: Effect.fnUntraced(function* (input) {
849 return yield* mobileRegistrationRequest(
850 {
851 clerkToken: input.clerkToken,
852 target: dpopProofTargets.unregisterDevice(input.deviceId),
853 },
854 (authorization) =>
855 client.mobile
856 .unregisterDevice({
857 headers: dpopHeaders(authorization),
858 params: { deviceId: input.deviceId },
859 })
860 .pipe(
861 Effect.mapError(relayRequestError("unregister relay mobile device")),
862 timeoutRelayRequest("Relay mobile device unregistration"),
863 ),
864 );
865 }, Effect.withSpan("clientRuntime.managedRelay.unregisterDevice")),
866 getAgentActivitySnapshot: Effect.fnUntraced(function* (input) {
867 return yield* mobileRegistrationRequest(
868 {
869 clerkToken: input.clerkToken,
870 target: dpopProofTargets.getAgentActivitySnapshot(),
871 },
872 (authorization) =>
873 client.mobile
874 .getAgentActivitySnapshot({
875 headers: dpopHeaders(authorization),
876 })
877 .pipe(
878 Effect.mapError(relayRequestError("read relay agent activity snapshot")),
879 timeoutRelayRequest("Relay agent activity snapshot"),
880 ),
881 );
882 }, Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot")),
883 registerLiveActivity: Effect.fnUntraced(function* (input) {
884 return yield* mobileRegistrationRequest(
885 {
886 clerkToken: input.clerkToken,
887 target: dpopProofTargets.registerLiveActivity(),
888 },
889 (authorization) =>
890 client.mobile
891 .registerLiveActivity({
892 headers: dpopHeaders(authorization),
893 payload: input.payload,
894 })
895 .pipe(
896 Effect.mapError(relayRequestError("register relay live activity")),
897 timeoutRelayRequest("Relay Live Activity registration"),
898 ),
899 );
900 }, Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity")),
946901 resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe(
947902 Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void),
948903 Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"),
949 withRelayClientTracing,
950904 ),
951905 });
952906});
packages/client-runtime/src/state/environmentHttpAuth.test.ts-32
......@@ -12,14 +12,12 @@ import {
1212 OrchestrationV2ThreadTranscript,
1313 type OrchestrationV2ThreadHistoryPage,
1414} from "@t3tools/contracts";
15import { RelayClientTracer } from "@t3tools/shared/relayTracing";
1615import * as Deferred from "effect/Deferred";
1716import * as Effect from "effect/Effect";
1817import * as Fiber from "effect/Fiber";
1918import * as Layer from "effect/Layer";
2019import * as Option from "effect/Option";
2120import * as Schema from "effect/Schema";
22import * as Tracer from "effect/Tracer";
2321import { TestClock } from "effect/testing";
2422import type { HttpClient } from "effect/http";
2523
......@@ -764,36 +762,6 @@ describe("authenticated environment HTTP requests", () => {
764762 );
765763});
766764
767describe("relay request tracing", () => {
768 it.effect("starts an exported trace for a T3 Connect request", () =>
769 Effect.gen(function* () {
770 const productSpans: Array<{ readonly name: string; readonly root: boolean }> = [];
771 const productTracer = Tracer.make({
772 span: (options) => {
773 productSpans.push({ name: options.name, root: Option.isNone(options.parent) });
774 return new Tracer.NativeSpan(options);
775 },
776 });
777 const harness = makeHarness(() => Response.json(DIFF_RESULT));
778
779 yield* PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({
780 ...harness.input,
781 diff: DIFF,
782 }).pipe(
783 Effect.withSpan("mobile.screen.local"),
784 Effect.provide(harness.httpLayer),
785 Effect.provideService(RelayClientTracer, Option.some(productTracer)),
786 );
787
788 expect(productSpans[0]).toEqual({
789 name: "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest",
790 root: true,
791 });
792 expect(productSpans.map((span) => span.name)).not.toContain("mobile.screen.local");
793 }),
794 );
795});
796
797765describe("orchestration HTTP compatibility header", () => {
798766 it("announces the current protocol without replacing bearer or DPoP authentication", () => {
799767 expect(
packages/client-runtime/src/state/environmentHttpAuth.ts+1-12
......@@ -5,7 +5,6 @@ import {
55 ORCHESTRATION_PROTOCOL_VERSION_TEXT,
66} from "@t3tools/contracts";
77import * as Result from "effect/Result";
8import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
98import { FetchHttpClient, type HttpMethod } from "effect/http";
109
1110import { HostedAuthAccess } from "../platform/capabilities.ts";
......@@ -106,10 +105,6 @@ const buildEnvironmentAuthHeaders = (
106105 * Resolve relay credentials at request time without replacing the live socket.
107106 * A rejected credential gets one refresh and retry, with a new request-bound
108107 * proof. Cookie and bearer requests keep their existing authentication behavior.
109 *
110 * A DPoP request is T3 Connect work, so its span starts an exported trace that
111 * the environment continues; its local caller's span would leave that trace
112 * without a root.
113108 */
114109export const executeAuthenticatedEnvironmentHttpRequest = <
115110 Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1],
......@@ -118,13 +113,7 @@ export const executeAuthenticatedEnvironmentHttpRequest = <
118113 R,
119114>(
120115 input: Parameters<typeof executeEnvironmentRequest<Group, A, E, R>>[0],
121) =>
122 input.prepared.httpAuthorization?._tag === "Dpop"
123 ? executeEnvironmentRequest(input).pipe(
124 Effect.withSpan(ENVIRONMENT_REQUEST_SPAN, { root: true }),
125 withRelayClientTracing,
126 )
127 : executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN));
116) => executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN));
128117
129118const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest";
130119
packages/contracts/src/auth.ts-5
......@@ -5,7 +5,6 @@ import {
55 AuthSessionId,
66 ForwardCompatibleArray,
77 ClientSurface,
8 ClientWebDeployment,
98 TrimmedNonEmptyString,
109} from "./baseSchemas.ts";
1110
......@@ -297,11 +296,7 @@ export const AuthClientPresentationMetadata = Schema.Struct({
297296 label: Schema.optionalKey(TrimmedNonEmptyString),
298297 deviceType: Schema.optionalKey(AuthClientMetadataDeviceType),
299298 os: Schema.optionalKey(TrimmedNonEmptyString),
300 osMajorVersion: Schema.optionalKey(Schema.Int),
301 deviceModel: Schema.optionalKey(TrimmedNonEmptyString),
302299 surface: Schema.optionalKey(ClientSurface),
303 webDeployment: Schema.optionalKey(ClientWebDeployment),
304 browser: Schema.optionalKey(TrimmedNonEmptyString),
305300 appVersion: Schema.optionalKey(TrimmedNonEmptyString),
306301});
307302export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type;
packages/contracts/src/baseSchemas.ts-6
......@@ -357,12 +357,6 @@ export const ClientOs = Schema.Literals([
357357]);
358358export type ClientOs = typeof ClientOs.Type;
359359
360export const ClientDeviceType = Schema.Literals(["desktop", "phone", "tablet", "unknown"]);
361export type ClientDeviceType = typeof ClientDeviceType.Type;
362
363export const ClientWebDeployment = Schema.Literals(["hosted", "server"]);
364export type ClientWebDeployment = typeof ClientWebDeployment.Type;
365
366360export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]);
367361export type ClientConnectionMethod = typeof ClientConnectionMethod.Type;
368362
packages/shared/package.json-4
......@@ -295,10 +295,6 @@
295295 "types": "./src/relayClient.ts",
296296 "import": "./src/relayClient.ts"
297297 },
298 "./relayTracing": {
299 "types": "./src/relayTracing.ts",
300 "import": "./src/relayTracing.ts"
301 },
302298 "./preview": {
303299 "types": "./src/preview.ts",
304300 "import": "./src/preview.ts"
packages/shared/src/relayTracing.test.ts deleted-149
......@@ -1,149 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Tracer from "effect/Tracer";
6import { FetchHttpClient } from "effect/http";
7import { vi } from "vite-plus/test";
8
9import { RelayClientTracer, withLocalTracing, withRelayClientTracing } from "./relayTracing.ts";
10import * as RelayTracing from "./relayTracing.ts";
11
12function collectingTracer(spans: Array<string>): Tracer.Tracer {
13 return Tracer.make({
14 span: (options) => {
15 const span = new Tracer.NativeSpan(options);
16 const end = span.end.bind(span);
17 span.end = (endTime, exit) => {
18 end(endTime, exit);
19 spans.push(span.name);
20 };
21 return span;
22 },
23 });
24}
25
26describe("withRelayClientTracing", () => {
27 it.effect("uses the product tracer only for relay operations", () =>
28 Effect.gen(function* () {
29 const userSpans: Array<string> = [];
30 const productSpans: Array<string> = [];
31 const userTracer = collectingTracer(userSpans);
32 const productTracer = collectingTracer(productSpans);
33
34 yield* Effect.void.pipe(Effect.withSpan("user.operation"), Effect.withTracer(userTracer));
35 yield* Effect.void.pipe(
36 Effect.withSpan("relay.operation"),
37 withRelayClientTracing,
38 Effect.provideService(RelayClientTracer, Option.some(productTracer)),
39 Effect.withTracer(userTracer),
40 );
41
42 expect(userSpans).toEqual(["user.operation"]);
43 expect(productSpans).toEqual(["relay.operation"]);
44 }),
45 );
46
47 it.effect("preserves the active tracer when product tracing is disabled", () =>
48 Effect.gen(function* () {
49 const userSpans: Array<string> = [];
50 const userTracer = collectingTracer(userSpans);
51
52 yield* Effect.void.pipe(
53 Effect.withSpan("relay.operation"),
54 withRelayClientTracing,
55 Effect.withTracer(userTracer),
56 );
57
58 expect(userSpans).toEqual(["relay.operation"]);
59 }),
60 );
61
62 it.effect("preserves nested error causes in exported relay spans", () => {
63 const fetchFn = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 }));
64 const layerHttpClient = FetchHttpClient.layer.pipe(
65 Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)),
66 );
67 const layerTracing = RelayTracing.layer(
68 {
69 tracesUrl: "https://api.axiom.test/v1/traces",
70 tracesDataset: "relay-traces",
71 tracesToken: "public-ingest-token",
72 },
73 {
74 serviceName: "relay-test",
75 runtime: "test",
76 client: "test",
77 },
78 ).pipe(Layer.provide(layerHttpClient));
79 const rootCause = new Error("relay socket closed");
80 const failure = new Error("relay request failed", { cause: rootCause });
81 const layerTracedApplication = Layer.effectDiscard(
82 Effect.fail(failure).pipe(
83 Effect.withSpan("relay.failed-operation"),
84 withRelayClientTracing,
85 Effect.exit,
86 ),
87 ).pipe(Layer.provide(layerTracing));
88
89 return Layer.build(layerTracedApplication).pipe(
90 Effect.scoped,
91 Effect.andThen(
92 Effect.sync(() => {
93 expect(fetchFn).toHaveBeenCalledOnce();
94 const payload = new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array);
95 expect(payload).toContain("relay request failed");
96 expect(payload).toContain("relay socket closed");
97 expect(payload).toContain('"key":"service.name","value":{"stringValue":"relay-test"}');
98 expect(payload).toContain('"key":"service.namespace","value":{"stringValue":"t3code"}');
99 }),
100 ),
101 );
102 });
103});
104
105describe("withLocalTracing", () => {
106 it.effect("keeps local work inside a relay span off the product tracer", () =>
107 Effect.gen(function* () {
108 const localSpans: Array<string> = [];
109 const productSpans: Array<string> = [];
110 const localTracer = collectingTracer(localSpans);
111 const productTracer = collectingTracer(productSpans);
112
113 yield* Effect.void.pipe(
114 Effect.withSpan("relay.connection.nested"),
115 Effect.andThen(
116 Effect.void.pipe(
117 Effect.withSpan("sql.execute"),
118 Effect.withSpan("ServerSecretStore.get"),
119 withLocalTracing,
120 ),
121 ),
122 Effect.withSpan("environment.orchestration.threadSnapshot"),
123 withRelayClientTracing,
124 Effect.provideService(RelayClientTracer, Option.some(productTracer)),
125 Effect.withTracer(localTracer),
126 );
127
128 expect(productSpans).toEqual([
129 "relay.connection.nested",
130 "environment.orchestration.threadSnapshot",
131 ]);
132 expect(localSpans).toEqual(["sql.execute", "ServerSecretStore.get"]);
133 }),
134 );
135
136 it.effect("leaves the current tracer alone outside relay tracing", () =>
137 Effect.gen(function* () {
138 const localSpans: Array<string> = [];
139
140 yield* Effect.void.pipe(
141 Effect.withSpan("sql.execute"),
142 withLocalTracing,
143 Effect.withTracer(collectingTracer(localSpans)),
144 );
145
146 expect(localSpans).toEqual(["sql.execute"]);
147 }),
148 );
149});
packages/shared/src/relayTracing.ts deleted-193
......@@ -1,193 +0,0 @@
1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Tracer from "effect/Tracer";
8import type { HttpClient } from "effect/http";
9import { OtlpSerialization, OtlpTracer } from "effect/observability";
10
11export interface RelayClientTracingConfig {
12 readonly tracesUrl: string;
13 readonly tracesDataset: string;
14 readonly tracesToken: string;
15}
16
17export interface RelayClientTracingResource {
18 readonly serviceName: string;
19 readonly serviceVersion?: string;
20 readonly runtime: string;
21 readonly client: string;
22 readonly component?: string;
23}
24
25export class RelayClientTracer extends Context.Reference(
26 "@t3tools/shared/relayTracing/RelayClientTracer",
27 {
28 defaultValue: () => Option.none<Tracer.Tracer>(),
29 },
30) {}
31
32/**
33 * The tracer that was active before relay tracing took over, so work nested
34 * inside a relay span can return to it.
35 */
36class LocalTracer extends Context.Reference("@t3tools/shared/relayTracing/LocalTracer", {
37 defaultValue: () => Option.none<Tracer.Tracer>(),
38}) {}
39
40/**
41 * Exports every span `effect` creates through the product tracer. Use it only
42 * around T3 Connect work; wrap local work inside it with
43 * {@link withLocalTracing} so it stays off the product tracer.
44 */
45export const withRelayClientTracing = <A, E, R>(
46 effect: Effect.Effect<A, E, R>,
47): Effect.Effect<A, E, R> =>
48 RelayClientTracer.pipe(
49 Effect.flatMap(
50 Option.match({
51 onNone: () => effect,
52 onSome: (tracer) =>
53 Tracer.Tracer.pipe(
54 Effect.flatMap((local) =>
55 effect.pipe(
56 Effect.provideService(Tracer.Tracer, tracer),
57 Effect.provideService(LocalTracer, Option.some(local)),
58 ),
59 ),
60 ),
61 }),
62 ),
63 );
64
65/**
66 * Runs `effect` on the tracer that was active outside relay tracing. Its spans
67 * stay in the local trace and are never exported, so a relay span can time the
68 * work without shipping what the user's machine did to answer it.
69 */
70export const withLocalTracing = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
71 LocalTracer.pipe(
72 Effect.flatMap(
73 Option.match({
74 onNone: () => effect,
75 onSome: (local) => effect.pipe(Effect.provideService(Tracer.Tracer, local)),
76 }),
77 ),
78 );
79
80function cleanTraceStack(error: Error): string {
81 const stack = error.stack ?? `${error.name}: ${error.message}`;
82 const lines = stack.split("\n");
83 const effectFrameIndex = lines.findIndex(
84 (line, index) => index > 0 && /(?:Generator\.next|~effect\/Effect)/.test(line),
85 );
86 return effectFrameIndex < 0 ? stack : lines.slice(0, effectFrameIndex).join("\n");
87}
88
89function traceSafeError(value: unknown, seen = new WeakSet<object>()): Error {
90 const message =
91 value instanceof Error
92 ? value.message
93 : typeof value === "object" &&
94 value !== null &&
95 "message" in value &&
96 typeof value.message === "string"
97 ? value.message
98 : String(value);
99
100 let cause: Error | undefined;
101 if (typeof value === "object" && value !== null && !seen.has(value)) {
102 seen.add(value);
103 if ("cause" in value && value.cause !== undefined) {
104 cause = traceSafeError(value.cause, seen);
105 }
106 }
107
108 const error = new Error(message, cause ? { cause } : undefined);
109 if (value instanceof Error) {
110 error.name = value.name;
111 error.stack = cleanTraceStack(value);
112 } else if (
113 typeof value === "object" &&
114 value !== null &&
115 "name" in value &&
116 typeof value.name === "string"
117 ) {
118 error.name = value.name;
119 }
120 if (cause) {
121 error.stack = `${error.stack ?? `${error.name}: ${error.message}`}\nCaused by: ${cause.stack ?? `${cause.name}: ${cause.message}`}`;
122 }
123 return error;
124}
125
126function traceSafeExit(exit: Exit.Exit<unknown, unknown>): Exit.Exit<unknown, unknown> {
127 if (Exit.isSuccess(exit)) {
128 return exit;
129 }
130 return Exit.failCause(
131 Cause.fromReasons(
132 exit.cause.reasons.map((reason) => {
133 if (Cause.isFailReason(reason)) {
134 return Cause.makeFailReason(traceSafeError(reason.error));
135 }
136 if (Cause.isDieReason(reason)) {
137 return Cause.makeDieReason(traceSafeError(reason.defect));
138 }
139 return reason;
140 }),
141 ),
142 );
143}
144
145function nonInterferingTracer(delegate: Tracer.Tracer): Tracer.Tracer {
146 return Tracer.make({
147 span(options) {
148 const span = delegate.span(options);
149 const end = span.end.bind(span);
150 span.end = (endTime, exit) => {
151 try {
152 end(endTime, traceSafeExit(exit));
153 } catch {
154 // Telemetry is best-effort and must never change application behavior.
155 }
156 };
157 return span;
158 },
159 ...(delegate.context ? { context: delegate.context } : {}),
160 });
161}
162
163export function layer(
164 config: RelayClientTracingConfig | null,
165 resource: RelayClientTracingResource,
166): Layer.Layer<never, never, HttpClient.HttpClient> {
167 if (config === null) {
168 return Layer.succeed(RelayClientTracer, Option.none());
169 }
170
171 const layerTracer = OtlpTracer.layer({
172 url: config.tracesUrl,
173 headers: {
174 Authorization: `Bearer ${config.tracesToken}`,
175 "X-Axiom-Dataset": config.tracesDataset,
176 },
177 resource: {
178 serviceName: resource.serviceName,
179 serviceVersion: resource.serviceVersion,
180 attributes: {
181 "service.namespace": "t3code",
182 "service.runtime": resource.runtime,
183 "service.component": resource.component ?? "relay-client",
184 "t3.client.surface": resource.client,
185 },
186 },
187 }).pipe(Layer.provide(OtlpSerialization.layerJson));
188
189 return Layer.effect(
190 RelayClientTracer,
191 Tracer.Tracer.pipe(Effect.map(nonInterferingTracer), Effect.asSome),
192 ).pipe(Layer.provide(layerTracer));
193}
scripts/lib/public-config.test.ts-64
......@@ -27,18 +27,6 @@ describe("loadRepoEnv", () => {
2727 expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined();
2828 expect(env.T3CODE_RELAY_URL).toBeUndefined();
2929 expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined();
30 expect(env.T3CODE_MOBILE_OTLP_TRACES_URL).toBeUndefined();
31 expect(env.T3CODE_MOBILE_OTLP_TRACES_DATASET).toBeUndefined();
32 expect(env.T3CODE_MOBILE_OTLP_TRACES_TOKEN).toBeUndefined();
33 expect(env.EXPO_PUBLIC_OTLP_TRACES_URL).toBeUndefined();
34 expect(env.EXPO_PUBLIC_OTLP_TRACES_DATASET).toBeUndefined();
35 expect(env.EXPO_PUBLIC_OTLP_TRACES_TOKEN).toBeUndefined();
36 expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL).toBeUndefined();
37 expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET).toBeUndefined();
38 expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN).toBeUndefined();
39 expect(env.VITE_RELAY_OTLP_TRACES_URL).toBeUndefined();
40 expect(env.VITE_RELAY_OTLP_TRACES_DATASET).toBeUndefined();
41 expect(env.VITE_RELAY_OTLP_TRACES_TOKEN).toBeUndefined();
4230 });
4331
4432 it("applies process, root local, and root precedence in that order", () => {
......@@ -85,64 +73,12 @@ describe("loadRepoEnv", () => {
8573 VITE_CLERK_JWT_TEMPLATE: "template_legacy",
8674 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical",
8775 VITE_T3CODE_RELAY_URL: "https://legacy.example.test",
88 EXPO_PUBLIC_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
89 EXPO_PUBLIC_OTLP_TRACES_DATASET: "mobile-traces",
90 EXPO_PUBLIC_OTLP_TRACES_TOKEN: "mobile-token",
9176 }),
9277 ).toEqual({
9378 clerkPublishableKey: "pk_legacy",
9479 clerkJwtTemplate: "template_legacy",
9580 clerkCliOAuthClientId: "oauth_canonical",
9681 relayUrl: "https://legacy.example.test",
97 mobileOtlpTracesUrl: "https://api.axiom.co/v1/traces",
98 mobileOtlpTracesDataset: "mobile-traces",
99 mobileOtlpTracesToken: "mobile-token",
100 relayClientOtlpTracesUrl: undefined,
101 relayClientOtlpTracesDataset: undefined,
102 relayClientOtlpTracesToken: undefined,
103 });
104 });
105
106 it("projects canonical relay client tracing values to web build aliases", () => {
107 expect(
108 loadRepoEnv({
109 baseEnv: {
110 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
111 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "relay-client-traces",
112 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "relay-client-token",
113 },
114 repoRoot: makeTemporaryDirectory(),
115 }),
116 ).toEqual({
117 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
118 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "relay-client-traces",
119 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "relay-client-token",
120 VITE_RELAY_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
121 VITE_RELAY_OTLP_TRACES_DATASET: "relay-client-traces",
122 VITE_RELAY_OTLP_TRACES_TOKEN: "relay-client-token",
123 });
124 });
125
126 it("projects canonical mobile tracing values to Expo public aliases", () => {
127 expect(
128 loadRepoEnv({
129 baseEnv: {
130 T3CODE_RELAY_URL: "https://relay.example.test",
131 T3CODE_MOBILE_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
132 T3CODE_MOBILE_OTLP_TRACES_DATASET: "mobile-traces",
133 T3CODE_MOBILE_OTLP_TRACES_TOKEN: "mobile-token",
134 },
135 repoRoot: makeTemporaryDirectory(),
136 }),
137 ).toEqual({
138 T3CODE_RELAY_URL: "https://relay.example.test",
139 VITE_T3CODE_RELAY_URL: "https://relay.example.test",
140 T3CODE_MOBILE_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
141 T3CODE_MOBILE_OTLP_TRACES_DATASET: "mobile-traces",
142 T3CODE_MOBILE_OTLP_TRACES_TOKEN: "mobile-token",
143 EXPO_PUBLIC_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
144 EXPO_PUBLIC_OTLP_TRACES_DATASET: "mobile-traces",
145 EXPO_PUBLIC_OTLP_TRACES_TOKEN: "mobile-token",
14682 });
14783 });
14884});
scripts/lib/public-config.ts-72
......@@ -9,12 +9,6 @@ export interface T3CodePublicConfig {
99 readonly clerkJwtTemplate: string | undefined;
1010 readonly clerkCliOAuthClientId: string | undefined;
1111 readonly relayUrl: string | undefined;
12 readonly mobileOtlpTracesUrl: string | undefined;
13 readonly mobileOtlpTracesDataset: string | undefined;
14 readonly mobileOtlpTracesToken: string | undefined;
15 readonly relayClientOtlpTracesUrl: string | undefined;
16 readonly relayClientOtlpTracesDataset: string | undefined;
17 readonly relayClientOtlpTracesToken: string | undefined;
1812}
1913
2014type Environment = Readonly<Record<string, string | undefined>>;
......@@ -64,42 +58,6 @@ export function loadRepoEnv({
6458 VITE_T3CODE_RELAY_URL: config.relayUrl,
6559 }
6660 : {}),
67 ...(config.mobileOtlpTracesUrl
68 ? {
69 T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileOtlpTracesUrl,
70 EXPO_PUBLIC_OTLP_TRACES_URL: config.mobileOtlpTracesUrl,
71 }
72 : {}),
73 ...(config.mobileOtlpTracesDataset
74 ? {
75 T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileOtlpTracesDataset,
76 EXPO_PUBLIC_OTLP_TRACES_DATASET: config.mobileOtlpTracesDataset,
77 }
78 : {}),
79 ...(config.mobileOtlpTracesToken
80 ? {
81 T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken,
82 EXPO_PUBLIC_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken,
83 }
84 : {}),
85 ...(config.relayClientOtlpTracesUrl
86 ? {
87 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.relayClientOtlpTracesUrl,
88 VITE_RELAY_OTLP_TRACES_URL: config.relayClientOtlpTracesUrl,
89 }
90 : {}),
91 ...(config.relayClientOtlpTracesDataset
92 ? {
93 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.relayClientOtlpTracesDataset,
94 VITE_RELAY_OTLP_TRACES_DATASET: config.relayClientOtlpTracesDataset,
95 }
96 : {}),
97 ...(config.relayClientOtlpTracesToken
98 ? {
99 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.relayClientOtlpTracesToken,
100 VITE_RELAY_OTLP_TRACES_TOKEN: config.relayClientOtlpTracesToken,
101 }
102 : {}),
10361 };
10462}
10563
......@@ -123,36 +81,6 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP
12381 "VITE_CLERK_CLI_OAUTH_CLIENT_ID",
12482 ),
12583 relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"),
126 mobileOtlpTracesUrl: firstNonEmpty(
127 sources,
128 "T3CODE_MOBILE_OTLP_TRACES_URL",
129 "EXPO_PUBLIC_OTLP_TRACES_URL",
130 ),
131 mobileOtlpTracesDataset: firstNonEmpty(
132 sources,
133 "T3CODE_MOBILE_OTLP_TRACES_DATASET",
134 "EXPO_PUBLIC_OTLP_TRACES_DATASET",
135 ),
136 mobileOtlpTracesToken: firstNonEmpty(
137 sources,
138 "T3CODE_MOBILE_OTLP_TRACES_TOKEN",
139 "EXPO_PUBLIC_OTLP_TRACES_TOKEN",
140 ),
141 relayClientOtlpTracesUrl: firstNonEmpty(
142 sources,
143 "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL",
144 "VITE_RELAY_OTLP_TRACES_URL",
145 ),
146 relayClientOtlpTracesDataset: firstNonEmpty(
147 sources,
148 "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET",
149 "VITE_RELAY_OTLP_TRACES_DATASET",
150 ),
151 relayClientOtlpTracesToken: firstNonEmpty(
152 sources,
153 "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN",
154 "VITE_RELAY_OTLP_TRACES_TOKEN",
155 ),
15684 };
15785}
15886