| author | |
| committer | |
| log | 70f13f54049d9837fe63d489015fd50cd55b98cb |
| tree | c887d358a89f02a3dc16c8a058384b2c725d0c83 |
| parent | 7caaa8c6b763db8a1e8934de235f956e13ff789d |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
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-sol78 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", () => { |
| 996 | 996 | ||
| 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"; | ||
| 1004 | 1002 | ||
| 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", () => { |
| 1008 | 1006 | ||
| 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 = [ |
| 102 | const WSL_FORWARDED_ENV_NAMES = [ | 102 | const 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 | }); |
| 363 | 362 | ||
| 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 | ); |
| 369 | 367 |
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; |
| 95 | const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; | 94 | const 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 | } |
| 230 | 230 | ||
| 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"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| 41 | 35 | ||
| ... | @@ -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 | }); |
| 68 | 52 | ||
| ... | @@ -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 | } |
| 31 | 26 | ||
| 32 | type UntrustedSection<T> = { | 27 | type 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 | } |
| 45 | 40 | ||
| 46 | function 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 | |||
| 59 | export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { | 41 | export 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 | } |
| 75 | 52 | ||
| ... | @@ -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 | } |
| 80 | 57 | ||
| 81 | type Configured<T> = { | ||
| 82 | readonly [Key in keyof T]: NonNullable<T[Key]>; | ||
| 83 | }; | ||
| 84 | |||
| 85 | type TracingPublicConfig = Omit<CloudPublicConfig, "observability"> & { | ||
| 86 | readonly observability: Configured<CloudPublicConfig["observability"]>; | ||
| 87 | }; | ||
| 88 | |||
| 89 | export 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 | |||
| 99 | export function resolveRelayClerkTokenOptions() { | 58 | export 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 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import { vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; | ||
| 8 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 9 | |||
| 10 | import * as Tracing from "./tracing"; | ||
| 11 | |||
| 12 | vi.mock("expo-constants", () => ({ | ||
| 13 | default: { | ||
| 14 | expoConfig: { | ||
| 15 | extra: {}, | ||
| 16 | }, | ||
| 17 | }, | ||
| 18 | })); | ||
| 19 | |||
| 20 | it.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 | |||
| 56 | it.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 @@ | ||
| 1 | import Constants from "expo-constants"; | ||
| 2 | import * as RelayTracing from "@t3tools/shared/relayTracing"; | ||
| 3 | |||
| 4 | import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; | ||
| 5 | |||
| 6 | export interface TracingConfig { | ||
| 7 | readonly tracesUrl: string; | ||
| 8 | readonly tracesDataset: string; | ||
| 9 | readonly tracesToken: string; | ||
| 10 | } | ||
| 11 | |||
| 12 | export interface TracingResource { | ||
| 13 | readonly serviceVersion?: string; | ||
| 14 | readonly appVariant: string; | ||
| 15 | } | ||
| 16 | |||
| 17 | export 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 | |||
| 26 | export 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 | |||
| 35 | export 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"; |
| 3 | import { Platform } from "react-native"; | 3 | import { Platform } from "react-native"; |
| 4 | 4 | ||
| 5 | export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { | 5 | export 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 | })); |
| 20 | 18 | ||
| 21 | vi.mock("./runtime", () => ({ | 19 | vi.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 | }); |
| 40 | 36 | ||
| 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 | }); |
| 51 | 45 | ||
| 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"; | ||
| 56 | 48 | ||
| 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 | }); |
| 63 | 53 | ||
| 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"; | ||
| 67 | 56 | ||
| 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 | }); |
| 74 | 62 |
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"; |
| 7 | import * as Dpop from "../features/cloud/dpop"; | 7 | import * as Dpop from "../features/cloud/dpop"; |
| 8 | import * as ManagedRelayLayer from "../features/cloud/managedRelayLayer"; | 8 | import * as ManagedRelayLayer from "../features/cloud/managedRelayLayer"; |
| 9 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; | 9 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; |
| 10 | import * as Tracing from "../features/observability/tracing"; | ||
| 11 | import * as Persistence from "../persistence/layer"; | 10 | import * as Persistence from "../persistence/layer"; |
| 12 | import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh"; | 11 | import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh"; |
| 13 | 12 | ||
| ... | @@ -24,8 +23,7 @@ type RuntimeLayerSource = | ... | @@ -24,8 +23,7 @@ type RuntimeLayerSource = |
| 24 | | typeof Socket.layerWebSocketConstructorGlobal | 23 | | typeof Socket.layerWebSocketConstructorGlobal |
| 25 | | typeof Dpop.layer | 24 | | typeof Dpop.layer |
| 26 | | typeof layerHttpClient | 25 | | typeof layerHttpClient |
| 27 | | typeof Persistence.layer | 26 | | typeof Persistence.layer; |
| 28 | | typeof Tracing.layer; | ||
| 29 | 27 | ||
| 30 | const layerRuntime = Layer.merge( | 28 | const 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 | ); |
| 39 | 36 |
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"; |
| 11 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | ||
| 12 | import { expect, it } from "@effect/vitest"; | 11 | import { expect, it } from "@effect/vitest"; |
| 13 | import * as Context from "effect/Context"; | 12 | import * as Context from "effect/Context"; |
| 14 | import * as Crypto from "effect/Crypto"; | 13 | import * as Crypto from "effect/Crypto"; |
| 15 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 16 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; | ||
| 18 | import * as Redacted from "effect/Redacted"; | 16 | import * as Redacted from "effect/Redacted"; |
| 19 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 20 | import * as Tracer from "effect/Tracer"; | 18 | import * 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 | ); |
| 167 | 165 | ||
| 168 | it.effect("exports only verified T3 Connect requests", () => | 166 | it.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 | ); |
| 222 | 218 | ||
| 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"]); | ||
| 230 | 221 | ||
| 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"]); |
| 236 | 225 | ||
| 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 { |
| 21 | import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; | 21 | import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; |
| 22 | import { parseOAuthScope } from "@t3tools/shared/oauthScope"; | 22 | import { parseOAuthScope } from "@t3tools/shared/oauthScope"; |
| 23 | import { causeErrorTag } from "@t3tools/shared/observability"; | 23 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 24 | import * as Clock from "effect/Clock"; | ||
| 25 | import * as DateTime from "effect/DateTime"; | 24 | import * as DateTime from "effect/DateTime"; |
| 26 | import * as Effect from "effect/Effect"; | 25 | import * as Effect from "effect/Effect"; |
| 27 | import * as Schema from "effect/Schema"; | 26 | import * as Schema from "effect/Schema"; |
| 28 | import { identity } from "effect/Function"; | ||
| 29 | import * as Layer from "effect/Layer"; | 27 | import * as Layer from "effect/Layer"; |
| 30 | import * as Cookies from "effect/http/Cookies"; | 28 | import * as Cookies from "effect/http/Cookies"; |
| 31 | import * as HttpEffect from "effect/http/HttpEffect"; | 29 | import * 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"; |
| 34 | 32 | ||
| 35 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 33 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 36 | import * as SessionStore from "./SessionStore.ts"; | 34 | import * as SessionStore from "./SessionStore.ts"; |
| 37 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; | ||
| 38 | import { deriveAuthClientMetadata } from "./utils.ts"; | 35 | import { deriveAuthClientMetadata } from "./utils.ts"; |
| 39 | import { verifyRequestDpopProof } from "./dpop.ts"; | 36 | import { verifyRequestDpopProof } from "./dpop.ts"; |
| 40 | 37 | ||
| ... | @@ -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 { |
| 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; | 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; |
| 8 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 8 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 9 | import * as RelayClient from "@t3tools/shared/relayClient"; | 9 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 10 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 11 | import * as Cause from "effect/Cause"; | 10 | import * as Cause from "effect/Cause"; |
| 12 | import * as Config from "effect/Config"; | 11 | import * as Config from "effect/Config"; |
| 13 | import * as Console from "effect/Console"; | 12 | import * 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"; |
| 40 | import { relayUrlConfig } from "../cloud/publicConfig.ts"; | 39 | import { relayUrlConfig } from "../cloud/publicConfig.ts"; |
| 41 | import * as RelayTracing from "../cloud/relayTracing.ts"; | ||
| 42 | import * as ServerConfig from "../config.ts"; | 40 | import * as ServerConfig from "../config.ts"; |
| 43 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 41 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 44 | import * as ExternalLauncher from "../process/externalLauncher.ts"; | 42 | import * 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.ok | 348 | 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"; |
| 37 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 38 | import { | 37 | import { |
| 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 | ); |
| 890 | 888 | ||
| 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 provision | 1335 | // 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"; |
| 18 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | 18 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 19 | import * as CloudLink from "./CloudLink.ts"; | 19 | import * as CloudLink from "./CloudLink.ts"; |
| 20 | import type { RelayRequestError } from "./relayResponse.ts"; | 20 | import type { RelayRequestError } from "./relayResponse.ts"; |
| 21 | import { traceRelayRequest } from "./traceRelayRequest.ts"; | ||
| 22 | 21 | ||
| 23 | const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { | 22 | const 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"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Result from "effect/Result"; | 4 | import * as Result from "effect/Result"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { hostedAppUrlConfig, makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts"; |
| 7 | hostedAppUrlConfig, | ||
| 8 | makeCloudCliOAuthConfig, | ||
| 9 | makeRelayUrlConfig, | ||
| 10 | resolveRelayClientTracingConfig, | ||
| 11 | } from "./publicConfig.ts"; | ||
| 12 | 7 | ||
| 13 | const provideEnv = (env: Readonly<Record<string, string>>) => | 8 | const 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 | |||
| 148 | it("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"; |
| 14 | declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; | 14 | declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; |
| 15 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; | 15 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; |
| 16 | declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined; | 16 | declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined; |
| 17 | declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined; | ||
| 18 | declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined; | ||
| 19 | declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; | ||
| 20 | 17 | ||
| 21 | const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338; | 18 | const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338; |
| 22 | const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; | 19 | const 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 | } |
| 42 | 39 | ||
| 43 | function 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 | |||
| 52 | const buildTimeRelayUrl = | 40 | const 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 | ? undefined | 51 | ? undefined |
| 64 | : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, | 52 | : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, |
| 65 | ); | 53 | ); |
| 66 | const 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 | |||
| 84 | export 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 | |||
| 98 | export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { | 54 | export 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 @@ | ||
| 1 | import * as RelayTracing from "@t3tools/shared/relayTracing"; | ||
| 2 | |||
| 3 | import { resolveRelayClientTracingConfig } from "./publicConfig.ts"; | ||
| 4 | |||
| 5 | const relayClientTracingConfig = resolveRelayClientTracingConfig(); | ||
| 6 | |||
| 7 | export const layerHeadlessRelayClient = RelayTracing.layer(relayClientTracingConfig, { | ||
| 8 | serviceName: "t3code-server", | ||
| 9 | runtime: "node", | ||
| 10 | client: "headless-cli", | ||
| 11 | }); | ||
| 12 | |||
| 13 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Tracer from "effect/Tracer"; | ||
| 5 | import { HttpServerRequest } from "effect/http"; | ||
| 6 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | traceAuthenticatedRelayRequest, | ||
| 10 | traceLocalHandlerWork, | ||
| 11 | traceRelayRequest, | ||
| 12 | } from "./traceRelayRequest.ts"; | ||
| 13 | |||
| 14 | const AUTHENTICATION = { startTime: 1_000n, endTime: 2_000n }; | ||
| 15 | |||
| 16 | describe("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 | |||
| 87 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | RelayClientTracer, | ||
| 3 | withLocalTracing, | ||
| 4 | withRelayClientTracing, | ||
| 5 | } from "@t3tools/shared/relayTracing"; | ||
| 6 | import * as Clock from "effect/Clock"; | ||
| 7 | import * as Context from "effect/Context"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Exit from "effect/Exit"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 11 | import * as Tracer from "effect/Tracer"; | ||
| 12 | import { 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 | */ | ||
| 19 | export 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 | */ | ||
| 34 | export 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 | */ | ||
| 77 | export 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 |
| 57 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 57 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 58 | import * as SharedAccounts from "./provider/SharedAccounts.ts"; | 58 | import * as SharedAccounts from "./provider/SharedAccounts.ts"; |
| 59 | import * as SharedProfiles from "./provider/SharedProfiles.ts"; | 59 | import * as SharedProfiles from "./provider/SharedProfiles.ts"; |
| 60 | import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; | ||
| 61 | import { | 60 | import { |
| 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( |
| 143 | 143 | ||
| 144 | const layer = it.layer(layerTest); | 144 | const layer = it.layer(layerTest); |
| 145 | 145 | ||
| 146 | it.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 | |||
| 246 | layer("ProviderEventIngestorV2", (it) => { | 146 | layer("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"; |
| 27 | import * as Schema from "effect/Schema"; | 24 | import * as Schema from "effect/Schema"; |
| 28 | 25 | ||
| 29 | import { AgentScope } from "@t3tools/shared/AgentScope"; | 26 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 30 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; | 27 | import { modelSelectionsEqual } from "@t3tools/shared/model"; |
| 31 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; | ||
| 32 | import * as EventSink from "./EventSink.ts"; | 28 | import * as EventSink from "./EventSink.ts"; |
| 33 | import * as ProjectionStore from "./ProjectionStore.ts"; | 29 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 34 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 30 | import * 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 | ]); |
| 72 | export type ProviderEventIngestorV2Error = typeof ProviderEventIngestorV2Error.Type; | 68 | export type ProviderEventIngestorV2Error = typeof ProviderEventIngestorV2Error.Type; |
| 73 | 69 | ||
| 74 | export interface ProviderTurnAnalyticsContext { | ||
| 75 | readonly modelSelection: ModelSelection; | ||
| 76 | readonly runtimeMode?: RuntimeMode; | ||
| 77 | readonly interactionMode?: ProviderInteractionMode; | ||
| 78 | } | ||
| 79 | |||
| 80 | export 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 | |||
| 86 | export 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 | |||
| 97 | function 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 | |||
| 139 | type TodoListPlan = Extract<OrchestrationV2PlanArtifact, { readonly kind: "todo_list" }>; | 70 | type TodoListPlan = Extract<OrchestrationV2PlanArtifact, { readonly kind: "todo_list" }>; |
| 140 | 71 | ||
| 141 | function withPlanStepDurations( | 72 | function 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 | } |
| 265 | 195 | ||
| 266 | export interface ProviderEventIngestorV2Shape { | 196 | export 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>(); | ||
| 323 | 251 | ||
| 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"; |
| 19 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 19 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 20 | import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; | ||
| 21 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; | 20 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; |
| 22 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; | 21 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; |
| 23 | import { | 22 | import { |
| ... | @@ -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"; |
| 15 | import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; | ||
| 16 | import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; | 15 | import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; |
| 17 | import * as ProjectService from "./ProjectService.ts"; | 16 | import * as ProjectService from "./ProjectService.ts"; |
| 18 | import { projectMutationOperation } from "./ProjectMutation.ts"; | 17 | import { 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* startup | 58 | 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"; |
| 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 18 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 18 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 19 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; | 19 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; |
| 20 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 21 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; | ||
| 22 | import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; | 20 | import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; |
| 23 | 21 | ||
| 24 | const assertSameCallback = (actual: string | null, expected: string | null) => { | 22 | const 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"); |
| 33 | const makeHarnessFor = Effect.fnUntraced(function* ( | 31 | const 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 | ); |
| 1841 | 1804 | ||
| 1842 | it.effect("records one anonymous auth outcome per success, failure, or cancellation", () => | 1805 | it.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 | |||
| 1902 | it.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 | |||
| 1914 | it.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 | |||
| 1958 | it.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 | |||
| 1990 | it.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 | |||
| 2008 | it.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 | |||
| 2039 | it.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"; |
| 11 | import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; | 11 | import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; |
| 12 | import * as Clock from "effect/Clock"; | 12 | import * as Clock from "effect/Clock"; |
| 13 | import * as Cause from "effect/Cause"; | ||
| 14 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; | ||
| 15 | import * as Crypto from "effect/Crypto"; | ||
| 16 | import * as Exit from "effect/Exit"; | 13 | import * as Exit from "effect/Exit"; |
| 17 | import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; | 14 | import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; |
| 18 | import * as Effect from "effect/Effect"; | 15 | import * 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"; |
| 25 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; | 22 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; |
| 26 | import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; | 23 | import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; |
| 27 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 24 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 28 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 29 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 25 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 30 | 26 | ||
| 31 | const isSetupError = Schema.is(ProviderSetupError); | ||
| 32 | const RESOURCE = "https://api.openai.com/v1"; | 27 | const RESOURCE = "https://api.openai.com/v1"; |
| 33 | const REQUIRED_SCOPE = "chatgpt.tokens.use.direct"; | 28 | const REQUIRED_SCOPE = "chatgpt.tokens.use.direct"; |
| 34 | const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration"; | 29 | const 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"; |
| 3 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; | 3 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 4 | 4 | ||
| 5 | import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; | 5 | import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; |
| 6 | import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; | ||
| 7 | import * as PullRequestService from "./PullRequestService.ts"; | 6 | import * as PullRequestService from "./PullRequestService.ts"; |
| 8 | 7 | ||
| 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 { |
| 14 | import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; | 14 | import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; |
| 15 | import { turnItemUpdateCanEndBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | 15 | import { turnItemUpdateCanEndBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 16 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | 16 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 17 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 18 | import { | 17 | import { |
| 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")); |
| 525 | 524 | ||
| 526 | // Per-thread shell read: this publish runs for every activity-relevant | 525 | // Per-thread shell read: this publish runs for every activity-relevant |
| 527 | // domain event, so materializing the full shell here would make the cost | 526 | // 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"; |
| 7 | import type * as HttpServerRequest from "effect/http/HttpServerRequest"; | 7 | import type * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 8 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; | 8 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 9 | import * as HttpTraceContext from "effect/http/HttpTraceContext"; | 9 | import * as HttpTraceContext from "effect/http/HttpTraceContext"; |
| 10 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 11 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; | 10 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 12 | 11 | ||
| 13 | import * as Metrics from "../observability/Metrics.ts"; | 12 | import * 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 |
| 67 | import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts"; | 67 | import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts"; |
| 68 | import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; | 68 | import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; |
| 69 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 69 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 70 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | ||
| 71 | import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts"; | ||
| 72 | import * as ModelManifest from "./provider/ModelManifest.ts"; | 70 | import * as ModelManifest from "./provider/ModelManifest.ts"; |
| 73 | import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; | 71 | import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; |
| 74 | import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts"; | 72 | import * 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"; |
| 162 | import * as CloudHttp from "./cloud/http.ts"; | 160 | import * as CloudHttp from "./cloud/http.ts"; |
| 163 | import * as CloudLink from "./cloud/CloudLink.ts"; | 161 | import * as CloudLink from "./cloud/CloudLink.ts"; |
| 164 | import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts"; | 162 | import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts"; |
| 165 | import * as RelayTracing from "./cloud/relayTracing.ts"; | ||
| 166 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; | 163 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; |
| 167 | import { | 164 | import { |
| 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( |
| 510 | 507 | ||
| 511 | const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( | 508 | const 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"; |
| 30 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 30 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 31 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; | 31 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 32 | import * as ServerSettings from "./serverSettings.ts"; | 32 | import * as ServerSettings from "./serverSettings.ts"; |
| 33 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | ||
| 34 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 33 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 35 | 34 | ||
| 36 | it.effect("parks automatic pull until activation without delaying command readiness", () => | 35 | it.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"; |
| 44 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 44 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 45 | import * as ServerSettings from "./serverSettings.ts"; | 45 | import * as ServerSettings from "./serverSettings.ts"; |
| 46 | import { forkParked, forkParkedFiber } from "./serverActivation.ts"; | 46 | import { forkParked, forkParkedFiber } from "./serverActivation.ts"; |
| 47 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | ||
| 48 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | 47 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 49 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 48 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 50 | import { | 49 | import { |
| ... | @@ -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 | }); |
| 145 | 144 | ||
| 146 | const 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 | |||
| 177 | export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ | 145 | export 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) => |
| 581 | 549 | ||
| 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) => |
| 696 | 657 | ||
| 697 | export const layerWithOptions = (options?: StartupOptions) => | 658 | export const layerWithOptions = (options?: StartupOptions) => |
| 698 | Layer.effect(ServerRuntimeStartup, make(options)); | 659 | Layer.effect(ServerRuntimeStartup, make(options)); |
| 699 | |||
| 700 | const layer = layerWithOptions(); |
apps/server/src/telemetry/AnalyticsService.test.ts deleted-277| ... | @@ -1,277 +0,0 @@ | ||
| 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { assert, it } from "@effect/vitest"; | ||
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as TestClock from "effect/testing/TestClock"; | ||
| 9 | import * as HttpClient from "effect/http/HttpClient"; | ||
| 10 | import * as HttpClientError from "effect/http/HttpClientError"; | ||
| 11 | import * as HttpServer from "effect/http/HttpServer"; | ||
| 12 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; | ||
| 13 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; | ||
| 14 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 15 | |||
| 16 | import * as ServerConfig from "../config.ts"; | ||
| 17 | import { getTelemetryIdentifier } from "./Identify.ts"; | ||
| 18 | import * as AnalyticsService from "./AnalyticsService.ts"; | ||
| 19 | |||
| 20 | interface 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 | |||
| 38 | interface 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 | |||
| 53 | const 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 | */ | ||
| 64 | const 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 | |||
| 82 | it("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 | |||
| 89 | it.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 @@ | ||
| 1 | import { 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 | */ | ||
| 12 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 13 | import type { ClientOs } from "@t3tools/contracts"; | ||
| 14 | import * as Clock from "effect/Clock"; | ||
| 15 | import * as Config from "effect/Config"; | ||
| 16 | import * as Context from "effect/Context"; | ||
| 17 | import * as Crypto from "effect/Crypto"; | ||
| 18 | import * as DateTime from "effect/DateTime"; | ||
| 19 | import * as Effect from "effect/Effect"; | ||
| 20 | import * as Exit from "effect/Exit"; | ||
| 21 | import * as Layer from "effect/Layer"; | ||
| 22 | import * as Option from "effect/Option"; | ||
| 23 | import * as Random from "effect/Random"; | ||
| 24 | import * as Ref from "effect/Ref"; | ||
| 25 | import * as Result from "effect/Result"; | ||
| 26 | import * as Semaphore from "effect/Semaphore"; | ||
| 27 | import * as HttpClient from "effect/http/HttpClient"; | ||
| 28 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; | ||
| 29 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | ||
| 30 | |||
| 31 | import * as ServerConfig from "../config.ts"; | ||
| 32 | import { getTelemetryIdentifier } from "./Identify.ts"; | ||
| 33 | |||
| 34 | interface BufferedAnalyticsEvent { | ||
| 35 | readonly uuid: string; | ||
| 36 | readonly event: string; | ||
| 37 | readonly properties?: Readonly<Record<string, unknown>>; | ||
| 38 | readonly capturedAt: string; | ||
| 39 | } | ||
| 40 | |||
| 41 | interface 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 | |||
| 52 | const FLUSH_INTERVAL_MS = 1_000; | ||
| 53 | // A hung send would hold the flush lock, and with it the shutdown flush. | ||
| 54 | const SEND_TIMEOUT = "10 seconds"; | ||
| 55 | const MAX_BATCH_ATTEMPTS = 5; | ||
| 56 | const RETRY_BASE_DELAY_MS = 2_000; | ||
| 57 | const 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 | */ | ||
| 64 | export 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 | |||
| 69 | const 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 | |||
| 84 | export 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 | |||
| 107 | function 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. */ | ||
| 123 | export 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 | |||
| 288 | export const layer = Layer.effect(AnalyticsService, make); | ||
apps/server/src/telemetry/Identify.test.ts deleted-253| ... | @@ -1,253 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as Crypto from "effect/Crypto"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Hex from "effect/encoding/Hex"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Logger from "effect/Logger"; | ||
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import * as PlatformError from "effect/PlatformError"; | ||
| 11 | import * as References from "effect/References"; | ||
| 12 | |||
| 13 | import * as ServerConfig from "../config.ts"; | ||
| 14 | import * as Identify from "./Identify.ts"; | ||
| 15 | |||
| 16 | interface CapturedLog { | ||
| 17 | readonly message: unknown; | ||
| 18 | readonly annotations: Readonly<Record<string, unknown>>; | ||
| 19 | } | ||
| 20 | |||
| 21 | const 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 | |||
| 28 | const makeCaptureLogger = (logs: CapturedLog[]) => | ||
| 29 | Logger.make(({ fiber, message }) => { | ||
| 30 | logs.push({ | ||
| 31 | message, | ||
| 32 | annotations: fiber.getRef(References.CurrentLogAnnotations), | ||
| 33 | }); | ||
| 34 | }); | ||
| 35 | |||
| 36 | const 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 | |||
| 42 | it.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 @@ | ||
| 1 | import * as Crypto from "effect/Crypto"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Hex from "effect/encoding/Hex"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import * as PlatformError from "effect/PlatformError"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | |||
| 10 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | ||
| 11 | import * as ServerConfig from "../config.ts"; | ||
| 12 | import * 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 | */ | ||
| 19 | const CodexAuthJsonSchema = Schema.Struct({ | ||
| 20 | tokens: Schema.optionalKey( | ||
| 21 | Schema.Struct({ | ||
| 22 | account_id: Schema.String, | ||
| 23 | }), | ||
| 24 | ), | ||
| 25 | }); | ||
| 26 | |||
| 27 | const ClaudeJsonSchema = Schema.Struct({ | ||
| 28 | userID: Schema.String, | ||
| 29 | }); | ||
| 30 | |||
| 31 | export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); | ||
| 32 | export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; | ||
| 33 | |||
| 34 | class 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 | |||
| 47 | class 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 | |||
| 60 | export 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 | |||
| 73 | export 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 | |||
| 86 | export 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 | |||
| 99 | type TelemetryIdentityError = | ||
| 100 | | TelemetryIdentityReadError | ||
| 101 | | TelemetryIdentityDecodeError | ||
| 102 | | TelemetryAnonymousIdGenerationError | ||
| 103 | | TelemetryAnonymousIdPersistenceError | ||
| 104 | | TelemetryIdentityHashError; | ||
| 105 | |||
| 106 | const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)); | ||
| 107 | const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)); | ||
| 108 | |||
| 109 | function isNotFoundError(error: PlatformError.PlatformError): boolean { | ||
| 110 | return error.reason._tag === "NotFound"; | ||
| 111 | } | ||
| 112 | |||
| 113 | const 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 | |||
| 126 | const 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 | |||
| 137 | const 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 | |||
| 158 | const 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 | |||
| 172 | const 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 | |||
| 199 | const 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 | |||
| 224 | const 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 | */ | ||
| 267 | export 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 | |||
| 314 | export 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"; |
| 224 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 219 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 225 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | 220 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 226 | import * as HostResources from "./resourceTelemetry/HostResources.ts"; | 221 | import * as HostResources from "./resourceTelemetry/HostResources.ts"; |
| 227 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | ||
| 228 | import * as UsageService from "./usage/UsageService.ts"; | 222 | import * as UsageService from "./usage/UsageService.ts"; |
| 229 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 223 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 230 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; | 224 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; |
| ... | @@ -602,13 +596,7 @@ function toAuthAccessStreamEvent( | ... | @@ -602,13 +596,7 @@ function toAuthAccessStreamEvent( |
| 602 | } | 596 | } |
| 603 | 597 | ||
| 604 | const isClientSurface = Schema.is(ClientSurface); | 598 | const isClientSurface = Schema.is(ClientSurface); |
| 605 | const isClientConnectionMethod = Schema.is(ClientConnectionMethod); | ||
| 606 | const isClientDeviceType = Schema.is(ClientDeviceType); | ||
| 607 | const isClientOs = Schema.is(ClientOs); | ||
| 608 | const isClientWebDeployment = Schema.is(ClientWebDeployment); | ||
| 609 | const MAX_CLIENT_APP_VERSION_LENGTH = 64; | 599 | const MAX_CLIENT_APP_VERSION_LENGTH = 64; |
| 610 | const MAX_CLIENT_BROWSER_LENGTH = 64; | ||
| 611 | const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; | ||
| 612 | 600 | ||
| 613 | export function hasCompatibleOrchestrationProtocol(url: URL): boolean { | 601 | export 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 | } |
| 645 | 633 | ||
| 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. | ||
| 648 | function 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 | |||
| 692 | const canReplayPersistedRange = Effect.fnUntraced(function* ( | 634 | const 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 |
| 1200 | const layerWsRpc = ( | 1142 | const 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 = ( |
| 1224 | 1165 | ||
| 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 sees | 3124 | // 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 | } |
| 26 | 21 | ||
| 27 | export function trimNonEmpty(value: string | undefined): string | null { | 22 | export function trimNonEmpty(value: string | undefined): string | null { |
| 28 | return value?.trim() || null; | 23 | return value?.trim() || null; |
| 29 | } | 24 | } |
| 30 | 25 | ||
| 31 | function 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 | |||
| 40 | export function resolveCloudPublicConfig(): CloudPublicConfig { | 26 | export 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 | } |
| 60 | 37 | ||
| 61 | export 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 | |||
| 72 | export function hasCloudPublicConfig(): boolean { | 38 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { browserClientOs, browserDeviceType, clientPresentationMetadata } from "./clientMetadata"; |
| 4 | browserClientOs, | ||
| 5 | browserDeviceType, | ||
| 6 | browserFamily, | ||
| 7 | clientPresentationMetadata, | ||
| 8 | } from "./clientMetadata"; | ||
| 9 | 4 | ||
| 10 | const desktopChrome = { | 5 | const 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 | }; |
| 16 | 11 | ||
| 17 | describe("client telemetry metadata", () => { | 12 | describe("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 | }); |
| 34 | 26 | ||
| 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 | }); |
| 44 | 35 | ||
| 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", () => { |
| 64 | 55 | ||
| 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 | } |
| 46 | 46 | ||
| 47 | export 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 | |||
| 58 | export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadataDeviceType { | 47 | export 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 |
| 71 | 60 | ||
| 72 | export function clientPresentationMetadata(input: { | 61 | export 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({ |
| 170 | function clientMetadata() { | 170 | function 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"; |
| 4 | import * as Socket from "effect/socket/Socket"; | 4 | import * as Socket from "effect/socket/Socket"; |
| 5 | 5 | ||
| 6 | import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; | 6 | import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; |
| 7 | import * as RelayTracing from "@t3tools/shared/relayTracing"; | ||
| 8 | import * as PrimaryEnvironmentHttpClient from "../environments/primary/httpClient"; | 7 | import * as PrimaryEnvironmentHttpClient from "../environments/primary/httpClient"; |
| 9 | import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; | 8 | import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; |
| 10 | 9 | ||
| 11 | import * as Dpop from "../cloud/dpop"; | 10 | import * as Dpop from "../cloud/dpop"; |
| 12 | import * as ManagedRelayLayer from "../cloud/managedRelayLayer"; | 11 | import * as ManagedRelayLayer from "../cloud/managedRelayLayer"; |
| 13 | import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; | 12 | import { resolveCloudPublicConfig } from "../cloud/publicConfig"; |
| 14 | import * as ClientTracer from "../observability/clientTracer"; | 13 | import * as ClientTracer from "../observability/clientTracer"; |
| 15 | 14 | ||
| 16 | function configuredRelayUrl(): string { | 15 | function configuredRelayUrl(): string { |
| ... | @@ -18,18 +17,10 @@ function configuredRelayUrl(): string { | ... | @@ -18,18 +17,10 @@ function configuredRelayUrl(): string { |
| 18 | } | 17 | } |
| 19 | 18 | ||
| 20 | const layerHttpClient = layerRemoteHttpClient((input, init) => globalThis.fetch(input, init)); | 19 | const layerHttpClient = layerRemoteHttpClient((input, init) => globalThis.fetch(input, init)); |
| 21 | const 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 | |||
| 28 | type RuntimeLayerSource = | 20 | type RuntimeLayerSource = |
| 29 | | typeof layerHttpClient | 21 | | typeof layerHttpClient |
| 30 | | typeof Dpop.layer | 22 | | typeof Dpop.layer |
| 31 | | typeof Socket.layerWebSocketConstructorGlobal | 23 | | typeof Socket.layerWebSocketConstructorGlobal |
| 32 | | typeof layerRelayTracing | ||
| 33 | | typeof ClientTracer.layer | 24 | | typeof ClientTracer.layer |
| 34 | | ReturnType<typeof ManagedRelayLayer.layer>; | 25 | | ReturnType<typeof ManagedRelayLayer.layer>; |
| 35 | 26 | ||
| ... | @@ -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 | } |
| 18 | 15 |
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() || ""; |
| 39 | const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; | 39 | const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; |
| 40 | const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; | 40 | const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; |
| 41 | const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || ""; | 41 | const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || ""; |
| 42 | const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || ""; | ||
| 43 | const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || ""; | ||
| 44 | const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; | ||
| 45 | const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; | 42 | const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; |
| 46 | const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; | 43 | const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; |
| 47 | const configuredHostedAppUrl = (() => { | 44 | const 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 | |||
| 3 | The server owns PostHog delivery, opt-out, and identity for every connected client. | ||
| 4 | [Identity selection](../../apps/server/src/telemetry/Identify.ts) hashes an available | ||
| 5 | provider account ID, falling back to an installation-scoped ID. This identity can | ||
| 6 | span several clients; it does not identify a browser session. Clients do not load | ||
| 7 | the PostHog browser SDK. Client-use events require an authenticated connection, so | ||
| 8 | visiting the hosted app without connecting does not count as product use. | ||
| 9 | |||
| 10 | ## Attribution boundaries | ||
| 11 | |||
| 12 | Client dimensions belong to the event's WebSocket connection. A server-global | ||
| 13 | "current client" would misattribute simultaneous web, desktop, and mobile use. | ||
| 14 | Provider execution has its own events because a turn can outlive the requesting | ||
| 15 | connection. | ||
| 16 | |||
| 17 | Keep client and server dimensions separate. A desktop host can serve a phone or a | ||
| 18 | remote browser, and a direct connection can cross a network. Older clients omit | ||
| 19 | metadata. Missing client values must stay unknown rather than being backfilled | ||
| 20 | from server properties. The legacy `clientType` property describes how the server | ||
| 21 | runs; use `surface` for the connected client. | ||
| 22 | |||
| 23 | ## Interpreting events | ||
| 24 | |||
| 25 | Use `client.turn.requested` for active-use reports. `client.connected` counts | ||
| 26 | reconnects, so network behavior can inflate it. One identity can appear in several | ||
| 27 | client groups during a period; adding those groups double-counts users. | ||
| 28 | |||
| 29 | Provider send and completion counts need not match. Providers can emit synthetic | ||
| 30 | turns without a send request. Collection is best effort, with no scan or backfill | ||
| 31 | of provider history. | ||
| 32 | |||
| 33 | Token totals cover the main agent. Child agents and model rerouting prevent a turn | ||
| 34 | from representing one provider/model combination's full cost. For provider | ||
| 35 | comparisons, require complete usage, no observed subagents, and no mixed models; | ||
| 36 | compare matching model, effort, interaction mode, and terminal status. Aggregate | ||
| 37 | output/input ratios should divide the summed totals. Averaging per-turn ratios | ||
| 38 | lets small-input turns dominate. | ||
| 39 | |||
| 40 | Unknown counts stay absent. Partial usage contains valid observed counts but | ||
| 41 | cannot establish a whole-turn total. Keep these distinctions when changing token | ||
| 42 | normalization or building reports. | ||
| 43 | |||
| 44 | ## Delivery | ||
| 45 | |||
| 46 | A 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 | ||
| 48 | uuid when it is recorded, backs off after a failed send, and drops a batch after a | ||
| 49 | few tries. Without these limits, one stuck batch was sent every second for days. | ||
| 50 | |||
| 51 | ## Collection boundary | ||
| 52 | |||
| 53 | Keep analytics payloads to product metadata and normalized measurements. Do not | ||
| 54 | send prompts, authentication material, raw provider payloads, user-assigned device | ||
| 55 | names, or conversation identifiers. Client metadata is best effort; invalid values | ||
| 56 | must 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: |
| 418 | 418 | ||
| 419 | `ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run | 419 | `ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run |
| 420 | started from a delivery is its own `ScheduledTaskService.runWebhookDelivery` trace. For a request | 420 | started from a delivery is its own `ScheduledTaskService.runWebhookDelivery` trace. For a request |
| 421 | the relay forwarded, the span also goes to the T3 Connect trace export as a child of the relay's | 421 | the relay forwarded, the local span retains the relay's parent span for correlation; The Knife |
| 422 | span; requests that reach the environment directly never join a sender's trace. | 422 | does not export it to T3 Connect. Requests that reach the environment directly never join a |
| 423 | sender's trace. | ||
| 423 | 424 | ||
| 424 | Use metrics when the question is: | 425 | Use metrics when the question is: |
| 425 | 426 |
docs/operations/relay-observability.md+6-5| ... | @@ -2,7 +2,9 @@ | ... | @@ -2,7 +2,9 @@ |
| 2 | 2 | ||
| 3 | > For maintainers. Using T3 Code? See [docs/user](../user/). | 3 | > For maintainers. Using T3 Code? See [docs/user](../user/). |
| 4 | 4 | ||
| 5 | The relay Alchemy stack owns a shared Axiom trace setup: | 5 | The relay Alchemy stack owns a shared Axiom trace setup. The Knife clients and environment |
| 6 | servers do not export to it; local diagnostics use the configured local or user-owned receiver. | ||
| 7 | The resources below belong to the upstream relay deployment: | ||
| 6 | 8 | ||
| 7 | - `t3-code-relay-traces-prod`, the OpenTelemetry trace dataset shared by the Worker, mobile app, and | 9 | - `t3-code-relay-traces-prod`, the OpenTelemetry trace dataset shared by the Worker, mobile app, and |
| 8 | first-party relay clients | 10 | first-party relay clients |
| ... | @@ -21,8 +23,7 @@ vp run deploy | ... | @@ -21,8 +23,7 @@ vp run deploy |
| 21 | ``` | 23 | ``` |
| 22 | 24 | ||
| 23 | Alchemy resolves account-level Axiom deployment credentials through its provider. At runtime, the | 25 | Alchemy resolves account-level Axiom deployment credentials through its provider. At runtime, the |
| 24 | Worker receives only its scoped ingest token. Mobile and relay clients use their own separately | 26 | Worker receives only its scoped ingest token. The Knife does not consume the provisioned mobile or relay-client ingest tokens. |
| 25 | provisioned scoped ingest tokens. | ||
| 26 | 27 | ||
| 27 | The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans. | 28 | The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans. |
| 28 | Effect's OpenTelemetry exporter stores semantic HTTP attributes below the `attributes.` prefix. | 29 | Effect'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 its | 71 | `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 a | 72 | `relay.inbox.deliver` span. The relay sends its own `traceparent` with each forward and drops any a |
| 72 | sender supplied, and signs each forward (`x-t3-relay-delivery`) so the environment trusts the | 73 | sender supplied, and signs each forward (`x-t3-relay-delivery`) so the environment trusts the |
| 73 | relay's delivery id, receive time and trace context only from the relay, so on environments that export to T3 Connect, the environment's | 74 | relay'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 which | 75 | its `ScheduledTaskService.triggerWebhook` span locally. `relay.hook.rate_limit` says which |
| 75 | budget ran out: `endpoint` or `hook`. `relay.hook.rate_limiter_failed_open` is set when the | 76 | budget ran out: `endpoint` or `hook`. `relay.hook.rate_limiter_failed_open` is set when the |
| 76 | Cloudflare rate limiter was unavailable and the request went through unlimited. | 77 | Cloudflare rate limiter was unavailable and the request went through unlimited. |
| 77 | 78 |
docs/user/telemetry.md+4-13| ... | @@ -1,17 +1,8 @@ | ... | @@ -1,17 +1,8 @@ |
| 1 | # Product usage data | 1 | # Diagnostics and data sharing |
| 2 | 2 | ||
| 3 | The T3 Code server sends product usage events to PostHog, associated with a hashed account or | 3 | The Knife does not collect or send product analytics. T3 Connect contacts its account and relay |
| 4 | installation identifier. Events include the provider, model, reasoning effort, permission mode, | 4 | services only when you use that connection option. Local diagnostics stay on your machine unless |
| 5 | turn result, duration, and main-agent token totals when available. | 5 | you configure an export endpoint. |
| 6 | |||
| 7 | Events do not include prompts, responses, file contents, authentication tokens, conversation IDs, | ||
| 8 | raw provider events, or child-agent output. Child-agent token use is excluded from the totals. | ||
| 9 | |||
| 10 | To disable collection, set `T3CODE_TELEMETRY_ENABLED=false` in the server's environment before | ||
| 11 | starting it. This stops product events from being recorded or sent. | ||
| 12 | |||
| 13 | The desktop app reads the variable from your shell profile (for example `~/.zshrc`) on macOS and | ||
| 14 | Linux, so export it there and restart the app. On Windows, set it as a user environment variable. | ||
| 15 | 6 | ||
| 16 | ## Export diagnostics to your own receiver | 7 | ## Export diagnostics to your own receiver |
| 17 | 8 |
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 | ); |
| 310 | 310 | ||
| ... | @@ -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)); |
| 540 | 537 | ||
| 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"; |
| 9 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | 8 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| ... | @@ -42,7 +41,6 @@ const clientMetadataTokenExchangeFields = ( | ... | @@ -42,7 +41,6 @@ const clientMetadataTokenExchangeFields = ( |
| 42 | export const appendClientConnectionParams = ( | 41 | export 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 | }; |
| 85 | 52 | ||
| 86 | export const exchangeRemoteDpopAccessToken = Effect.fn( | 53 | export 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( |
| 216 | 182 | ||
| 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 | }); |
| 222 | 188 | ||
| ... | @@ -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"; |
| 6 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; | 6 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; |
| 7 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 8 | import { | 7 | import { |
| 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"; |
| 6 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; | 8 | import * 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 | ); |
| 385 | 383 | ||
| 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", () => { |
| 409 | 406 | ||
| 410 | yield* broker | 407 | 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 | ); | ||
| 416 | 410 | ||
| 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 | ); |
| 423 | 416 |
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"; |
| 5 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 6 | import * as Context from "effect/Context"; | 5 | import * as Context from "effect/Context"; |
| 7 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; | 7 | import * 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 | } |
| 83 | 82 | ||
| ... | @@ -224,23 +223,19 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") | ... | @@ -224,23 +223,19 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") |
| 224 | const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { | 223 | const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { |
| 225 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | 224 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 226 | 225 | ||
| 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 | }); |
| 245 | 240 | ||
| 246 | const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () { | 241 | const 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 @@ |
| 1 | import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts"; | 1 | import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts"; |
| 2 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as Deferred from "effect/Deferred"; | 3 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| ... | @@ -295,7 +294,7 @@ describe("retryDelayMs", () => { | ... | @@ -295,7 +294,7 @@ describe("retryDelayMs", () => { |
| 295 | }); | 294 | }); |
| 296 | 295 | ||
| 297 | describe("EnvironmentSupervisor", () => { | 296 | describe("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 | ); | ||
| 322 | 318 | ||
| 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 @@ |
| 1 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 2 | import * as Cause from "effect/Cause"; | 1 | import * as Cause from "effect/Cause"; |
| 3 | import * as Clock from "effect/Clock"; | 2 | import * as Clock from "effect/Clock"; |
| 4 | import * as Context from "effect/Context"; | 3 | import * 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 | }; |
| 489 | 488 | ||
| 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"; |
| 32 | import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; | 32 | import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; |
| 33 | import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; | 33 | import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; |
| 34 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 35 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | 34 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; |
| 36 | import * as Clock from "effect/Clock"; | 35 | import * as Clock from "effect/Clock"; |
| 37 | import * as Context from "effect/Context"; | 36 | import * 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* ( |
| 725 | 724 | ||
| 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.client | 729 | .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"; |
| 15 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | ||
| 16 | import * as Deferred from "effect/Deferred"; | 15 | import * as Deferred from "effect/Deferred"; |
| 17 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 18 | import * as Fiber from "effect/Fiber"; | 17 | import * as Fiber from "effect/Fiber"; |
| 19 | import * as Layer from "effect/Layer"; | 18 | import * as Layer from "effect/Layer"; |
| 20 | import * as Option from "effect/Option"; | 19 | import * as Option from "effect/Option"; |
| 21 | import * as Schema from "effect/Schema"; | 20 | import * as Schema from "effect/Schema"; |
| 22 | import * as Tracer from "effect/Tracer"; | ||
| 23 | import { TestClock } from "effect/testing"; | 21 | import { TestClock } from "effect/testing"; |
| 24 | import type { HttpClient } from "effect/http"; | 22 | import type { HttpClient } from "effect/http"; |
| 25 | 23 | ||
| ... | @@ -764,36 +762,6 @@ describe("authenticated environment HTTP requests", () => { | ... | @@ -764,36 +762,6 @@ describe("authenticated environment HTTP requests", () => { |
| 764 | ); | 762 | ); |
| 765 | }); | 763 | }); |
| 766 | 764 | ||
| 767 | describe("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 | |||
| 797 | describe("orchestration HTTP compatibility header", () => { | 765 | describe("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"; |
| 7 | import * as Result from "effect/Result"; | 7 | import * as Result from "effect/Result"; |
| 8 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | ||
| 9 | import { FetchHttpClient, type HttpMethod } from "effect/http"; | 8 | import { FetchHttpClient, type HttpMethod } from "effect/http"; |
| 10 | 9 | ||
| 11 | import { HostedAuthAccess } from "../platform/capabilities.ts"; | 10 | import { 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-bound | 106 | * 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 | */ |
| 114 | export const executeAuthenticatedEnvironmentHttpRequest = < | 109 | export 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)); | ||
| 128 | 117 | ||
| 129 | const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest"; | 118 | const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest"; |
| 130 | 119 |
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"; |
| 11 | 10 | ||
| ... | @@ -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 | }); |
| 307 | export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type; | 302 | export 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 | ]); |
| 358 | export type ClientOs = typeof ClientOs.Type; | 358 | export type ClientOs = typeof ClientOs.Type; |
| 359 | 359 | ||
| 360 | export const ClientDeviceType = Schema.Literals(["desktop", "phone", "tablet", "unknown"]); | ||
| 361 | export type ClientDeviceType = typeof ClientDeviceType.Type; | ||
| 362 | |||
| 363 | export const ClientWebDeployment = Schema.Literals(["hosted", "server"]); | ||
| 364 | export type ClientWebDeployment = typeof ClientWebDeployment.Type; | ||
| 365 | |||
| 366 | export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]); | 360 | export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]); |
| 367 | export type ClientConnectionMethod = typeof ClientConnectionMethod.Type; | 361 | export type ClientConnectionMethod = typeof ClientConnectionMethod.Type; |
| 368 | 362 |
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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Tracer from "effect/Tracer"; | ||
| 6 | import { FetchHttpClient } from "effect/http"; | ||
| 7 | import { vi } from "vite-plus/test"; | ||
| 8 | |||
| 9 | import { RelayClientTracer, withLocalTracing, withRelayClientTracing } from "./relayTracing.ts"; | ||
| 10 | import * as RelayTracing from "./relayTracing.ts"; | ||
| 11 | |||
| 12 | function 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 | |||
| 26 | describe("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 | |||
| 105 | describe("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 @@ | ||
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Tracer from "effect/Tracer"; | ||
| 8 | import type { HttpClient } from "effect/http"; | ||
| 9 | import { OtlpSerialization, OtlpTracer } from "effect/observability"; | ||
| 10 | |||
| 11 | export interface RelayClientTracingConfig { | ||
| 12 | readonly tracesUrl: string; | ||
| 13 | readonly tracesDataset: string; | ||
| 14 | readonly tracesToken: string; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface RelayClientTracingResource { | ||
| 18 | readonly serviceName: string; | ||
| 19 | readonly serviceVersion?: string; | ||
| 20 | readonly runtime: string; | ||
| 21 | readonly client: string; | ||
| 22 | readonly component?: string; | ||
| 23 | } | ||
| 24 | |||
| 25 | export 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 | */ | ||
| 36 | class 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 | */ | ||
| 45 | export 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 | */ | ||
| 70 | export 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 | |||
| 80 | function 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 | |||
| 89 | function 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 | |||
| 126 | function 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 | |||
| 145 | function 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 | |||
| 163 | export 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 | }); |
| 43 | 31 | ||
| 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 | } |
| 19 | 13 | ||
| 20 | type Environment = Readonly<Record<string, string | undefined>>; | 14 | type 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 | } |
| 105 | 63 | ||
| ... | @@ -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 | } |
| 158 | 86 |