| 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 | 996 | |
| 997 | 997 | const previousWslEnv = process.env.WSLENV; |
| 998 | 998 | const previousDisabled = process.env.OTEL_SDK_DISABLED; |
| 999 | const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; | |
| 1000 | 999 | try { |
| 1001 | 1000 | delete process.env.WSLENV; |
| 1002 | 1001 | process.env.OTEL_SDK_DISABLED = "true"; |
| 1003 | process.env.T3CODE_TELEMETRY_ENABLED = "false"; | |
| 1004 | 1002 | |
| 1005 | 1003 | yield* Effect.gen(function* () { |
| 1006 | 1004 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| ... | ... | @@ -1008,8 +1006,6 @@ describe("DesktopBackendConfiguration", () => { |
| 1008 | 1006 | |
| 1009 | 1007 | assert.equal(config.env.OTEL_SDK_DISABLED, "true"); |
| 1010 | 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 | 1009 | }).pipe( |
| 1014 | 1010 | Effect.provide( |
| 1015 | 1011 | DesktopBackendConfiguration.layer.pipe( |
| ... | ... | @@ -1030,7 +1026,6 @@ describe("DesktopBackendConfiguration", () => { |
| 1030 | 1026 | } finally { |
| 1031 | 1027 | restoreEnv("WSLENV", previousWslEnv); |
| 1032 | 1028 | restoreEnv("OTEL_SDK_DISABLED", previousDisabled); |
| 1033 | restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); | |
| 1034 | 1029 | } |
| 1035 | 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 | 102 | const WSL_FORWARDED_ENV_NAMES = [ |
| 103 | 103 | "OPENAI_API_KEY", |
| 104 | 104 | "ANTHROPIC_API_KEY", |
| 105 | "T3CODE_TELEMETRY_ENABLED", | |
| 106 | 105 | // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. |
| 107 | 106 | "T3CODE_OTEL_SDK_DISABLED", |
| 108 | 107 | "OTEL_SDK_DISABLED", |
apps/desktop/src/shell/DesktopShellEnvironment.test.ts-2| ... | ... | @@ -357,13 +357,11 @@ describe("DesktopShellEnvironment", () => { |
| 357 | 357 | envOutput({ |
| 358 | 358 | PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", |
| 359 | 359 | SSH_AUTH_SOCK: "/tmp/secretive.sock", |
| 360 | T3CODE_TELEMETRY_ENABLED: "false", | |
| 361 | 360 | }), |
| 362 | 361 | }); |
| 363 | 362 | |
| 364 | 363 | assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); |
| 365 | 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 | 89 | "SSL_CERT_FILE", |
| 90 | 90 | "SSL_CERT_DIR", |
| 91 | 91 | "NODE_EXTRA_CA_CERTS", |
| 92 | "T3CODE_TELEMETRY_ENABLED", | |
| 93 | 92 | "__MISE_DIFF", |
| 94 | 93 | ] as const; |
| 95 | 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 | 462 | "SSL_CERT_FILE", |
| 464 | 463 | "SSL_CERT_DIR", |
| 465 | 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 | 465 | ] as const) { |
| 469 | 466 | if (!config.env[name] && shellEnvironment[name]) { |
| 470 | 467 | config.env[name] = shellEnvironment[name]; |
apps/mobile/app.config.ts-5| ... | ... | @@ -485,11 +485,6 @@ const config: ExpoConfig = { |
| 485 | 485 | EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID, |
| 486 | 486 | EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID, |
| 487 | 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 | 488 | eas: { |
| 494 | 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 | 229 | } |
| 230 | 230 | |
| 231 | 231 | return ( |
| 232 | <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}> | |
| 232 | <ClerkProvider telemetry={false} publishableKey={publishableKey} tokenCache={tokenCache}> | |
| 233 | 233 | <CloudAuthAvailableContext.Provider value> |
| 234 | 234 | <CloudAuthBridge>{props.children}</CloudAuthBridge> |
| 235 | 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 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | CloudPublicConfigMissingError, |
| 5 | hasTracingPublicConfig, | |
| 6 | 5 | resolveCloudPublicConfig, |
| 7 | 6 | resolveRelayClerkTokenOptions, |
| 8 | 7 | } from "./publicConfig"; |
| ... | ... | @@ -31,11 +30,6 @@ describe("resolveCloudPublicConfig", () => { |
| 31 | 30 | relay: { |
| 32 | 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 | 38 | resolveCloudPublicConfig({ |
| 45 | 39 | clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " }, |
| 46 | 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 | 42 | ).toEqual({ |
| 54 | 43 | clerk: { |
| ... | ... | @@ -58,11 +47,6 @@ describe("resolveCloudPublicConfig", () => { |
| 58 | 47 | relay: { |
| 59 | 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 | 64 | relay: { |
| 81 | 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 | 22 | readonly relay: { |
| 23 | 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 | 27 | type UntrustedSection<T> = { |
| ... | ... | @@ -43,19 +38,6 @@ function trimNonEmpty(value: unknown): string | null { |
| 43 | 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 | 41 | export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { |
| 60 | 42 | return { |
| 61 | 43 | clerk: { |
| ... | ... | @@ -65,11 +47,6 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig |
| 65 | 47 | relay: { |
| 66 | 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 | 50 | } satisfies CloudPublicConfig; |
| 74 | 51 | } |
| 75 | 52 | |
| ... | ... | @@ -78,24 +55,6 @@ export function hasCloudPublicConfig(): boolean { |
| 78 | 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 | 58 | export function resolveRelayClerkTokenOptions() { |
| 100 | 59 | const { jwtTemplate } = resolveCloudPublicConfig().clerk; |
| 101 | 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 | 3 | import { Platform } from "react-native"; |
| 4 | 4 | |
| 5 | 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 | 6 | return { |
| 10 | 7 | label: "The Knife Mobile", |
| 11 | 8 | deviceType: |
| ... | ... | @@ -15,8 +12,6 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM |
| 15 | 12 | ? "mobile" |
| 16 | 13 | : "unknown", |
| 17 | 14 | ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), |
| 18 | ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}), | |
| 19 | ...(deviceModel ? { deviceModel } : {}), | |
| 20 | 15 | surface: "mobile", |
| 21 | 16 | ...(appVersion ? { appVersion } : {}), |
| 22 | 17 | }; |
apps/mobile/src/lib/connection.test.ts+1-13| ... | ... | @@ -14,8 +14,6 @@ const mobileDevice = vi.hoisted(() => ({ |
| 14 | 14 | DESKTOP: 3, |
| 15 | 15 | TV: 4, |
| 16 | 16 | }, |
| 17 | osVersion: "18.4.1", | |
| 18 | modelName: "iPhone 15 Pro", | |
| 19 | 17 | })); |
| 20 | 18 | |
| 21 | 19 | vi.mock("./runtime", () => ({ |
| ... | ... | @@ -34,8 +32,6 @@ describe("mobile remote connection records", () => { |
| 34 | 32 | afterEach(() => { |
| 35 | 33 | mobilePlatform.OS = "ios"; |
| 36 | 34 | mobileDevice.deviceType = mobileDevice.DeviceType.PHONE; |
| 37 | mobileDevice.osVersion = "18.4.1"; | |
| 38 | mobileDevice.modelName = "iPhone 15 Pro"; | |
| 39 | 35 | }); |
| 40 | 36 | |
| 41 | 37 | it("identifies mobile token exchanges for authorized-client presentation", () => { |
| ... | ... | @@ -43,32 +39,24 @@ describe("mobile remote connection records", () => { |
| 43 | 39 | label: "The Knife Mobile", |
| 44 | 40 | deviceType: "mobile", |
| 45 | 41 | os: "iOS", |
| 46 | osMajorVersion: 18, | |
| 47 | deviceModel: "iPhone 15 Pro", | |
| 48 | 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 | 47 | mobilePlatform.OS = "android"; |
| 54 | mobileDevice.osVersion = "15.2.1"; | |
| 55 | mobileDevice.modelName = "Pixel 9"; | |
| 56 | 48 | |
| 57 | 49 | expect(authClientMetadata()).toMatchObject({ |
| 58 | 50 | os: "Android", |
| 59 | osMajorVersion: 15, | |
| 60 | deviceModel: "Pixel 9", | |
| 61 | 51 | }); |
| 62 | 52 | }); |
| 63 | 53 | |
| 64 | 54 | it("identifies native tablets separately from phones", () => { |
| 65 | 55 | mobileDevice.deviceType = mobileDevice.DeviceType.TABLET; |
| 66 | mobileDevice.modelName = "iPad Pro 13-inch"; | |
| 67 | 56 | |
| 68 | 57 | expect(authClientMetadata()).toMatchObject({ |
| 69 | 58 | deviceType: "tablet", |
| 70 | 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 | import * as Dpop from "../features/cloud/dpop"; |
| 8 | 8 | import * as ManagedRelayLayer from "../features/cloud/managedRelayLayer"; |
| 9 | 9 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; |
| 10 | import * as Tracing from "../features/observability/tracing"; | |
| 11 | 10 | import * as Persistence from "../persistence/layer"; |
| 12 | 11 | import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh"; |
| 13 | 12 | |
| ... | ... | @@ -24,8 +23,7 @@ type RuntimeLayerSource = |
| 24 | 23 | | typeof Socket.layerWebSocketConstructorGlobal |
| 25 | 24 | | typeof Dpop.layer |
| 26 | 25 | | typeof layerHttpClient |
| 27 | | typeof Persistence.layer | |
| 28 | | typeof Tracing.layer; | |
| 26 | | typeof Persistence.layer; | |
| 29 | 27 | |
| 30 | 28 | const layerRuntime = Layer.merge( |
| 31 | 29 | ManagedRelayLayer.layer(configuredRelayUrl()), |
| ... | ... | @@ -33,7 +31,6 @@ const layerRuntime = Layer.merge( |
| 33 | 31 | ).pipe( |
| 34 | 32 | Layer.provideMerge(Dpop.layer), |
| 35 | 33 | Layer.provideMerge(layerHttpClient), |
| 36 | Layer.provideMerge(Tracing.layer.pipe(Layer.provide(layerHttpClient))), | |
| 37 | 34 | Layer.provideMerge(Persistence.layer), |
| 38 | 35 | ); |
| 39 | 36 |
apps/server/src/auth/http.test.ts+2-14| ... | ... | @@ -8,13 +8,11 @@ import { |
| 8 | 8 | EnvironmentAuthenticatedAuth, |
| 9 | 9 | EnvironmentHttpApi, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | |
| 12 | 11 | import { expect, it } from "@effect/vitest"; |
| 13 | 12 | import * as Context from "effect/Context"; |
| 14 | 13 | import * as Crypto from "effect/Crypto"; |
| 15 | 14 | import * as Effect from "effect/Effect"; |
| 16 | 15 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; | |
| 18 | 16 | import * as Redacted from "effect/Redacted"; |
| 19 | 17 | import * as Schema from "effect/Schema"; |
| 20 | 18 | import * as Tracer from "effect/Tracer"; |
| ... | ... | @@ -165,9 +163,8 @@ it.effect("sets the selected browser session cookies through the HTTP route", () |
| 165 | 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 | 167 | Effect.gen(function* () { |
| 170 | const productSpans: Array<string> = []; | |
| 171 | 168 | const localSpans: Array<string> = []; |
| 172 | 169 | const collect = (into: Array<string>) => |
| 173 | 170 | Tracer.make({ |
| ... | ... | @@ -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 | 216 | Effect.withTracer(collect(localSpans)), |
| 221 | 217 | ); |
| 222 | 218 | |
| 223 | 219 | yield* handle("DPoP connect"); |
| 224 | expect(productSpans).toEqual([ | |
| 225 | "environment.relay.request", | |
| 226 | "EnvironmentAuth.authenticateHttpRequest", | |
| 227 | "environment.handler", | |
| 228 | ]); | |
| 229 | expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]); | |
| 220 | expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]); | |
| 230 | 221 | |
| 231 | productSpans.length = 0; | |
| 232 | 222 | localSpans.length = 0; |
| 233 | 223 | yield* handle("DPoP forged"); |
| 234 | expect(productSpans).toEqual([]); | |
| 235 | 224 | expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]); |
| 236 | 225 | |
| 237 | 226 | localSpans.length = 0; |
| 238 | 227 | yield* handle("Bearer access-token"); |
| 239 | expect(productSpans).toEqual([]); | |
| 240 | 228 | expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]); |
| 241 | 229 | }).pipe(Effect.scoped), |
| 242 | 230 | ); |
apps/server/src/auth/http.ts+1-10| ... | ... | @@ -21,11 +21,9 @@ import { |
| 21 | 21 | import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; |
| 22 | 22 | import { parseOAuthScope } from "@t3tools/shared/oauthScope"; |
| 23 | 23 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 24 | import * as Clock from "effect/Clock"; | |
| 25 | 24 | import * as DateTime from "effect/DateTime"; |
| 26 | 25 | import * as Effect from "effect/Effect"; |
| 27 | 26 | import * as Schema from "effect/Schema"; |
| 28 | import { identity } from "effect/Function"; | |
| 29 | 27 | import * as Layer from "effect/Layer"; |
| 30 | 28 | import * as Cookies from "effect/http/Cookies"; |
| 31 | 29 | import * as HttpEffect from "effect/http/HttpEffect"; |
| ... | ... | @@ -34,7 +32,6 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 34 | 32 | |
| 35 | 33 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 36 | 34 | import * as SessionStore from "./SessionStore.ts"; |
| 37 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; | |
| 38 | 35 | import { deriveAuthClientMetadata } from "./utils.ts"; |
| 39 | 36 | import { verifyRequestDpopProof } from "./dpop.ts"; |
| 40 | 37 | |
| ... | ... | @@ -233,7 +230,6 @@ export const layerAuthenticatedAuth = Layer.effect( |
| 233 | 230 | return (httpEffect) => |
| 234 | 231 | Effect.gen(function* () { |
| 235 | 232 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 236 | const startTime = yield* Clock.currentTimeNanos; | |
| 237 | 233 | const session = yield* serverAuth.authenticateHttpRequest(request).pipe( |
| 238 | 234 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 239 | 235 | failEnvironmentAuthInvalid( |
| ... | ... | @@ -245,16 +241,12 @@ export const layerAuthenticatedAuth = Layer.effect( |
| 245 | 241 | failEnvironmentInternal("internal_error", error), |
| 246 | 242 | ), |
| 247 | 243 | ); |
| 248 | const endTime = yield* Clock.currentTimeNanos; | |
| 249 | const handler = httpEffect.pipe( | |
| 244 | return yield* httpEffect.pipe( | |
| 250 | 245 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { |
| 251 | 246 | ...session, |
| 252 | 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 | 250 | }).pipe(Effect.catchTags({ EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized })); |
| 259 | 251 | }), |
| 260 | 252 | ); |
| ... | ... | @@ -397,7 +389,6 @@ export const layer = HttpApiBuilder.group( |
| 397 | 389 | proofKeyThumbprint ? { proofKeyThumbprint } : undefined, |
| 398 | 390 | ); |
| 399 | 391 | }, |
| 400 | traceRelayRequest, | |
| 401 | 392 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 402 | 393 | failEnvironmentAuthInvalid( |
| 403 | 394 | EnvironmentAuth.serverAuthCredentialReason(error), |
apps/server/src/cli/connect.ts-4| ... | ... | @@ -7,7 +7,6 @@ import { |
| 7 | 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; |
| 8 | 8 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 9 | 9 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 10 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | |
| 11 | 10 | import * as Cause from "effect/Cause"; |
| 12 | 11 | import * as Config from "effect/Config"; |
| 13 | 12 | import * as Console from "effect/Console"; |
| ... | ... | @@ -38,7 +37,6 @@ import { |
| 38 | 37 | RELAY_URL_SECRET, |
| 39 | 38 | } from "../cloud/config.ts"; |
| 40 | 39 | import { relayUrlConfig } from "../cloud/publicConfig.ts"; |
| 41 | import * as RelayTracing from "../cloud/relayTracing.ts"; | |
| 42 | 40 | import * as ServerConfig from "../config.ts"; |
| 43 | 41 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 44 | 42 | import * as ExternalLauncher from "../process/externalLauncher.ts"; |
| ... | ... | @@ -346,7 +344,6 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f |
| 346 | 344 | httpClient.execute, |
| 347 | 345 | Effect.flatMap(filterRelayResponse), |
| 348 | 346 | Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), |
| 349 | withRelayClientTracing, | |
| 350 | 347 | ); |
| 351 | 348 | return response.ok |
| 352 | 349 | ? ({ status: "revoked" } satisfies RelayUnlinkResult) |
| ... | ... | @@ -448,7 +445,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E |
| 448 | 445 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), |
| 449 | 446 | EnvironmentAuth.layerRuntime, |
| 450 | 447 | CliService.layer(config), |
| 451 | RelayTracing.layerHeadlessRelayClient, | |
| 452 | 448 | ).pipe( |
| 453 | 449 | Layer.provideMerge(FetchHttpClient.layer), |
| 454 | 450 | Layer.provideMerge(ServerConfig.layer(config)), |
apps/server/src/cloud/CloudLink.ts-3| ... | ... | @@ -34,7 +34,6 @@ import { |
| 34 | 34 | type RelayManagedEndpointRuntimeConfig, |
| 35 | 35 | RelayOkResponse, |
| 36 | 36 | } from "@t3tools/contracts/relay"; |
| 37 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | |
| 38 | 37 | import { |
| 39 | 38 | normalizeRelayIssuer, |
| 40 | 39 | RELAY_HEALTH_REQUEST_TYP, |
| ... | ... | @@ -885,7 +884,6 @@ const make = Effect.gen(function* () { |
| 885 | 884 | Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), |
| 886 | 885 | Effect.timeout(input.timeout ?? "10 seconds"), |
| 887 | 886 | Effect.mapError(relayRequestError), |
| 888 | withRelayClientTracing, | |
| 889 | 887 | ); |
| 890 | 888 | |
| 891 | 889 | const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")( |
| ... | ... | @@ -1333,7 +1331,6 @@ const make = Effect.gen(function* () { |
| 1333 | 1331 | httpClient.execute, |
| 1334 | 1332 | Effect.flatMap(filterRelayResponse), |
| 1335 | 1333 | Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), |
| 1336 | withRelayClientTracing, | |
| 1337 | 1334 | ); |
| 1338 | 1335 | // ok:false means the relay skipped deletion because a concurrent provision |
| 1339 | 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 | 18 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 19 | 19 | import * as CloudLink from "./CloudLink.ts"; |
| 20 | 20 | import type { RelayRequestError } from "./relayResponse.ts"; |
| 21 | import { traceRelayRequest } from "./traceRelayRequest.ts"; | |
| 22 | 21 | |
| 23 | 22 | const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { |
| 24 | 23 | "cache-control": "no-store", |
| ... | ... | @@ -151,10 +150,8 @@ export const layer = HttpApiBuilder.group( |
| 151 | 150 | ), |
| 152 | 151 | ) |
| 153 | 152 | .handle("t3MintCredential", ({ payload }) => |
| 154 | traceRelayRequest( | |
| 155 | toHttpError(cloudLink.mintCredential(payload)).pipe( | |
| 156 | Effect.tap(() => appendCloudCredentialResponseHeaders), | |
| 157 | ), | |
| 153 | toHttpError(cloudLink.mintCredential(payload)).pipe( | |
| 154 | Effect.tap(() => appendCloudCredentialResponseHeaders), | |
| 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 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Result from "effect/Result"; |
| 5 | 5 | |
| 6 | import { | |
| 7 | hostedAppUrlConfig, | |
| 8 | makeCloudCliOAuthConfig, | |
| 9 | makeRelayUrlConfig, | |
| 10 | resolveRelayClientTracingConfig, | |
| 11 | } from "./publicConfig.ts"; | |
| 6 | import { hostedAppUrlConfig, makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts"; | |
| 12 | 7 | |
| 13 | 8 | const provideEnv = (env: Readonly<Record<string, string>>) => |
| 14 | 9 | Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))); |
| ... | ... | @@ -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 | 14 | declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; |
| 15 | 15 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; |
| 16 | 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 | 18 | const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338; |
| 22 | 19 | const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; |
| ... | ... | @@ -40,15 +37,6 @@ function readBuildTimeValue(value: string | undefined): string { |
| 40 | 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 | 40 | const buildTimeRelayUrl = |
| 53 | 41 | typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" |
| 54 | 42 | ? "" |
| ... | ... | @@ -63,38 +51,6 @@ const buildTimeClerkCliOAuthClientId = readBuildTimeValue( |
| 63 | 51 | ? undefined |
| 64 | 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 | 54 | export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { |
| 99 | 55 | const runtimeConfig = Config.NonEmptyString("T3CODE_RELAY_URL"); |
| 100 | 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 | 48 | readonly providerLogsDir: string; |
| 49 | 49 | readonly providerEventLogPath: string; |
| 50 | 50 | readonly terminalLogsDir: string; |
| 51 | readonly anonymousIdPath: string; | |
| 52 | 51 | readonly environmentIdPath: string; |
| 53 | 52 | readonly serverRuntimeStatePath: string; |
| 54 | 53 | readonly secretsDir: string; |
| ... | ... | @@ -163,7 +162,6 @@ export const deriveServerPaths = Effect.fn(function* ( |
| 163 | 162 | providerLogsDir, |
| 164 | 163 | providerEventLogPath: join(providerLogsDir, "events.log"), |
| 165 | 164 | terminalLogsDir: join(logsDir, "terminals"), |
| 166 | anonymousIdPath: join(stateDir, "anonymous-id"), | |
| 167 | 165 | environmentIdPath: join(stateDir, "environment-id"), |
| 168 | 166 | serverRuntimeStatePath: join(stateDir, "server-runtime.json"), |
| 169 | 167 | secretsDir: join(stateDir, "secrets"), |
| ... | ... | @@ -185,7 +183,6 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server |
| 185 | 183 | fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), |
| 186 | 184 | fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), |
| 187 | 185 | fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), |
| 188 | fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }), | |
| 189 | 186 | fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }), |
| 190 | 187 | ], |
| 191 | 188 | { concurrency: "unbounded" }, |
apps/server/src/http.ts+1-2| ... | ... | @@ -57,7 +57,6 @@ import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts |
| 57 | 57 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 58 | 58 | import * as SharedAccounts from "./provider/SharedAccounts.ts"; |
| 59 | 59 | import * as SharedProfiles from "./provider/SharedProfiles.ts"; |
| 60 | import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; | |
| 61 | 60 | import { |
| 62 | 61 | annotateEnvironmentRequest, |
| 63 | 62 | failEnvironmentScopeRequired, |
| ... | ... | @@ -534,7 +533,7 @@ export const layerServerEnvironmentHttpApi = HttpApiBuilder.group( |
| 534 | 533 | Effect.fn("environment.metadata.descriptor")(function* (args) { |
| 535 | 534 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 536 | 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 | 173 | providerLogsDir, |
| 174 | 174 | providerEventLogPath: path.join(providerLogsDir, "events.log"), |
| 175 | 175 | terminalLogsDir, |
| 176 | anonymousIdPath: path.join(stateDir, "anonymous-id"), | |
| 177 | 176 | environmentIdPath: path.join(stateDir, "environment-id"), |
| 178 | 177 | serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"), |
| 179 | 178 | secretsDir: path.join(stateDir, "secrets"), |
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts-100| ... | ... | @@ -143,106 +143,6 @@ function threadCreatedEvent( |
| 143 | 143 | |
| 144 | 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 | 146 | layer("ProviderEventIngestorV2", (it) => { |
| 247 | 147 | it.effect("normalizes provider events through the real event log and projection store", () => |
| 248 | 148 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+2-103| ... | ... | @@ -5,12 +5,9 @@ import { |
| 5 | 5 | OrchestrationV2StoredEvent, |
| 6 | 6 | type OrchestrationV2PlanArtifact, |
| 7 | 7 | type OrchestrationV2Run, |
| 8 | type OrchestrationV2ProviderTurn, | |
| 8 | type ModelSelection, | |
| 9 | 9 | type OrchestrationV2Subagent, |
| 10 | 10 | type OrchestrationV2ThreadProjection, |
| 11 | type ModelSelection, | |
| 12 | type RuntimeMode, | |
| 13 | type ProviderInteractionMode, | |
| 14 | 11 | ProviderInstanceId, |
| 15 | 12 | ProviderSessionId, |
| 16 | 13 | ProviderThreadId, |
| ... | ... | @@ -27,8 +24,7 @@ import * as Layer from "effect/Layer"; |
| 27 | 24 | import * as Schema from "effect/Schema"; |
| 28 | 25 | |
| 29 | 26 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 30 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; | |
| 31 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; | |
| 27 | import { modelSelectionsEqual } from "@t3tools/shared/model"; | |
| 32 | 28 | import * as EventSink from "./EventSink.ts"; |
| 33 | 29 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 34 | 30 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| ... | ... | @@ -71,71 +67,6 @@ export const ProviderEventIngestorV2Error = Schema.Union([ |
| 71 | 67 | ]); |
| 72 | 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 | 70 | type TodoListPlan = Extract<OrchestrationV2PlanArtifact, { readonly kind: "todo_list" }>; |
| 140 | 71 | |
| 141 | 72 | function withPlanStepDurations( |
| ... | ... | @@ -260,7 +191,6 @@ export interface ProviderEventIngestInput { |
| 260 | 191 | readonly nodeId?: NodeId; |
| 261 | 192 | readonly rawEventId?: RawEventId; |
| 262 | 193 | readonly event: ProviderAdapter.ProviderAdapterV2Event; |
| 263 | readonly analyticsContext?: ProviderTurnAnalyticsContext; | |
| 264 | 194 | } |
| 265 | 195 | |
| 266 | 196 | export interface ProviderEventIngestorV2Shape { |
| ... | ... | @@ -317,9 +247,7 @@ export const layer: Layer.Layer< |
| 317 | 247 | const projections = yield* ProjectionStore.ProjectionStoreV2; |
| 318 | 248 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 319 | 249 | const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; |
| 320 | const analytics = yield* ProviderTurnAnalytics; | |
| 321 | 250 | const agentScope = yield* AgentScope; |
| 322 | const completedTurnAnalytics = new Set<string>(); | |
| 323 | 251 | |
| 324 | 252 | const makeDomainEvent = ( |
| 325 | 253 | input: ProviderEventIngestInput, |
| ... | ... | @@ -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 | 726 | (effect) => |
| 828 | 727 | input.event.type === "app_thread.created" |
| 829 | 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 | 1246 | event.type === "provider_thread.updated" && |
| 1247 | 1247 | event.providerThread.id === input.providerThread.id; |
| 1248 | 1248 | const storedEvents = yield* providerEventIngestor.ingestNormalized({ |
| 1249 | analyticsContext: { | |
| 1250 | modelSelection: input.modelSelection, | |
| 1251 | runtimeMode: input.runtimePolicy.runtimeMode, | |
| 1252 | interactionMode: input.runtimePolicy.interactionMode, | |
| 1253 | }, | |
| 1254 | 1249 | providerSessionId: input.providerSessionId, |
| 1255 | 1250 | providerInstanceId: input.run.providerInstanceId, |
| 1256 | 1251 | threadId: input.run.threadId, |
apps/server/src/orchestration-v2/http.ts+2-8| ... | ... | @@ -17,7 +17,6 @@ import { |
| 17 | 17 | requireEnvironmentScope, |
| 18 | 18 | } from "../auth/http.ts"; |
| 19 | 19 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 20 | import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; | |
| 21 | 20 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; |
| 22 | 21 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; |
| 23 | 22 | import { |
| ... | ... | @@ -162,7 +161,6 @@ export const layer = HttpApiBuilder.group( |
| 162 | 161 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 163 | 162 | yield* requireEnvironmentScope(AuthOrchestrationReadScope); |
| 164 | 163 | return yield* loadShellSnapshot().pipe( |
| 165 | traceLocalHandlerWork, | |
| 166 | 164 | Effect.catch((cause) => |
| 167 | 165 | failEnvironmentInternal("orchestration_snapshot_failed", cause), |
| 168 | 166 | ), |
| ... | ... | @@ -177,7 +175,7 @@ export const layer = HttpApiBuilder.group( |
| 177 | 175 | const snapshot = yield* loadThreadSnapshot( |
| 178 | 176 | args.params.threadId, |
| 179 | 177 | "orchestration_thread_snapshot_failed", |
| 180 | ).pipe(traceLocalHandlerWork); | |
| 178 | ); | |
| 181 | 179 | return { |
| 182 | 180 | snapshotSequence: snapshot.snapshotSequence, |
| 183 | 181 | projection: snapshot.projection, |
| ... | ... | @@ -191,7 +189,6 @@ export const layer = HttpApiBuilder.group( |
| 191 | 189 | yield* requireEnvironmentScope(AuthOrchestrationReadScope); |
| 192 | 190 | return yield* threadManagement.getThreadTranscript(args.params.threadId).pipe( |
| 193 | 191 | Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment), |
| 194 | traceLocalHandlerWork, | |
| 195 | 192 | Effect.catch( |
| 196 | 193 | Effect.fnUntraced(function* (error) { |
| 197 | 194 | if (isThreadTranscriptTooLargeError(error)) { |
| ... | ... | @@ -214,9 +211,7 @@ export const layer = HttpApiBuilder.group( |
| 214 | 211 | Effect.fn("environment.orchestration.threadBoundedSnapshot")(function* (args) { |
| 215 | 212 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 216 | 213 | yield* requireEnvironmentScope(AuthOrchestrationReadScope); |
| 217 | const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId).pipe( | |
| 218 | traceLocalHandlerWork, | |
| 219 | ); | |
| 214 | const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId); | |
| 220 | 215 | const bounded = buildBoundedThreadProjection({ |
| 221 | 216 | projection: snapshot.projection, |
| 222 | 217 | snapshotSequence: snapshot.snapshotSequence, |
| ... | ... | @@ -250,7 +245,6 @@ export const layer = HttpApiBuilder.group( |
| 250 | 245 | args.query.view === "conversation", |
| 251 | 246 | ) |
| 252 | 247 | .pipe( |
| 253 | traceLocalHandlerWork, | |
| 254 | 248 | Effect.catch( |
| 255 | 249 | Effect.fnUntraced(function* (cause) { |
| 256 | 250 | if (isThreadNotFound(cause)) |
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts-1| ... | ... | @@ -146,7 +146,6 @@ export function makeReplayServerConfig( |
| 146 | 146 | providerLogsDir, |
| 147 | 147 | providerEventLogPath: path.join(providerLogsDir, "events.log"), |
| 148 | 148 | terminalLogsDir, |
| 149 | anonymousIdPath: path.join(stateDir, "anonymous-id"), | |
| 150 | 149 | environmentIdPath: path.join(stateDir, "environment-id"), |
| 151 | 150 | serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"), |
| 152 | 151 | secretsDir: path.join(stateDir, "secrets"), |
apps/server/src/project/http.ts+1-5| ... | ... | @@ -12,7 +12,6 @@ import { |
| 12 | 12 | failEnvironmentInvalidRequest, |
| 13 | 13 | requireEnvironmentScope, |
| 14 | 14 | } from "../auth/http.ts"; |
| 15 | import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; | |
| 16 | 15 | import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; |
| 17 | 16 | import * as ProjectService from "./ProjectService.ts"; |
| 18 | 17 | import { projectMutationOperation } from "./ProjectMutation.ts"; |
| ... | ... | @@ -44,7 +43,6 @@ export const layer = HttpApiBuilder.group( |
| 44 | 43 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 45 | 44 | yield* requireEnvironmentScope(AuthOrchestrationReadScope); |
| 46 | 45 | return yield* projects.snapshot.pipe( |
| 47 | traceLocalHandlerWork, | |
| 48 | 46 | Effect.catch((cause) => failEnvironmentInternal("project_snapshot_failed", cause)), |
| 49 | 47 | ); |
| 50 | 48 | }), |
| ... | ... | @@ -57,9 +55,7 @@ export const layer = HttpApiBuilder.group( |
| 57 | 55 | yield* requireEnvironmentScope(scope); |
| 58 | 56 | } |
| 59 | 57 | const operation = projectMutationOperation(projects, args.payload); |
| 60 | return yield* startup | |
| 61 | .enqueueCommand(operation) | |
| 62 | .pipe(traceLocalHandlerWork, Effect.catch(failProjectMutation)); | |
| 58 | return yield* startup.enqueueCommand(operation).pipe(Effect.catch(failProjectMutation)); | |
| 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 | 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 18 | 18 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 19 | 19 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; |
| 20 | import * as ServerSettings from "../serverSettings.ts"; | |
| 21 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; | |
| 22 | 20 | import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; |
| 23 | 21 | |
| 24 | 22 | const assertSameCallback = (actual: string | null, expected: string | null) => { |
| ... | ... | @@ -33,7 +31,6 @@ const instanceId = ProviderInstanceId.make("managed-codex-test"); |
| 33 | 31 | const makeHarnessFor = Effect.fnUntraced(function* ( |
| 34 | 32 | instanceId: ProviderInstanceId, |
| 35 | 33 | bytes: Map<string, Uint8Array> = new Map(), |
| 36 | failAnalytics = false, | |
| 37 | 34 | ) { |
| 38 | 35 | const environmentId = environmentIds.get(bytes) ?? EnvironmentId.make(NodeCrypto.randomUUID()); |
| 39 | 36 | environmentIds.set(bytes, environmentId); |
| ... | ... | @@ -208,25 +205,11 @@ const makeHarnessFor = Effect.fnUntraced(function* ( |
| 208 | 205 | const address = server.address(); |
| 209 | 206 | if (!address || typeof address === "string") throw new Error("mock address"); |
| 210 | 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 | 208 | const auth = yield* makeCodexChatGptAuth({ |
| 225 | 209 | instanceId, |
| 226 | 210 | discoveryUrl: `${origin}/discovery`, |
| 227 | 211 | resource: `${origin}/v1`, |
| 228 | 212 | }).pipe( |
| 229 | Effect.provideService(AnalyticsService.AnalyticsService, analytics), | |
| 230 | 213 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), |
| 231 | 214 | Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment), |
| 232 | 215 | ); |
| ... | ... | @@ -278,7 +261,6 @@ const makeHarnessFor = Effect.fnUntraced(function* ( |
| 278 | 261 | }); |
| 279 | 262 | return { |
| 280 | 263 | auth, |
| 281 | analyticsEvents, | |
| 282 | 264 | secrets, |
| 283 | 265 | environmentId, |
| 284 | 266 | bytes, |
| ... | ... | @@ -293,7 +275,7 @@ const makeHarnessFor = Effect.fnUntraced(function* ( |
| 293 | 275 | }, |
| 294 | 276 | "owner", |
| 295 | 277 | { discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1` }, |
| 296 | ).pipe(Stream.provideService(AnalyticsService.AnalyticsService, analytics)), | |
| 278 | ), | |
| 297 | 279 | finishCallback: (state: { authorizationUrl: string | null }) => |
| 298 | 280 | Effect.promise(() => fetch(prepareCallback(state))), |
| 299 | 281 | destination: Effect.gen(function* () { |
| ... | ... | @@ -315,7 +297,6 @@ const makeHarnessFor = Effect.fnUntraced(function* ( |
| 315 | 297 | discoveryUrl: `${origin}/discovery`, |
| 316 | 298 | resource: `${origin}/v1`, |
| 317 | 299 | }).pipe( |
| 318 | Effect.provideService(AnalyticsService.AnalyticsService, analytics), | |
| 319 | 300 | Effect.provideService(ServerSecretStore.ServerSecretStore, destinationStore), |
| 320 | 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 | 1681 | ); |
| 1701 | 1682 | assert.isTrue(stopped); |
| 1702 | 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 | 1684 | const saved = Option.getOrThrow(yield* destination.auth.read); |
| 1722 | 1685 | assert.strictEqual(saved.clientId, result.profile.registration.clientId); |
| 1723 | 1686 | assert.strictEqual(saved.refreshToken, "initial-refresh"); |
| ... | ... | @@ -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", () => | |
| 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", () => | |
| 1805 | it.effect("expires sign-in when it reaches its deadline", () => | |
| 2040 | 1806 | provision( |
| 2041 | 1807 | Effect.gen(function* () { |
| 2042 | 1808 | const h = yield* makeHarness; |
| 2043 | 1809 | yield* h.startRemote(); |
| 2044 | 1810 | yield* TestClock.adjust(300_001); |
| 2045 | 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 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; |
| 12 | 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 | 13 | import * as Exit from "effect/Exit"; |
| 17 | 14 | import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; |
| 18 | 15 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -25,10 +22,8 @@ import { HttpClient, HttpClientRequest } from "effect/http"; |
| 25 | 22 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; |
| 26 | 23 | import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; |
| 27 | 24 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 28 | import * as ServerSettings from "../serverSettings.ts"; | |
| 29 | 25 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 30 | 26 | |
| 31 | const isSetupError = Schema.is(ProviderSetupError); | |
| 32 | 27 | const RESOURCE = "https://api.openai.com/v1"; |
| 33 | 28 | const REQUIRED_SCOPE = "chatgpt.tokens.use.direct"; |
| 34 | 29 | const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration"; |
| ... | ... | @@ -105,53 +100,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* |
| 105 | 100 | readonly resource?: string; |
| 106 | 101 | readonly defaultReturnUrl?: string; |
| 107 | 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 | 104 | const http = yield* HttpClient.HttpClient; |
| 156 | 105 | const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId); |
| 157 | 106 | const registrationStore = yield* ProviderCredentialStore.make( |
| ... | ... | @@ -159,7 +108,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* |
| 159 | 108 | options.instanceId, |
| 160 | 109 | ); |
| 161 | 110 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 162 | const crypto = yield* Crypto.Crypto; | |
| 163 | 111 | const environmentLocks = sessionLocks.get(secrets) ?? new Map<string, Semaphore.Semaphore>(); |
| 164 | 112 | sessionLocks.set(secrets, environmentLocks); |
| 165 | 113 | const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1)); |
| ... | ... | @@ -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 | 215 | const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) { |
| 316 | 216 | const saved = yield* readRegistrations; |
| 317 | 217 | profile = { |
| ... | ... | @@ -861,28 +761,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* |
| 861 | 761 | })), |
| 862 | 762 | ]; |
| 863 | 763 | }), |
| 864 | authenticate: (method, context) => | |
| 865 | lock.withPermit( | |
| 866 | track( | |
| 867 | "auth", | |
| 868 | { | |
| 869 | flow: options.telemetryFlow ?? "direct", | |
| 870 | intent: | |
| 871 | options.telemetryFlow === "primary_handoff" | |
| 872 | ? options.reconnectProfile | |
| 873 | ? "saved_profile" | |
| 874 | : "different_account" | |
| 875 | : method === "chatgpt-change-account" | |
| 876 | ? "different_account" | |
| 877 | : method.startsWith("chatgpt-profile:") | |
| 878 | ? "saved_profile" | |
| 879 | : "default", | |
| 880 | callbackMode: context.callbackMode ?? "server", | |
| 881 | }, | |
| 882 | authenticate(method, context), | |
| 883 | context.expiresAt, | |
| 884 | ), | |
| 885 | ), | |
| 764 | authenticate: (method, context) => lock.withPermit(authenticate(method, context)), | |
| 886 | 765 | logout: lock.withPermit(withSessionLock(logout)), |
| 887 | 766 | }); |
| 888 | 767 | const reconnectProfile = Effect.fnUntraced(function* (methodId: string) { |
| ... | ... | @@ -971,12 +850,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* |
| 971 | 850 | yield* save(profile.credentials, true); |
| 972 | 851 | }); |
| 973 | 852 | return lock.withPermit( |
| 974 | track( | |
| 975 | "transfer", | |
| 976 | { flow: "primary_handoff" }, | |
| 977 | validate.pipe( | |
| 978 | Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)), | |
| 979 | ), | |
| 853 | validate.pipe( | |
| 854 | Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)), | |
| 980 | 855 | ), |
| 981 | 856 | ); |
| 982 | 857 | }; |
apps/server/src/provider/CodexChatGptHandoff.ts-1| ... | ... | @@ -35,7 +35,6 @@ export function subscribeChatGptHandoff( |
| 35 | 35 | ...endpoints, |
| 36 | 36 | instanceId: input.instanceId, |
| 37 | 37 | reconnectProfile: input.profile, |
| 38 | telemetryFlow: "primary_handoff", | |
| 39 | 38 | defaultReturnUrl: input.returnUrl, |
| 40 | 39 | }).pipe( |
| 41 | 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 | 3 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 4 | 4 | |
| 5 | 5 | import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; |
| 6 | import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; | |
| 7 | 6 | import * as PullRequestService from "./PullRequestService.ts"; |
| 8 | 7 | |
| 9 | 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 | 16 | Effect.fn("environment.pullRequests.diff")(function* (args) { |
| 18 | 17 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 19 | 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 | 14 | import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; |
| 15 | 15 | import { turnItemUpdateCanEndBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 16 | 16 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 17 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | |
| 18 | 17 | import { |
| 19 | 18 | normalizeRelayIssuer, |
| 20 | 19 | RELAY_ACTIVITY_PUBLISH_TYP, |
| ... | ... | @@ -521,7 +520,7 @@ export const make = Effect.gen(function* () { |
| 521 | 520 | ok: response.ok, |
| 522 | 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 | 525 | // Per-thread shell read: this publish runs for every activity-relevant |
| 527 | 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 | import type * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 8 | 8 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 9 | 9 | import * as HttpTraceContext from "effect/http/HttpTraceContext"; |
| 10 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | |
| 11 | 10 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 12 | 11 | |
| 13 | 12 | import * as Metrics from "../observability/Metrics.ts"; |
| ... | ... | @@ -100,8 +99,7 @@ export const layer = HttpApiBuilder.group( |
| 100 | 99 | }) |
| 101 | 100 | .pipe( |
| 102 | 101 | Option.isSome(relayParent) |
| 103 | ? (effect) => | |
| 104 | effect.pipe(Effect.withParentSpan(relayParent.value), withRelayClientTracing) | |
| 102 | ? (effect) => effect.pipe(Effect.withParentSpan(relayParent.value)) | |
| 105 | 103 | : (effect) => effect, |
| 106 | 104 | // Defects too, so the sender only ever sees the fixed error body. |
| 107 | 105 | Effect.catchCause((cause) => |
apps/server/src/server.ts-6| ... | ... | @@ -67,8 +67,6 @@ import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts |
| 67 | 67 | import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts"; |
| 68 | 68 | import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; |
| 69 | 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 | 70 | import * as ModelManifest from "./provider/ModelManifest.ts"; |
| 73 | 71 | import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; |
| 74 | 72 | import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts"; |
| ... | ... | @@ -162,7 +160,6 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 162 | 160 | import * as CloudHttp from "./cloud/http.ts"; |
| 163 | 161 | import * as CloudLink from "./cloud/CloudLink.ts"; |
| 164 | 162 | import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts"; |
| 165 | import * as RelayTracing from "./cloud/relayTracing.ts"; | |
| 166 | 163 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; |
| 167 | 164 | import { |
| 168 | 165 | MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER, |
| ... | ... | @@ -510,7 +507,6 @@ const layerScheduledTaskWebhookOrigin = Layer.effect( |
| 510 | 507 | |
| 511 | 508 | const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( |
| 512 | 509 | Layer.provide(layerScheduledTaskWebhookOrigin), |
| 513 | Layer.provide(ProviderEventIngestor.layerAnalytics), | |
| 514 | 510 | Layer.provide(layerCheckpointStore), |
| 515 | 511 | Layer.provide(layerGitWorkflow), |
| 516 | 512 | Layer.provide(ResourceCleanupService.layer), |
| ... | ... | @@ -769,7 +765,6 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( |
| 769 | 765 | Layer.provideMerge(layerBackground), |
| 770 | 766 | Layer.provideMerge(layerResourceDiagnostics), |
| 771 | 767 | Layer.provideMerge(TraceDiagnostics.layer), |
| 772 | Layer.provideMerge(AnalyticsService.layer), | |
| 773 | 768 | Layer.provideMerge(ExternalLauncher.layer), |
| 774 | 769 | Layer.provideMerge(RemoteOpenTargets.layer), |
| 775 | 770 | Layer.provideMerge(DirectEndpoints.layer), |
| ... | ... | @@ -1206,7 +1201,6 @@ const layerMakeServer = Layer.unwrap( |
| 1206 | 1201 | ), |
| 1207 | 1202 | ), |
| 1208 | 1203 | Layer.provide(layerActivation), |
| 1209 | Layer.provideMerge(RelayTracing.layerServerRelayBroker), | |
| 1210 | 1204 | Layer.provideMerge(layerHttpServer), |
| 1211 | 1205 | Layer.provide(layerApplicationObservability), |
| 1212 | 1206 | Layer.provideMerge(FetchHttpClient.layer), |
apps/server/src/serverRuntimeStartup.autoPull.test.ts-2| ... | ... | @@ -30,7 +30,6 @@ import { ServerActivation } from "./serverActivation.ts"; |
| 30 | 30 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 31 | 31 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 32 | 32 | import * as ServerSettings from "./serverSettings.ts"; |
| 33 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | |
| 34 | 33 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 35 | 34 | |
| 36 | 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 | 161 | Effect.onInterrupt(() => Deferred.succeed(statusInterrupted, undefined)), |
| 163 | 162 | ), |
| 164 | 163 | }), |
| 165 | Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void }), | |
| 166 | 164 | NodeCrypto.layer, |
| 167 | 165 | Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), |
| 168 | 166 | Layer.mock(ExternalLauncher.ExternalLauncher)({}), |
apps/server/src/serverRuntimeStartup.ts-41| ... | ... | @@ -44,7 +44,6 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; |
| 44 | 44 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 45 | 45 | import * as ServerSettings from "./serverSettings.ts"; |
| 46 | 46 | import { forkParked, forkParkedFiber } from "./serverActivation.ts"; |
| 47 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | |
| 48 | 47 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 49 | 48 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 50 | 49 | import { |
| ... | ... | @@ -143,37 +142,6 @@ export const makeCommandGate = Effect.gen(function* () { |
| 143 | 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 | 145 | export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ |
| 178 | 146 | instanceId: ProviderInstanceId.make("codex"), |
| 179 | 147 | model: DEFAULT_MODEL, |
| ... | ... | @@ -581,13 +549,6 @@ const make = (options?: StartupOptions) => |
| 581 | 549 | |
| 582 | 550 | yield* forkParked( |
| 583 | 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 | 552 | if (serverConfig.startupPresentation === "headless") { |
| 592 | 553 | yield* Effect.logDebug("startup phase: headless access info"); |
| 593 | 554 | const accessInfo = yield* issueHeadlessServeAccessInfo(); |
| ... | ... | @@ -696,5 +657,3 @@ const make = (options?: StartupOptions) => |
| 696 | 657 | |
| 697 | 658 | export const layerWithOptions = (options?: StartupOptions) => |
| 698 | 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 | 33 | type AuthEnvironmentScope, |
| 34 | 34 | type ScheduledTaskListResult, |
| 35 | 35 | AuthSessionId, |
| 36 | ClientConnectionMethod, | |
| 37 | ClientDeviceType, | |
| 38 | ClientOs, | |
| 39 | 36 | ClientSurface, |
| 40 | ClientWebDeployment, | |
| 41 | 37 | type DiscoveredLocalServerList, |
| 42 | 38 | type EditorId, |
| 43 | 39 | type FileManagerRevealKind, |
| 44 | 40 | type OrchestrationClientOrigin, |
| 45 | type OrchestrationV2Command, | |
| 46 | 41 | type GitActionProgressEvent, |
| 47 | 42 | type GitManagerServiceError, |
| 48 | 43 | type MessageId, |
| ... | ... | @@ -224,7 +219,6 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 224 | 219 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 225 | 220 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 226 | 221 | import * as HostResources from "./resourceTelemetry/HostResources.ts"; |
| 227 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | |
| 228 | 222 | import * as UsageService from "./usage/UsageService.ts"; |
| 229 | 223 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 230 | 224 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; |
| ... | ... | @@ -602,13 +596,7 @@ function toAuthAccessStreamEvent( |
| 602 | 596 | } |
| 603 | 597 | |
| 604 | 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 | 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 | 601 | export function hasCompatibleOrchestrationProtocol(url: URL): boolean { |
| 614 | 602 | return ( |
| ... | ... | @@ -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 | 634 | const canReplayPersistedRange = Effect.fnUntraced(function* ( |
| 693 | 635 | afterSequence: number, |
| 694 | 636 | headSequence: number, |
| ... | ... | @@ -1200,7 +1142,6 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc |
| 1200 | 1142 | const layerWsRpc = ( |
| 1201 | 1143 | currentSession: EnvironmentAuth.AuthenticatedSession, |
| 1202 | 1144 | clientOrigin: OrchestrationClientOrigin, |
| 1203 | clientAnalyticsProps: Readonly<Record<string, unknown>>, | |
| 1204 | 1145 | previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], |
| 1205 | 1146 | serverBrowser: ServerBrowser.ServerBrowser["Service"], |
| 1206 | 1147 | ) => |
| ... | ... | @@ -1224,19 +1165,6 @@ const layerWsRpc = ( |
| 1224 | 1165 | |
| 1225 | 1166 | const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2; |
| 1226 | 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 | 1168 | const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; |
| 1241 | 1169 | const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; |
| 1242 | 1170 | const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; |
| ... | ... | @@ -1869,7 +1797,6 @@ const layerWsRpc = ( |
| 1869 | 1797 | ).pipe(Effect.provide(intakeContext)), |
| 1870 | 1798 | ) |
| 1871 | 1799 | .pipe( |
| 1872 | Effect.tap(() => recordClientCommandAnalytics(command)), | |
| 1873 | 1800 | Effect.map((result) => ({ sequence: result.sequence })), |
| 1874 | 1801 | Effect.mapError((cause) => { |
| 1875 | 1802 | const detail = userFacingDispatchErrorMessage(cause); |
| ... | ... | @@ -2009,18 +1936,6 @@ const layerWsRpc = ( |
| 2009 | 1936 | }).pipe(Effect.provide(intakeContext)), |
| 2010 | 1937 | ) |
| 2011 | 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 | 1939 | Effect.map((result) => ({ |
| 2025 | 1940 | ...result, |
| 2026 | 1941 | projection: projectThreadProjectionForWire(result.projection), |
| ... | ... | @@ -3178,7 +3093,6 @@ export const layer = Layer.unwrap( |
| 3178 | 3093 | } |
| 3179 | 3094 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 3180 | 3095 | const sessions = yield* SessionStore.SessionStore; |
| 3181 | const analytics = yield* AnalyticsService.AnalyticsService; | |
| 3182 | 3096 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( |
| 3183 | 3097 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 3184 | 3098 | failEnvironmentAuthInvalid( |
| ... | ... | @@ -3191,9 +3105,7 @@ export const layer = Layer.unwrap( |
| 3191 | 3105 | ), |
| 3192 | 3106 | ); |
| 3193 | 3107 | const clientOrigin = readClientConnectionOrigin(request); |
| 3194 | const clientAnalyticsProps = readClientAnalyticsProps(request); | |
| 3195 | 3108 | yield* sessions.recordClientConnection(session.sessionId, clientOrigin); |
| 3196 | yield* analytics.record("client.connected", clientAnalyticsProps); | |
| 3197 | 3109 | const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { |
| 3198 | 3110 | const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; |
| 3199 | 3111 | yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe( |
| ... | ... | @@ -3207,13 +3119,7 @@ export const layer = Layer.unwrap( |
| 3207 | 3119 | return httpEffect; |
| 3208 | 3120 | }).pipe( |
| 3209 | 3121 | Effect.provide( |
| 3210 | layerWsRpc( | |
| 3211 | session, | |
| 3212 | clientOrigin, | |
| 3213 | clientAnalyticsProps, | |
| 3214 | previewAutomationBroker, | |
| 3215 | serverBrowser, | |
| 3216 | ).pipe( | |
| 3122 | layerWsRpc(session, clientOrigin, previewAutomationBroker, serverBrowser).pipe( | |
| 3217 | 3123 | Layer.provideMerge(RpcSerialization.layerJson), |
| 3218 | 3124 | // Request fibers run in the handlers' context, so this reporter sees |
| 3219 | 3125 | // their defects, not the rest of the server's. |
apps/server/vite.config.ts-9| ... | ... | @@ -119,15 +119,6 @@ export default mergeConfig( |
| 119 | 119 | __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify( |
| 120 | 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 | 124 | test: { |
apps/web/src/cloud/publicConfig.ts-34| ... | ... | @@ -17,26 +17,12 @@ export interface CloudPublicConfig { |
| 17 | 17 | readonly clerkPublishableKey: string | null; |
| 18 | 18 | readonly clerkJwtTemplate: string | null; |
| 19 | 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 | 22 | export function trimNonEmpty(value: string | undefined): string | null { |
| 28 | 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 | 26 | export function resolveCloudPublicConfig(): CloudPublicConfig { |
| 41 | 27 | return { |
| 42 | 28 | clerkPublishableKey: trimNonEmpty( |
| ... | ... | @@ -46,29 +32,9 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { |
| 46 | 32 | relayUrl: normalizeSecureRelayUrl( |
| 47 | 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 | 38 | export function hasCloudPublicConfig(): boolean { |
| 73 | 39 | const config = resolveCloudPublicConfig(); |
| 74 | 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 | 11 | readonly children: ReactNode; |
| 12 | 12 | }) { |
| 13 | 13 | return ( |
| 14 | <ClerkProvider appearance={clerkAppearance} publishableKey={publishableKey}> | |
| 14 | <ClerkProvider telemetry={false} appearance={clerkAppearance} publishableKey={publishableKey}> | |
| 15 | 15 | {children} |
| 16 | 16 | </ClerkProvider> |
| 17 | 17 | ); |
apps/web/src/components/clerk/ElectronManagedAuthShell.tsx+6-1| ... | ... | @@ -13,7 +13,12 @@ export default function ElectronManagedAuthShell({ |
| 13 | 13 | readonly children: ReactNode; |
| 14 | 14 | }) { |
| 15 | 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 | 22 | {children} |
| 18 | 23 | </ClerkProvider> |
| 19 | 24 | ); |
apps/web/src/connection/clientMetadata.test.ts+4-15| ... | ... | @@ -1,11 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { | |
| 4 | browserClientOs, | |
| 5 | browserDeviceType, | |
| 6 | browserFamily, | |
| 7 | clientPresentationMetadata, | |
| 8 | } from "./clientMetadata"; | |
| 3 | import { browserClientOs, browserDeviceType, clientPresentationMetadata } from "./clientMetadata"; | |
| 9 | 4 | |
| 10 | 5 | const desktopChrome = { |
| 11 | 6 | userAgent: |
| ... | ... | @@ -14,32 +9,28 @@ const desktopChrome = { |
| 14 | 9 | maxTouchPoints: 0, |
| 15 | 10 | }; |
| 16 | 11 | |
| 17 | describe("client telemetry metadata", () => { | |
| 18 | it("distinguishes hosted web from server-served web", () => { | |
| 12 | describe("client presentation metadata", () => { | |
| 13 | it("identifies web clients and omits the development app version", () => { | |
| 19 | 14 | expect( |
| 20 | 15 | clientPresentationMetadata({ |
| 21 | 16 | appVersion: "1.2.3", |
| 22 | hosted: true, | |
| 23 | 17 | identity: desktopChrome, |
| 24 | 18 | desktopBridge: undefined, |
| 25 | 19 | }), |
| 26 | 20 | ).toMatchObject({ |
| 27 | 21 | surface: "web", |
| 28 | webDeployment: "hosted", | |
| 29 | 22 | deviceType: "desktop", |
| 30 | 23 | os: "Windows", |
| 31 | browser: "Chrome", | |
| 32 | 24 | appVersion: "1.2.3", |
| 33 | 25 | }); |
| 34 | 26 | |
| 35 | 27 | expect( |
| 36 | 28 | clientPresentationMetadata({ |
| 37 | 29 | appVersion: "0.0.0", |
| 38 | hosted: false, | |
| 39 | 30 | identity: desktopChrome, |
| 40 | 31 | desktopBridge: undefined, |
| 41 | 32 | }), |
| 42 | ).toMatchObject({ surface: "web", webDeployment: "server" }); | |
| 33 | ).not.toHaveProperty("appVersion"); | |
| 43 | 34 | }); |
| 44 | 35 | |
| 45 | 36 | it("identifies phone and tablet browsers", () => { |
| ... | ... | @@ -64,7 +55,6 @@ describe("client telemetry metadata", () => { |
| 64 | 55 | |
| 65 | 56 | expect(browserDeviceType(iphone)).toBe("mobile"); |
| 66 | 57 | expect(browserClientOs(iphone)).toBe("iOS"); |
| 67 | expect(browserFamily(iphone.userAgent)).toBe("Safari"); | |
| 68 | 58 | expect(browserDeviceType(androidTablet)).toBe("tablet"); |
| 69 | 59 | expect(browserClientOs(androidTablet)).toBe("Android"); |
| 70 | 60 | expect(browserDeviceType(ipadosDesktopUa)).toBe("tablet"); |
| ... | ... | @@ -75,7 +65,6 @@ describe("client telemetry metadata", () => { |
| 75 | 65 | expect( |
| 76 | 66 | clientPresentationMetadata({ |
| 77 | 67 | appVersion: "1.2.3", |
| 78 | hosted: false, | |
| 79 | 68 | identity: desktopChrome, |
| 80 | 69 | desktopBridge: { getClientPlatform: () => "darwin" }, |
| 81 | 70 | }), |
apps/web/src/connection/clientMetadata.ts-14| ... | ... | @@ -44,17 +44,6 @@ export function browserClientOs(identity: BrowserIdentity): ClientOs { |
| 44 | 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 | 47 | export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadataDeviceType { |
| 59 | 48 | const userAgent = identity.userAgent; |
| 60 | 49 | if (userAgent.trim() === "") return "unknown"; |
| ... | ... | @@ -71,7 +60,6 @@ export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadata |
| 71 | 60 | |
| 72 | 61 | export function clientPresentationMetadata(input: { |
| 73 | 62 | readonly appVersion: string; |
| 74 | readonly hosted: boolean; | |
| 75 | 63 | readonly identity: BrowserIdentity; |
| 76 | 64 | readonly desktopBridge: Pick<DesktopBridge, "getClientPlatform"> | undefined; |
| 77 | 65 | }): AuthClientPresentationMetadata { |
| ... | ... | @@ -90,8 +78,6 @@ export function clientPresentationMetadata(input: { |
| 90 | 78 | deviceType: browserDeviceType(input.identity), |
| 91 | 79 | os: browserClientOs(input.identity), |
| 92 | 80 | surface: "web", |
| 93 | webDeployment: input.hosted ? "hosted" : "server", | |
| 94 | browser: browserFamily(input.identity.userAgent), | |
| 95 | 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 | 170 | function clientMetadata() { |
| 171 | 171 | return clientPresentationMetadata({ |
| 172 | 172 | appVersion: APP_VERSION, |
| 173 | hosted: isHostedStaticApp(), | |
| 174 | 173 | identity: { |
| 175 | 174 | userAgent: navigator.userAgent, |
| 176 | 175 | platform: navigator.platform, |
apps/web/src/lib/runtime.ts+1-11| ... | ... | @@ -4,13 +4,12 @@ import * as Layer from "effect/Layer"; |
| 4 | 4 | import * as Socket from "effect/socket/Socket"; |
| 5 | 5 | |
| 6 | 6 | import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; |
| 7 | import * as RelayTracing from "@t3tools/shared/relayTracing"; | |
| 8 | 7 | import * as PrimaryEnvironmentHttpClient from "../environments/primary/httpClient"; |
| 9 | 8 | import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; |
| 10 | 9 | |
| 11 | 10 | import * as Dpop from "../cloud/dpop"; |
| 12 | 11 | import * as ManagedRelayLayer from "../cloud/managedRelayLayer"; |
| 13 | import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; | |
| 12 | import { resolveCloudPublicConfig } from "../cloud/publicConfig"; | |
| 14 | 13 | import * as ClientTracer from "../observability/clientTracer"; |
| 15 | 14 | |
| 16 | 15 | function configuredRelayUrl(): string { |
| ... | ... | @@ -18,18 +17,10 @@ function configuredRelayUrl(): string { |
| 18 | 17 | } |
| 19 | 18 | |
| 20 | 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 | 20 | type RuntimeLayerSource = |
| 29 | 21 | | typeof layerHttpClient |
| 30 | 22 | | typeof Dpop.layer |
| 31 | 23 | | typeof Socket.layerWebSocketConstructorGlobal |
| 32 | | typeof layerRelayTracing | |
| 33 | 24 | | typeof ClientTracer.layer |
| 34 | 25 | | ReturnType<typeof ManagedRelayLayer.layer>; |
| 35 | 26 | |
| ... | ... | @@ -59,7 +50,6 @@ const layerRuntime = Layer.mergeAll( |
| 59 | 50 | Dpop.layer, |
| 60 | 51 | Socket.layerWebSocketConstructorGlobal, |
| 61 | 52 | ClientTracer.layer, |
| 62 | layerRelayTracing, | |
| 63 | 53 | ManagedRelayLayer.layer(configuredRelayUrl()).pipe( |
| 64 | 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 | 10 | readonly VITE_CLERK_PUBLISHABLE_KEY: string; |
| 11 | 11 | readonly VITE_CLERK_JWT_TEMPLATE: string; |
| 12 | 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 | 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 | 39 | const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; |
| 40 | 40 | const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; |
| 41 | 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 | 42 | const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; |
| 46 | 43 | const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; |
| 47 | 44 | const configuredHostedAppUrl = (() => { |
| ... | ... | @@ -211,11 +208,6 @@ export default defineConfig(() => { |
| 211 | 208 | "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify( |
| 212 | 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 | 211 | "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), |
| 220 | 212 | "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), |
| 221 | 213 | "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), |
docs/README.md+1-2| ... | ... | @@ -17,7 +17,7 @@ |
| 17 | 17 | - [Use T3 Code as your default browser](./user/default-browser.md) |
| 18 | 18 | - [Devices](./user/devices.md) |
| 19 | 19 | - [Usage and limits](./user/usage.md) |
| 20 | - [Product usage data](./user/telemetry.md) | |
| 20 | - [Diagnostics and data sharing](./user/telemetry.md) | |
| 21 | 21 | - [Remote access](./user/remote-access.md) |
| 22 | 22 | - [Outside agents (MCP)](./user/outside-agents.md) |
| 23 | 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 | 45 | - [Remote environments](./internals/remote.md) |
| 46 | 46 | - [Server updates](./internals/server-updates.md) |
| 47 | 47 | - [Resource telemetry](./internals/resource-telemetry.md) |
| 48 | - [Product analytics](./internals/product-analytics.md) | |
| 49 | 48 | - [Environment auth](./internals/environment-auth.md) |
| 50 | 49 | - [T3 Connect](./internals/t3-connect.md) |
| 51 | 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 | 418 | |
| 419 | 419 | `ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run |
| 420 | 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 | |
| 422 | span; requests that reach the environment directly never join a sender's trace. | |
| 421 | the relay forwarded, the local span retains the relay's parent span for correlation; The Knife | |
| 422 | does not export it to T3 Connect. Requests that reach the environment directly never join a | |
| 423 | sender's trace. | |
| 423 | 424 | |
| 424 | 425 | Use metrics when the question is: |
| 425 | 426 |
docs/operations/relay-observability.md+6-5| ... | ... | @@ -2,7 +2,9 @@ |
| 2 | 2 | |
| 3 | 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 | 9 | - `t3-code-relay-traces-prod`, the OpenTelemetry trace dataset shared by the Worker, mobile app, and |
| 8 | 10 | first-party relay clients |
| ... | ... | @@ -21,8 +23,7 @@ vp run deploy |
| 21 | 23 | ``` |
| 22 | 24 | |
| 23 | 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 | |
| 25 | provisioned scoped ingest tokens. | |
| 26 | Worker receives only its scoped ingest token. The Knife does not consume the provisioned mobile or relay-client ingest tokens. | |
| 26 | 27 | |
| 27 | 28 | The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans. |
| 28 | 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 | 71 | `x-t3-hook-outcome` response header. A held request's delivery records the same on its |
| 71 | 72 | `relay.inbox.deliver` span. The relay sends its own `traceparent` with each forward and drops any a |
| 72 | 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 | `ScheduledTaskService.triggerWebhook` span lands in the same trace. `relay.hook.rate_limit` says which | |
| 74 | relay's delivery id, receive time and trace context only from the relay. The environment records | |
| 75 | its `ScheduledTaskService.triggerWebhook` span locally. `relay.hook.rate_limit` says which | |
| 75 | 76 | budget ran out: `endpoint` or `hook`. `relay.hook.rate_limiter_failed_open` is set when the |
| 76 | 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 | # 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 | |
| 4 | installation identifier. Events include the provider, model, reasoning effort, permission mode, | |
| 5 | turn result, duration, and main-agent token totals when available. | |
| 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. | |
| 3 | The Knife does not collect or send product analytics. T3 Connect contacts its account and relay | |
| 4 | services only when you use that connection option. Local diagnostics stay on your machine unless | |
| 5 | you configure an export endpoint. | |
| 15 | 6 | |
| 16 | 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 | 304 | deviceType: "desktop", |
| 305 | 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 | 532 | appVersion: "1.2.3", |
| 533 | 533 | deviceType: "mobile", |
| 534 | 534 | os: "Android", |
| 535 | osMajorVersion: 15, | |
| 536 | deviceModel: "Pixel 9", | |
| 537 | 535 | }, |
| 538 | connectionMethod: "relay", | |
| 539 | 536 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 540 | 537 | |
| 541 | 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 | 3 | type AuthClientPresentationMetadata, |
| 4 | 4 | AuthEnvironmentBootstrapTokenType, |
| 5 | 5 | AuthTokenExchangeGrantType, |
| 6 | type ClientConnectionMethod, | |
| 7 | 6 | type AuthEnvironmentScope, |
| 8 | 7 | } from "@t3tools/contracts"; |
| 9 | 8 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| ... | ... | @@ -42,7 +41,6 @@ const clientMetadataTokenExchangeFields = ( |
| 42 | 41 | export const appendClientConnectionParams = ( |
| 43 | 42 | url: URL, |
| 44 | 43 | clientMetadata: AuthClientPresentationMetadata | undefined, |
| 45 | connectionMethod?: ClientConnectionMethod, | |
| 46 | 44 | ): void => { |
| 47 | 45 | if (clientMetadata?.surface) { |
| 48 | 46 | url.searchParams.set("clientSurface", clientMetadata.surface); |
| ... | ... | @@ -50,37 +48,6 @@ export const appendClientConnectionParams = ( |
| 50 | 48 | if (clientMetadata?.appVersion) { |
| 51 | 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 | 53 | export const exchangeRemoteDpopAccessToken = Effect.fn( |
| ... | ... | @@ -205,7 +172,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 205 | 172 | readonly httpBaseUrl: string; |
| 206 | 173 | readonly bearerToken: string; |
| 207 | 174 | readonly clientMetadata?: AuthClientPresentationMetadata; |
| 208 | readonly connectionMethod?: ClientConnectionMethod; | |
| 209 | 175 | readonly timeoutMs?: number; |
| 210 | 176 | }) { |
| 211 | 177 | const issued = yield* issueRemoteWebSocketTicket({ |
| ... | ... | @@ -216,7 +182,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 216 | 182 | |
| 217 | 183 | const url = new URL(environmentWebSocketUrl(input.wsBaseUrl)); |
| 218 | 184 | url.searchParams.set("wsTicket", issued.ticket); |
| 219 | appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); | |
| 185 | appendClientConnectionParams(url, input.clientMetadata); | |
| 220 | 186 | return url.toString(); |
| 221 | 187 | }); |
| 222 | 188 | |
| ... | ... | @@ -228,7 +194,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( |
| 228 | 194 | readonly accessToken: string; |
| 229 | 195 | readonly dpopProof: string; |
| 230 | 196 | readonly clientMetadata?: AuthClientPresentationMetadata; |
| 231 | readonly connectionMethod?: ClientConnectionMethod; | |
| 232 | 197 | readonly timeoutMs?: number; |
| 233 | 198 | }) { |
| 234 | 199 | const issued = yield* issueRemoteDpopWebSocketTicket({ |
| ... | ... | @@ -239,6 +204,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( |
| 239 | 204 | }); |
| 240 | 205 | const url = new URL(environmentWebSocketUrl(input.wsBaseUrl)); |
| 241 | 206 | url.searchParams.set("wsTicket", issued.ticket); |
| 242 | appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); | |
| 207 | appendClientConnectionParams(url, input.clientMetadata); | |
| 243 | 208 | return url.toString(); |
| 244 | 209 | }); |
packages/client-runtime/src/authorization/service.ts-4| ... | ... | @@ -4,7 +4,6 @@ import { |
| 4 | 4 | type ExecutionEnvironmentDescriptor, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; |
| 7 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | |
| 8 | 7 | import { |
| 9 | 8 | exchangeRemoteDpopAccessToken, |
| 10 | 9 | type RemoteEnvironmentAuthError, |
| ... | ... | @@ -197,7 +196,6 @@ export const make = Effect.gen(function* () { |
| 197 | 196 | httpBaseUrl: input.httpBaseUrl, |
| 198 | 197 | bearerToken: input.bearerToken, |
| 199 | 198 | clientMetadata: presentation.metadata, |
| 200 | connectionMethod: input.connectionMethod, | |
| 201 | 199 | }).pipe( |
| 202 | 200 | Effect.mapError(mapRemoteEnvironmentError), |
| 203 | 201 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| ... | ... | @@ -242,7 +240,6 @@ export const make = Effect.gen(function* () { |
| 242 | 240 | accessToken: token.accessToken, |
| 243 | 241 | dpopProof: ticketProof, |
| 244 | 242 | clientMetadata: presentation.metadata, |
| 245 | connectionMethod: "relay", | |
| 246 | 243 | ...(timeoutMs === undefined ? {} : { timeoutMs }), |
| 247 | 244 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); |
| 248 | 245 | }, |
| ... | ... | @@ -551,7 +548,6 @@ export const make = Effect.gen(function* () { |
| 551 | 548 | getDpopToken(input).pipe( |
| 552 | 549 | Effect.map(({ token }) => httpAuthorization(token)), |
| 553 | 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 | 3 | ORCHESTRATION_PROTOCOL_VERSION, |
| 4 | 4 | type DesktopSshEnvironmentTarget, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | |
| 7 | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | 7 | import * as Effect from "effect/Effect"; |
| 9 | 8 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -269,8 +268,7 @@ describe("ConnectionResolver", () => { |
| 269 | 268 | environmentId: ENVIRONMENT_ID, |
| 270 | 269 | label: "Primary", |
| 271 | 270 | httpBaseUrl: "http://127.0.0.1:3777", |
| 272 | socketUrl: | |
| 273 | "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2", | |
| 271 | socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web&orchestrationProtocol=2", | |
| 274 | 272 | httpAuthorization: null, |
| 275 | 273 | target, |
| 276 | 274 | }); |
| ... | ... | @@ -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 | 385 | Effect.gen(function* () { |
| 388 | 386 | const userSpans: Array<string> = []; |
| 389 | const productSpans: Array<string> = []; | |
| 390 | 387 | const target = new RelayConnectionTarget({ |
| 391 | 388 | environmentId: ENVIRONMENT_ID, |
| 392 | 389 | label: "Cloud", |
| ... | ... | @@ -409,15 +406,11 @@ describe("ConnectionResolver", () => { |
| 409 | 406 | |
| 410 | 407 | yield* broker |
| 411 | 408 | .prepare(catalogEntry(target)) |
| 412 | .pipe( | |
| 413 | Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))), | |
| 414 | Effect.withTracer(collectingTracer(userSpans)), | |
| 415 | ); | |
| 409 | .pipe(Effect.withTracer(collectingTracer(userSpans))); | |
| 416 | 410 | |
| 417 | expect(productSpans).toContain("clientRuntime.connection.broker.relay"); | |
| 418 | expect(productSpans).toContain("test.remote.authorizeDpop"); | |
| 411 | expect(userSpans).toContain("clientRuntime.connection.broker.relay"); | |
| 412 | expect(userSpans).toContain("test.remote.authorizeDpop"); | |
| 419 | 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 | 2 | AuthClientPresentationMetadata, |
| 3 | 3 | ExecutionEnvironmentDescriptor, |
| 4 | 4 | } from "@t3tools/contracts"; |
| 5 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | |
| 6 | 5 | import * as Context from "effect/Context"; |
| 7 | 6 | import * as Effect from "effect/Effect"; |
| 8 | 7 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -77,7 +76,7 @@ function primarySocketUrl( |
| 77 | 76 | clientMetadata: AuthClientPresentationMetadata | undefined, |
| 78 | 77 | ): string { |
| 79 | 78 | const url = new URL(environmentWebSocketUrl(target.wsBaseUrl)); |
| 80 | appendClientConnectionParams(url, clientMetadata, "direct"); | |
| 79 | appendClientConnectionParams(url, clientMetadata); | |
| 81 | 80 | return url.toString(); |
| 82 | 81 | } |
| 83 | 82 | |
| ... | ... | @@ -224,23 +223,19 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") |
| 224 | 223 | const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { |
| 225 | 224 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 226 | 225 | |
| 227 | return Effect.fnUntraced( | |
| 228 | function* (target: RelayConnectionTarget) { | |
| 229 | const authorized = yield* remote.authorizeDpop({ | |
| 230 | expectedEnvironmentId: target.environmentId, | |
| 231 | }); | |
| 232 | return { | |
| 233 | environmentId: authorized.environmentId, | |
| 234 | label: authorized.label, | |
| 235 | httpBaseUrl: authorized.httpBaseUrl, | |
| 236 | socketUrl: authorized.socketUrl, | |
| 237 | httpAuthorization: authorized.httpAuthorization, | |
| 238 | target, | |
| 239 | } satisfies PreparedConnection; | |
| 240 | }, | |
| 241 | Effect.withSpan("clientRuntime.connection.broker.relay"), | |
| 242 | withRelayClientTracing, | |
| 243 | ); | |
| 226 | return Effect.fnUntraced(function* (target: RelayConnectionTarget) { | |
| 227 | const authorized = yield* remote.authorizeDpop({ | |
| 228 | expectedEnvironmentId: target.environmentId, | |
| 229 | }); | |
| 230 | return { | |
| 231 | environmentId: authorized.environmentId, | |
| 232 | label: authorized.label, | |
| 233 | httpBaseUrl: authorized.httpBaseUrl, | |
| 234 | socketUrl: authorized.socketUrl, | |
| 235 | httpAuthorization: authorized.httpAuthorization, | |
| 236 | target, | |
| 237 | } satisfies PreparedConnection; | |
| 238 | }, Effect.withSpan("clientRuntime.connection.broker.relay")); | |
| 244 | 239 | }); |
| 245 | 240 | |
| 246 | 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 | 1 | import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts"; |
| 2 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | |
| 3 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | 3 | import * as Deferred from "effect/Deferred"; |
| 5 | 4 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -295,7 +294,7 @@ describe("retryDelayMs", () => { |
| 295 | 294 | }); |
| 296 | 295 | |
| 297 | 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 | 298 | Effect.gen(function* () { |
| 300 | 299 | const spans: Array<Tracer.NativeSpan> = []; |
| 301 | 300 | const tracer = Tracer.make({ |
| ... | ... | @@ -315,10 +314,7 @@ describe("EnvironmentSupervisor", () => { |
| 315 | 314 | }); |
| 316 | 315 | const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { |
| 317 | 316 | initiallyDesired: true, |
| 318 | }).pipe( | |
| 319 | Effect.provide(harness.dependencies), | |
| 320 | Effect.provideService(RelayClientTracer, Option.some(tracer)), | |
| 321 | ); | |
| 317 | }).pipe(Effect.provide(harness.dependencies), Effect.withTracer(tracer)); | |
| 322 | 318 | |
| 323 | 319 | yield* awaitState( |
| 324 | 320 | supervisor.state, |
packages/client-runtime/src/connection/supervisor.ts+1-2| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | |
| 2 | 1 | import * as Cause from "effect/Cause"; |
| 3 | 2 | import * as Clock from "effect/Clock"; |
| 4 | 3 | import * as Context from "effect/Context"; |
| ... | ... | @@ -484,7 +483,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 484 | 483 | "connection.retry.reason": retry.reason, |
| 485 | 484 | }), |
| 486 | 485 | ), |
| 487 | }).pipe(withRelayClientTracing); | |
| 486 | }); | |
| 488 | 487 | }; |
| 489 | 488 | |
| 490 | 489 | const establishTracedConnection = Effect.fnUntraced(function* ( |
packages/client-runtime/src/relay/managedRelay.ts+169-215| ... | ... | @@ -31,7 +31,6 @@ import { |
| 31 | 31 | } from "@t3tools/contracts/relay"; |
| 32 | 32 | import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; |
| 33 | 33 | import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; |
| 34 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | |
| 35 | 34 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; |
| 36 | 35 | import * as Clock from "effect/Clock"; |
| 37 | 36 | import * as Context from "effect/Context"; |
| ... | ... | @@ -725,228 +724,183 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( |
| 725 | 724 | |
| 726 | 725 | return ManagedRelayClient.of({ |
| 727 | 726 | relayUrl, |
| 728 | listEnvironments: Effect.fnUntraced( | |
| 729 | function* (input) { | |
| 730 | return yield* client.client | |
| 731 | .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) | |
| 732 | .pipe( | |
| 733 | Effect.map((response) => response.environments), | |
| 734 | Effect.mapError(relayRequestError("list relay-managed environments")), | |
| 735 | timeoutRelayRequest("Relay environment listing"), | |
| 736 | ); | |
| 737 | }, | |
| 738 | Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), | |
| 739 | withRelayClientTracing, | |
| 740 | ), | |
| 741 | listDevices: Effect.fnUntraced( | |
| 742 | function* (input) { | |
| 743 | return yield* client.client | |
| 744 | .listDevicesV2({ | |
| 745 | headers: bearerHeaders(input.clerkToken), | |
| 746 | }) | |
| 747 | .pipe( | |
| 748 | Effect.map((response) => response.devices), | |
| 749 | Effect.mapError(relayRequestError("list relay client devices")), | |
| 750 | timeoutRelayRequest("Relay client device listing"), | |
| 751 | ); | |
| 752 | }, | |
| 753 | Effect.withSpan("clientRuntime.managedRelay.listDevices"), | |
| 754 | withRelayClientTracing, | |
| 755 | ), | |
| 756 | createEnvironmentLinkChallenge: Effect.fnUntraced( | |
| 757 | function* (input) { | |
| 758 | return yield* client.client | |
| 759 | .createEnvironmentLinkChallenge({ | |
| 760 | headers: bearerHeaders(input.clerkToken), | |
| 761 | payload: input.payload, | |
| 762 | }) | |
| 763 | .pipe( | |
| 764 | Effect.mapError(relayRequestError("create relay environment link challenge")), | |
| 765 | timeoutRelayRequest("Relay environment link challenge"), | |
| 766 | ); | |
| 767 | }, | |
| 768 | Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), | |
| 769 | withRelayClientTracing, | |
| 770 | ), | |
| 771 | linkEnvironment: Effect.fnUntraced( | |
| 772 | function* (input) { | |
| 773 | return yield* client.client | |
| 774 | .linkEnvironment({ | |
| 775 | headers: bearerHeaders(input.clerkToken), | |
| 776 | payload: input.payload, | |
| 777 | }) | |
| 778 | .pipe( | |
| 779 | Effect.mapError(relayRequestError("link relay environment")), | |
| 780 | timeoutRelayRequest("Relay environment linking"), | |
| 781 | ); | |
| 782 | }, | |
| 783 | Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), | |
| 784 | withRelayClientTracing, | |
| 785 | ), | |
| 786 | unlinkEnvironment: Effect.fnUntraced( | |
| 787 | function* (input) { | |
| 788 | return yield* client.client | |
| 789 | .unlinkEnvironment({ | |
| 790 | headers: bearerHeaders(input.clerkToken), | |
| 791 | params: { environmentId: input.environmentId }, | |
| 792 | }) | |
| 793 | .pipe( | |
| 794 | Effect.mapError(relayRequestError("unlink relay environment")), | |
| 795 | timeoutRelayRequest("Relay environment unlinking"), | |
| 796 | ); | |
| 797 | }, | |
| 798 | Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), | |
| 799 | withRelayClientTracing, | |
| 800 | ), | |
| 801 | getEnvironmentStatus: Effect.fnUntraced( | |
| 802 | function* (input) { | |
| 803 | yield* Effect.annotateCurrentSpan({ | |
| 804 | "environment.id": input.environmentId, | |
| 805 | }); | |
| 806 | return yield* runDpopRequest( | |
| 807 | { | |
| 808 | clerkToken: input.clerkToken, | |
| 809 | scopes: input.scopes, | |
| 810 | target: dpopProofTargets.getEnvironmentStatus(input.environmentId), | |
| 811 | }, | |
| 812 | (authorization) => | |
| 813 | client.dpopClient | |
| 814 | .getEnvironmentStatus({ | |
| 815 | headers: dpopHeaders(authorization), | |
| 816 | params: { environmentId: input.environmentId }, | |
| 817 | }) | |
| 818 | .pipe( | |
| 819 | Effect.mapError(relayRequestError("get relay environment status")), | |
| 820 | timeoutRelayRequest("Relay environment status request"), | |
| 821 | ), | |
| 822 | ); | |
| 823 | }, | |
| 824 | Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"), | |
| 825 | withRelayClientTracing, | |
| 826 | ), | |
| 827 | connectEnvironment: Effect.fnUntraced( | |
| 828 | function* (input) { | |
| 829 | yield* Effect.annotateCurrentSpan({ | |
| 830 | "environment.id": input.environmentId, | |
| 831 | }); | |
| 832 | return yield* runDpopRequest( | |
| 833 | { | |
| 834 | clerkToken: input.clerkToken, | |
| 835 | scopes: input.scopes, | |
| 836 | target: dpopProofTargets.connectEnvironment(input.environmentId), | |
| 837 | }, | |
| 838 | (authorization) => { | |
| 839 | const payload: RelayEnvironmentConnectRequest = { | |
| 840 | ...(input.deviceId ? { deviceId: input.deviceId } : {}), | |
| 841 | clientKeyThumbprint: authorization.thumbprint, | |
| 842 | }; | |
| 843 | return client.dpopClient | |
| 844 | .connectEnvironment({ | |
| 845 | headers: dpopHeaders(authorization), | |
| 846 | params: { environmentId: input.environmentId }, | |
| 847 | payload, | |
| 848 | }) | |
| 849 | .pipe( | |
| 850 | Effect.mapError(relayRequestError("connect relay environment")), | |
| 851 | timeoutRelayRequest("Relay environment connection"), | |
| 852 | ); | |
| 853 | }, | |
| 727 | listEnvironments: Effect.fnUntraced(function* (input) { | |
| 728 | return yield* client.client | |
| 729 | .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) | |
| 730 | .pipe( | |
| 731 | Effect.map((response) => response.environments), | |
| 732 | Effect.mapError(relayRequestError("list relay-managed environments")), | |
| 733 | timeoutRelayRequest("Relay environment listing"), | |
| 854 | 734 | ); |
| 855 | }, | |
| 856 | Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"), | |
| 857 | withRelayClientTracing, | |
| 858 | ), | |
| 859 | registerDevice: Effect.fnUntraced( | |
| 860 | function* (input) { | |
| 861 | return yield* mobileRegistrationRequest( | |
| 862 | { | |
| 863 | clerkToken: input.clerkToken, | |
| 864 | target: dpopProofTargets.registerDevice(), | |
| 865 | }, | |
| 866 | (authorization) => | |
| 867 | client.mobile | |
| 868 | .registerDevice({ | |
| 869 | headers: dpopHeaders(authorization), | |
| 870 | payload: input.payload, | |
| 871 | }) | |
| 872 | .pipe( | |
| 873 | Effect.mapError(relayRequestError("register relay mobile device")), | |
| 874 | timeoutRelayRequest("Relay mobile device registration"), | |
| 875 | ), | |
| 735 | }, Effect.withSpan("clientRuntime.managedRelay.listEnvironments")), | |
| 736 | listDevices: Effect.fnUntraced(function* (input) { | |
| 737 | return yield* client.client | |
| 738 | .listDevicesV2({ | |
| 739 | headers: bearerHeaders(input.clerkToken), | |
| 740 | }) | |
| 741 | .pipe( | |
| 742 | Effect.map((response) => response.devices), | |
| 743 | Effect.mapError(relayRequestError("list relay client devices")), | |
| 744 | timeoutRelayRequest("Relay client device listing"), | |
| 876 | 745 | ); |
| 877 | }, | |
| 878 | Effect.withSpan("clientRuntime.managedRelay.registerDevice"), | |
| 879 | withRelayClientTracing, | |
| 880 | ), | |
| 881 | unregisterDevice: Effect.fnUntraced( | |
| 882 | function* (input) { | |
| 883 | return yield* mobileRegistrationRequest( | |
| 884 | { | |
| 885 | clerkToken: input.clerkToken, | |
| 886 | target: dpopProofTargets.unregisterDevice(input.deviceId), | |
| 887 | }, | |
| 888 | (authorization) => | |
| 889 | client.mobile | |
| 890 | .unregisterDevice({ | |
| 891 | headers: dpopHeaders(authorization), | |
| 892 | params: { deviceId: input.deviceId }, | |
| 893 | }) | |
| 894 | .pipe( | |
| 895 | Effect.mapError(relayRequestError("unregister relay mobile device")), | |
| 896 | timeoutRelayRequest("Relay mobile device unregistration"), | |
| 897 | ), | |
| 746 | }, Effect.withSpan("clientRuntime.managedRelay.listDevices")), | |
| 747 | createEnvironmentLinkChallenge: Effect.fnUntraced(function* (input) { | |
| 748 | return yield* client.client | |
| 749 | .createEnvironmentLinkChallenge({ | |
| 750 | headers: bearerHeaders(input.clerkToken), | |
| 751 | payload: input.payload, | |
| 752 | }) | |
| 753 | .pipe( | |
| 754 | Effect.mapError(relayRequestError("create relay environment link challenge")), | |
| 755 | timeoutRelayRequest("Relay environment link challenge"), | |
| 898 | 756 | ); |
| 899 | }, | |
| 900 | Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), | |
| 901 | withRelayClientTracing, | |
| 902 | ), | |
| 903 | getAgentActivitySnapshot: Effect.fnUntraced( | |
| 904 | function* (input) { | |
| 905 | return yield* mobileRegistrationRequest( | |
| 906 | { | |
| 907 | clerkToken: input.clerkToken, | |
| 908 | target: dpopProofTargets.getAgentActivitySnapshot(), | |
| 909 | }, | |
| 910 | (authorization) => | |
| 911 | client.mobile | |
| 912 | .getAgentActivitySnapshot({ | |
| 913 | headers: dpopHeaders(authorization), | |
| 914 | }) | |
| 915 | .pipe( | |
| 916 | Effect.mapError(relayRequestError("read relay agent activity snapshot")), | |
| 917 | timeoutRelayRequest("Relay agent activity snapshot"), | |
| 918 | ), | |
| 757 | }, Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge")), | |
| 758 | linkEnvironment: Effect.fnUntraced(function* (input) { | |
| 759 | return yield* client.client | |
| 760 | .linkEnvironment({ | |
| 761 | headers: bearerHeaders(input.clerkToken), | |
| 762 | payload: input.payload, | |
| 763 | }) | |
| 764 | .pipe( | |
| 765 | Effect.mapError(relayRequestError("link relay environment")), | |
| 766 | timeoutRelayRequest("Relay environment linking"), | |
| 919 | 767 | ); |
| 920 | }, | |
| 921 | Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot"), | |
| 922 | withRelayClientTracing, | |
| 923 | ), | |
| 924 | registerLiveActivity: Effect.fnUntraced( | |
| 925 | function* (input) { | |
| 926 | return yield* mobileRegistrationRequest( | |
| 927 | { | |
| 928 | clerkToken: input.clerkToken, | |
| 929 | target: dpopProofTargets.registerLiveActivity(), | |
| 930 | }, | |
| 931 | (authorization) => | |
| 932 | client.mobile | |
| 933 | .registerLiveActivity({ | |
| 934 | headers: dpopHeaders(authorization), | |
| 935 | payload: input.payload, | |
| 936 | }) | |
| 937 | .pipe( | |
| 938 | Effect.mapError(relayRequestError("register relay live activity")), | |
| 939 | timeoutRelayRequest("Relay Live Activity registration"), | |
| 940 | ), | |
| 768 | }, Effect.withSpan("clientRuntime.managedRelay.linkEnvironment")), | |
| 769 | unlinkEnvironment: Effect.fnUntraced(function* (input) { | |
| 770 | return yield* client.client | |
| 771 | .unlinkEnvironment({ | |
| 772 | headers: bearerHeaders(input.clerkToken), | |
| 773 | params: { environmentId: input.environmentId }, | |
| 774 | }) | |
| 775 | .pipe( | |
| 776 | Effect.mapError(relayRequestError("unlink relay environment")), | |
| 777 | timeoutRelayRequest("Relay environment unlinking"), | |
| 941 | 778 | ); |
| 942 | }, | |
| 943 | Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), | |
| 944 | withRelayClientTracing, | |
| 945 | ), | |
| 779 | }, Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment")), | |
| 780 | getEnvironmentStatus: Effect.fnUntraced(function* (input) { | |
| 781 | yield* Effect.annotateCurrentSpan({ | |
| 782 | "environment.id": input.environmentId, | |
| 783 | }); | |
| 784 | return yield* runDpopRequest( | |
| 785 | { | |
| 786 | clerkToken: input.clerkToken, | |
| 787 | scopes: input.scopes, | |
| 788 | target: dpopProofTargets.getEnvironmentStatus(input.environmentId), | |
| 789 | }, | |
| 790 | (authorization) => | |
| 791 | client.dpopClient | |
| 792 | .getEnvironmentStatus({ | |
| 793 | headers: dpopHeaders(authorization), | |
| 794 | params: { environmentId: input.environmentId }, | |
| 795 | }) | |
| 796 | .pipe( | |
| 797 | Effect.mapError(relayRequestError("get relay environment status")), | |
| 798 | timeoutRelayRequest("Relay environment status request"), | |
| 799 | ), | |
| 800 | ); | |
| 801 | }, Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus")), | |
| 802 | connectEnvironment: Effect.fnUntraced(function* (input) { | |
| 803 | yield* Effect.annotateCurrentSpan({ | |
| 804 | "environment.id": input.environmentId, | |
| 805 | }); | |
| 806 | return yield* runDpopRequest( | |
| 807 | { | |
| 808 | clerkToken: input.clerkToken, | |
| 809 | scopes: input.scopes, | |
| 810 | target: dpopProofTargets.connectEnvironment(input.environmentId), | |
| 811 | }, | |
| 812 | (authorization) => { | |
| 813 | const payload: RelayEnvironmentConnectRequest = { | |
| 814 | ...(input.deviceId ? { deviceId: input.deviceId } : {}), | |
| 815 | clientKeyThumbprint: authorization.thumbprint, | |
| 816 | }; | |
| 817 | return client.dpopClient | |
| 818 | .connectEnvironment({ | |
| 819 | headers: dpopHeaders(authorization), | |
| 820 | params: { environmentId: input.environmentId }, | |
| 821 | payload, | |
| 822 | }) | |
| 823 | .pipe( | |
| 824 | Effect.mapError(relayRequestError("connect relay environment")), | |
| 825 | timeoutRelayRequest("Relay environment connection"), | |
| 826 | ); | |
| 827 | }, | |
| 828 | ); | |
| 829 | }, Effect.withSpan("clientRuntime.managedRelay.connectEnvironment")), | |
| 830 | registerDevice: Effect.fnUntraced(function* (input) { | |
| 831 | return yield* mobileRegistrationRequest( | |
| 832 | { | |
| 833 | clerkToken: input.clerkToken, | |
| 834 | target: dpopProofTargets.registerDevice(), | |
| 835 | }, | |
| 836 | (authorization) => | |
| 837 | client.mobile | |
| 838 | .registerDevice({ | |
| 839 | headers: dpopHeaders(authorization), | |
| 840 | payload: input.payload, | |
| 841 | }) | |
| 842 | .pipe( | |
| 843 | Effect.mapError(relayRequestError("register relay mobile device")), | |
| 844 | timeoutRelayRequest("Relay mobile device registration"), | |
| 845 | ), | |
| 846 | ); | |
| 847 | }, Effect.withSpan("clientRuntime.managedRelay.registerDevice")), | |
| 848 | unregisterDevice: Effect.fnUntraced(function* (input) { | |
| 849 | return yield* mobileRegistrationRequest( | |
| 850 | { | |
| 851 | clerkToken: input.clerkToken, | |
| 852 | target: dpopProofTargets.unregisterDevice(input.deviceId), | |
| 853 | }, | |
| 854 | (authorization) => | |
| 855 | client.mobile | |
| 856 | .unregisterDevice({ | |
| 857 | headers: dpopHeaders(authorization), | |
| 858 | params: { deviceId: input.deviceId }, | |
| 859 | }) | |
| 860 | .pipe( | |
| 861 | Effect.mapError(relayRequestError("unregister relay mobile device")), | |
| 862 | timeoutRelayRequest("Relay mobile device unregistration"), | |
| 863 | ), | |
| 864 | ); | |
| 865 | }, Effect.withSpan("clientRuntime.managedRelay.unregisterDevice")), | |
| 866 | getAgentActivitySnapshot: Effect.fnUntraced(function* (input) { | |
| 867 | return yield* mobileRegistrationRequest( | |
| 868 | { | |
| 869 | clerkToken: input.clerkToken, | |
| 870 | target: dpopProofTargets.getAgentActivitySnapshot(), | |
| 871 | }, | |
| 872 | (authorization) => | |
| 873 | client.mobile | |
| 874 | .getAgentActivitySnapshot({ | |
| 875 | headers: dpopHeaders(authorization), | |
| 876 | }) | |
| 877 | .pipe( | |
| 878 | Effect.mapError(relayRequestError("read relay agent activity snapshot")), | |
| 879 | timeoutRelayRequest("Relay agent activity snapshot"), | |
| 880 | ), | |
| 881 | ); | |
| 882 | }, Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot")), | |
| 883 | registerLiveActivity: Effect.fnUntraced(function* (input) { | |
| 884 | return yield* mobileRegistrationRequest( | |
| 885 | { | |
| 886 | clerkToken: input.clerkToken, | |
| 887 | target: dpopProofTargets.registerLiveActivity(), | |
| 888 | }, | |
| 889 | (authorization) => | |
| 890 | client.mobile | |
| 891 | .registerLiveActivity({ | |
| 892 | headers: dpopHeaders(authorization), | |
| 893 | payload: input.payload, | |
| 894 | }) | |
| 895 | .pipe( | |
| 896 | Effect.mapError(relayRequestError("register relay live activity")), | |
| 897 | timeoutRelayRequest("Relay Live Activity registration"), | |
| 898 | ), | |
| 899 | ); | |
| 900 | }, Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity")), | |
| 946 | 901 | resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( |
| 947 | 902 | Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), |
| 948 | 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 | 12 | OrchestrationV2ThreadTranscript, |
| 13 | 13 | type OrchestrationV2ThreadHistoryPage, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | |
| 16 | 15 | import * as Deferred from "effect/Deferred"; |
| 17 | 16 | import * as Effect from "effect/Effect"; |
| 18 | 17 | import * as Fiber from "effect/Fiber"; |
| 19 | 18 | import * as Layer from "effect/Layer"; |
| 20 | 19 | import * as Option from "effect/Option"; |
| 21 | 20 | import * as Schema from "effect/Schema"; |
| 22 | import * as Tracer from "effect/Tracer"; | |
| 23 | 21 | import { TestClock } from "effect/testing"; |
| 24 | 22 | import type { HttpClient } from "effect/http"; |
| 25 | 23 | |
| ... | ... | @@ -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 | 765 | describe("orchestration HTTP compatibility header", () => { |
| 798 | 766 | it("announces the current protocol without replacing bearer or DPoP authentication", () => { |
| 799 | 767 | expect( |
packages/client-runtime/src/state/environmentHttpAuth.ts+1-12| ... | ... | @@ -5,7 +5,6 @@ import { |
| 5 | 5 | ORCHESTRATION_PROTOCOL_VERSION_TEXT, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import * as Result from "effect/Result"; |
| 8 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | |
| 9 | 8 | import { FetchHttpClient, type HttpMethod } from "effect/http"; |
| 10 | 9 | |
| 11 | 10 | import { HostedAuthAccess } from "../platform/capabilities.ts"; |
| ... | ... | @@ -106,10 +105,6 @@ const buildEnvironmentAuthHeaders = ( |
| 106 | 105 | * Resolve relay credentials at request time without replacing the live socket. |
| 107 | 106 | * A rejected credential gets one refresh and retry, with a new request-bound |
| 108 | 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 | 109 | export const executeAuthenticatedEnvironmentHttpRequest = < |
| 115 | 110 | Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1], |
| ... | ... | @@ -118,13 +113,7 @@ export const executeAuthenticatedEnvironmentHttpRequest = < |
| 118 | 113 | R, |
| 119 | 114 | >( |
| 120 | 115 | input: Parameters<typeof executeEnvironmentRequest<Group, A, E, R>>[0], |
| 121 | ) => | |
| 122 | input.prepared.httpAuthorization?._tag === "Dpop" | |
| 123 | ? executeEnvironmentRequest(input).pipe( | |
| 124 | Effect.withSpan(ENVIRONMENT_REQUEST_SPAN, { root: true }), | |
| 125 | withRelayClientTracing, | |
| 126 | ) | |
| 127 | : executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN)); | |
| 116 | ) => executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN)); | |
| 128 | 117 | |
| 129 | 118 | const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest"; |
| 130 | 119 |
packages/contracts/src/auth.ts-5| ... | ... | @@ -5,7 +5,6 @@ import { |
| 5 | 5 | AuthSessionId, |
| 6 | 6 | ForwardCompatibleArray, |
| 7 | 7 | ClientSurface, |
| 8 | ClientWebDeployment, | |
| 9 | 8 | TrimmedNonEmptyString, |
| 10 | 9 | } from "./baseSchemas.ts"; |
| 11 | 10 | |
| ... | ... | @@ -297,11 +296,7 @@ export const AuthClientPresentationMetadata = Schema.Struct({ |
| 297 | 296 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 298 | 297 | deviceType: Schema.optionalKey(AuthClientMetadataDeviceType), |
| 299 | 298 | os: Schema.optionalKey(TrimmedNonEmptyString), |
| 300 | osMajorVersion: Schema.optionalKey(Schema.Int), | |
| 301 | deviceModel: Schema.optionalKey(TrimmedNonEmptyString), | |
| 302 | 299 | surface: Schema.optionalKey(ClientSurface), |
| 303 | webDeployment: Schema.optionalKey(ClientWebDeployment), | |
| 304 | browser: Schema.optionalKey(TrimmedNonEmptyString), | |
| 305 | 300 | appVersion: Schema.optionalKey(TrimmedNonEmptyString), |
| 306 | 301 | }); |
| 307 | 302 | export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type; |
packages/contracts/src/baseSchemas.ts-6| ... | ... | @@ -357,12 +357,6 @@ export const ClientOs = Schema.Literals([ |
| 357 | 357 | ]); |
| 358 | 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 | 360 | export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]); |
| 367 | 361 | export type ClientConnectionMethod = typeof ClientConnectionMethod.Type; |
| 368 | 362 |
packages/shared/package.json-4| ... | ... | @@ -295,10 +295,6 @@ |
| 295 | 295 | "types": "./src/relayClient.ts", |
| 296 | 296 | "import": "./src/relayClient.ts" |
| 297 | 297 | }, |
| 298 | "./relayTracing": { | |
| 299 | "types": "./src/relayTracing.ts", | |
| 300 | "import": "./src/relayTracing.ts" | |
| 301 | }, | |
| 302 | 298 | "./preview": { |
| 303 | 299 | "types": "./src/preview.ts", |
| 304 | 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 | 27 | expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined(); |
| 28 | 28 | expect(env.T3CODE_RELAY_URL).toBeUndefined(); |
| 29 | 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 | 32 | it("applies process, root local, and root precedence in that order", () => { |
| ... | ... | @@ -85,64 +73,12 @@ describe("loadRepoEnv", () => { |
| 85 | 73 | VITE_CLERK_JWT_TEMPLATE: "template_legacy", |
| 86 | 74 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical", |
| 87 | 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 | 77 | ).toEqual({ |
| 93 | 78 | clerkPublishableKey: "pk_legacy", |
| 94 | 79 | clerkJwtTemplate: "template_legacy", |
| 95 | 80 | clerkCliOAuthClientId: "oauth_canonical", |
| 96 | 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 | 9 | readonly clerkJwtTemplate: string | undefined; |
| 10 | 10 | readonly clerkCliOAuthClientId: string | undefined; |
| 11 | 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 | 14 | type Environment = Readonly<Record<string, string | undefined>>; |
| ... | ... | @@ -64,42 +58,6 @@ export function loadRepoEnv({ |
| 64 | 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 | 81 | "VITE_CLERK_CLI_OAUTH_CLIENT_ID", |
| 124 | 82 | ), |
| 125 | 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 |