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", () => {...@@ -996,11 +996,9 @@ describe("DesktopBackendConfiguration", () => {
996996
997 const previousWslEnv = process.env.WSLENV;997 const previousWslEnv = process.env.WSLENV;
998 const previousDisabled = process.env.OTEL_SDK_DISABLED;998 const previousDisabled = process.env.OTEL_SDK_DISABLED;
999 const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED;
1000 try {999 try {
1001 delete process.env.WSLENV;1000 delete process.env.WSLENV;
1002 process.env.OTEL_SDK_DISABLED = "true";1001 process.env.OTEL_SDK_DISABLED = "true";
1003 process.env.T3CODE_TELEMETRY_ENABLED = "false";
10041002
1005 yield* Effect.gen(function* () {1003 yield* Effect.gen(function* () {
1006 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;1004 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
...@@ -1008,8 +1006,6 @@ describe("DesktopBackendConfiguration", () => {...@@ -1008,8 +1006,6 @@ describe("DesktopBackendConfiguration", () => {
10081006
1009 assert.equal(config.env.OTEL_SDK_DISABLED, "true");1007 assert.equal(config.env.OTEL_SDK_DISABLED, "true");
1010 assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED");1008 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");
1013 }).pipe(1009 }).pipe(
1014 Effect.provide(1010 Effect.provide(
1015 DesktopBackendConfiguration.layer.pipe(1011 DesktopBackendConfiguration.layer.pipe(
...@@ -1030,7 +1026,6 @@ describe("DesktopBackendConfiguration", () => {...@@ -1030,7 +1026,6 @@ describe("DesktopBackendConfiguration", () => {
1030 } finally {1026 } finally {
1031 restoreEnv("WSLENV", previousWslEnv);1027 restoreEnv("WSLENV", previousWslEnv);
1032 restoreEnv("OTEL_SDK_DISABLED", previousDisabled);1028 restoreEnv("OTEL_SDK_DISABLED", previousDisabled);
1033 restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry);
1034 }1029 }
1035 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),1030 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1036 );1031 );
apps/desktop/src/backend/DesktopBackendConfiguration.ts-1
...@@ -102,7 +102,6 @@ const DESKTOP_BACKEND_ENV_NAMES = [...@@ -102,7 +102,6 @@ const DESKTOP_BACKEND_ENV_NAMES = [
102const WSL_FORWARDED_ENV_NAMES = [102const WSL_FORWARDED_ENV_NAMES = [
103 "OPENAI_API_KEY",103 "OPENAI_API_KEY",
104 "ANTHROPIC_API_KEY",104 "ANTHROPIC_API_KEY",
105 "T3CODE_TELEMETRY_ENABLED",
106 // Otherwise the WSL server keeps exporting to endpoints from the bootstrap.105 // Otherwise the WSL server keeps exporting to endpoints from the bootstrap.
107 "T3CODE_OTEL_SDK_DISABLED",106 "T3CODE_OTEL_SDK_DISABLED",
108 "OTEL_SDK_DISABLED",107 "OTEL_SDK_DISABLED",
apps/desktop/src/shell/DesktopShellEnvironment.test.ts-2
...@@ -357,13 +357,11 @@ describe("DesktopShellEnvironment", () => {...@@ -357,13 +357,11 @@ describe("DesktopShellEnvironment", () => {
357 envOutput({357 envOutput({
358 PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",358 PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
359 SSH_AUTH_SOCK: "/tmp/secretive.sock",359 SSH_AUTH_SOCK: "/tmp/secretive.sock",
360 T3CODE_TELEMETRY_ENABLED: "false",
361 }),360 }),
362 });361 });
363362
364 assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin");363 assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin");
365 assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");364 assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
366 assert.equal(env.T3CODE_TELEMETRY_ENABLED, "false");
367 }),365 }),
368 );366 );
369367
apps/desktop/src/shell/DesktopShellEnvironment.ts-3
...@@ -89,7 +89,6 @@ const LOGIN_SHELL_ENV_NAMES = [...@@ -89,7 +89,6 @@ const LOGIN_SHELL_ENV_NAMES = [
89 "SSL_CERT_FILE",89 "SSL_CERT_FILE",
90 "SSL_CERT_DIR",90 "SSL_CERT_DIR",
91 "NODE_EXTRA_CA_CERTS",91 "NODE_EXTRA_CA_CERTS",
92 "T3CODE_TELEMETRY_ENABLED",
93 "__MISE_DIFF",92 "__MISE_DIFF",
94] as const;93] as const;
95const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;94const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
...@@ -463,8 +462,6 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix...@@ -463,8 +462,6 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
463 "SSL_CERT_FILE",462 "SSL_CERT_FILE",
464 "SSL_CERT_DIR",463 "SSL_CERT_DIR",
465 "NODE_EXTRA_CA_CERTS",464 "NODE_EXTRA_CA_CERTS",
466 // The telemetry opt-out is documented as a shell variable; GUI launches never see it.
467 "T3CODE_TELEMETRY_ENABLED",
468 ] as const) {465 ] as const) {
469 if (!config.env[name] && shellEnvironment[name]) {466 if (!config.env[name] && shellEnvironment[name]) {
470 config.env[name] = shellEnvironment[name];467 config.env[name] = shellEnvironment[name];
apps/mobile/app.config.ts-5
...@@ -485,11 +485,6 @@ const config: ExpoConfig = {...@@ -485,11 +485,6 @@ const config: ExpoConfig = {
485 EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID,485 EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID,
486 EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID,486 EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID,
487 EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME,487 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 },
493 eas: {488 eas: {
494 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",489 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
495 },490 },
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+1-1
...@@ -229,7 +229,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) {...@@ -229,7 +229,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) {
229 }229 }
230230
231 return (231 return (
232 <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>232 <ClerkProvider telemetry={false} publishableKey={publishableKey} tokenCache={tokenCache}>
233 <CloudAuthAvailableContext.Provider value>233 <CloudAuthAvailableContext.Provider value>
234 <CloudAuthBridge>{props.children}</CloudAuthBridge>234 <CloudAuthBridge>{props.children}</CloudAuthBridge>
235 </CloudAuthAvailableContext.Provider>235 </CloudAuthAvailableContext.Provider>
apps/mobile/src/features/cloud/publicConfig.test.ts-62
...@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vite-plus/test";...@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vite-plus/test";
22
3import {3import {
4 CloudPublicConfigMissingError,4 CloudPublicConfigMissingError,
5 hasTracingPublicConfig,
6 resolveCloudPublicConfig,5 resolveCloudPublicConfig,
7 resolveRelayClerkTokenOptions,6 resolveRelayClerkTokenOptions,
8} from "./publicConfig";7} from "./publicConfig";
...@@ -31,11 +30,6 @@ describe("resolveCloudPublicConfig", () => {...@@ -31,11 +30,6 @@ describe("resolveCloudPublicConfig", () => {
31 relay: {30 relay: {
32 url: null,31 url: null,
33 },32 },
34 observability: {
35 tracesUrl: null,
36 tracesDataset: null,
37 tracesToken: null,
38 },
39 });33 });
40 });34 });
4135
...@@ -44,11 +38,6 @@ describe("resolveCloudPublicConfig", () => {...@@ -44,11 +38,6 @@ describe("resolveCloudPublicConfig", () => {
44 resolveCloudPublicConfig({38 resolveCloudPublicConfig({
45 clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " },39 clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " },
46 relay: { url: " https://relay.example.test/// " },40 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 },
52 }),41 }),
53 ).toEqual({42 ).toEqual({
54 clerk: {43 clerk: {
...@@ -58,11 +47,6 @@ describe("resolveCloudPublicConfig", () => {...@@ -58,11 +47,6 @@ describe("resolveCloudPublicConfig", () => {
58 relay: {47 relay: {
59 url: "https://relay.example.test",48 url: "https://relay.example.test",
60 },49 },
61 observability: {
62 tracesUrl: "https://api.axiom.co/v1/traces",
63 tracesDataset: "mobile-traces",
64 tracesToken: "public-ingest-token",
65 },
66 });50 });
67 });51 });
6852
...@@ -80,52 +64,6 @@ describe("resolveCloudPublicConfig", () => {...@@ -80,52 +64,6 @@ describe("resolveCloudPublicConfig", () => {
80 relay: {64 relay: {
81 url: null,65 url: null,
82 },66 },
83 observability: {
84 tracesUrl: null,
85 tracesDataset: null,
86 tracesToken: null,
87 },
88 });67 });
89 });68 });
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 });
131});69});
apps/mobile/src/features/cloud/publicConfig.ts-41
...@@ -22,11 +22,6 @@ export interface CloudPublicConfig {...@@ -22,11 +22,6 @@ export interface CloudPublicConfig {
22 readonly relay: {22 readonly relay: {
23 readonly url: string | null;23 readonly url: string | null;
24 };24 };
25 readonly observability: {
26 readonly tracesUrl: string | null;
27 readonly tracesDataset: string | null;
28 readonly tracesToken: string | null;
29 };
30}25}
3126
32type UntrustedSection<T> = {27type UntrustedSection<T> = {
...@@ -43,19 +38,6 @@ function trimNonEmpty(value: unknown): string | null {...@@ -43,19 +38,6 @@ function trimNonEmpty(value: unknown): string | null {
43 return typeof value === "string" && value.trim() ? value.trim() : null;38 return typeof value === "string" && value.trim() ? value.trim() : null;
44}39}
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
59export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) {41export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) {
60 return {42 return {
61 clerk: {43 clerk: {
...@@ -65,11 +47,6 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig...@@ -65,11 +47,6 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig
65 relay: {47 relay: {
66 url: normalizeSecureRelayUrl(trimNonEmpty(extra?.relay?.url) ?? ""),48 url: normalizeSecureRelayUrl(trimNonEmpty(extra?.relay?.url) ?? ""),
67 },49 },
68 observability: {
69 tracesUrl: normalizeSecureUrl(extra?.observability?.tracesUrl),
70 tracesDataset: trimNonEmpty(extra?.observability?.tracesDataset),
71 tracesToken: trimNonEmpty(extra?.observability?.tracesToken),
72 },
73 } satisfies CloudPublicConfig;50 } satisfies CloudPublicConfig;
74}51}
7552
...@@ -78,24 +55,6 @@ export function hasCloudPublicConfig(): boolean {...@@ -78,24 +55,6 @@ export function hasCloudPublicConfig(): boolean {
78 return Boolean(config.clerk.publishableKey && config.clerk.jwtTemplate && config.relay.url);55 return Boolean(config.clerk.publishableKey && config.clerk.jwtTemplate && config.relay.url);
79}56}
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
99export function resolveRelayClerkTokenOptions() {58export function resolveRelayClerkTokenOptions() {
100 const { jwtTemplate } = resolveCloudPublicConfig().clerk;59 const { jwtTemplate } = resolveCloudPublicConfig().clerk;
101 if (!jwtTemplate) {60 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";...@@ -3,9 +3,6 @@ import * as Device from "expo-device";
3import { Platform } from "react-native";3import { Platform } from "react-native";
44
5export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata {5export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata {
6 const osMajorVersion = Number.parseInt(Device.osVersion?.split(".")[0] ?? "", 10);
7 const deviceModel = Device.modelName?.trim();
8
9 return {6 return {
10 label: "The Knife Mobile",7 label: "The Knife Mobile",
11 deviceType:8 deviceType:
...@@ -15,8 +12,6 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM...@@ -15,8 +12,6 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM
15 ? "mobile"12 ? "mobile"
16 : "unknown",13 : "unknown",
17 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),14 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
18 ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}),
19 ...(deviceModel ? { deviceModel } : {}),
20 surface: "mobile",15 surface: "mobile",
21 ...(appVersion ? { appVersion } : {}),16 ...(appVersion ? { appVersion } : {}),
22 };17 };
apps/mobile/src/lib/connection.test.ts+1-13
...@@ -14,8 +14,6 @@ const mobileDevice = vi.hoisted(() => ({...@@ -14,8 +14,6 @@ const mobileDevice = vi.hoisted(() => ({
14 DESKTOP: 3,14 DESKTOP: 3,
15 TV: 4,15 TV: 4,
16 },16 },
17 osVersion: "18.4.1",
18 modelName: "iPhone 15 Pro",
19}));17}));
2018
21vi.mock("./runtime", () => ({19vi.mock("./runtime", () => ({
...@@ -34,8 +32,6 @@ describe("mobile remote connection records", () => {...@@ -34,8 +32,6 @@ describe("mobile remote connection records", () => {
34 afterEach(() => {32 afterEach(() => {
35 mobilePlatform.OS = "ios";33 mobilePlatform.OS = "ios";
36 mobileDevice.deviceType = mobileDevice.DeviceType.PHONE;34 mobileDevice.deviceType = mobileDevice.DeviceType.PHONE;
37 mobileDevice.osVersion = "18.4.1";
38 mobileDevice.modelName = "iPhone 15 Pro";
39 });35 });
4036
41 it("identifies mobile token exchanges for authorized-client presentation", () => {37 it("identifies mobile token exchanges for authorized-client presentation", () => {
...@@ -43,32 +39,24 @@ describe("mobile remote connection records", () => {...@@ -43,32 +39,24 @@ describe("mobile remote connection records", () => {
43 label: "The Knife Mobile",39 label: "The Knife Mobile",
44 deviceType: "mobile",40 deviceType: "mobile",
45 os: "iOS",41 os: "iOS",
46 osMajorVersion: 18,
47 deviceModel: "iPhone 15 Pro",
48 surface: "mobile",42 surface: "mobile",
49 });43 });
50 });44 });
5145
52 it("includes only the Android major version and hardware model", () => {46 it("identifies Android clients for authorized-client presentation", () => {
53 mobilePlatform.OS = "android";47 mobilePlatform.OS = "android";
54 mobileDevice.osVersion = "15.2.1";
55 mobileDevice.modelName = "Pixel 9";
5648
57 expect(authClientMetadata()).toMatchObject({49 expect(authClientMetadata()).toMatchObject({
58 os: "Android",50 os: "Android",
59 osMajorVersion: 15,
60 deviceModel: "Pixel 9",
61 });51 });
62 });52 });
6353
64 it("identifies native tablets separately from phones", () => {54 it("identifies native tablets separately from phones", () => {
65 mobileDevice.deviceType = mobileDevice.DeviceType.TABLET;55 mobileDevice.deviceType = mobileDevice.DeviceType.TABLET;
66 mobileDevice.modelName = "iPad Pro 13-inch";
6756
68 expect(authClientMetadata()).toMatchObject({57 expect(authClientMetadata()).toMatchObject({
69 deviceType: "tablet",58 deviceType: "tablet",
70 os: "iOS",59 os: "iOS",
71 deviceModel: "iPad Pro 13-inch",
72 });60 });
73 });61 });
7462
apps/mobile/src/lib/runtime.ts+1-4
...@@ -7,7 +7,6 @@ import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";...@@ -7,7 +7,6 @@ import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";
7import * as Dpop from "../features/cloud/dpop";7import * as Dpop from "../features/cloud/dpop";
8import * as ManagedRelayLayer from "../features/cloud/managedRelayLayer";8import * as ManagedRelayLayer from "../features/cloud/managedRelayLayer";
9import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";9import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
10import * as Tracing from "../features/observability/tracing";
11import * as Persistence from "../persistence/layer";10import * as Persistence from "../persistence/layer";
12import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh";11import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh";
1312
...@@ -24,8 +23,7 @@ type RuntimeLayerSource =...@@ -24,8 +23,7 @@ type RuntimeLayerSource =
24 | typeof Socket.layerWebSocketConstructorGlobal23 | typeof Socket.layerWebSocketConstructorGlobal
25 | typeof Dpop.layer24 | typeof Dpop.layer
26 | typeof layerHttpClient25 | typeof layerHttpClient
27 | typeof Persistence.layer26 | typeof Persistence.layer;
28 | typeof Tracing.layer;
2927
30const layerRuntime = Layer.merge(28const layerRuntime = Layer.merge(
31 ManagedRelayLayer.layer(configuredRelayUrl()),29 ManagedRelayLayer.layer(configuredRelayUrl()),
...@@ -33,7 +31,6 @@ const layerRuntime = Layer.merge(...@@ -33,7 +31,6 @@ const layerRuntime = Layer.merge(
33).pipe(31).pipe(
34 Layer.provideMerge(Dpop.layer),32 Layer.provideMerge(Dpop.layer),
35 Layer.provideMerge(layerHttpClient),33 Layer.provideMerge(layerHttpClient),
36 Layer.provideMerge(Tracing.layer.pipe(Layer.provide(layerHttpClient))),
37 Layer.provideMerge(Persistence.layer),34 Layer.provideMerge(Persistence.layer),
38);35);
3936
apps/server/src/auth/http.test.ts+2-14
...@@ -8,13 +8,11 @@ import {...@@ -8,13 +8,11 @@ import {
8 EnvironmentAuthenticatedAuth,8 EnvironmentAuthenticatedAuth,
9 EnvironmentHttpApi,9 EnvironmentHttpApi,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { RelayClientTracer } from "@t3tools/shared/relayTracing";
12import { expect, it } from "@effect/vitest";11import { expect, it } from "@effect/vitest";
13import * as Context from "effect/Context";12import * as Context from "effect/Context";
14import * as Crypto from "effect/Crypto";13import * as Crypto from "effect/Crypto";
15import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Redacted from "effect/Redacted";16import * as Redacted from "effect/Redacted";
19import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
20import * as Tracer from "effect/Tracer";18import * as Tracer from "effect/Tracer";
...@@ -165,9 +163,8 @@ it.effect("sets the selected browser session cookies through the HTTP route", ()...@@ -165,9 +163,8 @@ it.effect("sets the selected browser session cookies through the HTTP route", ()
165 }).pipe(Effect.provide(NodeServices.layer)),163 }).pipe(Effect.provide(NodeServices.layer)),
166);164);
167165
168it.effect("exports only verified T3 Connect requests", () =>166it.effect("keeps authenticated connection requests on the local tracer", () =>
169 Effect.gen(function* () {167 Effect.gen(function* () {
170 const productSpans: Array<string> = [];
171 const localSpans: Array<string> = [];168 const localSpans: Array<string> = [];
172 const collect = (into: Array<string>) =>169 const collect = (into: Array<string>) =>
173 Tracer.make({170 Tracer.make({
...@@ -216,27 +213,18 @@ it.effect("exports only verified T3 Connect requests", () =>...@@ -216,27 +213,18 @@ it.effect("exports only verified T3 Connect requests", () =>
216 }),213 }),
217 ),214 ),
218 ),215 ),
219 Effect.provideService(RelayClientTracer, Option.some(collect(productSpans))),
220 Effect.withTracer(collect(localSpans)),216 Effect.withTracer(collect(localSpans)),
221 );217 );
222218
223 yield* handle("DPoP connect");219 yield* handle("DPoP connect");
224 expect(productSpans).toEqual([220 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]);
225 "environment.relay.request",
226 "EnvironmentAuth.authenticateHttpRequest",
227 "environment.handler",
228 ]);
229 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]);
230221
231 productSpans.length = 0;
232 localSpans.length = 0;222 localSpans.length = 0;
233 yield* handle("DPoP forged");223 yield* handle("DPoP forged");
234 expect(productSpans).toEqual([]);
235 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]);224 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]);
236225
237 localSpans.length = 0;226 localSpans.length = 0;
238 yield* handle("Bearer access-token");227 yield* handle("Bearer access-token");
239 expect(productSpans).toEqual([]);
240 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]);228 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]);
241 }).pipe(Effect.scoped),229 }).pipe(Effect.scoped),
242);230);
apps/server/src/auth/http.ts+1-10
...@@ -21,11 +21,9 @@ import {...@@ -21,11 +21,9 @@ import {
21import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";21import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";
22import { parseOAuthScope } from "@t3tools/shared/oauthScope";22import { parseOAuthScope } from "@t3tools/shared/oauthScope";
23import { causeErrorTag } from "@t3tools/shared/observability";23import { causeErrorTag } from "@t3tools/shared/observability";
24import * as Clock from "effect/Clock";
25import * as DateTime from "effect/DateTime";24import * as DateTime from "effect/DateTime";
26import * as Effect from "effect/Effect";25import * as Effect from "effect/Effect";
27import * as Schema from "effect/Schema";26import * as Schema from "effect/Schema";
28import { identity } from "effect/Function";
29import * as Layer from "effect/Layer";27import * as Layer from "effect/Layer";
30import * as Cookies from "effect/http/Cookies";28import * as Cookies from "effect/http/Cookies";
31import * as HttpEffect from "effect/http/HttpEffect";29import * as HttpEffect from "effect/http/HttpEffect";
...@@ -34,7 +32,6 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";...@@ -34,7 +32,6 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
3432
35import * as EnvironmentAuth from "./EnvironmentAuth.ts";33import * as EnvironmentAuth from "./EnvironmentAuth.ts";
36import * as SessionStore from "./SessionStore.ts";34import * as SessionStore from "./SessionStore.ts";
37import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts";
38import { deriveAuthClientMetadata } from "./utils.ts";35import { deriveAuthClientMetadata } from "./utils.ts";
39import { verifyRequestDpopProof } from "./dpop.ts";36import { verifyRequestDpopProof } from "./dpop.ts";
4037
...@@ -233,7 +230,6 @@ export const layerAuthenticatedAuth = Layer.effect(...@@ -233,7 +230,6 @@ export const layerAuthenticatedAuth = Layer.effect(
233 return (httpEffect) =>230 return (httpEffect) =>
234 Effect.gen(function* () {231 Effect.gen(function* () {
235 const request = yield* HttpServerRequest.HttpServerRequest;232 const request = yield* HttpServerRequest.HttpServerRequest;
236 const startTime = yield* Clock.currentTimeNanos;
237 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(233 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
238 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>234 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
239 failEnvironmentAuthInvalid(235 failEnvironmentAuthInvalid(
...@@ -245,16 +241,12 @@ export const layerAuthenticatedAuth = Layer.effect(...@@ -245,16 +241,12 @@ export const layerAuthenticatedAuth = Layer.effect(
245 failEnvironmentInternal("internal_error", error),241 failEnvironmentInternal("internal_error", error),
246 ),242 ),
247 );243 );
248 const endTime = yield* Clock.currentTimeNanos;244 return yield* httpEffect.pipe(
249 const handler = httpEffect.pipe(
250 Effect.provideService(EnvironmentAuthenticatedPrincipal, {245 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
251 ...session,246 ...session,
252 scopes: new Set(session.scopes),247 scopes: new Set(session.scopes),
253 }),248 }),
254 );249 );
255 return yield* session.subject === "cloud-connect"
256 ? traceAuthenticatedRelayRequest(handler, { startTime, endTime })
257 : handler;
258 }).pipe(Effect.catchTags({ EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized }));250 }).pipe(Effect.catchTags({ EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized }));
259 }),251 }),
260);252);
...@@ -397,7 +389,6 @@ export const layer = HttpApiBuilder.group(...@@ -397,7 +389,6 @@ export const layer = HttpApiBuilder.group(
397 proofKeyThumbprint ? { proofKeyThumbprint } : undefined,389 proofKeyThumbprint ? { proofKeyThumbprint } : undefined,
398 );390 );
399 },391 },
400 traceRelayRequest,
401 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>392 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
402 failEnvironmentAuthInvalid(393 failEnvironmentAuthInvalid(
403 EnvironmentAuth.serverAuthCredentialReason(error),394 EnvironmentAuth.serverAuthCredentialReason(error),
apps/server/src/cli/connect.ts-4
...@@ -7,7 +7,6 @@ import {...@@ -7,7 +7,6 @@ import {
7import { RelayOkResponse } from "@t3tools/contracts/relay";7import { RelayOkResponse } from "@t3tools/contracts/relay";
8import * as HostProcess from "@t3tools/shared/HostProcess";8import * as HostProcess from "@t3tools/shared/HostProcess";
9import * as RelayClient from "@t3tools/shared/relayClient";9import * as RelayClient from "@t3tools/shared/relayClient";
10import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
11import * as Cause from "effect/Cause";10import * as Cause from "effect/Cause";
12import * as Config from "effect/Config";11import * as Config from "effect/Config";
13import * as Console from "effect/Console";12import * as Console from "effect/Console";
...@@ -38,7 +37,6 @@ import {...@@ -38,7 +37,6 @@ import {
38 RELAY_URL_SECRET,37 RELAY_URL_SECRET,
39} from "../cloud/config.ts";38} from "../cloud/config.ts";
40import { relayUrlConfig } from "../cloud/publicConfig.ts";39import { relayUrlConfig } from "../cloud/publicConfig.ts";
41import * as RelayTracing from "../cloud/relayTracing.ts";
42import * as ServerConfig from "../config.ts";40import * as ServerConfig from "../config.ts";
43import * as ServerEnvironment from "../environment/ServerEnvironment.ts";41import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
44import * as ExternalLauncher from "../process/externalLauncher.ts";42import * as ExternalLauncher from "../process/externalLauncher.ts";
...@@ -346,7 +344,6 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f...@@ -346,7 +344,6 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f
346 httpClient.execute,344 httpClient.execute,
347 Effect.flatMap(filterRelayResponse),345 Effect.flatMap(filterRelayResponse),
348 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),346 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
349 withRelayClientTracing,
350 );347 );
351 return response.ok348 return response.ok
352 ? ({ status: "revoked" } satisfies RelayUnlinkResult)349 ? ({ status: "revoked" } satisfies RelayUnlinkResult)
...@@ -448,7 +445,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E...@@ -448,7 +445,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
448 RelayClient.layerCloudflared({ baseDir: config.baseDir }),445 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
449 EnvironmentAuth.layerRuntime,446 EnvironmentAuth.layerRuntime,
450 CliService.layer(config),447 CliService.layer(config),
451 RelayTracing.layerHeadlessRelayClient,
452 ).pipe(448 ).pipe(
453 Layer.provideMerge(FetchHttpClient.layer),449 Layer.provideMerge(FetchHttpClient.layer),
454 Layer.provideMerge(ServerConfig.layer(config)),450 Layer.provideMerge(ServerConfig.layer(config)),
apps/server/src/cloud/CloudLink.ts-3
...@@ -34,7 +34,6 @@ import {...@@ -34,7 +34,6 @@ import {
34 type RelayManagedEndpointRuntimeConfig,34 type RelayManagedEndpointRuntimeConfig,
35 RelayOkResponse,35 RelayOkResponse,
36} from "@t3tools/contracts/relay";36} from "@t3tools/contracts/relay";
37import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
38import {37import {
39 normalizeRelayIssuer,38 normalizeRelayIssuer,
40 RELAY_HEALTH_REQUEST_TYP,39 RELAY_HEALTH_REQUEST_TYP,
...@@ -885,7 +884,6 @@ const make = Effect.gen(function* () {...@@ -885,7 +884,6 @@ const make = Effect.gen(function* () {
885 Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)),884 Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)),
886 Effect.timeout(input.timeout ?? "10 seconds"),885 Effect.timeout(input.timeout ?? "10 seconds"),
887 Effect.mapError(relayRequestError),886 Effect.mapError(relayRequestError),
888 withRelayClientTracing,
889 );887 );
890888
891 const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")(889 const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")(
...@@ -1333,7 +1331,6 @@ const make = Effect.gen(function* () {...@@ -1333,7 +1331,6 @@ const make = Effect.gen(function* () {
1333 httpClient.execute,1331 httpClient.execute,
1334 Effect.flatMap(filterRelayResponse),1332 Effect.flatMap(filterRelayResponse),
1335 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),1333 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
1336 withRelayClientTracing,
1337 );1334 );
1338 // ok:false means the relay skipped deletion because a concurrent provision1335 // ok:false means the relay skipped deletion because a concurrent provision
1339 // owns the recorded tunnel now — leave the stored config alone.1336 // 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";...@@ -18,7 +18,6 @@ import { requireEnvironmentScope } from "../auth/http.ts";
18import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";18import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
19import * as CloudLink from "./CloudLink.ts";19import * as CloudLink from "./CloudLink.ts";
20import type { RelayRequestError } from "./relayResponse.ts";20import type { RelayRequestError } from "./relayResponse.ts";
21import { traceRelayRequest } from "./traceRelayRequest.ts";
2221
23const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {22const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
24 "cache-control": "no-store",23 "cache-control": "no-store",
...@@ -151,10 +150,8 @@ export const layer = HttpApiBuilder.group(...@@ -151,10 +150,8 @@ export const layer = HttpApiBuilder.group(
151 ),150 ),
152 )151 )
153 .handle("t3MintCredential", ({ payload }) =>152 .handle("t3MintCredential", ({ payload }) =>
154 traceRelayRequest(153 toHttpError(cloudLink.mintCredential(payload)).pipe(
155 toHttpError(cloudLink.mintCredential(payload)).pipe(154 Effect.tap(() => appendCloudCredentialResponseHeaders),
156 Effect.tap(() => appendCloudCredentialResponseHeaders),
157 ),
158 ),155 ),
159 );156 );
160 }),157 }),
apps/server/src/cloud/publicConfig.test.ts+1-42
...@@ -3,12 +3,7 @@ import * as ConfigProvider from "effect/ConfigProvider";...@@ -3,12 +3,7 @@ import * as ConfigProvider from "effect/ConfigProvider";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Result from "effect/Result";4import * as Result from "effect/Result";
55
6import {6import { hostedAppUrlConfig, makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts";
7 hostedAppUrlConfig,
8 makeCloudCliOAuthConfig,
9 makeRelayUrlConfig,
10 resolveRelayClientTracingConfig,
11} from "./publicConfig.ts";
127
13const provideEnv = (env: Readonly<Record<string, string>>) =>8const provideEnv = (env: Readonly<Record<string, string>>) =>
14 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })));9 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })));
...@@ -144,39 +139,3 @@ it.effect("reports malformed Clerk publishable keys as typed configuration failu...@@ -144,39 +139,3 @@ it.effect("reports malformed Clerk publishable keys as typed configuration failu
144 }139 }
145 }),140 }),
146);141);
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";...@@ -14,9 +14,6 @@ import * as SchemaIssue from "effect/SchemaIssue";
14declare const __T3CODE_BUILD_RELAY_URL__: string | undefined;14declare const __T3CODE_BUILD_RELAY_URL__: string | undefined;
15declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;15declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
16declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined;16declare 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
21const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338;18const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338;
22const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES;19const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES;
...@@ -40,15 +37,6 @@ function readBuildTimeValue(value: string | undefined): string {...@@ -40,15 +37,6 @@ function readBuildTimeValue(value: string | undefined): string {
40 return typeof value === "undefined" ? "" : value.trim();37 return typeof value === "undefined" ? "" : value.trim();
41}38}
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
52const buildTimeRelayUrl =40const buildTimeRelayUrl =
53 typeof __T3CODE_BUILD_RELAY_URL__ === "undefined"41 typeof __T3CODE_BUILD_RELAY_URL__ === "undefined"
54 ? ""42 ? ""
...@@ -63,38 +51,6 @@ const buildTimeClerkCliOAuthClientId = readBuildTimeValue(...@@ -63,38 +51,6 @@ const buildTimeClerkCliOAuthClientId = readBuildTimeValue(
63 ? undefined51 ? undefined
64 : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__,52 : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__,
65);53);
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
98export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) {54export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) {
99 const runtimeConfig = Config.NonEmptyString("T3CODE_RELAY_URL");55 const runtimeConfig = Config.NonEmptyString("T3CODE_RELAY_URL");
100 return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe(56 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 {...@@ -48,7 +48,6 @@ export interface ServerDerivedPaths {
48 readonly providerLogsDir: string;48 readonly providerLogsDir: string;
49 readonly providerEventLogPath: string;49 readonly providerEventLogPath: string;
50 readonly terminalLogsDir: string;50 readonly terminalLogsDir: string;
51 readonly anonymousIdPath: string;
52 readonly environmentIdPath: string;51 readonly environmentIdPath: string;
53 readonly serverRuntimeStatePath: string;52 readonly serverRuntimeStatePath: string;
54 readonly secretsDir: string;53 readonly secretsDir: string;
...@@ -163,7 +162,6 @@ export const deriveServerPaths = Effect.fn(function* (...@@ -163,7 +162,6 @@ export const deriveServerPaths = Effect.fn(function* (
163 providerLogsDir,162 providerLogsDir,
164 providerEventLogPath: join(providerLogsDir, "events.log"),163 providerEventLogPath: join(providerLogsDir, "events.log"),
165 terminalLogsDir: join(logsDir, "terminals"),164 terminalLogsDir: join(logsDir, "terminals"),
166 anonymousIdPath: join(stateDir, "anonymous-id"),
167 environmentIdPath: join(stateDir, "environment-id"),165 environmentIdPath: join(stateDir, "environment-id"),
168 serverRuntimeStatePath: join(stateDir, "server-runtime.json"),166 serverRuntimeStatePath: join(stateDir, "server-runtime.json"),
169 secretsDir: join(stateDir, "secrets"),167 secretsDir: join(stateDir, "secrets"),
...@@ -185,7 +183,6 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server...@@ -185,7 +183,6 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server
185 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),183 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),
186 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),184 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
187 fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }),185 fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }),
188 fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),
189 fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }),186 fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }),
190 ],187 ],
191 { concurrency: "unbounded" },188 { concurrency: "unbounded" },
apps/server/src/http.ts+1-2
...@@ -57,7 +57,6 @@ import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts...@@ -57,7 +57,6 @@ import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts
57import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";57import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
58import * as SharedAccounts from "./provider/SharedAccounts.ts";58import * as SharedAccounts from "./provider/SharedAccounts.ts";
59import * as SharedProfiles from "./provider/SharedProfiles.ts";59import * as SharedProfiles from "./provider/SharedProfiles.ts";
60import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
61import {60import {
62 annotateEnvironmentRequest,61 annotateEnvironmentRequest,
63 failEnvironmentScopeRequired,62 failEnvironmentScopeRequired,
...@@ -534,7 +533,7 @@ export const layerServerEnvironmentHttpApi = HttpApiBuilder.group(...@@ -534,7 +533,7 @@ export const layerServerEnvironmentHttpApi = HttpApiBuilder.group(
534 Effect.fn("environment.metadata.descriptor")(function* (args) {533 Effect.fn("environment.metadata.descriptor")(function* (args) {
535 yield* annotateEnvironmentRequest(args.endpoint.name);534 yield* annotateEnvironmentRequest(args.endpoint.name);
536 return yield* serverEnvironment.getDescriptor;535 return yield* serverEnvironment.getDescriptor;
537 }, traceRelayRequest),536 }),
538 );537 );
539 }),538 }),
540);539);
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts-1
...@@ -173,7 +173,6 @@ export function makeReplayServerConfig(...@@ -173,7 +173,6 @@ export function makeReplayServerConfig(
173 providerLogsDir,173 providerLogsDir,
174 providerEventLogPath: path.join(providerLogsDir, "events.log"),174 providerEventLogPath: path.join(providerLogsDir, "events.log"),
175 terminalLogsDir,175 terminalLogsDir,
176 anonymousIdPath: path.join(stateDir, "anonymous-id"),
177 environmentIdPath: path.join(stateDir, "environment-id"),176 environmentIdPath: path.join(stateDir, "environment-id"),
178 serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"),177 serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"),
179 secretsDir: path.join(stateDir, "secrets"),178 secretsDir: path.join(stateDir, "secrets"),
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts-100
...@@ -143,106 +143,6 @@ function threadCreatedEvent(...@@ -143,106 +143,6 @@ function threadCreatedEvent(
143143
144const layer = it.layer(layerTest);144const 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
246layer("ProviderEventIngestorV2", (it) => {146layer("ProviderEventIngestorV2", (it) => {
247 it.effect("normalizes provider events through the real event log and projection store", () =>147 it.effect("normalizes provider events through the real event log and projection store", () =>
248 Effect.gen(function* () {148 Effect.gen(function* () {
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+2-103
...@@ -5,12 +5,9 @@ import {...@@ -5,12 +5,9 @@ import {
5 OrchestrationV2StoredEvent,5 OrchestrationV2StoredEvent,
6 type OrchestrationV2PlanArtifact,6 type OrchestrationV2PlanArtifact,
7 type OrchestrationV2Run,7 type OrchestrationV2Run,
8 type OrchestrationV2ProviderTurn,8 type ModelSelection,
9 type OrchestrationV2Subagent,9 type OrchestrationV2Subagent,
10 type OrchestrationV2ThreadProjection,10 type OrchestrationV2ThreadProjection,
11 type ModelSelection,
12 type RuntimeMode,
13 type ProviderInteractionMode,
14 ProviderInstanceId,11 ProviderInstanceId,
15 ProviderSessionId,12 ProviderSessionId,
16 ProviderThreadId,13 ProviderThreadId,
...@@ -27,8 +24,7 @@ import * as Layer from "effect/Layer";...@@ -27,8 +24,7 @@ import * as Layer from "effect/Layer";
27import * as Schema from "effect/Schema";24import * as Schema from "effect/Schema";
2825
29import { AgentScope } from "@t3tools/shared/AgentScope";26import { AgentScope } from "@t3tools/shared/AgentScope";
30import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";27import { modelSelectionsEqual } from "@t3tools/shared/model";
31import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
32import * as EventSink from "./EventSink.ts";28import * as EventSink from "./EventSink.ts";
33import * as ProjectionStore from "./ProjectionStore.ts";29import * as ProjectionStore from "./ProjectionStore.ts";
34import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";30import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
...@@ -71,71 +67,6 @@ export const ProviderEventIngestorV2Error = Schema.Union([...@@ -71,71 +67,6 @@ export const ProviderEventIngestorV2Error = Schema.Union([
71]);67]);
72export type ProviderEventIngestorV2Error = typeof ProviderEventIngestorV2Error.Type;68export 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
139type TodoListPlan = Extract<OrchestrationV2PlanArtifact, { readonly kind: "todo_list" }>;70type TodoListPlan = Extract<OrchestrationV2PlanArtifact, { readonly kind: "todo_list" }>;
14071
141function withPlanStepDurations(72function withPlanStepDurations(
...@@ -260,7 +191,6 @@ export interface ProviderEventIngestInput {...@@ -260,7 +191,6 @@ export interface ProviderEventIngestInput {
260 readonly nodeId?: NodeId;191 readonly nodeId?: NodeId;
261 readonly rawEventId?: RawEventId;192 readonly rawEventId?: RawEventId;
262 readonly event: ProviderAdapter.ProviderAdapterV2Event;193 readonly event: ProviderAdapter.ProviderAdapterV2Event;
263 readonly analyticsContext?: ProviderTurnAnalyticsContext;
264}194}
265195
266export interface ProviderEventIngestorV2Shape {196export interface ProviderEventIngestorV2Shape {
...@@ -317,9 +247,7 @@ export const layer: Layer.Layer<...@@ -317,9 +247,7 @@ export const layer: Layer.Layer<
317 const projections = yield* ProjectionStore.ProjectionStoreV2;247 const projections = yield* ProjectionStore.ProjectionStoreV2;
318 const idAllocator = yield* IdAllocator.IdAllocatorV2;248 const idAllocator = yield* IdAllocator.IdAllocatorV2;
319 const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;249 const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;
320 const analytics = yield* ProviderTurnAnalytics;
321 const agentScope = yield* AgentScope;250 const agentScope = yield* AgentScope;
322 const completedTurnAnalytics = new Set<string>();
323251
324 const makeDomainEvent = (252 const makeDomainEvent = (
325 input: ProviderEventIngestInput,253 input: ProviderEventIngestInput,
...@@ -795,35 +723,6 @@ export const layer: Layer.Layer<...@@ -795,35 +723,6 @@ export const layer: Layer.Layer<
795 ),723 ),
796 ),724 ),
797 ),725 ),
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 ),
827 (effect) =>726 (effect) =>
828 input.event.type === "app_thread.created"727 input.event.type === "app_thread.created"
829 ? threadCommands.withLock(input.event.appThread.id, effect)728 ? threadCommands.withLock(input.event.appThread.id, effect)
apps/server/src/orchestration-v2/RunExecutionService.ts-5
...@@ -1246,11 +1246,6 @@ export const layer: Layer.Layer<...@@ -1246,11 +1246,6 @@ export const layer: Layer.Layer<
1246 event.type === "provider_thread.updated" &&1246 event.type === "provider_thread.updated" &&
1247 event.providerThread.id === input.providerThread.id;1247 event.providerThread.id === input.providerThread.id;
1248 const storedEvents = yield* providerEventIngestor.ingestNormalized({1248 const storedEvents = yield* providerEventIngestor.ingestNormalized({
1249 analyticsContext: {
1250 modelSelection: input.modelSelection,
1251 runtimeMode: input.runtimePolicy.runtimeMode,
1252 interactionMode: input.runtimePolicy.interactionMode,
1253 },
1254 providerSessionId: input.providerSessionId,1249 providerSessionId: input.providerSessionId,
1255 providerInstanceId: input.run.providerInstanceId,1250 providerInstanceId: input.run.providerInstanceId,
1256 threadId: input.run.threadId,1251 threadId: input.run.threadId,
apps/server/src/orchestration-v2/http.ts+2-8
...@@ -17,7 +17,6 @@ import {...@@ -17,7 +17,6 @@ import {
17 requireEnvironmentScope,17 requireEnvironmentScope,
18} from "../auth/http.ts";18} from "../auth/http.ts";
19import * as ServerEnvironment from "../environment/ServerEnvironment.ts";19import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
20import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts";
21import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";20import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
22import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";21import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
23import {22import {
...@@ -162,7 +161,6 @@ export const layer = HttpApiBuilder.group(...@@ -162,7 +161,6 @@ export const layer = HttpApiBuilder.group(
162 yield* annotateEnvironmentRequest(args.endpoint.name);161 yield* annotateEnvironmentRequest(args.endpoint.name);
163 yield* requireEnvironmentScope(AuthOrchestrationReadScope);162 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
164 return yield* loadShellSnapshot().pipe(163 return yield* loadShellSnapshot().pipe(
165 traceLocalHandlerWork,
166 Effect.catch((cause) =>164 Effect.catch((cause) =>
167 failEnvironmentInternal("orchestration_snapshot_failed", cause),165 failEnvironmentInternal("orchestration_snapshot_failed", cause),
168 ),166 ),
...@@ -177,7 +175,7 @@ export const layer = HttpApiBuilder.group(...@@ -177,7 +175,7 @@ export const layer = HttpApiBuilder.group(
177 const snapshot = yield* loadThreadSnapshot(175 const snapshot = yield* loadThreadSnapshot(
178 args.params.threadId,176 args.params.threadId,
179 "orchestration_thread_snapshot_failed",177 "orchestration_thread_snapshot_failed",
180 ).pipe(traceLocalHandlerWork);178 );
181 return {179 return {
182 snapshotSequence: snapshot.snapshotSequence,180 snapshotSequence: snapshot.snapshotSequence,
183 projection: snapshot.projection,181 projection: snapshot.projection,
...@@ -191,7 +189,6 @@ export const layer = HttpApiBuilder.group(...@@ -191,7 +189,6 @@ export const layer = HttpApiBuilder.group(
191 yield* requireEnvironmentScope(AuthOrchestrationReadScope);189 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
192 return yield* threadManagement.getThreadTranscript(args.params.threadId).pipe(190 return yield* threadManagement.getThreadTranscript(args.params.threadId).pipe(
193 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment),191 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment),
194 traceLocalHandlerWork,
195 Effect.catch(192 Effect.catch(
196 Effect.fnUntraced(function* (error) {193 Effect.fnUntraced(function* (error) {
197 if (isThreadTranscriptTooLargeError(error)) {194 if (isThreadTranscriptTooLargeError(error)) {
...@@ -214,9 +211,7 @@ export const layer = HttpApiBuilder.group(...@@ -214,9 +211,7 @@ export const layer = HttpApiBuilder.group(
214 Effect.fn("environment.orchestration.threadBoundedSnapshot")(function* (args) {211 Effect.fn("environment.orchestration.threadBoundedSnapshot")(function* (args) {
215 yield* annotateEnvironmentRequest(args.endpoint.name);212 yield* annotateEnvironmentRequest(args.endpoint.name);
216 yield* requireEnvironmentScope(AuthOrchestrationReadScope);213 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
217 const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId).pipe(214 const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId);
218 traceLocalHandlerWork,
219 );
220 const bounded = buildBoundedThreadProjection({215 const bounded = buildBoundedThreadProjection({
221 projection: snapshot.projection,216 projection: snapshot.projection,
222 snapshotSequence: snapshot.snapshotSequence,217 snapshotSequence: snapshot.snapshotSequence,
...@@ -250,7 +245,6 @@ export const layer = HttpApiBuilder.group(...@@ -250,7 +245,6 @@ export const layer = HttpApiBuilder.group(
250 args.query.view === "conversation",245 args.query.view === "conversation",
251 )246 )
252 .pipe(247 .pipe(
253 traceLocalHandlerWork,
254 Effect.catch(248 Effect.catch(
255 Effect.fnUntraced(function* (cause) {249 Effect.fnUntraced(function* (cause) {
256 if (isThreadNotFound(cause))250 if (isThreadNotFound(cause))
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts-1
...@@ -146,7 +146,6 @@ export function makeReplayServerConfig(...@@ -146,7 +146,6 @@ export function makeReplayServerConfig(
146 providerLogsDir,146 providerLogsDir,
147 providerEventLogPath: path.join(providerLogsDir, "events.log"),147 providerEventLogPath: path.join(providerLogsDir, "events.log"),
148 terminalLogsDir,148 terminalLogsDir,
149 anonymousIdPath: path.join(stateDir, "anonymous-id"),
150 environmentIdPath: path.join(stateDir, "environment-id"),149 environmentIdPath: path.join(stateDir, "environment-id"),
151 serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"),150 serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"),
152 secretsDir: path.join(stateDir, "secrets"),151 secretsDir: path.join(stateDir, "secrets"),
apps/server/src/project/http.ts+1-5
...@@ -12,7 +12,6 @@ import {...@@ -12,7 +12,6 @@ import {
12 failEnvironmentInvalidRequest,12 failEnvironmentInvalidRequest,
13 requireEnvironmentScope,13 requireEnvironmentScope,
14} from "../auth/http.ts";14} from "../auth/http.ts";
15import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts";
16import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";15import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
17import * as ProjectService from "./ProjectService.ts";16import * as ProjectService from "./ProjectService.ts";
18import { projectMutationOperation } from "./ProjectMutation.ts";17import { projectMutationOperation } from "./ProjectMutation.ts";
...@@ -44,7 +43,6 @@ export const layer = HttpApiBuilder.group(...@@ -44,7 +43,6 @@ export const layer = HttpApiBuilder.group(
44 yield* annotateEnvironmentRequest(args.endpoint.name);43 yield* annotateEnvironmentRequest(args.endpoint.name);
45 yield* requireEnvironmentScope(AuthOrchestrationReadScope);44 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
46 return yield* projects.snapshot.pipe(45 return yield* projects.snapshot.pipe(
47 traceLocalHandlerWork,
48 Effect.catch((cause) => failEnvironmentInternal("project_snapshot_failed", cause)),46 Effect.catch((cause) => failEnvironmentInternal("project_snapshot_failed", cause)),
49 );47 );
50 }),48 }),
...@@ -57,9 +55,7 @@ export const layer = HttpApiBuilder.group(...@@ -57,9 +55,7 @@ export const layer = HttpApiBuilder.group(
57 yield* requireEnvironmentScope(scope);55 yield* requireEnvironmentScope(scope);
58 }56 }
59 const operation = projectMutationOperation(projects, args.payload);57 const operation = projectMutationOperation(projects, args.payload);
60 return yield* startup58 return yield* startup.enqueueCommand(operation).pipe(Effect.catch(failProjectMutation));
61 .enqueueCommand(operation)
62 .pipe(traceLocalHandlerWork, Effect.catch(failProjectMutation));
63 }),59 }),
64 );60 );
65 }),61 }),
apps/server/src/provider/CodexChatGptAuth.test.ts+2-240
...@@ -17,8 +17,6 @@ import { exportJWK, generateKeyPair, SignJWT } from "jose";...@@ -17,8 +17,6 @@ import { exportJWK, generateKeyPair, SignJWT } from "jose";
17import * as ServerSecretStore from "../auth/ServerSecretStore.ts";17import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
18import * as ServerEnvironment from "../environment/ServerEnvironment.ts";18import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
19import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";19import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
20import * as ServerSettings from "../serverSettings.ts";
21import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
22import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts";20import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts";
2321
24const assertSameCallback = (actual: string | null, expected: string | null) => {22const assertSameCallback = (actual: string | null, expected: string | null) => {
...@@ -33,7 +31,6 @@ const instanceId = ProviderInstanceId.make("managed-codex-test");...@@ -33,7 +31,6 @@ const instanceId = ProviderInstanceId.make("managed-codex-test");
33const makeHarnessFor = Effect.fnUntraced(function* (31const makeHarnessFor = Effect.fnUntraced(function* (
34 instanceId: ProviderInstanceId,32 instanceId: ProviderInstanceId,
35 bytes: Map<string, Uint8Array> = new Map(),33 bytes: Map<string, Uint8Array> = new Map(),
36 failAnalytics = false,
37) {34) {
38 const environmentId = environmentIds.get(bytes) ?? EnvironmentId.make(NodeCrypto.randomUUID());35 const environmentId = environmentIds.get(bytes) ?? EnvironmentId.make(NodeCrypto.randomUUID());
39 environmentIds.set(bytes, environmentId);36 environmentIds.set(bytes, environmentId);
...@@ -208,25 +205,11 @@ const makeHarnessFor = Effect.fnUntraced(function* (...@@ -208,25 +205,11 @@ const makeHarnessFor = Effect.fnUntraced(function* (
208 const address = server.address();205 const address = server.address();
209 if (!address || typeof address === "string") throw new Error("mock address");206 if (!address || typeof address === "string") throw new Error("mock address");
210 origin = `http://127.0.0.1:${address.port}`;207 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 });
224 const auth = yield* makeCodexChatGptAuth({208 const auth = yield* makeCodexChatGptAuth({
225 instanceId,209 instanceId,
226 discoveryUrl: `${origin}/discovery`,210 discoveryUrl: `${origin}/discovery`,
227 resource: `${origin}/v1`,211 resource: `${origin}/v1`,
228 }).pipe(212 }).pipe(
229 Effect.provideService(AnalyticsService.AnalyticsService, analytics),
230 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),213 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
231 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment),214 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment),
232 );215 );
...@@ -278,7 +261,6 @@ const makeHarnessFor = Effect.fnUntraced(function* (...@@ -278,7 +261,6 @@ const makeHarnessFor = Effect.fnUntraced(function* (
278 });261 });
279 return {262 return {
280 auth,263 auth,
281 analyticsEvents,
282 secrets,264 secrets,
283 environmentId,265 environmentId,
284 bytes,266 bytes,
...@@ -293,7 +275,7 @@ const makeHarnessFor = Effect.fnUntraced(function* (...@@ -293,7 +275,7 @@ const makeHarnessFor = Effect.fnUntraced(function* (
293 },275 },
294 "owner",276 "owner",
295 { discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1` },277 { discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1` },
296 ).pipe(Stream.provideService(AnalyticsService.AnalyticsService, analytics)),278 ),
297 finishCallback: (state: { authorizationUrl: string | null }) =>279 finishCallback: (state: { authorizationUrl: string | null }) =>
298 Effect.promise(() => fetch(prepareCallback(state))),280 Effect.promise(() => fetch(prepareCallback(state))),
299 destination: Effect.gen(function* () {281 destination: Effect.gen(function* () {
...@@ -315,7 +297,6 @@ const makeHarnessFor = Effect.fnUntraced(function* (...@@ -315,7 +297,6 @@ const makeHarnessFor = Effect.fnUntraced(function* (
315 discoveryUrl: `${origin}/discovery`,297 discoveryUrl: `${origin}/discovery`,
316 resource: `${origin}/v1`,298 resource: `${origin}/v1`,
317 }).pipe(299 }).pipe(
318 Effect.provideService(AnalyticsService.AnalyticsService, analytics),
319 Effect.provideService(ServerSecretStore.ServerSecretStore, destinationStore),300 Effect.provideService(ServerSecretStore.ServerSecretStore, destinationStore),
320 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment),301 Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment),
321 );302 );
...@@ -1700,24 +1681,6 @@ it.effect("primary completes OAuth and destination imports and owns the refresh...@@ -1700,24 +1681,6 @@ it.effect("primary completes OAuth and destination imports and owns the refresh
1700 );1681 );
1701 assert.isTrue(stopped);1682 assert.isTrue(stopped);
1702 assert.strictEqual(imported.phase, "succeeded");1683 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);
1721 const saved = Option.getOrThrow(yield* destination.auth.read);1684 const saved = Option.getOrThrow(yield* destination.auth.read);
1722 assert.strictEqual(saved.clientId, result.profile.registration.clientId);1685 assert.strictEqual(saved.clientId, result.profile.registration.clientId);
1723 assert.strictEqual(saved.refreshToken, "initial-refresh");1686 assert.strictEqual(saved.refreshToken, "initial-refresh");
...@@ -1839,214 +1802,13 @@ it.effect(...@@ -1839,214 +1802,13 @@ it.effect(
1839 ),1802 ),
1840);1803);
18411804
1842it.effect("records one anonymous auth outcome per success, failure, or cancellation", () =>1805it.effect("expires sign-in when it reaches its deadline", () =>
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", () =>
2040 provision(1806 provision(
2041 Effect.gen(function* () {1807 Effect.gen(function* () {
2042 const h = yield* makeHarness;1808 const h = yield* makeHarness;
2043 yield* h.startRemote();1809 yield* h.startRemote();
2044 yield* TestClock.adjust(300_001);1810 yield* TestClock.adjust(300_001);
2045 assert.include((yield* h.phase("failed")).message ?? "", "expired");1811 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);
2050 }),1812 }),
2051 ),1813 ),
2052);1814);
apps/server/src/provider/CodexChatGptAuth.ts+3-128
...@@ -10,9 +10,6 @@ import {...@@ -10,9 +10,6 @@ import {
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff";11import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff";
12import * as Clock from "effect/Clock";12import * as Clock from "effect/Clock";
13import * as Cause from "effect/Cause";
14import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
15import * as Crypto from "effect/Crypto";
16import * as Exit from "effect/Exit";13import * as Exit from "effect/Exit";
17import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts";14import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts";
18import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
...@@ -25,10 +22,8 @@ import { HttpClient, HttpClientRequest } from "effect/http";...@@ -25,10 +22,8 @@ import { HttpClient, HttpClientRequest } from "effect/http";
25import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";22import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
26import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts";23import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts";
27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";24import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
28import * as ServerSettings from "../serverSettings.ts";
29import * as ServerEnvironment from "../environment/ServerEnvironment.ts";25import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
3026
31const isSetupError = Schema.is(ProviderSetupError);
32const RESOURCE = "https://api.openai.com/v1";27const RESOURCE = "https://api.openai.com/v1";
33const REQUIRED_SCOPE = "chatgpt.tokens.use.direct";28const REQUIRED_SCOPE = "chatgpt.tokens.use.direct";
34const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration";29const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration";
...@@ -105,53 +100,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*...@@ -105,53 +100,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
105 readonly resource?: string;100 readonly resource?: string;
106 readonly defaultReturnUrl?: string;101 readonly defaultReturnUrl?: string;
107 readonly reconnectProfile?: ChatGptReconnectProfile | null;102 readonly reconnectProfile?: ChatGptReconnectProfile | null;
108 readonly telemetryFlow?: "direct" | "primary_handoff";
109}) {103}) {
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 });
155 const http = yield* HttpClient.HttpClient;104 const http = yield* HttpClient.HttpClient;
156 const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId);105 const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId);
157 const registrationStore = yield* ProviderCredentialStore.make(106 const registrationStore = yield* ProviderCredentialStore.make(
...@@ -159,7 +108,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*...@@ -159,7 +108,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
159 options.instanceId,108 options.instanceId,
160 );109 );
161 const secrets = yield* ServerSecretStore.ServerSecretStore;110 const secrets = yield* ServerSecretStore.ServerSecretStore;
162 const crypto = yield* Crypto.Crypto;
163 const environmentLocks = sessionLocks.get(secrets) ?? new Map<string, Semaphore.Semaphore>();111 const environmentLocks = sessionLocks.get(secrets) ?? new Map<string, Semaphore.Semaphore>();
164 sessionLocks.set(secrets, environmentLocks);112 sessionLocks.set(secrets, environmentLocks);
165 const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1));113 const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1));
...@@ -264,54 +212,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*...@@ -264,54 +212,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
264 ),212 ),
265 ),213 ),
266 );214 );
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 );
315 const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) {215 const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) {
316 const saved = yield* readRegistrations;216 const saved = yield* readRegistrations;
317 profile = {217 profile = {
...@@ -861,28 +761,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*...@@ -861,28 +761,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
861 })),761 })),
862 ];762 ];
863 }),763 }),
864 authenticate: (method, context) =>764 authenticate: (method, context) => lock.withPermit(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 ),
886 logout: lock.withPermit(withSessionLock(logout)),765 logout: lock.withPermit(withSessionLock(logout)),
887 });766 });
888 const reconnectProfile = Effect.fnUntraced(function* (methodId: string) {767 const reconnectProfile = Effect.fnUntraced(function* (methodId: string) {
...@@ -971,12 +850,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*...@@ -971,12 +850,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
971 yield* save(profile.credentials, true);850 yield* save(profile.credentials, true);
972 });851 });
973 return lock.withPermit(852 return lock.withPermit(
974 track(853 validate.pipe(
975 "transfer",854 Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)),
976 { flow: "primary_handoff" },
977 validate.pipe(
978 Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)),
979 ),
980 ),855 ),
981 );856 );
982 };857 };
apps/server/src/provider/CodexChatGptHandoff.ts-1
...@@ -35,7 +35,6 @@ export function subscribeChatGptHandoff(...@@ -35,7 +35,6 @@ export function subscribeChatGptHandoff(
35 ...endpoints,35 ...endpoints,
36 instanceId: input.instanceId,36 instanceId: input.instanceId,
37 reconnectProfile: input.profile,37 reconnectProfile: input.profile,
38 telemetryFlow: "primary_handoff",
39 defaultReturnUrl: input.returnUrl,38 defaultReturnUrl: input.returnUrl,
40 }).pipe(39 }).pipe(
41 Effect.provideService(ServerSecretStore.ServerSecretStore, store),40 Effect.provideService(ServerSecretStore.ServerSecretStore, store),
apps/server/src/pullRequest/http.ts+1-2
...@@ -3,7 +3,6 @@ import * as Effect from "effect/Effect";...@@ -3,7 +3,6 @@ import * as Effect from "effect/Effect";
3import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";3import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
44
5import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts";5import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts";
6import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts";
7import * as PullRequestService from "./PullRequestService.ts";6import * as PullRequestService from "./PullRequestService.ts";
87
9/** The patch is often the largest PR payload and benefits from HTTP compression and flow control. */8/** 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(...@@ -17,7 +16,7 @@ export const layer = HttpApiBuilder.group(
17 Effect.fn("environment.pullRequests.diff")(function* (args) {16 Effect.fn("environment.pullRequests.diff")(function* (args) {
18 yield* annotateEnvironmentRequest(args.endpoint.name);17 yield* annotateEnvironmentRequest(args.endpoint.name);
19 yield* requireEnvironmentScope(AuthOrchestrationReadScope);18 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
20 return yield* pullRequests.diff(args.payload).pipe(traceLocalHandlerWork);19 return yield* pullRequests.diff(args.payload);
21 }),20 }),
22 );21 );
23 }),22 }),
apps/server/src/relay/AgentAwarenessRelay.ts+1-2
...@@ -14,7 +14,6 @@ import {...@@ -14,7 +14,6 @@ import {
14import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness";14import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness";
15import { turnItemUpdateCanEndBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";15import { turnItemUpdateCanEndBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
16import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";16import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
17import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
18import {17import {
19 normalizeRelayIssuer,18 normalizeRelayIssuer,
20 RELAY_ACTIVITY_PUBLISH_TYP,19 RELAY_ACTIVITY_PUBLISH_TYP,
...@@ -521,7 +520,7 @@ export const make = Effect.gen(function* () {...@@ -521,7 +520,7 @@ export const make = Effect.gen(function* () {
521 ok: response.ok,520 ok: response.ok,
522 deliveries: deliveryStats(response.deliveries),521 deliveries: deliveryStats(response.deliveries),
523 });522 });
524 }).pipe(Effect.withSpan("relay.agent_activity.publish"), withRelayClientTracing);523 }).pipe(Effect.withSpan("relay.agent_activity.publish"));
525524
526 // Per-thread shell read: this publish runs for every activity-relevant525 // Per-thread shell read: this publish runs for every activity-relevant
527 // domain event, so materializing the full shell here would make the cost526 // 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";...@@ -7,7 +7,6 @@ import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage";
7import type * as HttpServerRequest from "effect/http/HttpServerRequest";7import type * as HttpServerRequest from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import * as HttpTraceContext from "effect/http/HttpTraceContext";9import * as HttpTraceContext from "effect/http/HttpTraceContext";
10import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
11import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";10import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
1211
13import * as Metrics from "../observability/Metrics.ts";12import * as Metrics from "../observability/Metrics.ts";
...@@ -100,8 +99,7 @@ export const layer = HttpApiBuilder.group(...@@ -100,8 +99,7 @@ export const layer = HttpApiBuilder.group(
100 })99 })
101 .pipe(100 .pipe(
102 Option.isSome(relayParent)101 Option.isSome(relayParent)
103 ? (effect) =>102 ? (effect) => effect.pipe(Effect.withParentSpan(relayParent.value))
104 effect.pipe(Effect.withParentSpan(relayParent.value), withRelayClientTracing)
105 : (effect) => effect,103 : (effect) => effect,
106 // Defects too, so the sender only ever sees the fixed error body.104 // Defects too, so the sender only ever sees the fixed error body.
107 Effect.catchCause((cause) =>105 Effect.catchCause((cause) =>
apps/server/src/server.ts-6
...@@ -67,8 +67,6 @@ import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts...@@ -67,8 +67,6 @@ import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts
67import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts";67import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts";
68import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts";68import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts";
69import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";69import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
70import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
71import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts";
72import * as ModelManifest from "./provider/ModelManifest.ts";70import * as ModelManifest from "./provider/ModelManifest.ts";
73import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts";71import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts";
74import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts";72import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts";
...@@ -162,7 +160,6 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";...@@ -162,7 +160,6 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
162import * as CloudHttp from "./cloud/http.ts";160import * as CloudHttp from "./cloud/http.ts";
163import * as CloudLink from "./cloud/CloudLink.ts";161import * as CloudLink from "./cloud/CloudLink.ts";
164import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts";162import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts";
165import * as RelayTracing from "./cloud/relayTracing.ts";
166import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";163import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
167import {164import {
168 MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER,165 MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER,
...@@ -510,7 +507,6 @@ const layerScheduledTaskWebhookOrigin = Layer.effect(...@@ -510,7 +507,6 @@ const layerScheduledTaskWebhookOrigin = Layer.effect(
510507
511const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe(508const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe(
512 Layer.provide(layerScheduledTaskWebhookOrigin),509 Layer.provide(layerScheduledTaskWebhookOrigin),
513 Layer.provide(ProviderEventIngestor.layerAnalytics),
514 Layer.provide(layerCheckpointStore),510 Layer.provide(layerCheckpointStore),
515 Layer.provide(layerGitWorkflow),511 Layer.provide(layerGitWorkflow),
516 Layer.provide(ResourceCleanupService.layer),512 Layer.provide(ResourceCleanupService.layer),
...@@ -769,7 +765,6 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe(...@@ -769,7 +765,6 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe(
769 Layer.provideMerge(layerBackground),765 Layer.provideMerge(layerBackground),
770 Layer.provideMerge(layerResourceDiagnostics),766 Layer.provideMerge(layerResourceDiagnostics),
771 Layer.provideMerge(TraceDiagnostics.layer),767 Layer.provideMerge(TraceDiagnostics.layer),
772 Layer.provideMerge(AnalyticsService.layer),
773 Layer.provideMerge(ExternalLauncher.layer),768 Layer.provideMerge(ExternalLauncher.layer),
774 Layer.provideMerge(RemoteOpenTargets.layer),769 Layer.provideMerge(RemoteOpenTargets.layer),
775 Layer.provideMerge(DirectEndpoints.layer),770 Layer.provideMerge(DirectEndpoints.layer),
...@@ -1206,7 +1201,6 @@ const layerMakeServer = Layer.unwrap(...@@ -1206,7 +1201,6 @@ const layerMakeServer = Layer.unwrap(
1206 ),1201 ),
1207 ),1202 ),
1208 Layer.provide(layerActivation),1203 Layer.provide(layerActivation),
1209 Layer.provideMerge(RelayTracing.layerServerRelayBroker),
1210 Layer.provideMerge(layerHttpServer),1204 Layer.provideMerge(layerHttpServer),
1211 Layer.provide(layerApplicationObservability),1205 Layer.provide(layerApplicationObservability),
1212 Layer.provideMerge(FetchHttpClient.layer),1206 Layer.provideMerge(FetchHttpClient.layer),
apps/server/src/serverRuntimeStartup.autoPull.test.ts-2
...@@ -30,7 +30,6 @@ import { ServerActivation } from "./serverActivation.ts";...@@ -30,7 +30,6 @@ import { ServerActivation } from "./serverActivation.ts";
30import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";30import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
31import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";31import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
32import * as ServerSettings from "./serverSettings.ts";32import * as ServerSettings from "./serverSettings.ts";
33import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
34import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";33import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
3534
36it.effect("parks automatic pull until activation without delaying command readiness", () =>35it.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...@@ -162,7 +161,6 @@ it.effect("parks automatic pull until activation without delaying command readin
162 Effect.onInterrupt(() => Deferred.succeed(statusInterrupted, undefined)),161 Effect.onInterrupt(() => Deferred.succeed(statusInterrupted, undefined)),
163 ),162 ),
164 }),163 }),
165 Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void }),
166 NodeCrypto.layer,164 NodeCrypto.layer,
167 Layer.mock(EnvironmentAuth.EnvironmentAuth)({}),165 Layer.mock(EnvironmentAuth.EnvironmentAuth)({}),
168 Layer.mock(ExternalLauncher.ExternalLauncher)({}),166 Layer.mock(ExternalLauncher.ExternalLauncher)({}),
apps/server/src/serverRuntimeStartup.ts-41
...@@ -44,7 +44,6 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";...@@ -44,7 +44,6 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
44import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";44import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
45import * as ServerSettings from "./serverSettings.ts";45import * as ServerSettings from "./serverSettings.ts";
46import { forkParked, forkParkedFiber } from "./serverActivation.ts";46import { forkParked, forkParkedFiber } from "./serverActivation.ts";
47import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
48import * as ServerEnvironment from "./environment/ServerEnvironment.ts";47import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
49import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";48import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
50import {49import {
...@@ -143,37 +142,6 @@ export const makeCommandGate = Effect.gen(function* () {...@@ -143,37 +142,6 @@ export const makeCommandGate = Effect.gen(function* () {
143 } satisfies CommandGate;142 } satisfies CommandGate;
144});143});
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
177export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({145export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({
178 instanceId: ProviderInstanceId.make("codex"),146 instanceId: ProviderInstanceId.make("codex"),
179 model: DEFAULT_MODEL,147 model: DEFAULT_MODEL,
...@@ -581,13 +549,6 @@ const make = (options?: StartupOptions) =>...@@ -581,13 +549,6 @@ const make = (options?: StartupOptions) =>
581549
582 yield* forkParked(550 yield* forkParked(
583 Effect.gen(function* () {551 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 );
591 if (serverConfig.startupPresentation === "headless") {552 if (serverConfig.startupPresentation === "headless") {
592 yield* Effect.logDebug("startup phase: headless access info");553 yield* Effect.logDebug("startup phase: headless access info");
593 const accessInfo = yield* issueHeadlessServeAccessInfo();554 const accessInfo = yield* issueHeadlessServeAccessInfo();
...@@ -696,5 +657,3 @@ const make = (options?: StartupOptions) =>...@@ -696,5 +657,3 @@ const make = (options?: StartupOptions) =>
696657
697export const layerWithOptions = (options?: StartupOptions) =>658export const layerWithOptions = (options?: StartupOptions) =>
698 Layer.effect(ServerRuntimeStartup, make(options));659 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 {...@@ -33,16 +33,11 @@ import {
33 type AuthEnvironmentScope,33 type AuthEnvironmentScope,
34 type ScheduledTaskListResult,34 type ScheduledTaskListResult,
35 AuthSessionId,35 AuthSessionId,
36 ClientConnectionMethod,
37 ClientDeviceType,
38 ClientOs,
39 ClientSurface,36 ClientSurface,
40 ClientWebDeployment,
41 type DiscoveredLocalServerList,37 type DiscoveredLocalServerList,
42 type EditorId,38 type EditorId,
43 type FileManagerRevealKind,39 type FileManagerRevealKind,
44 type OrchestrationClientOrigin,40 type OrchestrationClientOrigin,
45 type OrchestrationV2Command,
46 type GitActionProgressEvent,41 type GitActionProgressEvent,
47 type GitManagerServiceError,42 type GitManagerServiceError,
48 type MessageId,43 type MessageId,
...@@ -224,7 +219,6 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";...@@ -224,7 +219,6 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
224import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";219import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
225import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";220import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
226import * as HostResources from "./resourceTelemetry/HostResources.ts";221import * as HostResources from "./resourceTelemetry/HostResources.ts";
227import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
228import * as UsageService from "./usage/UsageService.ts";222import * as UsageService from "./usage/UsageService.ts";
229import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";223import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
230import * as PullRequestService from "./pullRequest/PullRequestService.ts";224import * as PullRequestService from "./pullRequest/PullRequestService.ts";
...@@ -602,13 +596,7 @@ function toAuthAccessStreamEvent(...@@ -602,13 +596,7 @@ function toAuthAccessStreamEvent(
602}596}
603597
604const isClientSurface = Schema.is(ClientSurface);598const isClientSurface = Schema.is(ClientSurface);
605const isClientConnectionMethod = Schema.is(ClientConnectionMethod);
606const isClientDeviceType = Schema.is(ClientDeviceType);
607const isClientOs = Schema.is(ClientOs);
608const isClientWebDeployment = Schema.is(ClientWebDeployment);
609const MAX_CLIENT_APP_VERSION_LENGTH = 64;599const MAX_CLIENT_APP_VERSION_LENGTH = 64;
610const MAX_CLIENT_BROWSER_LENGTH = 64;
611const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80;
612600
613export function hasCompatibleOrchestrationProtocol(url: URL): boolean {601export function hasCompatibleOrchestrationProtocol(url: URL): boolean {
614 return (602 return (
...@@ -643,52 +631,6 @@ function readClientConnectionOrigin(...@@ -643,52 +631,6 @@ function readClientConnectionOrigin(
643 };631 };
644}632}
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
692const canReplayPersistedRange = Effect.fnUntraced(function* (634const canReplayPersistedRange = Effect.fnUntraced(function* (
693 afterSequence: number,635 afterSequence: number,
694 headSequence: number,636 headSequence: number,
...@@ -1200,7 +1142,6 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc...@@ -1200,7 +1142,6 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc
1200const layerWsRpc = (1142const layerWsRpc = (
1201 currentSession: EnvironmentAuth.AuthenticatedSession,1143 currentSession: EnvironmentAuth.AuthenticatedSession,
1202 clientOrigin: OrchestrationClientOrigin,1144 clientOrigin: OrchestrationClientOrigin,
1203 clientAnalyticsProps: Readonly<Record<string, unknown>>,
1204 previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],1145 previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],
1205 serverBrowser: ServerBrowser.ServerBrowser["Service"],1146 serverBrowser: ServerBrowser.ServerBrowser["Service"],
1206) =>1147) =>
...@@ -1224,19 +1165,6 @@ const layerWsRpc = (...@@ -1224,19 +1165,6 @@ const layerWsRpc = (
12241165
1225 const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2;1166 const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2;
1226 const mcpAppRequests = yield* McpAppRequests.McpAppRequests;1167 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 };
1240 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;1168 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;
1241 const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2;1169 const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2;
1242 const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService;1170 const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService;
...@@ -1869,7 +1797,6 @@ const layerWsRpc = (...@@ -1869,7 +1797,6 @@ const layerWsRpc = (
1869 ).pipe(Effect.provide(intakeContext)),1797 ).pipe(Effect.provide(intakeContext)),
1870 )1798 )
1871 .pipe(1799 .pipe(
1872 Effect.tap(() => recordClientCommandAnalytics(command)),
1873 Effect.map((result) => ({ sequence: result.sequence })),1800 Effect.map((result) => ({ sequence: result.sequence })),
1874 Effect.mapError((cause) => {1801 Effect.mapError((cause) => {
1875 const detail = userFacingDispatchErrorMessage(cause);1802 const detail = userFacingDispatchErrorMessage(cause);
...@@ -2009,18 +1936,6 @@ const layerWsRpc = (...@@ -2009,18 +1936,6 @@ const layerWsRpc = (
2009 }).pipe(Effect.provide(intakeContext)),1936 }).pipe(Effect.provide(intakeContext)),
2010 )1937 )
2011 .pipe(1938 .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 ),
2024 Effect.map((result) => ({1939 Effect.map((result) => ({
2025 ...result,1940 ...result,
2026 projection: projectThreadProjectionForWire(result.projection),1941 projection: projectThreadProjectionForWire(result.projection),
...@@ -3178,7 +3093,6 @@ export const layer = Layer.unwrap(...@@ -3178,7 +3093,6 @@ export const layer = Layer.unwrap(
3178 }3093 }
3179 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;3094 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
3180 const sessions = yield* SessionStore.SessionStore;3095 const sessions = yield* SessionStore.SessionStore;
3181 const analytics = yield* AnalyticsService.AnalyticsService;
3182 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(3096 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
3183 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>3097 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
3184 failEnvironmentAuthInvalid(3098 failEnvironmentAuthInvalid(
...@@ -3191,9 +3105,7 @@ export const layer = Layer.unwrap(...@@ -3191,9 +3105,7 @@ export const layer = Layer.unwrap(
3191 ),3105 ),
3192 );3106 );
3193 const clientOrigin = readClientConnectionOrigin(request);3107 const clientOrigin = readClientConnectionOrigin(request);
3194 const clientAnalyticsProps = readClientAnalyticsProps(request);
3195 yield* sessions.recordClientConnection(session.sessionId, clientOrigin);3108 yield* sessions.recordClientConnection(session.sessionId, clientOrigin);
3196 yield* analytics.record("client.connected", clientAnalyticsProps);
3197 const rpcWebSocketHttpEffect = yield* Effect.gen(function* () {3109 const rpcWebSocketHttpEffect = yield* Effect.gen(function* () {
3198 const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket;3110 const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket;
3199 yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe(3111 yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe(
...@@ -3207,13 +3119,7 @@ export const layer = Layer.unwrap(...@@ -3207,13 +3119,7 @@ export const layer = Layer.unwrap(
3207 return httpEffect;3119 return httpEffect;
3208 }).pipe(3120 }).pipe(
3209 Effect.provide(3121 Effect.provide(
3210 layerWsRpc(3122 layerWsRpc(session, clientOrigin, previewAutomationBroker, serverBrowser).pipe(
3211 session,
3212 clientOrigin,
3213 clientAnalyticsProps,
3214 previewAutomationBroker,
3215 serverBrowser,
3216 ).pipe(
3217 Layer.provideMerge(RpcSerialization.layerJson),3123 Layer.provideMerge(RpcSerialization.layerJson),
3218 // Request fibers run in the handlers' context, so this reporter sees3124 // Request fibers run in the handlers' context, so this reporter sees
3219 // their defects, not the rest of the server's.3125 // their defects, not the rest of the server's.
apps/server/vite.config.ts-9
...@@ -119,15 +119,6 @@ export default mergeConfig(...@@ -119,15 +119,6 @@ export default mergeConfig(
119 __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify(119 __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify(
120 repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "",120 repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "",
121 ),121 ),
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 ),
131 },122 },
132 },123 },
133 test: {124 test: {
apps/web/src/cloud/publicConfig.ts-34
...@@ -17,26 +17,12 @@ export interface CloudPublicConfig {...@@ -17,26 +17,12 @@ export interface CloudPublicConfig {
17 readonly clerkPublishableKey: string | null;17 readonly clerkPublishableKey: string | null;
18 readonly clerkJwtTemplate: string | null;18 readonly clerkJwtTemplate: string | null;
19 readonly relayUrl: string | null;19 readonly relayUrl: string | null;
20 readonly relayTracing: {
21 readonly tracesUrl: string | null;
22 readonly tracesDataset: string | null;
23 readonly tracesToken: string | null;
24 };
25}20}
2621
27export function trimNonEmpty(value: string | undefined): string | null {22export function trimNonEmpty(value: string | undefined): string | null {
28 return value?.trim() || null;23 return value?.trim() || null;
29}24}
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
40export function resolveCloudPublicConfig(): CloudPublicConfig {26export function resolveCloudPublicConfig(): CloudPublicConfig {
41 return {27 return {
42 clerkPublishableKey: trimNonEmpty(28 clerkPublishableKey: trimNonEmpty(
...@@ -46,29 +32,9 @@ export function resolveCloudPublicConfig(): CloudPublicConfig {...@@ -46,29 +32,9 @@ export function resolveCloudPublicConfig(): CloudPublicConfig {
46 relayUrl: normalizeSecureRelayUrl(32 relayUrl: normalizeSecureRelayUrl(
47 (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "",33 (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "",
48 ),34 ),
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 },
58 };35 };
59}36}
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
72export function hasCloudPublicConfig(): boolean {38export function hasCloudPublicConfig(): boolean {
73 const config = resolveCloudPublicConfig();39 const config = resolveCloudPublicConfig();
74 return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl);40 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({...@@ -11,7 +11,7 @@ export default function BrowserManagedAuthShell({
11 readonly children: ReactNode;11 readonly children: ReactNode;
12}) {12}) {
13 return (13 return (
14 <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey}>14 <ClerkProvider telemetry={false} appearance={clerkAppearance} publishableKey={publishableKey}>
15 {children}15 {children}
16 </ClerkProvider>16 </ClerkProvider>
17 );17 );
apps/web/src/components/clerk/ElectronManagedAuthShell.tsx+6-1
...@@ -13,7 +13,12 @@ export default function ElectronManagedAuthShell({...@@ -13,7 +13,12 @@ export default function ElectronManagedAuthShell({
13 readonly children: ReactNode;13 readonly children: ReactNode;
14}) {14}) {
15 return (15 return (
16 <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey} passkeys={passkeys}>16 <ClerkProvider
17 telemetry={false}
18 appearance={clerkAppearance}
19 publishableKey={publishableKey}
20 passkeys={passkeys}
21 >
17 {children}22 {children}
18 </ClerkProvider>23 </ClerkProvider>
19 );24 );
apps/web/src/connection/clientMetadata.test.ts+4-15
...@@ -1,11 +1,6 @@...@@ -1,11 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import { browserClientOs, browserDeviceType, clientPresentationMetadata } from "./clientMetadata";
4 browserClientOs,
5 browserDeviceType,
6 browserFamily,
7 clientPresentationMetadata,
8} from "./clientMetadata";
94
10const desktopChrome = {5const desktopChrome = {
11 userAgent:6 userAgent:
...@@ -14,32 +9,28 @@ const desktopChrome = {...@@ -14,32 +9,28 @@ const desktopChrome = {
14 maxTouchPoints: 0,9 maxTouchPoints: 0,
15};10};
1611
17describe("client telemetry metadata", () => {12describe("client presentation metadata", () => {
18 it("distinguishes hosted web from server-served web", () => {13 it("identifies web clients and omits the development app version", () => {
19 expect(14 expect(
20 clientPresentationMetadata({15 clientPresentationMetadata({
21 appVersion: "1.2.3",16 appVersion: "1.2.3",
22 hosted: true,
23 identity: desktopChrome,17 identity: desktopChrome,
24 desktopBridge: undefined,18 desktopBridge: undefined,
25 }),19 }),
26 ).toMatchObject({20 ).toMatchObject({
27 surface: "web",21 surface: "web",
28 webDeployment: "hosted",
29 deviceType: "desktop",22 deviceType: "desktop",
30 os: "Windows",23 os: "Windows",
31 browser: "Chrome",
32 appVersion: "1.2.3",24 appVersion: "1.2.3",
33 });25 });
3426
35 expect(27 expect(
36 clientPresentationMetadata({28 clientPresentationMetadata({
37 appVersion: "0.0.0",29 appVersion: "0.0.0",
38 hosted: false,
39 identity: desktopChrome,30 identity: desktopChrome,
40 desktopBridge: undefined,31 desktopBridge: undefined,
41 }),32 }),
42 ).toMatchObject({ surface: "web", webDeployment: "server" });33 ).not.toHaveProperty("appVersion");
43 });34 });
4435
45 it("identifies phone and tablet browsers", () => {36 it("identifies phone and tablet browsers", () => {
...@@ -64,7 +55,6 @@ describe("client telemetry metadata", () => {...@@ -64,7 +55,6 @@ describe("client telemetry metadata", () => {
6455
65 expect(browserDeviceType(iphone)).toBe("mobile");56 expect(browserDeviceType(iphone)).toBe("mobile");
66 expect(browserClientOs(iphone)).toBe("iOS");57 expect(browserClientOs(iphone)).toBe("iOS");
67 expect(browserFamily(iphone.userAgent)).toBe("Safari");
68 expect(browserDeviceType(androidTablet)).toBe("tablet");58 expect(browserDeviceType(androidTablet)).toBe("tablet");
69 expect(browserClientOs(androidTablet)).toBe("Android");59 expect(browserClientOs(androidTablet)).toBe("Android");
70 expect(browserDeviceType(ipadosDesktopUa)).toBe("tablet");60 expect(browserDeviceType(ipadosDesktopUa)).toBe("tablet");
...@@ -75,7 +65,6 @@ describe("client telemetry metadata", () => {...@@ -75,7 +65,6 @@ describe("client telemetry metadata", () => {
75 expect(65 expect(
76 clientPresentationMetadata({66 clientPresentationMetadata({
77 appVersion: "1.2.3",67 appVersion: "1.2.3",
78 hosted: false,
79 identity: desktopChrome,68 identity: desktopChrome,
80 desktopBridge: { getClientPlatform: () => "darwin" },69 desktopBridge: { getClientPlatform: () => "darwin" },
81 }),70 }),
apps/web/src/connection/clientMetadata.ts-14
...@@ -44,17 +44,6 @@ export function browserClientOs(identity: BrowserIdentity): ClientOs {...@@ -44,17 +44,6 @@ export function browserClientOs(identity: BrowserIdentity): ClientOs {
44 return "other";44 return "other";
45}45}
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
58export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadataDeviceType {47export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadataDeviceType {
59 const userAgent = identity.userAgent;48 const userAgent = identity.userAgent;
60 if (userAgent.trim() === "") return "unknown";49 if (userAgent.trim() === "") return "unknown";
...@@ -71,7 +60,6 @@ export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadata...@@ -71,7 +60,6 @@ export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadata
7160
72export function clientPresentationMetadata(input: {61export function clientPresentationMetadata(input: {
73 readonly appVersion: string;62 readonly appVersion: string;
74 readonly hosted: boolean;
75 readonly identity: BrowserIdentity;63 readonly identity: BrowserIdentity;
76 readonly desktopBridge: Pick<DesktopBridge, "getClientPlatform"> | undefined;64 readonly desktopBridge: Pick<DesktopBridge, "getClientPlatform"> | undefined;
77}): AuthClientPresentationMetadata {65}): AuthClientPresentationMetadata {
...@@ -90,8 +78,6 @@ export function clientPresentationMetadata(input: {...@@ -90,8 +78,6 @@ export function clientPresentationMetadata(input: {
90 deviceType: browserDeviceType(input.identity),78 deviceType: browserDeviceType(input.identity),
91 os: browserClientOs(input.identity),79 os: browserClientOs(input.identity),
92 surface: "web",80 surface: "web",
93 webDeployment: input.hosted ? "hosted" : "server",
94 browser: browserFamily(input.identity.userAgent),
95 ...(input.appVersion === "0.0.0" ? {} : { appVersion: input.appVersion }),81 ...(input.appVersion === "0.0.0" ? {} : { appVersion: input.appVersion }),
96 };82 };
97}83}
apps/web/src/connection/platform.ts-1
...@@ -170,7 +170,6 @@ const layerWakeups = Wakeups.layer({...@@ -170,7 +170,6 @@ const layerWakeups = Wakeups.layer({
170function clientMetadata() {170function clientMetadata() {
171 return clientPresentationMetadata({171 return clientPresentationMetadata({
172 appVersion: APP_VERSION,172 appVersion: APP_VERSION,
173 hosted: isHostedStaticApp(),
174 identity: {173 identity: {
175 userAgent: navigator.userAgent,174 userAgent: navigator.userAgent,
176 platform: navigator.platform,175 platform: navigator.platform,
apps/web/src/lib/runtime.ts+1-11
...@@ -4,13 +4,12 @@ import * as Layer from "effect/Layer";...@@ -4,13 +4,12 @@ import * as Layer from "effect/Layer";
4import * as Socket from "effect/socket/Socket";4import * as Socket from "effect/socket/Socket";
55
6import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";6import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";
7import * as RelayTracing from "@t3tools/shared/relayTracing";
8import * as PrimaryEnvironmentHttpClient from "../environments/primary/httpClient";7import * as PrimaryEnvironmentHttpClient from "../environments/primary/httpClient";
9import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";8import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";
109
11import * as Dpop from "../cloud/dpop";10import * as Dpop from "../cloud/dpop";
12import * as ManagedRelayLayer from "../cloud/managedRelayLayer";11import * as ManagedRelayLayer from "../cloud/managedRelayLayer";
13import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig";12import { resolveCloudPublicConfig } from "../cloud/publicConfig";
14import * as ClientTracer from "../observability/clientTracer";13import * as ClientTracer from "../observability/clientTracer";
1514
16function configuredRelayUrl(): string {15function configuredRelayUrl(): string {
...@@ -18,18 +17,10 @@ function configuredRelayUrl(): string {...@@ -18,18 +17,10 @@ function configuredRelayUrl(): string {
18}17}
1918
20const layerHttpClient = layerRemoteHttpClient((input, init) => globalThis.fetch(input, init));19const 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
28type RuntimeLayerSource =20type RuntimeLayerSource =
29 | typeof layerHttpClient21 | typeof layerHttpClient
30 | typeof Dpop.layer22 | typeof Dpop.layer
31 | typeof Socket.layerWebSocketConstructorGlobal23 | typeof Socket.layerWebSocketConstructorGlobal
32 | typeof layerRelayTracing
33 | typeof ClientTracer.layer24 | typeof ClientTracer.layer
34 | ReturnType<typeof ManagedRelayLayer.layer>;25 | ReturnType<typeof ManagedRelayLayer.layer>;
3526
...@@ -59,7 +50,6 @@ const layerRuntime = Layer.mergeAll(...@@ -59,7 +50,6 @@ const layerRuntime = Layer.mergeAll(
59 Dpop.layer,50 Dpop.layer,
60 Socket.layerWebSocketConstructorGlobal,51 Socket.layerWebSocketConstructorGlobal,
61 ClientTracer.layer,52 ClientTracer.layer,
62 layerRelayTracing,
63 ManagedRelayLayer.layer(configuredRelayUrl()).pipe(53 ManagedRelayLayer.layer(configuredRelayUrl()).pipe(
64 Layer.provide(Layer.mergeAll(layerHttpClient, Dpop.layer)),54 Layer.provide(Layer.mergeAll(layerHttpClient, Dpop.layer)),
65 ),55 ),
apps/web/src/vite-env.d.ts-3
...@@ -10,9 +10,6 @@ interface ImportMetaEnv {...@@ -10,9 +10,6 @@ interface ImportMetaEnv {
10 readonly VITE_CLERK_PUBLISHABLE_KEY: string;10 readonly VITE_CLERK_PUBLISHABLE_KEY: string;
11 readonly VITE_CLERK_JWT_TEMPLATE: string;11 readonly VITE_CLERK_JWT_TEMPLATE: string;
12 readonly VITE_CLERK_CLI_OAUTH_CLIENT_ID: string;12 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;
16 readonly APP_VERSION: string;13 readonly APP_VERSION: string;
17}14}
1815
apps/web/vite.config.ts-8
...@@ -39,9 +39,6 @@ const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";...@@ -39,9 +39,6 @@ const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
39const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";39const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
40const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";40const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
41const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || "";41const 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() || "";
45const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";42const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
46const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;43const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
47const configuredHostedAppUrl = (() => {44const configuredHostedAppUrl = (() => {
...@@ -211,11 +208,6 @@ export default defineConfig(() => {...@@ -211,11 +208,6 @@ export default defineConfig(() => {
211 "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify(208 "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify(
212 configuredClerkCliOAuthClientId,209 configuredClerkCliOAuthClientId,
213 ),210 ),
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),
219 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),211 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
220 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),212 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
221 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),213 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),
docs/README.md+1-2
...@@ -17,7 +17,7 @@...@@ -17,7 +17,7 @@
17- [Use T3 Code as your default browser](./user/default-browser.md)17- [Use T3 Code as your default browser](./user/default-browser.md)
18- [Devices](./user/devices.md)18- [Devices](./user/devices.md)
19- [Usage and limits](./user/usage.md)19- [Usage and limits](./user/usage.md)
20- [Product usage data](./user/telemetry.md)20- [Diagnostics and data sharing](./user/telemetry.md)
21- [Remote access](./user/remote-access.md)21- [Remote access](./user/remote-access.md)
22- [Outside agents (MCP)](./user/outside-agents.md)22- [Outside agents (MCP)](./user/outside-agents.md)
23- [Running in the background](./user/background-service.md)23- [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...@@ -45,7 +45,6 @@ source alone does not explain. Most code changes do not need an internal documen
45- [Remote environments](./internals/remote.md)45- [Remote environments](./internals/remote.md)
46- [Server updates](./internals/server-updates.md)46- [Server updates](./internals/server-updates.md)
47- [Resource telemetry](./internals/resource-telemetry.md)47- [Resource telemetry](./internals/resource-telemetry.md)
48- [Product analytics](./internals/product-analytics.md)
49- [Environment auth](./internals/environment-auth.md)48- [Environment auth](./internals/environment-auth.md)
50- [T3 Connect](./internals/t3-connect.md)49- [T3 Connect](./internals/t3-connect.md)
51- [Assistant citations](./internals/assistant-citations.md)50- [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:...@@ -418,8 +418,9 @@ Webhooks have their own families:
418418
419`ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run419`ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run
420started from a delivery is its own `ScheduledTaskService.runWebhookDelivery` trace. For a request420started 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's421the relay forwarded, the local span retains the relay's parent span for correlation; The Knife
422span; requests that reach the environment directly never join a sender's trace.422does not export it to T3 Connect. Requests that reach the environment directly never join a
423sender's trace.
423424
424Use metrics when the question is:425Use metrics when the question is:
425426
docs/operations/relay-observability.md+6-5
...@@ -2,7 +2,9 @@...@@ -2,7 +2,9 @@
22
3> For maintainers. Using T3 Code? See [docs/user](../user/).3> 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
7- `t3-code-relay-traces-prod`, the OpenTelemetry trace dataset shared by the Worker, mobile app, and9- `t3-code-relay-traces-prod`, the OpenTelemetry trace dataset shared by the Worker, mobile app, and
8 first-party relay clients10 first-party relay clients
...@@ -21,8 +23,7 @@ vp run deploy...@@ -21,8 +23,7 @@ vp run deploy
21```23```
2224
23Alchemy resolves account-level Axiom deployment credentials through its provider. At runtime, the25Alchemy 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 separately26Worker receives only its scoped ingest token. The Knife does not consume the provisioned mobile or relay-client ingest tokens.
25provisioned scoped ingest tokens.
2627
27The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans.28The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans.
28Effect's OpenTelemetry exporter stores semantic HTTP attributes below the `attributes.` prefix.29Effect'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...@@ -70,8 +71,8 @@ managed endpoint, and with it the environment. On a forward, `relay.hook.upstrea
70`x-t3-hook-outcome` response header. A held request's delivery records the same on its71`x-t3-hook-outcome` response header. A held request's delivery records the same on its
71`relay.inbox.deliver` span. The relay sends its own `traceparent` with each forward and drops any a72`relay.inbox.deliver` span. The relay sends its own `traceparent` with each forward and drops any a
72sender supplied, and signs each forward (`x-t3-relay-delivery`) so the environment trusts the73sender 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's74relay's delivery id, receive time and trace context only from the relay. The environment records
74`ScheduledTaskService.triggerWebhook` span lands in the same trace. `relay.hook.rate_limit` says which75its `ScheduledTaskService.triggerWebhook` span locally. `relay.hook.rate_limit` says which
75budget ran out: `endpoint` or `hook`. `relay.hook.rate_limiter_failed_open` is set when the76budget ran out: `endpoint` or `hook`. `relay.hook.rate_limiter_failed_open` is set when the
76Cloudflare rate limiter was unavailable and the request went through unlimited.77Cloudflare rate limiter was unavailable and the request went through unlimited.
7778
docs/user/telemetry.md+4-13
...@@ -1,17 +1,8 @@...@@ -1,17 +1,8 @@
1# Product usage data1# Diagnostics and data sharing
22
3The T3 Code server sends product usage events to PostHog, associated with a hashed account or3The Knife does not collect or send product analytics. T3 Connect contacts its account and relay
4installation identifier. Events include the provider, model, reasoning effort, permission mode,4services only when you use that connection option. Local diagnostics stay on your machine unless
5turn result, duration, and main-agent token totals when available.5you configure an export endpoint.
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.
156
16## Export diagnostics to your own receiver7## 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", () => {...@@ -304,7 +304,7 @@ describe("remote environment authorization", () => {
304 deviceType: "desktop",304 deviceType: "desktop",
305 os: "unknown",305 os: "unknown",
306 });306 });
307 expect(websocketUrl.searchParams.get("clientOs")).toBe("unknown");307 expect(websocketUrl.searchParams.get("clientOs")).toBeNull();
308 }),308 }),
309 );309 );
310310
...@@ -532,14 +532,11 @@ describe("remote environment authorization", () => {...@@ -532,14 +532,11 @@ describe("remote environment authorization", () => {
532 appVersion: "1.2.3",532 appVersion: "1.2.3",
533 deviceType: "mobile",533 deviceType: "mobile",
534 os: "Android",534 os: "Android",
535 osMajorVersion: 15,
536 deviceModel: "Pixel 9",
537 },535 },
538 connectionMethod: "relay",
539 }).pipe(provideRemoteHttp(fetch.fetchFn));536 }).pipe(provideRemoteHttp(fetch.fetchFn));
540537
541 expect(url).toBe(538 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",
543 );540 );
544 }),541 }),
545 );542 );
packages/client-runtime/src/authorization/remote.ts+2-37
...@@ -3,7 +3,6 @@ import {...@@ -3,7 +3,6 @@ import {
3 type AuthClientPresentationMetadata,3 type AuthClientPresentationMetadata,
4 AuthEnvironmentBootstrapTokenType,4 AuthEnvironmentBootstrapTokenType,
5 AuthTokenExchangeGrantType,5 AuthTokenExchangeGrantType,
6 type ClientConnectionMethod,
7 type AuthEnvironmentScope,6 type AuthEnvironmentScope,
8} from "@t3tools/contracts";7} from "@t3tools/contracts";
9import { encodeOAuthScope } from "@t3tools/shared/oauthScope";8import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
...@@ -42,7 +41,6 @@ const clientMetadataTokenExchangeFields = (...@@ -42,7 +41,6 @@ const clientMetadataTokenExchangeFields = (
42export const appendClientConnectionParams = (41export const appendClientConnectionParams = (
43 url: URL,42 url: URL,
44 clientMetadata: AuthClientPresentationMetadata | undefined,43 clientMetadata: AuthClientPresentationMetadata | undefined,
45 connectionMethod?: ClientConnectionMethod,
46): void => {44): void => {
47 if (clientMetadata?.surface) {45 if (clientMetadata?.surface) {
48 url.searchParams.set("clientSurface", clientMetadata.surface);46 url.searchParams.set("clientSurface", clientMetadata.surface);
...@@ -50,37 +48,6 @@ export const appendClientConnectionParams = (...@@ -50,37 +48,6 @@ export const appendClientConnectionParams = (
50 if (clientMetadata?.appVersion) {48 if (clientMetadata?.appVersion) {
51 url.searchParams.set("clientAppVersion", clientMetadata.appVersion);49 url.searchParams.set("clientAppVersion", clientMetadata.appVersion);
52 }50 }
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 }
84};51};
8552
86export const exchangeRemoteDpopAccessToken = Effect.fn(53export const exchangeRemoteDpopAccessToken = Effect.fn(
...@@ -205,7 +172,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(...@@ -205,7 +172,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
205 readonly httpBaseUrl: string;172 readonly httpBaseUrl: string;
206 readonly bearerToken: string;173 readonly bearerToken: string;
207 readonly clientMetadata?: AuthClientPresentationMetadata;174 readonly clientMetadata?: AuthClientPresentationMetadata;
208 readonly connectionMethod?: ClientConnectionMethod;
209 readonly timeoutMs?: number;175 readonly timeoutMs?: number;
210}) {176}) {
211 const issued = yield* issueRemoteWebSocketTicket({177 const issued = yield* issueRemoteWebSocketTicket({
...@@ -216,7 +182,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(...@@ -216,7 +182,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
216182
217 const url = new URL(environmentWebSocketUrl(input.wsBaseUrl));183 const url = new URL(environmentWebSocketUrl(input.wsBaseUrl));
218 url.searchParams.set("wsTicket", issued.ticket);184 url.searchParams.set("wsTicket", issued.ticket);
219 appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod);185 appendClientConnectionParams(url, input.clientMetadata);
220 return url.toString();186 return url.toString();
221});187});
222188
...@@ -228,7 +194,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(...@@ -228,7 +194,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(
228 readonly accessToken: string;194 readonly accessToken: string;
229 readonly dpopProof: string;195 readonly dpopProof: string;
230 readonly clientMetadata?: AuthClientPresentationMetadata;196 readonly clientMetadata?: AuthClientPresentationMetadata;
231 readonly connectionMethod?: ClientConnectionMethod;
232 readonly timeoutMs?: number;197 readonly timeoutMs?: number;
233}) {198}) {
234 const issued = yield* issueRemoteDpopWebSocketTicket({199 const issued = yield* issueRemoteDpopWebSocketTicket({
...@@ -239,6 +204,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(...@@ -239,6 +204,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(
239 });204 });
240 const url = new URL(environmentWebSocketUrl(input.wsBaseUrl));205 const url = new URL(environmentWebSocketUrl(input.wsBaseUrl));
241 url.searchParams.set("wsTicket", issued.ticket);206 url.searchParams.set("wsTicket", issued.ticket);
242 appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod);207 appendClientConnectionParams(url, input.clientMetadata);
243 return url.toString();208 return url.toString();
244});209});
packages/client-runtime/src/authorization/service.ts-4
...@@ -4,7 +4,6 @@ import {...@@ -4,7 +4,6 @@ import {
4 type ExecutionEnvironmentDescriptor,4 type ExecutionEnvironmentDescriptor,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";6import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
7import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
8import {7import {
9 exchangeRemoteDpopAccessToken,8 exchangeRemoteDpopAccessToken,
10 type RemoteEnvironmentAuthError,9 type RemoteEnvironmentAuthError,
...@@ -197,7 +196,6 @@ export const make = Effect.gen(function* () {...@@ -197,7 +196,6 @@ export const make = Effect.gen(function* () {
197 httpBaseUrl: input.httpBaseUrl,196 httpBaseUrl: input.httpBaseUrl,
198 bearerToken: input.bearerToken,197 bearerToken: input.bearerToken,
199 clientMetadata: presentation.metadata,198 clientMetadata: presentation.metadata,
200 connectionMethod: input.connectionMethod,
201 }).pipe(199 }).pipe(
202 Effect.mapError(mapRemoteEnvironmentError),200 Effect.mapError(mapRemoteEnvironmentError),
203 Effect.provideService(HttpClient.HttpClient, httpClient),201 Effect.provideService(HttpClient.HttpClient, httpClient),
...@@ -242,7 +240,6 @@ export const make = Effect.gen(function* () {...@@ -242,7 +240,6 @@ export const make = Effect.gen(function* () {
242 accessToken: token.accessToken,240 accessToken: token.accessToken,
243 dpopProof: ticketProof,241 dpopProof: ticketProof,
244 clientMetadata: presentation.metadata,242 clientMetadata: presentation.metadata,
245 connectionMethod: "relay",
246 ...(timeoutMs === undefined ? {} : { timeoutMs }),243 ...(timeoutMs === undefined ? {} : { timeoutMs }),
247 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));244 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
248 },245 },
...@@ -551,7 +548,6 @@ export const make = Effect.gen(function* () {...@@ -551,7 +548,6 @@ export const make = Effect.gen(function* () {
551 getDpopToken(input).pipe(548 getDpopToken(input).pipe(
552 Effect.map(({ token }) => httpAuthorization(token)),549 Effect.map(({ token }) => httpAuthorization(token)),
553 Effect.withSpan("environment.authorization.http"),550 Effect.withSpan("environment.authorization.http"),
554 withRelayClientTracing,
555 ),551 ),
556 });552 });
557});553});
packages/client-runtime/src/connection/resolver.test.ts+5-12
...@@ -3,7 +3,6 @@ import {...@@ -3,7 +3,6 @@ import {
3 ORCHESTRATION_PROTOCOL_VERSION,3 ORCHESTRATION_PROTOCOL_VERSION,
4 type DesktopSshEnvironmentTarget,4 type DesktopSshEnvironmentTarget,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { RelayClientTracer } from "@t3tools/shared/relayTracing";
7import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
...@@ -269,8 +268,7 @@ describe("ConnectionResolver", () => {...@@ -269,8 +268,7 @@ describe("ConnectionResolver", () => {
269 environmentId: ENVIRONMENT_ID,268 environmentId: ENVIRONMENT_ID,
270 label: "Primary",269 label: "Primary",
271 httpBaseUrl: "http://127.0.0.1:3777",270 httpBaseUrl: "http://127.0.0.1:3777",
272 socketUrl:271 socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web&orchestrationProtocol=2",
273 "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2",
274 httpAuthorization: null,272 httpAuthorization: null,
275 target,273 target,
276 });274 });
...@@ -383,10 +381,9 @@ describe("ConnectionResolver", () => {...@@ -383,10 +381,9 @@ describe("ConnectionResolver", () => {
383 }),381 }),
384 );382 );
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", () =>
387 Effect.gen(function* () {385 Effect.gen(function* () {
388 const userSpans: Array<string> = [];386 const userSpans: Array<string> = [];
389 const productSpans: Array<string> = [];
390 const target = new RelayConnectionTarget({387 const target = new RelayConnectionTarget({
391 environmentId: ENVIRONMENT_ID,388 environmentId: ENVIRONMENT_ID,
392 label: "Cloud",389 label: "Cloud",
...@@ -409,15 +406,11 @@ describe("ConnectionResolver", () => {...@@ -409,15 +406,11 @@ describe("ConnectionResolver", () => {
409406
410 yield* broker407 yield* broker
411 .prepare(catalogEntry(target))408 .prepare(catalogEntry(target))
412 .pipe(409 .pipe(Effect.withTracer(collectingTracer(userSpans)));
413 Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))),
414 Effect.withTracer(collectingTracer(userSpans)),
415 );
416410
417 expect(productSpans).toContain("clientRuntime.connection.broker.relay");411 expect(userSpans).toContain("clientRuntime.connection.broker.relay");
418 expect(productSpans).toContain("test.remote.authorizeDpop");412 expect(userSpans).toContain("test.remote.authorizeDpop");
419 expect(userSpans).toContain("clientRuntime.connection.broker.prepare");413 expect(userSpans).toContain("clientRuntime.connection.broker.prepare");
420 expect(userSpans).not.toContain("test.remote.authorizeDpop");
421 }),414 }),
422 );415 );
423416
packages/client-runtime/src/connection/resolver.ts+14-19
...@@ -2,7 +2,6 @@ import type {...@@ -2,7 +2,6 @@ import type {
2 AuthClientPresentationMetadata,2 AuthClientPresentationMetadata,
3 ExecutionEnvironmentDescriptor,3 ExecutionEnvironmentDescriptor,
4} from "@t3tools/contracts";4} from "@t3tools/contracts";
5import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
6import * as Context from "effect/Context";5import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
...@@ -77,7 +76,7 @@ function primarySocketUrl(...@@ -77,7 +76,7 @@ function primarySocketUrl(
77 clientMetadata: AuthClientPresentationMetadata | undefined,76 clientMetadata: AuthClientPresentationMetadata | undefined,
78): string {77): string {
79 const url = new URL(environmentWebSocketUrl(target.wsBaseUrl));78 const url = new URL(environmentWebSocketUrl(target.wsBaseUrl));
80 appendClientConnectionParams(url, clientMetadata, "direct");79 appendClientConnectionParams(url, clientMetadata);
81 return url.toString();80 return url.toString();
82}81}
8382
...@@ -224,23 +223,19 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")...@@ -224,23 +223,19 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
224const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () {223const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () {
225 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;224 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
226225
227 return Effect.fnUntraced(226 return Effect.fnUntraced(function* (target: RelayConnectionTarget) {
228 function* (target: RelayConnectionTarget) {227 const authorized = yield* remote.authorizeDpop({
229 const authorized = yield* remote.authorizeDpop({228 expectedEnvironmentId: target.environmentId,
230 expectedEnvironmentId: target.environmentId,229 });
231 });230 return {
232 return {231 environmentId: authorized.environmentId,
233 environmentId: authorized.environmentId,232 label: authorized.label,
234 label: authorized.label,233 httpBaseUrl: authorized.httpBaseUrl,
235 httpBaseUrl: authorized.httpBaseUrl,234 socketUrl: authorized.socketUrl,
236 socketUrl: authorized.socketUrl,235 httpAuthorization: authorized.httpAuthorization,
237 httpAuthorization: authorized.httpAuthorization,236 target,
238 target,237 } satisfies PreparedConnection;
239 } satisfies PreparedConnection;238 }, Effect.withSpan("clientRuntime.connection.broker.relay"));
240 },
241 Effect.withSpan("clientRuntime.connection.broker.relay"),
242 withRelayClientTracing,
243 );
244});239});
245240
246const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () {241const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () {
packages/client-runtime/src/connection/supervisor.test.ts+2-6
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts";1import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts";
2import { RelayClientTracer } from "@t3tools/shared/relayTracing";
3import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
4import * as Deferred from "effect/Deferred";3import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -295,7 +294,7 @@ describe("retryDelayMs", () => {...@@ -295,7 +294,7 @@ describe("retryDelayMs", () => {
295});294});
296295
297describe("EnvironmentSupervisor", () => {296describe("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", () =>
299 Effect.gen(function* () {298 Effect.gen(function* () {
300 const spans: Array<Tracer.NativeSpan> = [];299 const spans: Array<Tracer.NativeSpan> = [];
301 const tracer = Tracer.make({300 const tracer = Tracer.make({
...@@ -315,10 +314,7 @@ describe("EnvironmentSupervisor", () => {...@@ -315,10 +314,7 @@ describe("EnvironmentSupervisor", () => {
315 });314 });
316 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {315 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
317 initiallyDesired: true,316 initiallyDesired: true,
318 }).pipe(317 }).pipe(Effect.provide(harness.dependencies), Effect.withTracer(tracer));
319 Effect.provide(harness.dependencies),
320 Effect.provideService(RelayClientTracer, Option.some(tracer)),
321 );
322318
323 yield* awaitState(319 yield* awaitState(
324 supervisor.state,320 supervisor.state,
packages/client-runtime/src/connection/supervisor.ts+1-2
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
2import * as Cause from "effect/Cause";1import * as Cause from "effect/Cause";
3import * as Clock from "effect/Clock";2import * as Clock from "effect/Clock";
4import * as Context from "effect/Context";3import * as Context from "effect/Context";
...@@ -484,7 +483,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -484,7 +483,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
484 "connection.retry.reason": retry.reason,483 "connection.retry.reason": retry.reason,
485 }),484 }),
486 ),485 ),
487 }).pipe(withRelayClientTracing);486 });
488 };487 };
489488
490 const establishTracedConnection = Effect.fnUntraced(function* (489 const establishTracedConnection = Effect.fnUntraced(function* (
packages/client-runtime/src/relay/managedRelay.ts+169-215
...@@ -31,7 +31,6 @@ import {...@@ -31,7 +31,6 @@ import {
31} from "@t3tools/contracts/relay";31} from "@t3tools/contracts/relay";
32import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";32import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
33import { decodeRelayJwt } from "@t3tools/shared/relayJwt";33import { decodeRelayJwt } from "@t3tools/shared/relayJwt";
34import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
35import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";34import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
36import * as Clock from "effect/Clock";35import * as Clock from "effect/Clock";
37import * as Context from "effect/Context";36import * as Context from "effect/Context";
...@@ -725,228 +724,183 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (...@@ -725,228 +724,183 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
725724
726 return ManagedRelayClient.of({725 return ManagedRelayClient.of({
727 relayUrl,726 relayUrl,
728 listEnvironments: Effect.fnUntraced(727 listEnvironments: Effect.fnUntraced(function* (input) {
729 function* (input) {728 return yield* client.client
730 return yield* client.client729 .listEnvironments({ headers: bearerHeaders(input.clerkToken) })
731 .listEnvironments({ headers: bearerHeaders(input.clerkToken) })730 .pipe(
732 .pipe(731 Effect.map((response) => response.environments),
733 Effect.map((response) => response.environments),732 Effect.mapError(relayRequestError("list relay-managed environments")),
734 Effect.mapError(relayRequestError("list relay-managed environments")),733 timeoutRelayRequest("Relay environment listing"),
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 },
854 );734 );
855 },735 }, Effect.withSpan("clientRuntime.managedRelay.listEnvironments")),
856 Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"),736 listDevices: Effect.fnUntraced(function* (input) {
857 withRelayClientTracing,737 return yield* client.client
858 ),738 .listDevicesV2({
859 registerDevice: Effect.fnUntraced(739 headers: bearerHeaders(input.clerkToken),
860 function* (input) {740 })
861 return yield* mobileRegistrationRequest(741 .pipe(
862 {742 Effect.map((response) => response.devices),
863 clerkToken: input.clerkToken,743 Effect.mapError(relayRequestError("list relay client devices")),
864 target: dpopProofTargets.registerDevice(),744 timeoutRelayRequest("Relay client device listing"),
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 ),
876 );745 );
877 },746 }, Effect.withSpan("clientRuntime.managedRelay.listDevices")),
878 Effect.withSpan("clientRuntime.managedRelay.registerDevice"),747 createEnvironmentLinkChallenge: Effect.fnUntraced(function* (input) {
879 withRelayClientTracing,748 return yield* client.client
880 ),749 .createEnvironmentLinkChallenge({
881 unregisterDevice: Effect.fnUntraced(750 headers: bearerHeaders(input.clerkToken),
882 function* (input) {751 payload: input.payload,
883 return yield* mobileRegistrationRequest(752 })
884 {753 .pipe(
885 clerkToken: input.clerkToken,754 Effect.mapError(relayRequestError("create relay environment link challenge")),
886 target: dpopProofTargets.unregisterDevice(input.deviceId),755 timeoutRelayRequest("Relay environment link challenge"),
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 ),
898 );756 );
899 },757 }, Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge")),
900 Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"),758 linkEnvironment: Effect.fnUntraced(function* (input) {
901 withRelayClientTracing,759 return yield* client.client
902 ),760 .linkEnvironment({
903 getAgentActivitySnapshot: Effect.fnUntraced(761 headers: bearerHeaders(input.clerkToken),
904 function* (input) {762 payload: input.payload,
905 return yield* mobileRegistrationRequest(763 })
906 {764 .pipe(
907 clerkToken: input.clerkToken,765 Effect.mapError(relayRequestError("link relay environment")),
908 target: dpopProofTargets.getAgentActivitySnapshot(),766 timeoutRelayRequest("Relay environment linking"),
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 ),
919 );767 );
920 },768 }, Effect.withSpan("clientRuntime.managedRelay.linkEnvironment")),
921 Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot"),769 unlinkEnvironment: Effect.fnUntraced(function* (input) {
922 withRelayClientTracing,770 return yield* client.client
923 ),771 .unlinkEnvironment({
924 registerLiveActivity: Effect.fnUntraced(772 headers: bearerHeaders(input.clerkToken),
925 function* (input) {773 params: { environmentId: input.environmentId },
926 return yield* mobileRegistrationRequest(774 })
927 {775 .pipe(
928 clerkToken: input.clerkToken,776 Effect.mapError(relayRequestError("unlink relay environment")),
929 target: dpopProofTargets.registerLiveActivity(),777 timeoutRelayRequest("Relay environment unlinking"),
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 ),
941 );778 );
942 },779 }, Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment")),
943 Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"),780 getEnvironmentStatus: Effect.fnUntraced(function* (input) {
944 withRelayClientTracing,781 yield* Effect.annotateCurrentSpan({
945 ),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")),
946 resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe(901 resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe(
947 Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void),902 Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void),
948 Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"),903 Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"),
949 withRelayClientTracing,
950 ),904 ),
951 });905 });
952});906});
packages/client-runtime/src/state/environmentHttpAuth.test.ts-32
...@@ -12,14 +12,12 @@ import {...@@ -12,14 +12,12 @@ import {
12 OrchestrationV2ThreadTranscript,12 OrchestrationV2ThreadTranscript,
13 type OrchestrationV2ThreadHistoryPage,13 type OrchestrationV2ThreadHistoryPage,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { RelayClientTracer } from "@t3tools/shared/relayTracing";
16import * as Deferred from "effect/Deferred";15import * as Deferred from "effect/Deferred";
17import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
18import * as Fiber from "effect/Fiber";17import * as Fiber from "effect/Fiber";
19import * as Layer from "effect/Layer";18import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";19import * as Option from "effect/Option";
21import * as Schema from "effect/Schema";20import * as Schema from "effect/Schema";
22import * as Tracer from "effect/Tracer";
23import { TestClock } from "effect/testing";21import { TestClock } from "effect/testing";
24import type { HttpClient } from "effect/http";22import type { HttpClient } from "effect/http";
2523
...@@ -764,36 +762,6 @@ describe("authenticated environment HTTP requests", () => {...@@ -764,36 +762,6 @@ describe("authenticated environment HTTP requests", () => {
764 );762 );
765});763});
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
797describe("orchestration HTTP compatibility header", () => {765describe("orchestration HTTP compatibility header", () => {
798 it("announces the current protocol without replacing bearer or DPoP authentication", () => {766 it("announces the current protocol without replacing bearer or DPoP authentication", () => {
799 expect(767 expect(
packages/client-runtime/src/state/environmentHttpAuth.ts+1-12
...@@ -5,7 +5,6 @@ import {...@@ -5,7 +5,6 @@ import {
5 ORCHESTRATION_PROTOCOL_VERSION_TEXT,5 ORCHESTRATION_PROTOCOL_VERSION_TEXT,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import * as Result from "effect/Result";7import * as Result from "effect/Result";
8import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
9import { FetchHttpClient, type HttpMethod } from "effect/http";8import { FetchHttpClient, type HttpMethod } from "effect/http";
109
11import { HostedAuthAccess } from "../platform/capabilities.ts";10import { HostedAuthAccess } from "../platform/capabilities.ts";
...@@ -106,10 +105,6 @@ const buildEnvironmentAuthHeaders = (...@@ -106,10 +105,6 @@ const buildEnvironmentAuthHeaders = (
106 * Resolve relay credentials at request time without replacing the live socket.105 * Resolve relay credentials at request time without replacing the live socket.
107 * A rejected credential gets one refresh and retry, with a new request-bound106 * A rejected credential gets one refresh and retry, with a new request-bound
108 * proof. Cookie and bearer requests keep their existing authentication behavior.107 * 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.
113 */108 */
114export const executeAuthenticatedEnvironmentHttpRequest = <109export const executeAuthenticatedEnvironmentHttpRequest = <
115 Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1],110 Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1],
...@@ -118,13 +113,7 @@ export const executeAuthenticatedEnvironmentHttpRequest = <...@@ -118,13 +113,7 @@ export const executeAuthenticatedEnvironmentHttpRequest = <
118 R,113 R,
119>(114>(
120 input: Parameters<typeof executeEnvironmentRequest<Group, A, E, R>>[0],115 input: Parameters<typeof executeEnvironmentRequest<Group, A, E, R>>[0],
121) =>116) => executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN));
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));
128117
129const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest";118const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest";
130119
packages/contracts/src/auth.ts-5
...@@ -5,7 +5,6 @@ import {...@@ -5,7 +5,6 @@ import {
5 AuthSessionId,5 AuthSessionId,
6 ForwardCompatibleArray,6 ForwardCompatibleArray,
7 ClientSurface,7 ClientSurface,
8 ClientWebDeployment,
9 TrimmedNonEmptyString,8 TrimmedNonEmptyString,
10} from "./baseSchemas.ts";9} from "./baseSchemas.ts";
1110
...@@ -297,11 +296,7 @@ export const AuthClientPresentationMetadata = Schema.Struct({...@@ -297,11 +296,7 @@ export const AuthClientPresentationMetadata = Schema.Struct({
297 label: Schema.optionalKey(TrimmedNonEmptyString),296 label: Schema.optionalKey(TrimmedNonEmptyString),
298 deviceType: Schema.optionalKey(AuthClientMetadataDeviceType),297 deviceType: Schema.optionalKey(AuthClientMetadataDeviceType),
299 os: Schema.optionalKey(TrimmedNonEmptyString),298 os: Schema.optionalKey(TrimmedNonEmptyString),
300 osMajorVersion: Schema.optionalKey(Schema.Int),
301 deviceModel: Schema.optionalKey(TrimmedNonEmptyString),
302 surface: Schema.optionalKey(ClientSurface),299 surface: Schema.optionalKey(ClientSurface),
303 webDeployment: Schema.optionalKey(ClientWebDeployment),
304 browser: Schema.optionalKey(TrimmedNonEmptyString),
305 appVersion: Schema.optionalKey(TrimmedNonEmptyString),300 appVersion: Schema.optionalKey(TrimmedNonEmptyString),
306});301});
307export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type;302export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type;
packages/contracts/src/baseSchemas.ts-6
...@@ -357,12 +357,6 @@ export const ClientOs = Schema.Literals([...@@ -357,12 +357,6 @@ export const ClientOs = Schema.Literals([
357]);357]);
358export type ClientOs = typeof ClientOs.Type;358export 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
366export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]);360export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]);
367export type ClientConnectionMethod = typeof ClientConnectionMethod.Type;361export type ClientConnectionMethod = typeof ClientConnectionMethod.Type;
368362
packages/shared/package.json-4
...@@ -295,10 +295,6 @@...@@ -295,10 +295,6 @@
295 "types": "./src/relayClient.ts",295 "types": "./src/relayClient.ts",
296 "import": "./src/relayClient.ts"296 "import": "./src/relayClient.ts"
297 },297 },
298 "./relayTracing": {
299 "types": "./src/relayTracing.ts",
300 "import": "./src/relayTracing.ts"
301 },
302 "./preview": {298 "./preview": {
303 "types": "./src/preview.ts",299 "types": "./src/preview.ts",
304 "import": "./src/preview.ts"300 "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", () => {...@@ -27,18 +27,6 @@ describe("loadRepoEnv", () => {
27 expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined();27 expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined();
28 expect(env.T3CODE_RELAY_URL).toBeUndefined();28 expect(env.T3CODE_RELAY_URL).toBeUndefined();
29 expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined();29 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();
42 });30 });
4331
44 it("applies process, root local, and root precedence in that order", () => {32 it("applies process, root local, and root precedence in that order", () => {
...@@ -85,64 +73,12 @@ describe("loadRepoEnv", () => {...@@ -85,64 +73,12 @@ describe("loadRepoEnv", () => {
85 VITE_CLERK_JWT_TEMPLATE: "template_legacy",73 VITE_CLERK_JWT_TEMPLATE: "template_legacy",
86 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical",74 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical",
87 VITE_T3CODE_RELAY_URL: "https://legacy.example.test",75 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",
91 }),76 }),
92 ).toEqual({77 ).toEqual({
93 clerkPublishableKey: "pk_legacy",78 clerkPublishableKey: "pk_legacy",
94 clerkJwtTemplate: "template_legacy",79 clerkJwtTemplate: "template_legacy",
95 clerkCliOAuthClientId: "oauth_canonical",80 clerkCliOAuthClientId: "oauth_canonical",
96 relayUrl: "https://legacy.example.test",81 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",
146 });82 });
147 });83 });
148});84});
scripts/lib/public-config.ts-72
...@@ -9,12 +9,6 @@ export interface T3CodePublicConfig {...@@ -9,12 +9,6 @@ export interface T3CodePublicConfig {
9 readonly clerkJwtTemplate: string | undefined;9 readonly clerkJwtTemplate: string | undefined;
10 readonly clerkCliOAuthClientId: string | undefined;10 readonly clerkCliOAuthClientId: string | undefined;
11 readonly relayUrl: string | undefined;11 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;
18}12}
1913
20type Environment = Readonly<Record<string, string | undefined>>;14type Environment = Readonly<Record<string, string | undefined>>;
...@@ -64,42 +58,6 @@ export function loadRepoEnv({...@@ -64,42 +58,6 @@ export function loadRepoEnv({
64 VITE_T3CODE_RELAY_URL: config.relayUrl,58 VITE_T3CODE_RELAY_URL: config.relayUrl,
65 }59 }
66 : {}),60 : {}),
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 : {}),
103 };61 };
104}62}
10563
...@@ -123,36 +81,6 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP...@@ -123,36 +81,6 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP
123 "VITE_CLERK_CLI_OAUTH_CLIENT_ID",81 "VITE_CLERK_CLI_OAUTH_CLIENT_ID",
124 ),82 ),
125 relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"),83 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 ),
156 };84 };
157}85}
15886