From 70f13f54049d9837fe63d489015fd50cd55b98cb Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 16 Oct 2026 12:18:30 -0700 Subject: [PATCH] fix: remove product analytics and hosted trace exports Clover wants the fork to contact T3 services only for explicitly used T3 Connect features. Remove server PostHog analytics, account identity collection, hosted relay tracing, analytics-only client metadata, and Clerk telemetry across desktop, web, and mobile. Delete unused exporters and configuration instead of retaining inert services. Preserve connection authentication, registration, tunnels, and provider traffic, along with local diagnostics and explicitly configured OTLP exports. Keep ordinary authenticated requests on the local tracer. Assisted-by: gpt-6.1-sol --- .../DesktopBackendConfiguration.test.ts | 5 - .../backend/DesktopBackendConfiguration.ts | 1 - .../src/shell/DesktopShellEnvironment.test.ts | 2 - .../src/shell/DesktopShellEnvironment.ts | 3 - apps/mobile/app.config.ts | 5 - .../src/features/cloud/CloudAuthProvider.tsx | 2 +- .../src/features/cloud/publicConfig.test.ts | 62 --- .../mobile/src/features/cloud/publicConfig.ts | 41 -- .../features/observability/tracing.test.ts | 97 ----- .../src/features/observability/tracing.ts | 41 -- apps/mobile/src/lib/authClientMetadata.ts | 5 - apps/mobile/src/lib/connection.test.ts | 14 +- apps/mobile/src/lib/runtime.ts | 5 +- apps/server/src/auth/http.test.ts | 16 +- apps/server/src/auth/http.ts | 11 +- apps/server/src/cli/connect.ts | 4 - apps/server/src/cloud/CloudLink.ts | 3 - apps/server/src/cloud/http.ts | 7 +- apps/server/src/cloud/publicConfig.test.ts | 43 +- apps/server/src/cloud/publicConfig.ts | 44 -- apps/server/src/cloud/relayTracing.ts | 18 - .../src/cloud/traceRelayRequest.test.ts | 127 ------ apps/server/src/cloud/traceRelayRequest.ts | 77 ---- apps/server/src/config.ts | 3 - apps/server/src/http.ts | 3 +- .../Adapters/CodexAdapterV2.testkit.ts | 1 - .../ProviderEventIngestor.test.ts | 100 ----- .../orchestration-v2/ProviderEventIngestor.ts | 105 +---- .../orchestration-v2/RunExecutionService.ts | 5 - apps/server/src/orchestration-v2/http.ts | 10 +- .../testkit/ProviderReplayHarness.ts | 1 - apps/server/src/project/http.ts | 6 +- .../src/provider/CodexChatGptAuth.test.ts | 242 +---------- apps/server/src/provider/CodexChatGptAuth.ts | 131 +----- .../src/provider/CodexChatGptHandoff.ts | 1 - apps/server/src/pullRequest/http.ts | 3 +- apps/server/src/relay/AgentAwarenessRelay.ts | 3 +- .../server/src/scheduledTasks/webhookRoute.ts | 4 +- apps/server/src/server.ts | 6 - .../src/serverRuntimeStartup.autoPull.test.ts | 2 - apps/server/src/serverRuntimeStartup.ts | 41 -- .../src/telemetry/AnalyticsService.test.ts | 277 ------------- apps/server/src/telemetry/AnalyticsService.ts | 288 ------------- apps/server/src/telemetry/Identify.test.ts | 253 ------------ apps/server/src/telemetry/Identify.ts | 317 --------------- apps/server/src/ws.ts | 96 +---- apps/server/vite.config.ts | 9 - apps/web/src/cloud/publicConfig.ts | 34 -- .../clerk/BrowserManagedAuthShell.tsx | 2 +- .../clerk/ElectronManagedAuthShell.tsx | 7 +- .../web/src/connection/clientMetadata.test.ts | 19 +- apps/web/src/connection/clientMetadata.ts | 14 - apps/web/src/connection/platform.ts | 1 - apps/web/src/lib/runtime.ts | 12 +- apps/web/src/vite-env.d.ts | 3 - apps/web/vite.config.ts | 8 - docs/README.md | 3 +- docs/internals/product-analytics.md | 56 --- docs/operations/observability.md | 5 +- docs/operations/relay-observability.md | 11 +- docs/user/telemetry.md | 17 +- .../src/authorization/remote.test.ts | 7 +- .../src/authorization/remote.ts | 39 +- .../src/authorization/service.ts | 4 - .../src/connection/resolver.test.ts | 17 +- .../client-runtime/src/connection/resolver.ts | 33 +- .../src/connection/supervisor.test.ts | 8 +- .../src/connection/supervisor.ts | 3 +- .../client-runtime/src/relay/managedRelay.ts | 384 ++++++++---------- .../src/state/environmentHttpAuth.test.ts | 32 -- .../src/state/environmentHttpAuth.ts | 13 +- packages/contracts/src/auth.ts | 5 - packages/contracts/src/baseSchemas.ts | 6 - packages/shared/package.json | 4 - packages/shared/src/relayTracing.test.ts | 149 ------- packages/shared/src/relayTracing.ts | 193 --------- scripts/lib/public-config.test.ts | 64 --- scripts/lib/public-config.ts | 72 ---- 78 files changed, 246 insertions(+), 3519 deletions(-) delete mode 100644 apps/mobile/src/features/observability/tracing.test.ts delete mode 100644 apps/mobile/src/features/observability/tracing.ts delete mode 100644 apps/server/src/cloud/relayTracing.ts delete mode 100644 apps/server/src/cloud/traceRelayRequest.test.ts delete mode 100644 apps/server/src/cloud/traceRelayRequest.ts delete mode 100644 apps/server/src/telemetry/AnalyticsService.test.ts delete mode 100644 apps/server/src/telemetry/AnalyticsService.ts delete mode 100644 apps/server/src/telemetry/Identify.test.ts delete mode 100644 apps/server/src/telemetry/Identify.ts delete mode 100644 docs/internals/product-analytics.md delete mode 100644 packages/shared/src/relayTracing.test.ts delete mode 100644 packages/shared/src/relayTracing.ts diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 0a09771f9b79b4a852a689c09429a5088fe4542c..fefd37a8cf48e31e203813bb9fe3e7202e2c489b 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -996,11 +996,9 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousDisabled = process.env.OTEL_SDK_DISABLED; - const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; try { delete process.env.WSLENV; process.env.OTEL_SDK_DISABLED = "true"; - process.env.T3CODE_TELEMETRY_ENABLED = "false"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -1008,8 +1006,6 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.env.OTEL_SDK_DISABLED, "true"); assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); - assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); - assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); }).pipe( Effect.provide( DesktopBackendConfiguration.layer.pipe( @@ -1030,7 +1026,6 @@ describe("DesktopBackendConfiguration", () => { } finally { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OTEL_SDK_DISABLED", previousDisabled); - restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 9994ee1cda276ed86dfd15e1be0497c8d3688c96..e9962ee5e81448dbaa4bb2f7daabd81ee4444d87 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -102,7 +102,6 @@ const DESKTOP_BACKEND_ENV_NAMES = [ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", - "T3CODE_TELEMETRY_ENABLED", // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", "OTEL_SDK_DISABLED", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 6efe817574a34beba449bdbafbb43bd82ea2fce4..c3de58aaa7b4c12e8aa05b85bf93ffcca39386e2 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -357,13 +357,11 @@ describe("DesktopShellEnvironment", () => { envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/secretive.sock", - T3CODE_TELEMETRY_ENABLED: "false", }), }); assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); - assert.equal(env.T3CODE_TELEMETRY_ENABLED, "false"); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 2b41b6ea7db94daaee603c9a9d287c242951bfe9..4492b234e4c9b6cc3f288a938e219cabe1ebac98 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -89,7 +89,6 @@ const LOGIN_SHELL_ENV_NAMES = [ "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", - "T3CODE_TELEMETRY_ENABLED", "__MISE_DIFF", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; @@ -463,8 +462,6 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", - // The telemetry opt-out is documented as a shell variable; GUI launches never see it. - "T3CODE_TELEMETRY_ENABLED", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e03832705daace6b90cbc95aa0ee15d09fa64ee7..353557bb967a5455d8d899e04c647916cc7ae0d3 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -485,11 +485,6 @@ const config: ExpoConfig = { EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID, EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID, EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME, - observability: { - tracesUrl: repoEnv.EXPO_PUBLIC_OTLP_TRACES_URL ?? "https://api.axiom.co/v1/traces", - tracesDataset: repoEnv.EXPO_PUBLIC_OTLP_TRACES_DATASET ?? null, - tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, - }, eas: { projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", }, diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index 39e382af866ddb1bdbb81dfc9c4740642b0decee..68b815f538aeab047a384fb5a9685bc13d078747 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -229,7 +229,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) { } return ( - + {props.children} diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index 05bf1a8fbccdc822a6b74a35368947a4c2a35ea6..dbf51d7f1218e3b96dee46c249d9d90ab1ce988a 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { CloudPublicConfigMissingError, - hasTracingPublicConfig, resolveCloudPublicConfig, resolveRelayClerkTokenOptions, } from "./publicConfig"; @@ -31,11 +30,6 @@ describe("resolveCloudPublicConfig", () => { relay: { url: null, }, - observability: { - tracesUrl: null, - tracesDataset: null, - tracesToken: null, - }, }); }); @@ -44,11 +38,6 @@ describe("resolveCloudPublicConfig", () => { resolveCloudPublicConfig({ clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " }, relay: { url: " https://relay.example.test/// " }, - observability: { - tracesUrl: " https://api.axiom.co/v1/traces ", - tracesDataset: " mobile-traces ", - tracesToken: " public-ingest-token ", - }, }), ).toEqual({ clerk: { @@ -58,11 +47,6 @@ describe("resolveCloudPublicConfig", () => { relay: { url: "https://relay.example.test", }, - observability: { - tracesUrl: "https://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, }); }); @@ -80,52 +64,6 @@ describe("resolveCloudPublicConfig", () => { relay: { url: null, }, - observability: { - tracesUrl: null, - tracesDataset: null, - tracesToken: null, - }, }); }); - - it("rejects an insecure traces URL", () => { - expect( - resolveCloudPublicConfig({ - observability: { - tracesUrl: "http://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - }).observability, - ).toEqual({ - tracesUrl: null, - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }); - }); - - it("keeps tracing disabled unless every public tracing value is configured", () => { - expect(hasTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); - expect( - hasTracingPublicConfig( - resolveCloudPublicConfig({ - observability: { - tracesUrl: "https://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - }, - }), - ), - ).toBe(false); - expect( - hasTracingPublicConfig( - resolveCloudPublicConfig({ - observability: { - tracesUrl: "https://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - }), - ), - ).toBe(true); - }); }); diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts index a91a8024d48516008f87e08f3e8905b3abb46930..26886b919a5e4c2d5150533fc7a1b2cd3537cd7a 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -22,11 +22,6 @@ export interface CloudPublicConfig { readonly relay: { readonly url: string | null; }; - readonly observability: { - readonly tracesUrl: string | null; - readonly tracesDataset: string | null; - readonly tracesToken: string | null; - }; } type UntrustedSection = { @@ -43,19 +38,6 @@ function trimNonEmpty(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } -function normalizeSecureUrl(value: unknown): string | null { - const raw = trimNonEmpty(value); - if (raw === null) { - return null; - } - try { - const url = new URL(raw); - return url.protocol === "https:" ? url.toString() : null; - } catch { - return null; - } -} - export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { return { clerk: { @@ -65,11 +47,6 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig relay: { url: normalizeSecureRelayUrl(trimNonEmpty(extra?.relay?.url) ?? ""), }, - observability: { - tracesUrl: normalizeSecureUrl(extra?.observability?.tracesUrl), - tracesDataset: trimNonEmpty(extra?.observability?.tracesDataset), - tracesToken: trimNonEmpty(extra?.observability?.tracesToken), - }, } satisfies CloudPublicConfig; } @@ -78,24 +55,6 @@ export function hasCloudPublicConfig(): boolean { return Boolean(config.clerk.publishableKey && config.clerk.jwtTemplate && config.relay.url); } -type Configured = { - readonly [Key in keyof T]: NonNullable; -}; - -type TracingPublicConfig = Omit & { - readonly observability: Configured; -}; - -export function hasTracingPublicConfig( - config: CloudPublicConfig = resolveCloudPublicConfig(), -): config is TracingPublicConfig { - return Boolean( - config.observability.tracesUrl && - config.observability.tracesDataset && - config.observability.tracesToken, - ); -} - export function resolveRelayClerkTokenOptions() { const { jwtTemplate } = resolveCloudPublicConfig().clerk; if (!jwtTemplate) { diff --git a/apps/mobile/src/features/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts deleted file mode 100644 index ed62f579fea9269962b09f46df61df186cc00e04..0000000000000000000000000000000000000000 --- a/apps/mobile/src/features/observability/tracing.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { vi } from "vite-plus/test"; - -import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; - -import * as Tracing from "./tracing"; - -vi.mock("expo-constants", () => ({ - default: { - expoConfig: { - extra: {}, - }, - }, -})); - -it.effect("exports spans through the scoped mobile OTLP layer", () => { - const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); - const layerTracing = Tracing.layerFromConfig( - { - tracesUrl: "https://api.axiom.test/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - { - appVariant: "test", - serviceVersion: "1.2.3", - }, - ).pipe(Layer.provide(layerRemoteHttpClient(fetchFn))); - const layerTracedApplication = Layer.effectDiscard( - Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), - ).pipe(Layer.provide(layerTracing)); - - return Effect.gen(function* () { - yield* Layer.build(layerTracedApplication); - - expect(fetchFn).not.toHaveBeenCalled(); - }).pipe( - Effect.scoped, - Effect.andThen( - Effect.sync(() => { - expect(fetchFn).toHaveBeenCalledOnce(); - const [url, init] = fetchFn.mock.calls[0]!; - expect(String(url)).toBe("https://api.axiom.test/v1/traces"); - expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); - expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); - expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); - }), - ), - ); -}); - -it.effect("does not let OTLP serialization failures alter application effects", () => { - const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); - const layerTracing = Tracing.layerFromConfig( - { - tracesUrl: "https://api.axiom.test/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - { - appVariant: "test", - serviceVersion: "1.2.3", - }, - ).pipe(Layer.provide(layerRemoteHttpClient(fetchFn))); - const failure = { durationNanos: 1n }; - const layerTracedApplication = Layer.effectDiscard( - Effect.fail(failure).pipe( - Effect.withSpan("mobile.test.failed-span"), - withRelayClientTracing, - Effect.exit, - Effect.flatMap((exit) => { - const reason = exit._tag === "Failure" ? exit.cause.reasons[0] : undefined; - return reason && Cause.isFailReason(reason) - ? Effect.sync(() => { - expect(reason.error).toBe(failure); - }) - : Effect.die(new Error("Expected the original typed failure.")); - }), - ), - ).pipe(Layer.provide(layerTracing)); - - return Layer.build(layerTracedApplication).pipe( - Effect.scoped, - Effect.andThen( - Effect.sync(() => { - expect(fetchFn).toHaveBeenCalledOnce(); - expect(new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array)).toContain( - "mobile.test.failed-span", - ); - }), - ), - ); -}); diff --git a/apps/mobile/src/features/observability/tracing.ts b/apps/mobile/src/features/observability/tracing.ts deleted file mode 100644 index e2b8ee0f1688319d7845d76e6f90dc13758bc0fc..0000000000000000000000000000000000000000 --- a/apps/mobile/src/features/observability/tracing.ts +++ /dev/null @@ -1,41 +0,0 @@ -import Constants from "expo-constants"; -import * as RelayTracing from "@t3tools/shared/relayTracing"; - -import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; - -export interface TracingConfig { - readonly tracesUrl: string; - readonly tracesDataset: string; - readonly tracesToken: string; -} - -export interface TracingResource { - readonly serviceVersion?: string; - readonly appVariant: string; -} - -export function resolveTracingConfig(): TracingConfig | null { - const config = resolveCloudPublicConfig(); - if (!hasTracingPublicConfig(config)) { - return null; - } - const { tracesUrl, tracesDataset, tracesToken } = config.observability; - return { tracesUrl, tracesDataset, tracesToken }; -} - -export function layerFromConfig(config: TracingConfig | null, resource: TracingResource) { - return RelayTracing.layer(config, { - serviceName: "t3code-mobile", - serviceVersion: resource.serviceVersion, - runtime: "react-native", - client: `mobile-${resource.appVariant}`, - }); -} - -export const layer = layerFromConfig(resolveTracingConfig(), { - serviceVersion: Constants.expoConfig?.version, - appVariant: - typeof Constants.expoConfig?.extra?.appVariant === "string" - ? Constants.expoConfig.extra.appVariant - : "unknown", -}); diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index c8eacd24c9f522e9b5fc8dca27dc9ee167c17f0f..9a6a128b959d440cdc2ae3a670d50cf1b9c3e239 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -3,9 +3,6 @@ import * as Device from "expo-device"; import { Platform } from "react-native"; export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { - const osMajorVersion = Number.parseInt(Device.osVersion?.split(".")[0] ?? "", 10); - const deviceModel = Device.modelName?.trim(); - return { label: "The Knife Mobile", deviceType: @@ -15,8 +12,6 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM ? "mobile" : "unknown", ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), - ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}), - ...(deviceModel ? { deviceModel } : {}), surface: "mobile", ...(appVersion ? { appVersion } : {}), }; diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 44381534d0f9a7ad0c3b745bf9a261bd036bac50..b8b4e02e547dd0a002868a38f3aa57b3a710773a 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -14,8 +14,6 @@ const mobileDevice = vi.hoisted(() => ({ DESKTOP: 3, TV: 4, }, - osVersion: "18.4.1", - modelName: "iPhone 15 Pro", })); vi.mock("./runtime", () => ({ @@ -34,8 +32,6 @@ describe("mobile remote connection records", () => { afterEach(() => { mobilePlatform.OS = "ios"; mobileDevice.deviceType = mobileDevice.DeviceType.PHONE; - mobileDevice.osVersion = "18.4.1"; - mobileDevice.modelName = "iPhone 15 Pro"; }); it("identifies mobile token exchanges for authorized-client presentation", () => { @@ -43,32 +39,24 @@ describe("mobile remote connection records", () => { label: "The Knife Mobile", deviceType: "mobile", os: "iOS", - osMajorVersion: 18, - deviceModel: "iPhone 15 Pro", surface: "mobile", }); }); - it("includes only the Android major version and hardware model", () => { + it("identifies Android clients for authorized-client presentation", () => { mobilePlatform.OS = "android"; - mobileDevice.osVersion = "15.2.1"; - mobileDevice.modelName = "Pixel 9"; expect(authClientMetadata()).toMatchObject({ os: "Android", - osMajorVersion: 15, - deviceModel: "Pixel 9", }); }); it("identifies native tablets separately from phones", () => { mobileDevice.deviceType = mobileDevice.DeviceType.TABLET; - mobileDevice.modelName = "iPad Pro 13-inch"; expect(authClientMetadata()).toMatchObject({ deviceType: "tablet", os: "iOS", - deviceModel: "iPad Pro 13-inch", }); }); diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index f46c3709c0e378fc2d35ddefde436edae632d739..6c98055c97d76b6a1bff80ed991f53e3eedb5aab 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -7,7 +7,6 @@ import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; import * as Dpop from "../features/cloud/dpop"; import * as ManagedRelayLayer from "../features/cloud/managedRelayLayer"; import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; -import * as Tracing from "../features/observability/tracing"; import * as Persistence from "../persistence/layer"; import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh"; @@ -24,8 +23,7 @@ type RuntimeLayerSource = | typeof Socket.layerWebSocketConstructorGlobal | typeof Dpop.layer | typeof layerHttpClient - | typeof Persistence.layer - | typeof Tracing.layer; + | typeof Persistence.layer; const layerRuntime = Layer.merge( ManagedRelayLayer.layer(configuredRelayUrl()), @@ -33,7 +31,6 @@ const layerRuntime = Layer.merge( ).pipe( Layer.provideMerge(Dpop.layer), Layer.provideMerge(layerHttpClient), - Layer.provideMerge(Tracing.layer.pipe(Layer.provide(layerHttpClient))), Layer.provideMerge(Persistence.layer), ); diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts index c5a60e5416fdc84a723d0ede23dc3bbb67faea08..afffe7c225919f8a2bd3bc14146e61db18ae1abf 100644 --- a/apps/server/src/auth/http.test.ts +++ b/apps/server/src/auth/http.test.ts @@ -8,13 +8,11 @@ import { EnvironmentAuthenticatedAuth, EnvironmentHttpApi, } from "@t3tools/contracts"; -import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; @@ -165,9 +163,8 @@ it.effect("sets the selected browser session cookies through the HTTP route", () }).pipe(Effect.provide(NodeServices.layer)), ); -it.effect("exports only verified T3 Connect requests", () => +it.effect("keeps authenticated connection requests on the local tracer", () => Effect.gen(function* () { - const productSpans: Array = []; const localSpans: Array = []; const collect = (into: Array) => Tracer.make({ @@ -216,27 +213,18 @@ it.effect("exports only verified T3 Connect requests", () => }), ), ), - Effect.provideService(RelayClientTracer, Option.some(collect(productSpans))), Effect.withTracer(collect(localSpans)), ); yield* handle("DPoP connect"); - expect(productSpans).toEqual([ - "environment.relay.request", - "EnvironmentAuth.authenticateHttpRequest", - "environment.handler", - ]); - expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]); + expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]); - productSpans.length = 0; localSpans.length = 0; yield* handle("DPoP forged"); - expect(productSpans).toEqual([]); expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]); localSpans.length = 0; yield* handle("Bearer access-token"); - expect(productSpans).toEqual([]); expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 1c6814d0af32f9e694b1b7f79d34c69219873bcf..a6ef61f41ad360b618f8730c6eab44ce014d19b0 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -21,11 +21,9 @@ import { import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; import { parseOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; -import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Cookies from "effect/http/Cookies"; import * as HttpEffect from "effect/http/HttpEffect"; @@ -34,7 +32,6 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; -import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; @@ -233,7 +230,6 @@ export const layerAuthenticatedAuth = Layer.effect( return (httpEffect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const startTime = yield* Clock.currentTimeNanos; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid( @@ -245,16 +241,12 @@ export const layerAuthenticatedAuth = Layer.effect( failEnvironmentInternal("internal_error", error), ), ); - const endTime = yield* Clock.currentTimeNanos; - const handler = httpEffect.pipe( + return yield* httpEffect.pipe( Effect.provideService(EnvironmentAuthenticatedPrincipal, { ...session, scopes: new Set(session.scopes), }), ); - return yield* session.subject === "cloud-connect" - ? traceAuthenticatedRelayRequest(handler, { startTime, endTime }) - : handler; }).pipe(Effect.catchTags({ EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized })); }), ); @@ -397,7 +389,6 @@ export const layer = HttpApiBuilder.group( proofKeyThumbprint ? { proofKeyThumbprint } : undefined, ); }, - traceRelayRequest, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid( EnvironmentAuth.serverAuthCredentialReason(error), diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 9f812e615e0088c8942767f0ca3689b2f4727082..4432689f78f94c69395393f3f32fa382b9678ebc 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -7,7 +7,6 @@ import { import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; import * as Console from "effect/Console"; @@ -38,7 +37,6 @@ import { RELAY_URL_SECRET, } from "../cloud/config.ts"; import { relayUrlConfig } from "../cloud/publicConfig.ts"; -import * as RelayTracing from "../cloud/relayTracing.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; @@ -346,7 +344,6 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f httpClient.execute, Effect.flatMap(filterRelayResponse), Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), - withRelayClientTracing, ); return response.ok ? ({ status: "revoked" } satisfies RelayUnlinkResult) @@ -448,7 +445,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* - traceRelayRequest( - toHttpError(cloudLink.mintCredential(payload)).pipe( - Effect.tap(() => appendCloudCredentialResponseHeaders), - ), + toHttpError(cloudLink.mintCredential(payload)).pipe( + Effect.tap(() => appendCloudCredentialResponseHeaders), ), ); }), diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index 02276e87d6de88adef64601e985ba5c6d082502d..1d05a511bfa10105b48d0578656f1ddce53a6441 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -3,12 +3,7 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Result from "effect/Result"; -import { - hostedAppUrlConfig, - makeCloudCliOAuthConfig, - makeRelayUrlConfig, - resolveRelayClientTracingConfig, -} from "./publicConfig.ts"; +import { hostedAppUrlConfig, makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts"; const provideEnv = (env: Readonly>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))); @@ -144,39 +139,3 @@ it.effect("reports malformed Clerk publishable keys as typed configuration failu } }), ); - -it("resolves relay client tracing from runtime config with build-time fallback", () => { - const fallback = { - tracesUrl: "https://embedded.example.test/v1/traces", - tracesDataset: "embedded-dataset", - tracesToken: "embedded-token", - }; - - assert.deepEqual(resolveRelayClientTracingConfig({}, fallback), fallback); - assert.deepEqual( - resolveRelayClientTracingConfig( - { - T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://runtime.example.test/v1/traces", - T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", - T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", - }, - fallback, - ), - { - tracesUrl: "https://runtime.example.test/v1/traces", - tracesDataset: "runtime-dataset", - tracesToken: "runtime-token", - }, - ); - assert.equal( - resolveRelayClientTracingConfig( - { - T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "http://insecure.example.test/v1/traces", - T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", - T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", - }, - fallback, - ), - null, - ); -}); diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 903f4a88a1e07ee3ef86e04a4e5758faf87ed98b..395a6e1282efe82182672b59cdc0bf466a4c89f3 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -14,9 +14,6 @@ import * as SchemaIssue from "effect/SchemaIssue"; declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined; -declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined; -declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined; -declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338; const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; @@ -40,15 +37,6 @@ function readBuildTimeValue(value: string | undefined): string { return typeof value === "undefined" ? "" : value.trim(); } -function normalizeSecureUrl(value: string): string | null { - try { - const url = new URL(value); - return url.protocol === "https:" ? url.toString() : null; - } catch { - return null; - } -} - const buildTimeRelayUrl = typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" ? "" @@ -63,38 +51,6 @@ const buildTimeClerkCliOAuthClientId = readBuildTimeValue( ? undefined : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, ); -const buildTimeRelayClientTracing = { - tracesUrl: readBuildTimeValue( - typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__ === "undefined" - ? undefined - : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__, - ), - tracesDataset: readBuildTimeValue( - typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__ === "undefined" - ? undefined - : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__, - ), - tracesToken: readBuildTimeValue( - typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__ === "undefined" - ? undefined - : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__, - ), -} as const; - -export function resolveRelayClientTracingConfig( - env: Readonly> = process.env, - fallback = buildTimeRelayClientTracing, -) { - const tracesUrl = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() || fallback.tracesUrl; - const tracesDataset = - env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() || fallback.tracesDataset; - const tracesToken = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() || fallback.tracesToken; - const normalizedTracesUrl = normalizeSecureUrl(tracesUrl); - return normalizedTracesUrl && tracesDataset && tracesToken - ? { tracesUrl: normalizedTracesUrl, tracesDataset, tracesToken } - : null; -} - export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { const runtimeConfig = Config.NonEmptyString("T3CODE_RELAY_URL"); return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe( diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts deleted file mode 100644 index 1b76f0a228f65f952937ac54cc7cb6c49907e2a5..0000000000000000000000000000000000000000 --- a/apps/server/src/cloud/relayTracing.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as RelayTracing from "@t3tools/shared/relayTracing"; - -import { resolveRelayClientTracingConfig } from "./publicConfig.ts"; - -const relayClientTracingConfig = resolveRelayClientTracingConfig(); - -export const layerHeadlessRelayClient = RelayTracing.layer(relayClientTracingConfig, { - serviceName: "t3code-server", - runtime: "node", - client: "headless-cli", -}); - -export const layerServerRelayBroker = RelayTracing.layer(relayClientTracingConfig, { - serviceName: "t3code-server", - runtime: "node", - client: "environment-server", - component: "relay-broker", -}); diff --git a/apps/server/src/cloud/traceRelayRequest.test.ts b/apps/server/src/cloud/traceRelayRequest.test.ts deleted file mode 100644 index a65fd571a3bb60c49eb3f187445de95c513e45b1..0000000000000000000000000000000000000000 --- a/apps/server/src/cloud/traceRelayRequest.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Tracer from "effect/Tracer"; -import { HttpServerRequest } from "effect/http"; -import { RelayClientTracer } from "@t3tools/shared/relayTracing"; - -import { - traceAuthenticatedRelayRequest, - traceLocalHandlerWork, - traceRelayRequest, -} from "./traceRelayRequest.ts"; - -const AUTHENTICATION = { startTime: 1_000n, endTime: 2_000n }; - -describe("relay request tracing", () => { - it.effect("does not accept an unauthenticated request trace parent", () => - Effect.gen(function* () { - const spans: Array = []; - const productTracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const request = HttpServerRequest.fromWeb( - new Request("https://environment.example.test/api/t3-cloud/mint-credential", { - headers: { - traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", - }, - }), - ); - - yield* traceRelayRequest(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, request), - Effect.provideService(RelayClientTracer, Option.some(productTracer)), - ); - - expect(spans).toHaveLength(1); - const span = spans[0]!; - expect(span.traceId).not.toBe("0123456789abcdef0123456789abcdef"); - expect(Option.isNone(span.parent)).toBe(true); - }), - ); - - it.effect("continues an authenticated relay trace with the product tracer", () => - Effect.gen(function* () { - const spans: Array = []; - const productTracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const request = HttpServerRequest.fromWeb( - new Request("https://environment.example.test/api/t3-cloud/mint-credential", { - headers: { - traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", - }, - }), - ); - - yield* traceAuthenticatedRelayRequest( - Effect.void.pipe(Effect.withSpan("relay.mint.handler")), - AUTHENTICATION, - ).pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, request), - Effect.provideService(RelayClientTracer, Option.some(productTracer)), - ); - - expect(spans.map((span) => span.name)).toEqual([ - "environment.relay.request", - "EnvironmentAuth.authenticateHttpRequest", - "relay.mint.handler", - ]); - const [relaySpan, authentication, handler] = spans; - expect(Option.getOrUndefined(authentication!.parent)?.spanId).toBe(relaySpan!.spanId); - expect(relaySpan!.traceId).toBe("0123456789abcdef0123456789abcdef"); - expect(Option.getOrUndefined(relaySpan!.parent)?.spanId).toBe("0123456789abcdef"); - expect(Option.getOrUndefined(handler!.parent)?.spanId).toBe(relaySpan!.spanId); - }), - ); -}); - -describe("relay request tracing boundary", () => { - it.effect("exports a T3 Connect handler span but not its local work", () => - Effect.gen(function* () { - const productSpans: Array = []; - const localSpans: Array = []; - const collect = (into: Array) => - Tracer.make({ - span: (options) => { - into.push(options.name); - return new Tracer.NativeSpan(options); - }, - }); - const request = HttpServerRequest.fromWeb( - new Request("https://environment.example.test/api/orchestration/threads/thread-1"), - ); - - // Shaped like a real handler: an Effect.fn span whose service call runs - // on the local tracer. - const handler = Effect.fn("environment.orchestration.threadSnapshot")(function* () { - yield* Effect.void.pipe( - Effect.withSpan("sql.execute"), - Effect.withSpan("ServerSecretStore.get"), - traceLocalHandlerWork, - ); - }); - - yield* traceAuthenticatedRelayRequest(handler(), AUTHENTICATION).pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, request), - Effect.provideService(RelayClientTracer, Option.some(collect(productSpans))), - Effect.withTracer(collect(localSpans)), - ); - - expect(productSpans).toEqual([ - "environment.relay.request", - "EnvironmentAuth.authenticateHttpRequest", - "environment.orchestration.threadSnapshot", - ]); - expect(localSpans).toEqual(["ServerSecretStore.get", "sql.execute"]); - }), - ); -}); diff --git a/apps/server/src/cloud/traceRelayRequest.ts b/apps/server/src/cloud/traceRelayRequest.ts deleted file mode 100644 index 8a25ba6e6a4517ea9c38b1ac557553bfae901902..0000000000000000000000000000000000000000 --- a/apps/server/src/cloud/traceRelayRequest.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - RelayClientTracer, - withLocalTracing, - withRelayClientTracing, -} from "@t3tools/shared/relayTracing"; -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Tracer from "effect/Tracer"; -import { HttpServerRequest, HttpTraceContext } from "effect/http"; - -/** - * Exports every span of a handler that is itself T3 Connect work (the token - * exchange, the environment descriptor, credential minting), so the whole - * connection path can be measured. - */ -export const traceRelayRequest = ( - effect: Effect.Effect, -): Effect.Effect => effect.pipe(withRelayClientTracing); - -/** - * Traces a request that arrived over T3 Connect, continuing the client's - * trace. The request's span and its authentication timing are exported, so - * their latency and errors are visible; what the handler then does on the - * user's machine (database reads, project indexing, processes) is not, unless - * the handler is connection work and opts back in with {@link traceRelayRequest}. - * - * Call it only after the session is verified as T3 Connect: an unverified - * request must not add spans to a trace it names. Authentication therefore - * runs on the local tracer, and its timing is recorded here afterwards. - */ -export const traceAuthenticatedRelayRequest = ( - effect: Effect.Effect, - authentication: { readonly startTime: bigint; readonly endTime: bigint }, -): Effect.Effect => - Effect.all([RelayClientTracer, HttpServerRequest.HttpServerRequest]).pipe( - Effect.flatMap(([tracer, request]): Effect.Effect => { - if (Option.isNone(tracer)) return effect; - const parent = HttpTraceContext.fromHeaders(request.headers); - const sampled = Option.isNone(parent) || parent.value.sampled; - const startSpan = ( - name: string, - kind: Tracer.SpanKind, - parent: Option.Option, - ) => - tracer.value.span({ - name, - parent, - annotations: Context.empty(), - links: [], - startTime: authentication.startTime, - kind, - root: Option.isNone(parent), - sampled, - }); - const span = startSpan("environment.relay.request", "server", parent); - startSpan("EnvironmentAuth.authenticateHttpRequest", "internal", Option.some(span)).end( - authentication.endTime, - Exit.void, - ); - return effect.pipe( - Effect.withParentSpan(span), - Effect.onExit((exit) => - Clock.currentTimeNanos.pipe(Effect.map((endTime) => span.end(endTime, exit))), - ), - withRelayClientTracing, - ); - }), - ); - -/** - * Keeps the work inside a relay-traced handler on the local tracer. The - * handler's span still records the timing and outcome. - */ -export const traceLocalHandlerWork = withLocalTracing; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index f4290a697ea82163c04f76e013e4741542415f2f..c10e851ff9e6fcf3a49f702b175d13cdbe4af820 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -48,7 +48,6 @@ export interface ServerDerivedPaths { readonly providerLogsDir: string; readonly providerEventLogPath: string; readonly terminalLogsDir: string; - readonly anonymousIdPath: string; readonly environmentIdPath: string; readonly serverRuntimeStatePath: string; readonly secretsDir: string; @@ -163,7 +162,6 @@ export const deriveServerPaths = Effect.fn(function* ( providerLogsDir, providerEventLogPath: join(providerLogsDir, "events.log"), terminalLogsDir: join(logsDir, "terminals"), - anonymousIdPath: join(stateDir, "anonymous-id"), environmentIdPath: join(stateDir, "environment-id"), serverRuntimeStatePath: join(stateDir, "server-runtime.json"), secretsDir: join(stateDir, "secrets"), @@ -185,7 +183,6 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), - fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }), ], { concurrency: "unbounded" }, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 7512de5b500e7ee83391615d7adc1fd157136f14..dbccfed69da9862e06f7faaf70201009f3cfb69a 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -57,7 +57,6 @@ import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as SharedAccounts from "./provider/SharedAccounts.ts"; import * as SharedProfiles from "./provider/SharedProfiles.ts"; -import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; import { annotateEnvironmentRequest, failEnvironmentScopeRequired, @@ -534,7 +533,7 @@ export const layerServerEnvironmentHttpApi = HttpApiBuilder.group( Effect.fn("environment.metadata.descriptor")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); return yield* serverEnvironment.getDescriptor; - }, traceRelayRequest), + }), ); }), ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts index ea949828076da2c0c129b4d8a8f2debd4bd7453f..c53c41143cf77ca919b5955ef1bc35995abdd274 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts @@ -173,7 +173,6 @@ export function makeReplayServerConfig( providerLogsDir, providerEventLogPath: path.join(providerLogsDir, "events.log"), terminalLogsDir, - anonymousIdPath: path.join(stateDir, "anonymous-id"), environmentIdPath: path.join(stateDir, "environment-id"), serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"), secretsDir: path.join(stateDir, "secrets"), diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index f2a8ed7ff0e18c6915d756710af7cae8ba400312..3eabc17e585dbe0e47fea1dd23aa877d26762b89 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -143,106 +143,6 @@ function threadCreatedEvent( const layer = it.layer(layerTest); -it.effect("records accepted billed turn usage once without billing the context window", () => { - const recorded: Array>> = []; - const layerAnalytics = Layer.succeed(ProviderEventIngestor.ProviderTurnAnalytics, { - record: (properties: Readonly>) => - Effect.sync(() => { - recorded.push(properties); - }), - }); - return Effect.gen(function* () { - const now = yield* DateTime.now; - const eventSink = yield* EventSink.EventSinkV2; - const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const threadEvent = yield* threadCreatedEvent(now); - yield* eventSink.write({ events: [threadEvent] }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId: threadEvent.threadId, - }); - const providerThreadId = idAllocator.derive.providerThread({ - driver: CODEX_DRIVER, - nativeThreadId: "billing-thread", - }); - const providerTurn = { - id: idAllocator.derive.providerTurn({ driver: CODEX_DRIVER, nativeTurnId: "billing-turn" }), - providerThreadId, - nodeId: NodeId.make("node:billing-turn"), - runAttemptId: null, - nativeTurnRef: null, - ordinal: 1, - status: "completed" as const, - startedAt: now, - completedAt: DateTime.makeUnsafe(DateTime.toEpochMillis(now) + 120), - tokenUsage: { - inputTokens: 4000, - cachedInputTokens: 3000, - outputTokens: 100, - usedTokens: 4100, - updatedAt: DateTime.formatIso(now), - }, - turnTokenUsage: { - usageStatus: "complete" as const, - usageScope: "main_agent" as const, - hasSubagents: false, - inputTokens: 40, - cachedInputTokens: 30, - outputTokens: 10, - }, - }; - const input = { - providerSessionId, - providerInstanceId: modelSelection.instanceId, - threadId: threadEvent.threadId, - analyticsContext: { - modelSelection, - runtimeMode: "full-access" as const, - interactionMode: "default" as const, - }, - event: { type: "provider_turn.updated" as const, driver: CODEX_DRIVER, providerTurn }, - }; - yield* ingestor.ingestNormalized(input); - yield* ingestor.ingestNormalized(input); - const ignored = yield* ingestor.ingestNormalized({ - ...input, - event: { - ...input.event, - providerTurn: { - ...providerTurn, - id: idAllocator.derive.providerTurn({ - driver: CODEX_DRIVER, - nativeTurnId: "stale-billing-turn", - }), - }, - }, - writeIfRunCurrent: { - runId: RunId.make("missing-run"), - activeAttemptId: RunAttemptId.make("stale-attempt"), - expectedStatus: "running", - }, - }); - assert.isEmpty(ignored); - assert.lengthOf(recorded, 1); - assert.deepEqual(recorded[0], { - provider: CODEX_DRIVER, - terminalStatus: "completed", - usageStatus: "complete", - usageScope: "main_agent", - hasSubagents: false, - inputTokens: 40, - cachedInputTokens: 30, - outputTokens: 10, - model: modelSelection.model, - mixedModels: false, - runtimeMode: "full-access", - interactionMode: "default", - durationMs: 120, - }); - }).pipe(Effect.provide(layerTest.pipe(Layer.provide(layerAnalytics)))); -}); - layer("ProviderEventIngestorV2", (it) => { it.effect("normalizes provider events through the real event log and projection store", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index ddf2efbcff8bf1f2b2c65fd35750790f84e05972..b8fc47aa92233e1998a64d5ca7b2e31629a402b9 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -5,12 +5,9 @@ import { OrchestrationV2StoredEvent, type OrchestrationV2PlanArtifact, type OrchestrationV2Run, - type OrchestrationV2ProviderTurn, + type ModelSelection, type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, - type ModelSelection, - type RuntimeMode, - type ProviderInteractionMode, ProviderInstanceId, ProviderSessionId, ProviderThreadId, @@ -27,8 +24,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { AgentScope } from "@t3tools/shared/AgentScope"; -import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; -import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import { modelSelectionsEqual } from "@t3tools/shared/model"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -71,71 +67,6 @@ export const ProviderEventIngestorV2Error = Schema.Union([ ]); export type ProviderEventIngestorV2Error = typeof ProviderEventIngestorV2Error.Type; -export interface ProviderTurnAnalyticsContext { - readonly modelSelection: ModelSelection; - readonly runtimeMode?: RuntimeMode; - readonly interactionMode?: ProviderInteractionMode; -} - -export class ProviderTurnAnalytics extends Context.Reference<{ - readonly record: (properties: Readonly>) => Effect.Effect; -}>("t3/orchestration-v2/ProviderTurnAnalytics", { - defaultValue: () => ({ record: () => Effect.void }), -}) {} - -export const layerAnalytics = Layer.effect( - ProviderTurnAnalytics, - Effect.gen(function* () { - const analytics = yield* AnalyticsService.AnalyticsService; - return { - record: (properties: Readonly>) => - analytics.record("provider.turn.completed", properties), - }; - }), -); - -function providerTurnAnalyticsProperties(input: { - readonly driver: ProviderAdapter.ProviderAdapterV2Event["driver"]; - readonly providerTurn: OrchestrationV2ProviderTurn; - readonly context?: ProviderTurnAnalyticsContext; -}): Readonly> { - const usage = input.providerTurn.turnTokenUsage; - const modelSelection = input.context?.modelSelection; - const effort = modelSelection - ? (getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? - getModelSelectionStringOptionValue(modelSelection, "effort")) - : undefined; - return { - provider: input.driver, - terminalStatus: input.providerTurn.status, - usageStatus: usage?.usageStatus ?? "unavailable", - usageScope: usage?.usageScope ?? "main_agent", - ...(usage ? { hasSubagents: usage.hasSubagents } : {}), - ...(usage?.inputTokens === undefined ? {} : { inputTokens: usage.inputTokens }), - ...(usage?.cachedInputTokens === undefined - ? {} - : { cachedInputTokens: usage.cachedInputTokens }), - ...(usage?.cacheCreationTokens === undefined - ? {} - : { cacheCreationTokens: usage.cacheCreationTokens }), - ...(usage?.outputTokens === undefined ? {} : { outputTokens: usage.outputTokens }), - ...(usage?.reasoningTokens === undefined ? {} : { reasoningTokens: usage.reasoningTokens }), - ...(modelSelection ? { model: modelSelection.model, mixedModels: false } : {}), - ...(effort ? { effort } : {}), - ...(input.context?.runtimeMode ? { runtimeMode: input.context.runtimeMode } : {}), - ...(input.context?.interactionMode ? { interactionMode: input.context.interactionMode } : {}), - ...(input.providerTurn.startedAt && input.providerTurn.completedAt - ? { - durationMs: Math.max( - 0, - DateTime.toEpochMillis(input.providerTurn.completedAt) - - DateTime.toEpochMillis(input.providerTurn.startedAt), - ), - } - : {}), - }; -} - type TodoListPlan = Extract; function withPlanStepDurations( @@ -260,7 +191,6 @@ export interface ProviderEventIngestInput { readonly nodeId?: NodeId; readonly rawEventId?: RawEventId; readonly event: ProviderAdapter.ProviderAdapterV2Event; - readonly analyticsContext?: ProviderTurnAnalyticsContext; } export interface ProviderEventIngestorV2Shape { @@ -317,9 +247,7 @@ export const layer: Layer.Layer< const projections = yield* ProjectionStore.ProjectionStoreV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; - const analytics = yield* ProviderTurnAnalytics; const agentScope = yield* AgentScope; - const completedTurnAnalytics = new Set(); const makeDomainEvent = ( input: ProviderEventIngestInput, @@ -795,35 +723,6 @@ export const layer: Layer.Layer< ), ), ), - Effect.tap((storedEvents) => - Effect.gen(function* () { - if (storedEvents.length === 0 || input.event.type !== "provider_turn.updated") return; - const providerTurn = input.event.providerTurn; - if ( - providerTurn.status !== "completed" && - providerTurn.status !== "failed" && - providerTurn.status !== "interrupted" && - providerTurn.status !== "cancelled" - ) - return; - const key = `${input.providerInstanceId}:${providerTurn.id}`; - if (completedTurnAnalytics.has(key)) return; - completedTurnAnalytics.add(key); - if (completedTurnAnalytics.size > 4096) { - const oldest = completedTurnAnalytics.values().next().value; - if (oldest !== undefined) completedTurnAnalytics.delete(oldest); - } - yield* analytics.record( - providerTurnAnalyticsProperties({ - driver: input.event.driver, - providerTurn, - ...(input.analyticsContext === undefined - ? {} - : { context: input.analyticsContext }), - }), - ); - }), - ), (effect) => input.event.type === "app_thread.created" ? threadCommands.withLock(input.event.appThread.id, effect) diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index b2fe77f6cd85b90d4f211fbfb450d845d2e8577e..0c4199a65011efb63cbf10444fb3f4d23d4b4108 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -1246,11 +1246,6 @@ export const layer: Layer.Layer< event.type === "provider_thread.updated" && event.providerThread.id === input.providerThread.id; const storedEvents = yield* providerEventIngestor.ingestNormalized({ - analyticsContext: { - modelSelection: input.modelSelection, - runtimeMode: input.runtimePolicy.runtimeMode, - interactionMode: input.runtimePolicy.interactionMode, - }, providerSessionId: input.providerSessionId, providerInstanceId: input.run.providerInstanceId, threadId: input.run.threadId, diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index cccec8e919c147fc658f68bdc5755788bf9808b4..37fc1ae07744bdfbff9c39c03c3585e3ca190bc7 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -17,7 +17,6 @@ import { requireEnvironmentScope, } from "../auth/http.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import { @@ -162,7 +161,6 @@ export const layer = HttpApiBuilder.group( yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); return yield* loadShellSnapshot().pipe( - traceLocalHandlerWork, Effect.catch((cause) => failEnvironmentInternal("orchestration_snapshot_failed", cause), ), @@ -177,7 +175,7 @@ export const layer = HttpApiBuilder.group( const snapshot = yield* loadThreadSnapshot( args.params.threadId, "orchestration_thread_snapshot_failed", - ).pipe(traceLocalHandlerWork); + ); return { snapshotSequence: snapshot.snapshotSequence, projection: snapshot.projection, @@ -191,7 +189,6 @@ export const layer = HttpApiBuilder.group( yield* requireEnvironmentScope(AuthOrchestrationReadScope); return yield* threadManagement.getThreadTranscript(args.params.threadId).pipe( Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment), - traceLocalHandlerWork, Effect.catch( Effect.fnUntraced(function* (error) { if (isThreadTranscriptTooLargeError(error)) { @@ -214,9 +211,7 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.threadBoundedSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId).pipe( - traceLocalHandlerWork, - ); + const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId); const bounded = buildBoundedThreadProjection({ projection: snapshot.projection, snapshotSequence: snapshot.snapshotSequence, @@ -250,7 +245,6 @@ export const layer = HttpApiBuilder.group( args.query.view === "conversation", ) .pipe( - traceLocalHandlerWork, Effect.catch( Effect.fnUntraced(function* (cause) { if (isThreadNotFound(cause)) diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 491a6446a4360dbafc791a3be40d76b6699ba5b1..de43296e80ac633f092703f35be4b5d3fc7a0c9b 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -146,7 +146,6 @@ export function makeReplayServerConfig( providerLogsDir, providerEventLogPath: path.join(providerLogsDir, "events.log"), terminalLogsDir, - anonymousIdPath: path.join(stateDir, "anonymous-id"), environmentIdPath: path.join(stateDir, "environment-id"), serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"), secretsDir: path.join(stateDir, "secrets"), diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index e1a04655d8353d1449ee18262dbbf09e890d033f..bec9c916f3d6a2347e8ba165a7e77cc7d28b137e 100644 --- a/apps/server/src/project/http.ts +++ b/apps/server/src/project/http.ts @@ -12,7 +12,6 @@ import { failEnvironmentInvalidRequest, requireEnvironmentScope, } from "../auth/http.ts"; -import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; import * as ProjectService from "./ProjectService.ts"; import { projectMutationOperation } from "./ProjectMutation.ts"; @@ -44,7 +43,6 @@ export const layer = HttpApiBuilder.group( yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); return yield* projects.snapshot.pipe( - traceLocalHandlerWork, Effect.catch((cause) => failEnvironmentInternal("project_snapshot_failed", cause)), ); }), @@ -57,9 +55,7 @@ export const layer = HttpApiBuilder.group( yield* requireEnvironmentScope(scope); } const operation = projectMutationOperation(projects, args.payload); - return yield* startup - .enqueueCommand(operation) - .pipe(traceLocalHandlerWork, Effect.catch(failProjectMutation)); + return yield* startup.enqueueCommand(operation).pipe(Effect.catch(failProjectMutation)); }), ); }), diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index 96465947b651e7bd46a6e7e810cee502070c9486..a00be2b5864927ab80371b85282a0bc0170a4e60 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -17,8 +17,6 @@ import { exportJWK, generateKeyPair, SignJWT } from "jose"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; const assertSameCallback = (actual: string | null, expected: string | null) => { @@ -33,7 +31,6 @@ const instanceId = ProviderInstanceId.make("managed-codex-test"); const makeHarnessFor = Effect.fnUntraced(function* ( instanceId: ProviderInstanceId, bytes: Map = new Map(), - failAnalytics = false, ) { const environmentId = environmentIds.get(bytes) ?? EnvironmentId.make(NodeCrypto.randomUUID()); environmentIds.set(bytes, environmentId); @@ -208,25 +205,11 @@ const makeHarnessFor = Effect.fnUntraced(function* ( const address = server.address(); if (!address || typeof address === "string") throw new Error("mock address"); origin = `http://127.0.0.1:${address.port}`; - const analyticsEvents: { - event: string; - properties: Readonly> | undefined; - }[] = []; - const analytics = AnalyticsService.AnalyticsService.of({ - record: (event, properties) => - failAnalytics - ? Effect.die("analytics unavailable") - : Effect.sync(() => { - analyticsEvents.push({ event, properties }); - }), - flush: Effect.void, - }); const auth = yield* makeCodexChatGptAuth({ instanceId, discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1`, }).pipe( - Effect.provideService(AnalyticsService.AnalyticsService, analytics), Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment), ); @@ -278,7 +261,6 @@ const makeHarnessFor = Effect.fnUntraced(function* ( }); return { auth, - analyticsEvents, secrets, environmentId, bytes, @@ -293,7 +275,7 @@ const makeHarnessFor = Effect.fnUntraced(function* ( }, "owner", { discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1` }, - ).pipe(Stream.provideService(AnalyticsService.AnalyticsService, analytics)), + ), finishCallback: (state: { authorizationUrl: string | null }) => Effect.promise(() => fetch(prepareCallback(state))), destination: Effect.gen(function* () { @@ -315,7 +297,6 @@ const makeHarnessFor = Effect.fnUntraced(function* ( discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1`, }).pipe( - Effect.provideService(AnalyticsService.AnalyticsService, analytics), Effect.provideService(ServerSecretStore.ServerSecretStore, destinationStore), Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment), ); @@ -1700,24 +1681,6 @@ it.effect("primary completes OAuth and destination imports and owns the refresh ); assert.isTrue(stopped); assert.strictEqual(imported.phase, "succeeded"); - assert.deepStrictEqual( - h.analyticsEvents.map(({ event }) => event), - [ - "chatgpt.auth.started", - "chatgpt.auth.completed", - "chatgpt.transfer.started", - "chatgpt.transfer.completed", - ], - ); - assert.isTrue( - h.analyticsEvents.every(({ properties }) => properties?.flow === "primary_handoff"), - ); - assert.strictEqual(h.analyticsEvents[1]?.properties?.outcome, "succeeded"); - assert.strictEqual(h.analyticsEvents[3]?.properties?.outcome, "succeeded"); - assert.notProperty(h.analyticsEvents[1]?.properties ?? {}, "connectedAccountCount"); - assert.strictEqual(h.analyticsEvents[1]?.properties?.intent, "different_account"); - assert.strictEqual(h.analyticsEvents[3]?.properties?.connectedAccountCount, 1); - assert.strictEqual(h.analyticsEvents[3]?.properties?.savedConnectionCount, 1); const saved = Option.getOrThrow(yield* destination.auth.read); assert.strictEqual(saved.clientId, result.profile.registration.clientId); assert.strictEqual(saved.refreshToken, "initial-refresh"); @@ -1839,214 +1802,13 @@ it.effect( ), ); -it.effect("records one anonymous auth outcome per success, failure, or cancellation", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - h.setInvalidNonce(); - yield* h.signIn; - yield* h.phase("failed"); - const { waiting } = yield* h.startRemote(); - yield* h.auth.controller.cancel("owner", waiting.flowId!); - yield* h.phase("cancelled"); - const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); - assert.deepStrictEqual( - completed.map(({ properties }) => properties?.outcome), - ["succeeded", "failed", "cancelled"], - ); - assert.strictEqual( - h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.started").length, - 3, - ); - assert.strictEqual(completed[1]?.properties?.failureStage, "verify"); - for (const { properties } of completed) { - assert.isNumber(properties?.durationMs); - assert.strictEqual(properties?.flow, "direct"); - assert.deepStrictEqual( - Object.keys(properties!).sort(), - [ - ...(properties?.outcome === "succeeded" - ? [ - "accountCountScope", - "connectedAccountCount", - "connectedConnectionCount", - "savedConnectionCount", - "unidentifiedConnectedConnectionCount", - ] - : []), - "callbackMode", - "durationMs", - ...(properties?.failureStage ? ["failureStage"] : []), - "flow", - "intent", - "outcome", - ].sort(), - ); - } - const serialized = JSON.stringify(h.analyticsEvents); - for (const secret of [ - "hidden@example.test", - "oaiapp_test", - "user-test", - "access_token", - "refresh_token", - ]) { - assert.notInclude(serialized, secret); - } - }), - ), -); - -it.effect("telemetry failures do not fail a verified ChatGPT sign-in", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarnessFor(instanceId, new Map(), true); - yield* h.signIn; - const state = yield* h.phase("succeeded"); - assert.strictEqual(state.phase, "succeeded"); - assert.isTrue(Option.isSome(yield* h.auth.read)); - }), - ), -); - -it.effect("counts accounts separately from saved connections across additions and reconnects", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - yield* h.signIn; - yield* h.phase("succeeded"); - h.setCallbackClientId("oaiapp_same_account"); - h.setIdentity("another-client-subject", "HIDDEN@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - h.setCallbackClientId("oaiapp_other_account"); - h.setIdentity("other-user", "other@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - yield* h.auth.controller.logout(Effect.void); - h.setCallbackClientId("oaiapp_test"); - h.setIdentity("user-test", "hidden@example.test"); - yield* h.reconnectProfile("oaiapp_test"); - yield* h.phase("succeeded"); - const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); - assert.deepStrictEqual( - completed.map(({ properties }) => [ - properties?.connectedAccountCount, - properties?.connectedConnectionCount, - properties?.savedConnectionCount, - ]), - [ - [1, 1, 1], - [1, 1, 1], - [1, 2, 2], - [2, 3, 3], - [1, 2, 3], - ], - ); - assert.isTrue( - completed.every(({ properties }) => properties?.unidentifiedConnectedConnectionCount === 0), - ); - assert.notInclude(JSON.stringify(h.analyticsEvents), "example.test"); - }), - ), -); - -it.effect("includes accounts from other Codex instances in the environment", () => - provision( - Effect.gen(function* () { - const bytes = new Map(); - const personal = yield* makeHarnessFor(instanceId, bytes); - const work = yield* makeHarnessFor(ProviderInstanceId.make("managed-work"), bytes); - yield* personal.signIn; - yield* personal.phase("succeeded"); - work.setCallbackClientId("oaiapp_work"); - work.setIdentity("work-user", "work@example.test"); - yield* work.signIn; - yield* work.phase("succeeded"); - const completed = work.analyticsEvents.find( - ({ event }) => event === "chatgpt.auth.completed", - ); - assert.strictEqual(completed?.properties?.accountCountScope, "environment"); - assert.strictEqual(completed?.properties?.connectedAccountCount, 2); - assert.strictEqual(completed?.properties?.connectedConnectionCount, 2); - assert.strictEqual(completed?.properties?.savedConnectionCount, 2); - }).pipe( - Effect.provide( - ServerSettings.layerTest({ - providerInstances: { - [instanceId]: { driver: "codex", enabled: true }, - [ProviderInstanceId.make("managed-work")]: { driver: "codex", enabled: true }, - }, - }), - ), - ), - ), -); - -it.effect("an unreadable unrelated profile omits counts without failing sign-in", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - const unrelated = yield* ProviderCredentialStore.make("codex-chatgpt", "codex").pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, h.secrets), - ); - // The harness owns its store; seed the corresponding binding in that store. - h.bytes.set(unrelated.binding.key, new TextEncoder().encode("invalid")); - yield* h.signIn; - yield* h.phase("succeeded"); - const completed = h.analyticsEvents.find(({ event }) => event === "chatgpt.auth.completed"); - assert.strictEqual(completed?.properties?.outcome, "succeeded"); - assert.notProperty(completed?.properties ?? {}, "connectedAccountCount"); - }).pipe(Effect.provide(ServerSettings.layerTest())), - ), -); - -it.effect("reports connections without an email separately from identifiable accounts", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, h.secrets), - ); - yield* store.set( - new TextEncoder().encode( - JSON.stringify({ - activeClientId: "oaiapp_test", - sessions: [{ ...Option.getOrThrow(yield* h.auth.read), email: null }], - }), - ), - ); - h.setCallbackClientId("oaiapp_other_account"); - h.setIdentity("other-user", "other@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - const completed = h.analyticsEvents.findLast( - ({ event }) => event === "chatgpt.auth.completed", - ); - assert.strictEqual(completed?.properties?.connectedAccountCount, 1); - assert.strictEqual(completed?.properties?.connectedConnectionCount, 2); - assert.strictEqual(completed?.properties?.unidentifiedConnectedConnectionCount, 1); - }), - ), -); - -it.effect("records an expired auth outcome when sign-in reaches its deadline", () => +it.effect("expires sign-in when it reaches its deadline", () => provision( Effect.gen(function* () { const h = yield* makeHarness; yield* h.startRemote(); yield* TestClock.adjust(300_001); assert.include((yield* h.phase("failed")).message ?? "", "expired"); - const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); - assert.strictEqual(completed.length, 1); - assert.strictEqual(completed[0]?.properties?.outcome, "expired"); - assert.isAtLeast(completed[0]?.properties?.durationMs as number, 300_000); }), ), ); diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 2eda1e31533dedc320fc5c623b17d13d8bab21d5..8ecf3f2b265b8f41c33de94201a5968542252008 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -10,9 +10,6 @@ import { } from "@t3tools/contracts"; import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; import * as Clock from "effect/Clock"; -import * as Cause from "effect/Cause"; -import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; -import * as Crypto from "effect/Crypto"; import * as Exit from "effect/Exit"; import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; import * as Effect from "effect/Effect"; @@ -25,10 +22,8 @@ import { HttpClient, HttpClientRequest } from "effect/http"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ServerSettings from "../serverSettings.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -const isSetupError = Schema.is(ProviderSetupError); const RESOURCE = "https://api.openai.com/v1"; const REQUIRED_SCOPE = "chatgpt.tokens.use.direct"; const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration"; @@ -105,53 +100,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* readonly resource?: string; readonly defaultReturnUrl?: string; readonly reconnectProfile?: ChatGptReconnectProfile | null; - readonly telemetryFlow?: "direct" | "primary_handoff"; }) { - const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService); - const settings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); - const track = ( - event: "auth" | "transfer", - properties: Readonly>, - task: Effect.Effect, - expiresAt?: number, - ) => - Effect.gen(function* () { - if (Option.isNone(analytics)) return yield* task; - const record = (name: string, properties: Readonly>) => - analytics.value.record(name, properties).pipe(Effect.ignoreCause); - const startedAt = yield* Clock.currentTimeMillis; - yield* record(`chatgpt.${event}.started`, properties); - return yield* task.pipe( - Effect.onExit((result) => - Effect.gen(function* () { - const endedAt = yield* Clock.currentTimeMillis; - const error = Exit.isFailure(result) - ? Cause.findErrorOption(result.cause) - : Option.none(); - const counts = - Exit.isSuccess(result) && - (event === "transfer" || options.telemetryFlow !== "primary_handoff") - ? yield* accountCounts.pipe(Effect.catchCause(() => Effect.succeed({}))) - : {}; - yield* record(`chatgpt.${event}.completed`, { - ...counts, - ...properties, - outcome: Exit.isSuccess(result) - ? "succeeded" - : Cause.hasInterruptsOnly(result.cause) - ? expiresAt !== undefined && endedAt >= expiresAt - ? "expired" - : "cancelled" - : "failed", - durationMs: Math.max(0, endedAt - startedAt), - ...(Option.isSome(error) && isSetupError(error.value) - ? { failureStage: error.value.operation } - : {}), - }); - }), - ), - ); - }); const http = yield* HttpClient.HttpClient; const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId); const registrationStore = yield* ProviderCredentialStore.make( @@ -159,7 +108,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* options.instanceId, ); const secrets = yield* ServerSecretStore.ServerSecretStore; - const crypto = yield* Crypto.Crypto; const environmentLocks = sessionLocks.get(secrets) ?? new Map(); sessionLocks.set(secrets, environmentLocks); const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1)); @@ -264,54 +212,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* ), ), ); - const accountCounts = Effect.gen(function* () { - const instanceIds = new Set([options.instanceId]); - if (Option.isSome(settings)) { - const current = yield* settings.value.getSettings; - // Include the legacy default instance as well as explicitly configured ones. - instanceIds.add("codex"); - for (const [id, instance] of Object.entries(current.providerInstances)) { - if (instance.driver === "codex") instanceIds.add(id); - } - } - const accounts = new Set(); - const connections = new Set(); - const savedConnections = new Set(); - let unidentifiedConnectedConnectionCount = 0; - for (const id of instanceIds) { - const registrations = yield* ProviderCredentialStore.make("codex-chatgpt-registration", id); - const registrationBytes = yield* registrations.get; - if (Option.isSome(registrationBytes)) { - const saved = yield* decodeRegistration(new TextDecoder().decode(registrationBytes.value)); - for (const profile of "profiles" in saved ? saved.profiles : [saved]) { - savedConnections.add(profile.clientId); - } - } - const credentials = yield* ProviderCredentialStore.make("codex-chatgpt", id); - const credentialBytes = yield* credentials.get; - if (Option.isNone(credentialBytes)) continue; - const saved = yield* decodeSessions(new TextDecoder().decode(credentialBytes.value)); - for (const session of "sessions" in saved ? saved.sessions : [saved]) { - if (!session.scopes.includes(REQUIRED_SCOPE) || connections.has(session.clientId)) continue; - connections.add(session.clientId); - // Subjects are client-scoped. Use verified email only for counting locally, - // never export it or merge the underlying profiles. - const email = session.email?.trim().toLowerCase(); - if (email) accounts.add(email); - else unidentifiedConnectedConnectionCount++; - } - } - return { - accountCountScope: Option.isSome(settings) ? "environment" : "provider_instance", - connectedAccountCount: accounts.size, - connectedConnectionCount: connections.size, - savedConnectionCount: savedConnections.size, - unidentifiedConnectedConnectionCount, - }; - }).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - Effect.provideService(Crypto.Crypto, crypto), - ); const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) { const saved = yield* readRegistrations; profile = { @@ -861,28 +761,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* })), ]; }), - authenticate: (method, context) => - lock.withPermit( - track( - "auth", - { - flow: options.telemetryFlow ?? "direct", - intent: - options.telemetryFlow === "primary_handoff" - ? options.reconnectProfile - ? "saved_profile" - : "different_account" - : method === "chatgpt-change-account" - ? "different_account" - : method.startsWith("chatgpt-profile:") - ? "saved_profile" - : "default", - callbackMode: context.callbackMode ?? "server", - }, - authenticate(method, context), - context.expiresAt, - ), - ), + authenticate: (method, context) => lock.withPermit(authenticate(method, context)), logout: lock.withPermit(withSessionLock(logout)), }); const reconnectProfile = Effect.fnUntraced(function* (methodId: string) { @@ -971,12 +850,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* yield* save(profile.credentials, true); }); return lock.withPermit( - track( - "transfer", - { flow: "primary_handoff" }, - validate.pipe( - Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)), - ), + validate.pipe( + Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)), ), ); }; diff --git a/apps/server/src/provider/CodexChatGptHandoff.ts b/apps/server/src/provider/CodexChatGptHandoff.ts index d2a9e042836a43ca163410edae3a73f6ffae4873..da9ad2edfb920c889bea7f2aecc2b4c805752151 100644 --- a/apps/server/src/provider/CodexChatGptHandoff.ts +++ b/apps/server/src/provider/CodexChatGptHandoff.ts @@ -35,7 +35,6 @@ export function subscribeChatGptHandoff( ...endpoints, instanceId: input.instanceId, reconnectProfile: input.profile, - telemetryFlow: "primary_handoff", defaultReturnUrl: input.returnUrl, }).pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, store), diff --git a/apps/server/src/pullRequest/http.ts b/apps/server/src/pullRequest/http.ts index 746e29a506a658cabc60c7a2064bb6f5bc40c3ac..8c0c28abd8a4499326250000c2714e6f416f3dce 100644 --- a/apps/server/src/pullRequest/http.ts +++ b/apps/server/src/pullRequest/http.ts @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; -import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; import * as PullRequestService from "./PullRequestService.ts"; /** 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( Effect.fn("environment.pullRequests.diff")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - return yield* pullRequests.diff(args.payload).pipe(traceLocalHandlerWork); + return yield* pullRequests.diff(args.payload); }), ); }), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index ae9d053ac1fb68dc4b76e9419bd42e3a0ef289ae..e55cd275e35b23d6c54f128853c260fced219e67 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -14,7 +14,6 @@ import { import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; import { turnItemUpdateCanEndBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { normalizeRelayIssuer, RELAY_ACTIVITY_PUBLISH_TYP, @@ -521,7 +520,7 @@ export const make = Effect.gen(function* () { ok: response.ok, deliveries: deliveryStats(response.deliveries), }); - }).pipe(Effect.withSpan("relay.agent_activity.publish"), withRelayClientTracing); + }).pipe(Effect.withSpan("relay.agent_activity.publish")); // Per-thread shell read: this publish runs for every activity-relevant // domain event, so materializing the full shell here would make the cost diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index 9fcf8344f50d7a769f0ac0e97dc2d83217b93f87..5aec227015ad2c7e0842bf4ce55acce97b4f1baa 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -7,7 +7,6 @@ import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage"; import type * as HttpServerRequest from "effect/http/HttpServerRequest"; import * as HttpServerResponse from "effect/http/HttpServerResponse"; import * as HttpTraceContext from "effect/http/HttpTraceContext"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as Metrics from "../observability/Metrics.ts"; @@ -100,8 +99,7 @@ export const layer = HttpApiBuilder.group( }) .pipe( Option.isSome(relayParent) - ? (effect) => - effect.pipe(Effect.withParentSpan(relayParent.value), withRelayClientTracing) + ? (effect) => effect.pipe(Effect.withParentSpan(relayParent.value)) : (effect) => effect, // Defects too, so the sender only ever sees the fixed error body. Effect.catchCause((cause) => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 47782e1e5a6c0977caadf5f31972de4548626397..ce907c44111caa2f5189d37c44e4ce40414fb073 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -67,8 +67,6 @@ import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts"; import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; -import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts"; @@ -162,7 +160,6 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as CloudHttp from "./cloud/http.ts"; import * as CloudLink from "./cloud/CloudLink.ts"; import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts"; -import * as RelayTracing from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import { MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER, @@ -510,7 +507,6 @@ const layerScheduledTaskWebhookOrigin = Layer.effect( const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( Layer.provide(layerScheduledTaskWebhookOrigin), - Layer.provide(ProviderEventIngestor.layerAnalytics), Layer.provide(layerCheckpointStore), Layer.provide(layerGitWorkflow), Layer.provide(ResourceCleanupService.layer), @@ -769,7 +765,6 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provideMerge(layerBackground), Layer.provideMerge(layerResourceDiagnostics), Layer.provideMerge(TraceDiagnostics.layer), - Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), Layer.provideMerge(DirectEndpoints.layer), @@ -1206,7 +1201,6 @@ const layerMakeServer = Layer.unwrap( ), ), Layer.provide(layerActivation), - Layer.provideMerge(RelayTracing.layerServerRelayBroker), Layer.provideMerge(layerHttpServer), Layer.provide(layerApplicationObservability), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 8313d33dc220dcda7b95a758ab022400c7e7d5bc..27e8a32cca8bb50a2854f4a195e92daa6f7a224c 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -30,7 +30,6 @@ import { ServerActivation } from "./serverActivation.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; 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 Effect.onInterrupt(() => Deferred.succeed(statusInterrupted, undefined)), ), }), - Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void }), NodeCrypto.layer, Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), Layer.mock(ExternalLauncher.ExternalLauncher)({}), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 84c002e3d60d562e731e1ce45ae48ba3cf93bf3c..93f93a0db5e9fb338714257df8e262b55a61f953 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -44,7 +44,6 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; import { forkParked, forkParkedFiber } from "./serverActivation.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { @@ -143,37 +142,6 @@ export const makeCommandGate = Effect.gen(function* () { } satisfies CommandGate; }); -const recordStartupHeartbeat = Effect.gen(function* () { - const analytics = yield* AnalyticsService.AnalyticsService; - const projects = yield* ProjectService.ProjectService; - const threads = yield* ThreadManagement.ThreadManagementService; - - const { threadCount, projectCount } = yield* Effect.all({ - projects: projects.snapshot, - threads: threads.getShellSnapshot(), - }).pipe( - Effect.map(({ projects: projectSnapshot, threads: shellSnapshot }) => ({ - projectCount: projectSnapshot.projects.length, - threadCount: shellSnapshot.threads.length + shellSnapshot.archivedThreads.length, - })), - Effect.catch((cause) => - Effect.logWarning("failed to gather V2 startup counts for telemetry", { - cause, - }).pipe( - Effect.as({ - threadCount: 0, - projectCount: 0, - }), - ), - ), - ); - - yield* analytics.record("server.boot.heartbeat", { - threadCount, - projectCount, - }); -}); - export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, @@ -581,13 +549,6 @@ const make = (options?: StartupOptions) => yield* forkParked( Effect.gen(function* () { - yield* Effect.logDebug("startup phase: recording startup heartbeat"); - yield* recordStartupHeartbeat.pipe( - Effect.withSpan("server.startup.heartbeat.record", { - attributes: { "startup.phase": "heartbeat.record" }, - }), - Effect.ignoreCause({ log: true }), - ); if (serverConfig.startupPresentation === "headless") { yield* Effect.logDebug("startup phase: headless access info"); const accessInfo = yield* issueHeadlessServeAccessInfo(); @@ -696,5 +657,3 @@ const make = (options?: StartupOptions) => export const layerWithOptions = (options?: StartupOptions) => Layer.effect(ServerRuntimeStartup, make(options)); - -const layer = layerWithOptions(); diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts deleted file mode 100644 index 73f844e4017cb69d61f3674aa11b035e459903ef..0000000000000000000000000000000000000000 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as ConfigProvider from "effect/ConfigProvider"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpServer from "effect/http/HttpServer"; -import * as HttpServerRequest from "effect/http/HttpServerRequest"; -import * as HttpServerResponse from "effect/http/HttpServerResponse"; -import * as HostProcess from "@t3tools/shared/HostProcess"; - -import * as ServerConfig from "../config.ts"; -import { getTelemetryIdentifier } from "./Identify.ts"; -import * as AnalyticsService from "./AnalyticsService.ts"; - -interface RecordedBatchRequest { - readonly path: string; - readonly body: { - readonly batch?: ReadonlyArray<{ - readonly event?: string; - readonly properties?: { - readonly index?: number; - readonly clientType?: string; - readonly serverOs?: string; - readonly serverArch?: string; - readonly serverAppVersion?: string; - readonly serverMode?: string; - readonly t3CodeVersion?: string; - }; - }>; - } | null; -} - -interface RecordedBatchBody { - readonly batch: ReadonlyArray<{ - readonly event?: string; - readonly properties?: { - readonly index?: number; - readonly clientType?: string; - readonly serverOs?: string; - readonly serverArch?: string; - readonly serverAppVersion?: string; - readonly serverMode?: string; - readonly t3CodeVersion?: string; - }; - }>; -} - -const SentBatch = Schema.fromJsonString( - Schema.Struct({ - batch: Schema.Array(Schema.Struct({ uuid: Schema.String })), - }), -); - -/** - * HTTP client that reads each batch, then fails as if the connection dropped - * before the response arrived. PostHog stores these batches, so the server - * must not send them forever. - */ -const layerAcceptThenFailClient = (batches: Array>) => - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.gen(function* () { - if (request.body._tag === "Uint8Array") { - const body = yield* Schema.decodeEffect(SentBatch)( - new TextDecoder().decode(request.body.body), - ).pipe(Effect.orDie); - batches.push(body.batch); - } - return yield* new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request, cause: "connection reset" }), - }); - }), - ), - ); - -it("retryDelayMs doubles from 2s and stays under the 5 minute cap", () => { - assert.equal(AnalyticsService.retryDelayMs(1, 0), 1_000); - assert.equal(AnalyticsService.retryDelayMs(2, 0.999_999), 4_000); - assert.equal(AnalyticsService.retryDelayMs(30, 0), 150_000); - assert.equal(AnalyticsService.retryDelayMs(30, 0.999_999), 300_000); -}); - -it.layer(NodeServices.layer)("AnalyticsService test", (it) => { - it.effect("a batch that keeps failing is retried with backoff, then dropped", () => - Effect.gen(function* () { - const batches: Array> = []; - const layerRuntime = AnalyticsService.layer.pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-retry-" }), - ), - Layer.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - T3CODE_TELEMETRY_ENABLED: true, - T3CODE_POSTHOG_KEY: "phc_test_key", - T3CODE_POSTHOG_HOST: "http://localhost", - }), - ), - ), - Layer.provide( - Layer.mergeAll( - Layer.succeed(HostProcess.Platform, "win32"), - Layer.succeed(HostProcess.Architecture, "x64"), - layerAcceptThenFailClient(batches), - ), - ), - ); - - yield* Effect.gen(function* () { - const analytics = yield* AnalyticsService.AnalyticsService; - for (let index = 0; index < 20; index += 1) { - yield* analytics.record("test.retry", { index }); - } - // Before the fix this loop sent the batch about once a second. - for (let second = 0; second < 600; second += 1) { - yield* TestClock.adjust("1 second"); - } - }).pipe(Effect.provide(layerRuntime)); - - assert.equal(batches.length, 5); - const uuids = batches.map((batch) => batch.map((event) => event.uuid).join(",")); - assert.equal(new Set(uuids).size, 1, "every retry carries the same uuids"); - assert.equal(new Set(batches[0]?.map((event) => event.uuid)).size, 20); - }), - ); - - it.effect("flush drains all buffered events across multiple batches", () => - Effect.gen(function* () { - const capturedRequests: Array = []; - const layerServerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-base-", - }); - - const layerTelemetry = AnalyticsService.layer.pipe(Layer.provideMerge(layerServerConfig)); - const layerConfig = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - T3CODE_TELEMETRY_ENABLED: true, - T3CODE_POSTHOG_KEY: "phc_test_key", - T3CODE_POSTHOG_HOST: "http://localhost", - T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20, - }), - ); - const layerBatchServer = HttpServer.serve( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.method !== "POST") { - return HttpServerResponse.empty({ status: 404 }); - } - - const payload = yield* request.json.pipe( - Effect.map((body) => body as RecordedBatchRequest["body"]), - Effect.orElseSucceed(() => null), - ); - - capturedRequests.push({ path: request.url, body: payload }); - - return HttpServerResponse.jsonUnsafe({}); - }), - ); - const layerRuntime = layerTelemetry.pipe( - Layer.provide(layerConfig), - Layer.provide( - Layer.mergeAll( - Layer.succeed(HostProcess.Platform, "linux"), - Layer.succeed(HostProcess.Architecture, "arm64"), - ), - ), - Layer.provideMerge(NodeHttpServer.layerTest), - ); - - yield* Effect.gen(function* () { - yield* Layer.launch(layerBatchServer).pipe(Effect.forkScoped); - const telemetryIdentifier = yield* getTelemetryIdentifier; - assert.equal(telemetryIdentifier !== null, true); - const analytics = yield* AnalyticsService.AnalyticsService; - - for (let index = 0; index < 45; index += 1) { - yield* analytics.record("test.flush.drain", { index }); - } - - yield* analytics.flush; - }).pipe(Effect.provide(layerRuntime)); - - const batchRequests = capturedRequests.filter( - (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => - Array.isArray(request.body?.batch), - ); - assert.equal(batchRequests.length, 3); - assert.equal( - batchRequests.every( - (request) => request.path.endsWith("/batch/") || request.path.endsWith("/batch"), - ), - true, - ); - const deliveredIndexes = batchRequests.flatMap((request) => - request.body.batch - .filter((event) => event.event === "test.flush.drain") - .map((event) => event.properties?.index) - .filter((index): index is number => typeof index === "number"), - ); - - const sorted = deliveredIndexes.toSorted((a, b) => a - b); - assert.equal(sorted.length, 45); - assert.deepEqual( - sorted, - Array.from({ length: 45 }, (_, index) => index), - ); - assert.equal( - batchRequests.every((request) => - request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"), - ), - true, - ); - assert.equal( - batchRequests.every((request) => - request.body.batch.every( - (event) => - event.properties?.serverOs === "Linux" && - event.properties.serverArch === "arm64" && - event.properties.serverAppVersion === event.properties.t3CodeVersion && - event.properties.serverMode === "web", - ), - ), - true, - ); - }), - ); - - it.effect("does not send batch requests when telemetry is disabled", () => - Effect.gen(function* () { - const capturedPaths: Array = []; - const layerServerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-disabled-", - }); - const layerTelemetry = AnalyticsService.layer.pipe(Layer.provideMerge(layerServerConfig)); - const layerConfig = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - T3CODE_TELEMETRY_ENABLED: false, - T3CODE_POSTHOG_KEY: "phc_test_key", - T3CODE_POSTHOG_HOST: "http://localhost", - }), - ); - const layerBatchServer = HttpServer.serve( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - capturedPaths.push(request.url); - return HttpServerResponse.jsonUnsafe({}); - }), - ); - const layerRuntime = layerTelemetry.pipe( - Layer.provide(layerConfig), - Layer.provide( - Layer.mergeAll( - Layer.succeed(HostProcess.Platform, "linux"), - Layer.succeed(HostProcess.Architecture, "arm64"), - ), - ), - Layer.provideMerge(NodeHttpServer.layerTest), - ); - - yield* Effect.gen(function* () { - yield* Layer.launch(layerBatchServer).pipe(Effect.forkScoped); - const analytics = yield* AnalyticsService.AnalyticsService; - yield* analytics.record("test.disabled", { index: 1 }); - yield* analytics.flush; - }).pipe(Effect.provide(layerRuntime)); - - assert.deepEqual(capturedPaths, []); - }), - ); -}); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts deleted file mode 100644 index f481385647033bd8ed7a2f638094c7c3fe2c25ca..0000000000000000000000000000000000000000 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { SERVER_VERSION } from "../version.ts"; -/** - * Anonymous PostHog telemetry service. - * - * Persists an installation-scoped anonymous identifier, buffers events in - * memory, and flushes batches over Effect's HTTP client. A failed batch is - * retried with backoff and dropped after a few tries. Each event carries a - * uuid, so PostHog can tell a retried copy from a new event. - * - * @module AnalyticsService - */ -import * as HostProcess from "@t3tools/shared/HostProcess"; -import type { ClientOs } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Config from "effect/Config"; -import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Random from "effect/Random"; -import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; -import * as Semaphore from "effect/Semaphore"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import * as HttpClientResponse from "effect/http/HttpClientResponse"; - -import * as ServerConfig from "../config.ts"; -import { getTelemetryIdentifier } from "./Identify.ts"; - -interface BufferedAnalyticsEvent { - readonly uuid: string; - readonly event: string; - readonly properties?: Readonly>; - readonly capturedAt: string; -} - -interface DeliveryState { - /** Batch that failed last. It is sent again before newer events. */ - readonly failedBatch: ReadonlyArray; - /** Failed sends of `failedBatch`. */ - readonly batchAttempts: number; - /** Failed sends since the last success. Sets the backoff delay. */ - readonly failures: number; - /** The background flush does not send before this time (epoch ms). */ - readonly retryAt: number; -} - -const FLUSH_INTERVAL_MS = 1_000; -// A hung send would hold the flush lock, and with it the shutdown flush. -const SEND_TIMEOUT = "10 seconds"; -const MAX_BATCH_ATTEMPTS = 5; -const RETRY_BASE_DELAY_MS = 2_000; -const RETRY_MAX_DELAY_MS = 300_000; - -/** - * Delay before the next send after `failures` consecutive failed sends. The - * ceiling doubles from 2s up to 5 minutes, and the delay is a random point in - * its upper half. `random` is in [0, 1). - */ -export function retryDelayMs(failures: number, random: number): number { - const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1)); - return Math.round(ceiling / 2 + (ceiling / 2) * random); -} - -const TelemetryEnvConfig = Config.all({ - posthogKey: Config.String("T3CODE_POSTHOG_KEY").pipe( - Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), - ), - posthogHost: Config.String("T3CODE_POSTHOG_HOST").pipe( - Config.withDefault("https://us.i.posthog.com"), - ), - enabled: Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)), - flushBatchSize: Config.Number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)), - maxBufferedEvents: Config.Number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( - Config.withDefault(1_000), - ), - wslDistroName: Config.String("WSL_DISTRO_NAME").pipe(Config.option), -}); - -export class AnalyticsService extends Context.Service< - AnalyticsService, - { - /** Record an anonymous event for best-effort buffered delivery. */ - readonly record: ( - event: string, - properties?: Readonly>, - ) => Effect.Effect; - - /** Flush all currently queued telemetry events. */ - readonly flush: Effect.Effect; - } ->()("t3/telemetry/AnalyticsService") { - /** No-op layer for callers that intentionally disable telemetry. */ - static readonly layerTest = Layer.succeed( - AnalyticsService, - AnalyticsService.of({ - record: () => Effect.void, - flush: Effect.void, - }), - ); -} - -function serverOsFromNodePlatform(platform: string): ClientOs { - switch (platform) { - case "darwin": - return "macOS"; - case "win32": - return "Windows"; - case "linux": - return "Linux"; - case "android": - return "Android"; - default: - return "other"; - } -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const telemetryConfig = yield* TelemetryEnvConfig; - const httpClient = yield* HttpClient.HttpClient; - const serverConfig = yield* ServerConfig.ServerConfig; - const identifier = yield* getTelemetryIdentifier; - const crypto = yield* Crypto.Crypto; - const bufferRef = yield* Ref.make>([]); - const deliveryRef = yield* Ref.make({ - failedBatch: [], - batchAttempts: 0, - failures: 0, - retryAt: 0, - }); - // The background flush and the shutdown flush must not send the same batch at once. - const flushLock = yield* Semaphore.make(1); - const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - const hostPlatform = yield* HostProcess.Platform; - const hostArchitecture = yield* HostProcess.Architecture; - - const enqueueBufferedEvent = ( - uuid: string, - event: string, - properties?: Readonly>, - ) => - Effect.flatMap(DateTime.now, (now) => - Ref.modify(bufferRef, (current) => { - const appended = [ - ...current, - { - uuid, - event, - ...(properties ? { properties } : {}), - capturedAt: DateTime.formatIso(now), - } satisfies BufferedAnalyticsEvent, - ]; - - const next = - appended.length > telemetryConfig.maxBufferedEvents - ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents) - : appended; - - return [ - { - size: next.length, - dropped: next.length !== appended.length, - } as const, - next, - ] as const; - }), - ); - - const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* ( - events: ReadonlyArray, - ) { - if (!telemetryConfig.enabled || !identifier) return; - - const payload = { - api_key: telemetryConfig.posthogKey, - batch: events.map((event) => ({ - uuid: event.uuid, - event: event.event, - distinct_id: identifier, - properties: { - ...event.properties, - $process_person_profile: false, - platform: hostPlatform, - wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), - arch: hostArchitecture, - t3CodeVersion: SERVER_VERSION, - clientType, - serverOs: serverOsFromNodePlatform(hostPlatform), - serverArch: hostArchitecture, - serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName), - serverAppVersion: SERVER_VERSION, - serverMode: serverConfig.mode, - }, - timestamp: event.capturedAt, - })), - }; - - yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe( - HttpClientRequest.bodyJson(payload), - Effect.flatMap(httpClient.execute), - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.timeout(SEND_TIMEOUT), - ); - }); - - const takeBatch = Ref.modify(bufferRef, (current) => { - const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); - return [nextBatch, current.slice(nextBatch.length)] as const; - }); - - // Sends batches until the buffer is empty or a send fails. A failed batch is - // kept for the next flush, and dropped after MAX_BATCH_ATTEMPTS failed sends. - const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { - while (true) { - const delivery = yield* Ref.get(deliveryRef); - const batch = delivery.failedBatch.length > 0 ? delivery.failedBatch : yield* takeBatch; - if (batch.length === 0) { - return; - } - - const sent = yield* Effect.result(sendBatch(batch)); - if (Result.isSuccess(sent)) { - yield* Ref.set(deliveryRef, { failedBatch: [], batchAttempts: 0, failures: 0, retryAt: 0 }); - continue; - } - - const failures = delivery.failures + 1; - const batchAttempts = delivery.batchAttempts + 1; - const retryAt = (yield* Clock.currentTimeMillis) + retryDelayMs(failures, yield* Random.next); - if (batchAttempts < MAX_BATCH_ATTEMPTS) { - yield* Ref.set(deliveryRef, { failedBatch: batch, batchAttempts, failures, retryAt }); - yield* Effect.logDebug("Failed to send telemetry batch; will retry", { - attempt: batchAttempts, - cause: sent.failure, - }); - return; - } - yield* Ref.set(deliveryRef, { failedBatch: [], batchAttempts: 0, failures, retryAt }); - yield* Effect.logWarning("Dropped telemetry batch after repeated send failures", { - events: batch.length, - attempts: batchAttempts, - cause: sent.failure, - }); - return; - } - }).pipe(flushLock.withPermit); - - const flushWhenDue = Effect.gen(function* () { - const { retryAt } = yield* Ref.get(deliveryRef); - if ((yield* Clock.currentTimeMillis) >= retryAt) { - yield* flush; - } - }); - - const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( - function* (event, properties) { - if (!telemetryConfig.enabled || !identifier) return; - - // Telemetry is best effort: an event without a uuid is not sent. The - // Node implementation throws (a defect) rather than failing, so catch both. - const uuid = yield* Effect.exit(crypto.randomUUIDv7()); - if (Exit.isFailure(uuid)) return; - - const enqueueResult = yield* enqueueBufferedEvent(uuid.value, event, properties); - if (enqueueResult.dropped) { - yield* Effect.logDebug("analytics buffer full; dropping oldest event", { - size: enqueueResult.size, - event, - }); - } - }, - ); - - yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), { - disableYield: true, - }).pipe(Effect.forkScoped); - - yield* Effect.addFinalizer(() => flush); - - return AnalyticsService.of({ record, flush }); -}); - -export const layer = Layer.effect(AnalyticsService, make); diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts deleted file mode 100644 index b556499547aada050f91c77240f39f73bad69824..0000000000000000000000000000000000000000 --- a/apps/server/src/telemetry/Identify.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Hex from "effect/encoding/Hex"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as References from "effect/References"; - -import * as ServerConfig from "../config.ts"; -import * as Identify from "./Identify.ts"; - -interface CapturedLog { - readonly message: unknown; - readonly annotations: Readonly>; -} - -const sha256 = Effect.fn("test.sha256")(function* (value: string) { - const crypto = yield* Crypto.Crypto; - return Hex.encode( - yield* crypto.digest("SHA-256", new TextEncoder().encode(value)).pipe(Effect.orDie), - ); -}); - -const makeCaptureLogger = (logs: CapturedLog[]) => - Logger.make(({ fiber, message }) => { - logs.push({ - message, - annotations: fiber.getRef(References.CurrentLogAnnotations), - }); - }); - -const findIdentityLog = ( - logs: ReadonlyArray, - source: Identify.TelemetryIdentitySource, - errorTag: string, -) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); - -it.layer(NodeServices.layer)("telemetry identity", (it) => { - it.effect("uses the persisted anonymous id when provider identities are absent", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const anonymousId = "persisted-anonymous-id"; - - yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); - - const identifier = yield* Identify.getTelemetryIdentifierForHome( - path.join(config.baseDir, "home"), - ); - - assert.equal(identifier, yield* sha256(anonymousId)); - }).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-anonymous-", - }), - ), - ), - ); - - it.effect("leaves no torn anonymous id behind when its write fails midway", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tornWrites = FileSystem.FileSystem.of({ - ...fileSystem, - writeFileString: (filePath, data, options) => - fileSystem - .writeFileString(filePath, data.slice(0, Math.floor(data.length / 2)), options) - .pipe( - Effect.andThen( - Effect.fail( - PlatformError.systemError({ - _tag: "WriteZero", - module: "FileSystem", - method: "writeFileString", - pathOrDescriptor: filePath, - }), - ), - ), - ), - }); - - const identifier = yield* Identify.getTelemetryIdentifierForHome( - path.join(config.baseDir, "home"), - ).pipe(Effect.provideService(FileSystem.FileSystem, tornWrites)); - - assert.isNull(identifier); - assert.isFalse(yield* fileSystem.exists(config.anonymousIdPath)); - }).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-torn-", - }), - ), - ), - ); - - it.effect.each([ - { - login: "an API key", - secret: "sk-private-openai-api-key", - authJson: (secret: string) => `{"auth_mode":"apikey","OPENAI_API_KEY":"${secret}"}`, - }, - { - login: "an agent identity", - secret: "private-agent-identity-jwt", - authJson: (secret: string) => - `{"auth_mode":"agentIdentity","OPENAI_API_KEY":null,"agent_identity":"${secret}"}`, - }, - { - login: "a personal access token", - secret: "private-personal-access-token", - authJson: (secret: string) => `{"OPENAI_API_KEY":null,"personal_access_token":"${secret}"}`, - }, - ])("falls back quietly when Codex authenticates with $login", ({ secret, authJson }) => { - const logs: CapturedLog[] = []; - const logger = makeCaptureLogger(logs); - - return Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDirectory = path.join(config.baseDir, "home"); - const codexAuthPath = path.join(homeDirectory, ".codex", "auth.json"); - const anonymousId = "tokenless-codex-anonymous-id"; - - yield* fileSystem.makeDirectory(path.dirname(codexAuthPath), { recursive: true }); - yield* fileSystem.writeFileString(codexAuthPath, authJson(secret)); - yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); - - const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); - - assert.equal(identifier, yield* sha256(anonymousId)); - assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError")); - assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityReadError")); - const allLogs = logs - .map((log) => - [String(log.message), ...Object.values(log.annotations).map(String)].join("\n"), - ) - .join("\n"); - assert.notInclude(allLogs, secret); - }).pipe( - Effect.provide( - Layer.merge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-tokenless-", - }), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); - - it.effect("logs structured decode context and falls back from malformed Codex auth", () => { - const logs: CapturedLog[] = []; - const logger = makeCaptureLogger(logs); - - return Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDirectory = path.join(config.baseDir, "home"); - const codexAuthPath = path.join(homeDirectory, ".codex", "auth.json"); - const anonymousId = "decode-fallback-anonymous-id"; - const privateAccessToken = "private-codex-access-token"; - - yield* fileSystem.makeDirectory(path.dirname(codexAuthPath), { recursive: true }); - yield* fileSystem.writeFileString( - codexAuthPath, - `{"tokens":{"access_token":"${privateAccessToken}"}}`, - ); - yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); - - const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); - - assert.equal(identifier, yield* sha256(anonymousId)); - const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"); - assert.isDefined(decodeLog); - assert.equal( - decodeLog?.message, - `Failed to decode codex telemetry identity at '${codexAuthPath}'.`, - ); - - assert.equal(decodeLog?.annotations.filePath, codexAuthPath); - assert.equal(decodeLog?.annotations.causeKind, "schema"); - assert.notProperty(decodeLog?.annotations ?? {}, "cause"); - const errorStack = decodeLog?.annotations.errorStack; - assert.isString(errorStack); - assert.include(errorStack, "Failed to decode codex telemetry identity"); - const annotations = Object.values(decodeLog?.annotations ?? {}) - .map(String) - .join("\n"); - assert.notInclude(annotations, privateAccessToken); - }).pipe( - Effect.provide( - Layer.merge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-decode-", - }), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); - - it.effect("does not overwrite the anonymous id path after a non-NotFound read failure", () => { - const logs: CapturedLog[] = []; - const logger = makeCaptureLogger(logs); - - return Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDirectory = path.join(config.baseDir, "home"); - - yield* fileSystem.makeDirectory(config.anonymousIdPath); - - const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); - - assert.isNull(identifier); - assert.deepEqual(yield* fileSystem.readDirectory(config.anonymousIdPath), []); - - const readLog = findIdentityLog(logs, "anonymous", "TelemetryIdentityReadError"); - assert.isDefined(readLog); - assert.equal(readLog?.annotations.filePath, config.anonymousIdPath); - assert.equal(readLog?.annotations.causeKind, "platform"); - assert.notEqual(readLog?.annotations.platformReason, "NotFound"); - assert.notProperty(readLog?.annotations ?? {}, "cause"); - const errorStack = readLog?.annotations.errorStack; - assert.isString(errorStack); - assert.include(errorStack, "Failed to read anonymous telemetry identity"); - assert.isUndefined( - findIdentityLog(logs, "anonymous", "TelemetryAnonymousIdPersistenceError"), - ); - }).pipe( - Effect.provide( - Layer.merge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-read-", - }), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); -}); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts deleted file mode 100644 index 6fd75eb25231644d553c4e5d28bae97cda81c853..0000000000000000000000000000000000000000 --- a/apps/server/src/telemetry/Identify.ts +++ /dev/null @@ -1,317 +0,0 @@ -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Hex from "effect/encoding/Hex"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; - -import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import * as ServerConfig from "../config.ts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; - -/** - * Codex writes `tokens` only for ChatGPT logins and omits the key for API-key, - * agent-identity, and personal-access-token logins, so its absence is a - * supported install and not a malformed file. - */ -const CodexAuthJsonSchema = Schema.Struct({ - tokens: Schema.optionalKey( - Schema.Struct({ - account_id: Schema.String, - }), - ), -}); - -const ClaudeJsonSchema = Schema.Struct({ - userID: Schema.String, -}); - -export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); -export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; - -class TelemetryIdentityReadError extends Schema.TaggedError()( - "TelemetryIdentityReadError", - { - source: TelemetryIdentitySource, - filePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`; - } -} - -class TelemetryIdentityDecodeError extends Schema.TaggedError()( - "TelemetryIdentityDecodeError", - { - source: Schema.Literals(["codex", "claude"]), - filePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to decode ${this.source} telemetry identity at '${this.filePath}'.`; - } -} - -export class TelemetryAnonymousIdGenerationError extends Schema.TaggedError()( - "TelemetryAnonymousIdGenerationError", - { - source: Schema.Literal("anonymous"), - filePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to generate anonymous telemetry identity for '${this.filePath}'.`; - } -} - -export class TelemetryAnonymousIdPersistenceError extends Schema.TaggedError()( - "TelemetryAnonymousIdPersistenceError", - { - source: Schema.Literal("anonymous"), - filePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to persist anonymous telemetry identity at '${this.filePath}'.`; - } -} - -export class TelemetryIdentityHashError extends Schema.TaggedError()( - "TelemetryIdentityHashError", - { - source: TelemetryIdentitySource, - algorithm: Schema.Literal("SHA-256"), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to hash ${this.source} telemetry identity with ${this.algorithm}.`; - } -} - -type TelemetryIdentityError = - | TelemetryIdentityReadError - | TelemetryIdentityDecodeError - | TelemetryAnonymousIdGenerationError - | TelemetryAnonymousIdPersistenceError - | TelemetryIdentityHashError; - -const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)); -const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)); - -function isNotFoundError(error: PlatformError.PlatformError): boolean { - return error.reason._tag === "NotFound"; -} - -const getTelemetryIdentityCauseAnnotations = (cause: unknown) => { - if (cause instanceof PlatformError.PlatformError) { - return { - causeKind: "platform", - platformReason: cause.reason._tag, - }; - } - if (cause instanceof Schema.SchemaError) { - return { causeKind: "schema" }; - } - return { causeKind: "other" }; -}; - -const logTelemetryIdentityError = (error: TelemetryIdentityError) => - Effect.logWarning(error.message).pipe( - Effect.annotateLogs({ - errorTag: error._tag, - source: error.source, - ...("filePath" in error ? { filePath: error.filePath } : {}), - ...getTelemetryIdentityCauseAnnotations(error.cause), - ...(error.stack === undefined ? {} : { errorStack: error.stack }), - }), - ); - -const readIdentityFile = ( - fileSystem: FileSystem.FileSystem, - source: TelemetryIdentitySource, - filePath: string, -) => - fileSystem.readFileString(filePath).pipe( - Effect.asSome, - Effect.catchTags({ - PlatformError: (cause) => - isNotFoundError(cause) - ? Effect.succeed(Option.none()) - : Effect.fail( - new TelemetryIdentityReadError({ - source, - filePath, - cause, - }), - ), - }), - ); - -const hash = (source: TelemetryIdentitySource, value: string) => - Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.digest("SHA-256", new TextEncoder().encode(value))), - Effect.map(Hex.encode), - Effect.mapError( - (cause) => - new TelemetryIdentityHashError({ - source, - algorithm: "SHA-256", - cause, - }), - ), - ); - -const getCodexAccountId = Effect.fn("TelemetryIdentity.getCodexAccountId")(function* ( - homeDirectory: string, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const authJsonPath = path.join(homeDirectory, ".codex", "auth.json"); - const encoded = yield* readIdentityFile(fileSystem, "codex", authJsonPath); - if (Option.isNone(encoded)) { - return Option.none(); - } - const authJson = yield* decodeCodexAuthJson(encoded.value).pipe( - Effect.mapError( - (cause) => - new TelemetryIdentityDecodeError({ - source: "codex", - filePath: authJsonPath, - cause, - }), - ), - ); - - return authJson.tokens === undefined - ? Option.none() - : Option.some(authJson.tokens.account_id); -}); - -const getClaudeUserId = Effect.fn("TelemetryIdentity.getClaudeUserId")(function* ( - homeDirectory: string, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const claudeJsonPath = path.join(homeDirectory, ".claude.json"); - const encoded = yield* readIdentityFile(fileSystem, "claude", claudeJsonPath); - if (Option.isNone(encoded)) { - return Option.none(); - } - const claudeJson = yield* decodeClaudeJson(encoded.value).pipe( - Effect.mapError( - (cause) => - new TelemetryIdentityDecodeError({ - source: "claude", - filePath: claudeJsonPath, - cause, - }), - ), - ); - - return Option.some(claudeJson.userID); -}); - -const upsertAnonymousId = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const { anonymousIdPath } = yield* ServerConfig.ServerConfig; - - const existing = yield* readIdentityFile(fileSystem, "anonymous", anonymousIdPath); - if (Option.isSome(existing)) { - return existing.value; - } - - const anonymousId = yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4()), - Effect.mapError( - (cause) => - new TelemetryAnonymousIdGenerationError({ - source: "anonymous", - filePath: anonymousIdPath, - cause, - }), - ), - ); - yield* writeFileStringAtomically({ filePath: anonymousIdPath, contents: anonymousId }).pipe( - Effect.mapError( - (cause) => - new TelemetryAnonymousIdPersistenceError({ - source: "anonymous", - filePath: anonymousIdPath, - cause, - }), - ), - ); - - return anonymousId; -}); - -/** - * getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value. - * 1. ~/.codex/auth.json tokens.account_id - * 2. ~/.claude.json userID - * 3. ~/.t3/telemetry/anonymous-id - * - * A missing file or an API-key-only Codex auth.json falls through quietly. Only - * unreadable or malformed files warn. - */ -export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")( - function* (homeDirectory: string) { - const codexAccountId = yield* getCodexAccountId(homeDirectory).pipe( - Effect.catchTags({ - TelemetryIdentityReadError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - TelemetryIdentityDecodeError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - }), - ); - if (Option.isSome(codexAccountId)) { - return yield* hash("codex", codexAccountId.value); - } - - const claudeUserId = yield* getClaudeUserId(homeDirectory).pipe( - Effect.catchTags({ - TelemetryIdentityReadError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - TelemetryIdentityDecodeError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - }), - ); - if (Option.isSome(claudeUserId)) { - return yield* hash("claude", claudeUserId.value); - } - - const anonymousId = yield* upsertAnonymousId.pipe( - Effect.asSome, - Effect.catchTags({ - TelemetryIdentityReadError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - TelemetryAnonymousIdGenerationError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - TelemetryAnonymousIdPersistenceError: (error) => - logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), - }), - ); - if (Option.isSome(anonymousId)) { - return yield* hash("anonymous", anonymousId.value); - } - - return null; - }, - Effect.tapError(logTelemetryIdentityError), - Effect.orElseSucceed(() => null), -); - -export const getTelemetryIdentifier = Effect.flatMap( - HostProcess.HomeDirectory, - getTelemetryIdentifierForHome, -); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7c26f559a7df9c6bea77f9189f9ac09a9962f46b..e27aa261c1476fa7e81f71cd98e42764d5d22a15 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -33,16 +33,11 @@ import { type AuthEnvironmentScope, type ScheduledTaskListResult, AuthSessionId, - ClientConnectionMethod, - ClientDeviceType, - ClientOs, ClientSurface, - ClientWebDeployment, type DiscoveredLocalServerList, type EditorId, type FileManagerRevealKind, type OrchestrationClientOrigin, - type OrchestrationV2Command, type GitActionProgressEvent, type GitManagerServiceError, type MessageId, @@ -224,7 +219,6 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -602,13 +596,7 @@ function toAuthAccessStreamEvent( } const isClientSurface = Schema.is(ClientSurface); -const isClientConnectionMethod = Schema.is(ClientConnectionMethod); -const isClientDeviceType = Schema.is(ClientDeviceType); -const isClientOs = Schema.is(ClientOs); -const isClientWebDeployment = Schema.is(ClientWebDeployment); const MAX_CLIENT_APP_VERSION_LENGTH = 64; -const MAX_CLIENT_BROWSER_LENGTH = 64; -const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; export function hasCompatibleOrchestrationProtocol(url: URL): boolean { return ( @@ -643,52 +631,6 @@ function readClientConnectionOrigin( }; } -// Client telemetry stays in this socket's RPC layer. It must not become a -// server-global "current client" because several client types can connect at once. -function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return {}; - } - - const surface = url.value.searchParams.get("clientSurface"); - const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; - const deviceType = url.value.searchParams.get("clientDeviceType"); - const os = url.value.searchParams.get("clientOs"); - const webDeployment = url.value.searchParams.get("clientWebDeployment"); - const browser = url.value.searchParams.get("clientBrowser")?.trim() ?? ""; - const connectionMethod = url.value.searchParams.get("connectionMethod"); - const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? ""; - const osMajorVersion = Number(rawOsMajorVersion); - const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? ""; - const isMobile = surface === "mobile"; - const hasOsMajorVersion = - isMobile && rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0; - const hasDeviceModel = - isMobile && deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH; - - return { - ...(isClientSurface(surface) ? { surface } : {}), - ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH - ? { appVersion, clientAppVersion: appVersion } - : {}), - ...(isClientOs(os) - ? { - clientOs: os, - ...(isMobile && (os === "iOS" || os === "Android") ? { os } : {}), - } - : {}), - ...(isClientDeviceType(deviceType) ? { clientDeviceType: deviceType } : {}), - ...(surface === "web" && isClientWebDeployment(webDeployment) ? { webDeployment } : {}), - ...(surface === "web" && browser !== "" && browser.length <= MAX_CLIENT_BROWSER_LENGTH - ? { clientBrowser: browser } - : {}), - ...(hasOsMajorVersion ? { osMajorVersion, clientOsMajorVersion: osMajorVersion } : {}), - ...(hasDeviceModel ? { deviceModel, clientDeviceModel: deviceModel } : {}), - ...(isClientConnectionMethod(connectionMethod) ? { connectionMethod } : {}), - }; -} - const canReplayPersistedRange = Effect.fnUntraced(function* ( afterSequence: number, headSequence: number, @@ -1200,7 +1142,6 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc const layerWsRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, - clientAnalyticsProps: Readonly>, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], serverBrowser: ServerBrowser.ServerBrowser["Service"], ) => @@ -1224,19 +1165,6 @@ const layerWsRpc = ( const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2; const mcpAppRequests = yield* McpAppRequests.McpAppRequests; - const analytics = yield* AnalyticsService.AnalyticsService; - // Client-origin attribution (#7774): every thread/turn the connecting - // client starts is credited to its surface + app version. Best-effort: - // attribution must never fail the user's command. - const originProps = clientAnalyticsProps; - const recordClientCommandAnalytics = (command: OrchestrationV2Command) => { - switch (command.type) { - case "message.dispatch": - return analytics.record("client.turn.requested", originProps).pipe(Effect.ignore); - default: - return Effect.void; - } - }; const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; @@ -1869,7 +1797,6 @@ const layerWsRpc = ( ).pipe(Effect.provide(intakeContext)), ) .pipe( - Effect.tap(() => recordClientCommandAnalytics(command)), Effect.map((result) => ({ sequence: result.sequence })), Effect.mapError((cause) => { const detail = userFacingDispatchErrorMessage(cause); @@ -2009,18 +1936,6 @@ const layerWsRpc = ( }).pipe(Effect.provide(intakeContext)), ) .pipe( - Effect.tap(() => - analytics - .record("client.thread.started", originProps) - .pipe( - Effect.andThen( - input.initialMessage === undefined - ? Effect.void - : analytics.record("client.turn.requested", originProps), - ), - Effect.ignore, - ), - ), Effect.map((result) => ({ ...result, projection: projectThreadProjectionForWire(result.projection), @@ -3178,7 +3093,6 @@ export const layer = Layer.unwrap( } const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; - const analytics = yield* AnalyticsService.AnalyticsService; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid( @@ -3191,9 +3105,7 @@ export const layer = Layer.unwrap( ), ); const clientOrigin = readClientConnectionOrigin(request); - const clientAnalyticsProps = readClientAnalyticsProps(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); - yield* analytics.record("client.connected", clientAnalyticsProps); const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe( @@ -3207,13 +3119,7 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - layerWsRpc( - session, - clientOrigin, - clientAnalyticsProps, - previewAutomationBroker, - serverBrowser, - ).pipe( + layerWsRpc(session, clientOrigin, previewAutomationBroker, serverBrowser).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Request fibers run in the handlers' context, so this reporter sees // their defects, not the rest of the server's. diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index bcb0208d9c8679cbab05ced427af61381ae5cfa4..5308fc03a750f512c83b8658ef18eb4fbf5f93ad 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -119,15 +119,6 @@ export default mergeConfig( __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify( repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "", ), - __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: JSON.stringify( - repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() ?? "", - ), - __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: JSON.stringify( - repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() ?? "", - ), - __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: JSON.stringify( - repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() ?? "", - ), }, }, test: { diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index 8c3b9073e3b4e82dbe12045cbb13b9abe3ffedd5..cbb24b9309d85dfab19f35dd9caa62c3d79d1f46 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -17,26 +17,12 @@ export interface CloudPublicConfig { readonly clerkPublishableKey: string | null; readonly clerkJwtTemplate: string | null; readonly relayUrl: string | null; - readonly relayTracing: { - readonly tracesUrl: string | null; - readonly tracesDataset: string | null; - readonly tracesToken: string | null; - }; } export function trimNonEmpty(value: string | undefined): string | null { return value?.trim() || null; } -function normalizeSecureUrl(value: string): string | null { - try { - const url = new URL(value); - return url.protocol === "https:" ? url.toString() : null; - } catch { - return null; - } -} - export function resolveCloudPublicConfig(): CloudPublicConfig { return { clerkPublishableKey: trimNonEmpty( @@ -46,29 +32,9 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { relayUrl: normalizeSecureRelayUrl( (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "", ), - relayTracing: { - tracesUrl: normalizeSecureUrl( - (import.meta.env.VITE_RELAY_OTLP_TRACES_URL as string | undefined) ?? "", - ), - tracesDataset: trimNonEmpty( - import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET as string | undefined, - ), - tracesToken: trimNonEmpty(import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN as string | undefined), - }, }; } -export function resolveRelayTracingConfig() { - const { relayTracing } = resolveCloudPublicConfig(); - return relayTracing.tracesUrl && relayTracing.tracesDataset && relayTracing.tracesToken - ? { - tracesUrl: relayTracing.tracesUrl, - tracesDataset: relayTracing.tracesDataset, - tracesToken: relayTracing.tracesToken, - } - : null; -} - export function hasCloudPublicConfig(): boolean { const config = resolveCloudPublicConfig(); return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); diff --git a/apps/web/src/components/clerk/BrowserManagedAuthShell.tsx b/apps/web/src/components/clerk/BrowserManagedAuthShell.tsx index 6534037072bfb4d4b00fbe6febb07c0aebc6b9af..6a53ebe3d944b5614e91c3ef01bbc506556b78a7 100644 --- a/apps/web/src/components/clerk/BrowserManagedAuthShell.tsx +++ b/apps/web/src/components/clerk/BrowserManagedAuthShell.tsx @@ -11,7 +11,7 @@ export default function BrowserManagedAuthShell({ readonly children: ReactNode; }) { return ( - + {children} ); diff --git a/apps/web/src/components/clerk/ElectronManagedAuthShell.tsx b/apps/web/src/components/clerk/ElectronManagedAuthShell.tsx index 71e69401ea1e8de4be932ecf3eb3f6aeb299b90d..2bf785bdb31af4eb7130bc8cca4eef6a07c08760 100644 --- a/apps/web/src/components/clerk/ElectronManagedAuthShell.tsx +++ b/apps/web/src/components/clerk/ElectronManagedAuthShell.tsx @@ -13,7 +13,12 @@ export default function ElectronManagedAuthShell({ readonly children: ReactNode; }) { return ( - + {children} ); diff --git a/apps/web/src/connection/clientMetadata.test.ts b/apps/web/src/connection/clientMetadata.test.ts index 9555e6e01cef35201dc85e3e5335a97128f54b90..4eca05748185a2bc2b85c1a7fcbd208cfb454b37 100644 --- a/apps/web/src/connection/clientMetadata.test.ts +++ b/apps/web/src/connection/clientMetadata.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - browserClientOs, - browserDeviceType, - browserFamily, - clientPresentationMetadata, -} from "./clientMetadata"; +import { browserClientOs, browserDeviceType, clientPresentationMetadata } from "./clientMetadata"; const desktopChrome = { userAgent: @@ -14,32 +9,28 @@ const desktopChrome = { maxTouchPoints: 0, }; -describe("client telemetry metadata", () => { - it("distinguishes hosted web from server-served web", () => { +describe("client presentation metadata", () => { + it("identifies web clients and omits the development app version", () => { expect( clientPresentationMetadata({ appVersion: "1.2.3", - hosted: true, identity: desktopChrome, desktopBridge: undefined, }), ).toMatchObject({ surface: "web", - webDeployment: "hosted", deviceType: "desktop", os: "Windows", - browser: "Chrome", appVersion: "1.2.3", }); expect( clientPresentationMetadata({ appVersion: "0.0.0", - hosted: false, identity: desktopChrome, desktopBridge: undefined, }), - ).toMatchObject({ surface: "web", webDeployment: "server" }); + ).not.toHaveProperty("appVersion"); }); it("identifies phone and tablet browsers", () => { @@ -64,7 +55,6 @@ describe("client telemetry metadata", () => { expect(browserDeviceType(iphone)).toBe("mobile"); expect(browserClientOs(iphone)).toBe("iOS"); - expect(browserFamily(iphone.userAgent)).toBe("Safari"); expect(browserDeviceType(androidTablet)).toBe("tablet"); expect(browserClientOs(androidTablet)).toBe("Android"); expect(browserDeviceType(ipadosDesktopUa)).toBe("tablet"); @@ -75,7 +65,6 @@ describe("client telemetry metadata", () => { expect( clientPresentationMetadata({ appVersion: "1.2.3", - hosted: false, identity: desktopChrome, desktopBridge: { getClientPlatform: () => "darwin" }, }), diff --git a/apps/web/src/connection/clientMetadata.ts b/apps/web/src/connection/clientMetadata.ts index d5d2562309d67428926dc8e28a23b82dcfb5f73f..259d2c5acff07582c9a532ab4c4c7d734c0394aa 100644 --- a/apps/web/src/connection/clientMetadata.ts +++ b/apps/web/src/connection/clientMetadata.ts @@ -44,17 +44,6 @@ export function browserClientOs(identity: BrowserIdentity): ClientOs { return "other"; } -export function browserFamily(userAgent: string): string { - if (userAgent.trim() === "") return "unknown"; - if (/edg(?:e|a|ios)?\//i.test(userAgent)) return "Edge"; - if (/opr\/|opios\//i.test(userAgent)) return "Opera"; - if (/samsungbrowser\//i.test(userAgent)) return "Samsung Internet"; - if (/firefox\/|fxios\//i.test(userAgent)) return "Firefox"; - if (/chrome\/|crios\//i.test(userAgent)) return "Chrome"; - if (/safari\//i.test(userAgent)) return "Safari"; - return "other"; -} - export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadataDeviceType { const userAgent = identity.userAgent; if (userAgent.trim() === "") return "unknown"; @@ -71,7 +60,6 @@ export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadata export function clientPresentationMetadata(input: { readonly appVersion: string; - readonly hosted: boolean; readonly identity: BrowserIdentity; readonly desktopBridge: Pick | undefined; }): AuthClientPresentationMetadata { @@ -90,8 +78,6 @@ export function clientPresentationMetadata(input: { deviceType: browserDeviceType(input.identity), os: browserClientOs(input.identity), surface: "web", - webDeployment: input.hosted ? "hosted" : "server", - browser: browserFamily(input.identity.userAgent), ...(input.appVersion === "0.0.0" ? {} : { appVersion: input.appVersion }), }; } diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index d975572d2fbddde2d5bdb8ff166a79eceab75e22..dbdeff377f0127f76d88b6cda4bda6e8ffdd870b 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -170,7 +170,6 @@ const layerWakeups = Wakeups.layer({ function clientMetadata() { return clientPresentationMetadata({ appVersion: APP_VERSION, - hosted: isHostedStaticApp(), identity: { userAgent: navigator.userAgent, platform: navigator.platform, diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index d983c716e526a9489c920f6a3e07b4d0170be31e..b2bf45c180c8a2c0199a7e9bc344374dec2f049c 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -4,13 +4,12 @@ import * as Layer from "effect/Layer"; import * as Socket from "effect/socket/Socket"; import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; -import * as RelayTracing from "@t3tools/shared/relayTracing"; import * as PrimaryEnvironmentHttpClient from "../environments/primary/httpClient"; import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; import * as Dpop from "../cloud/dpop"; import * as ManagedRelayLayer from "../cloud/managedRelayLayer"; -import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; +import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import * as ClientTracer from "../observability/clientTracer"; function configuredRelayUrl(): string { @@ -18,18 +17,10 @@ function configuredRelayUrl(): string { } const layerHttpClient = layerRemoteHttpClient((input, init) => globalThis.fetch(input, init)); -const layerRelayTracing = RelayTracing.layer(resolveRelayTracingConfig(), { - serviceName: "t3code-web", - serviceVersion: import.meta.env.APP_VERSION, - runtime: "browser", - client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", -}).pipe(Layer.provide(layerHttpClient)); - type RuntimeLayerSource = | typeof layerHttpClient | typeof Dpop.layer | typeof Socket.layerWebSocketConstructorGlobal - | typeof layerRelayTracing | typeof ClientTracer.layer | ReturnType; @@ -59,7 +50,6 @@ const layerRuntime = Layer.mergeAll( Dpop.layer, Socket.layerWebSocketConstructorGlobal, ClientTracer.layer, - layerRelayTracing, ManagedRelayLayer.layer(configuredRelayUrl()).pipe( Layer.provide(Layer.mergeAll(layerHttpClient, Dpop.layer)), ), diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 31260c8ae666fcb7814d8e078f4dac4a930a0f6d..e77bb9776f6ebe3de6716f5b1b4c26d3d8028c09 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -10,9 +10,6 @@ interface ImportMetaEnv { readonly VITE_CLERK_PUBLISHABLE_KEY: string; readonly VITE_CLERK_JWT_TEMPLATE: string; readonly VITE_CLERK_CLI_OAUTH_CLIENT_ID: string; - readonly VITE_RELAY_OTLP_TRACES_URL: string; - readonly VITE_RELAY_OTLP_TRACES_DATASET: string; - readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; readonly APP_VERSION: string; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 44e07a8e1792844183399a9fdbabda88188ac01c..60de9eeab2f0258ec2ef30f8ced30ffbbc4e50c4 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -39,9 +39,6 @@ const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || ""; -const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || ""; -const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || ""; -const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; const configuredHostedAppUrl = (() => { @@ -211,11 +208,6 @@ export default defineConfig(() => { "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify( configuredClerkCliOAuthClientId, ), - "import.meta.env.VITE_RELAY_OTLP_TRACES_URL": JSON.stringify(configuredRelayTracingUrl), - "import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET": JSON.stringify( - configuredRelayTracingDataset, - ), - "import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN": JSON.stringify(configuredRelayTracingToken), "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), diff --git a/docs/README.md b/docs/README.md index d7628960cb8b654c7372e5be46cb209c1ab441bd..0b1771c85eee7920961b308ffb008c6c3ddc1e0e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ - [Use T3 Code as your default browser](./user/default-browser.md) - [Devices](./user/devices.md) - [Usage and limits](./user/usage.md) -- [Product usage data](./user/telemetry.md) +- [Diagnostics and data sharing](./user/telemetry.md) - [Remote access](./user/remote-access.md) - [Outside agents (MCP)](./user/outside-agents.md) - [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 - [Remote environments](./internals/remote.md) - [Server updates](./internals/server-updates.md) - [Resource telemetry](./internals/resource-telemetry.md) -- [Product analytics](./internals/product-analytics.md) - [Environment auth](./internals/environment-auth.md) - [T3 Connect](./internals/t3-connect.md) - [Assistant citations](./internals/assistant-citations.md) diff --git a/docs/internals/product-analytics.md b/docs/internals/product-analytics.md deleted file mode 100644 index e9123640a541a411038e51c65ed3cbfe8d5c01bb..0000000000000000000000000000000000000000 --- a/docs/internals/product-analytics.md +++ /dev/null @@ -1,56 +0,0 @@ -# Product analytics - -The server owns PostHog delivery, opt-out, and identity for every connected client. -[Identity selection](../../apps/server/src/telemetry/Identify.ts) hashes an available -provider account ID, falling back to an installation-scoped ID. This identity can -span several clients; it does not identify a browser session. Clients do not load -the PostHog browser SDK. Client-use events require an authenticated connection, so -visiting the hosted app without connecting does not count as product use. - -## Attribution boundaries - -Client dimensions belong to the event's WebSocket connection. A server-global -"current client" would misattribute simultaneous web, desktop, and mobile use. -Provider execution has its own events because a turn can outlive the requesting -connection. - -Keep client and server dimensions separate. A desktop host can serve a phone or a -remote browser, and a direct connection can cross a network. Older clients omit -metadata. Missing client values must stay unknown rather than being backfilled -from server properties. The legacy `clientType` property describes how the server -runs; use `surface` for the connected client. - -## Interpreting events - -Use `client.turn.requested` for active-use reports. `client.connected` counts -reconnects, so network behavior can inflate it. One identity can appear in several -client groups during a period; adding those groups double-counts users. - -Provider send and completion counts need not match. Providers can emit synthetic -turns without a send request. Collection is best effort, with no scan or backfill -of provider history. - -Token totals cover the main agent. Child agents and model rerouting prevent a turn -from representing one provider/model combination's full cost. For provider -comparisons, require complete usage, no observed subagents, and no mixed models; -compare matching model, effort, interaction mode, and terminal status. Aggregate -output/input ratios should divide the summed totals. Averaging per-turn ratios -lets small-input turns dominate. - -Unknown counts stay absent. Partial usage contains valid observed counts but -cannot establish a whole-turn total. Keep these distinctions when changing token -normalization or building reports. - -## Delivery - -A send can fail after PostHog has stored the batch, so every retry is a copy. -[Delivery](../../apps/server/src/telemetry/AnalyticsService.ts) gives each event a -uuid when it is recorded, backs off after a failed send, and drops a batch after a -few tries. Without these limits, one stuck batch was sent every second for days. - -## Collection boundary - -Keep analytics payloads to product metadata and normalized measurements. Do not -send prompts, authentication material, raw provider payloads, user-assigned device -names, or conversation identifiers. Client metadata is best effort; invalid values -must not reject a connection. PostHog person profiles remain disabled. diff --git a/docs/operations/observability.md b/docs/operations/observability.md index eedb8732ea2717b9214a822c75896af8a2841935..33cd79cabe00f9543706836c08aa28f76a572bf3 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -418,8 +418,9 @@ Webhooks have their own families: `ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run started from a delivery is its own `ScheduledTaskService.runWebhookDelivery` trace. For a request -the relay forwarded, the span also goes to the T3 Connect trace export as a child of the relay's -span; requests that reach the environment directly never join a sender's trace. +the relay forwarded, the local span retains the relay's parent span for correlation; The Knife +does not export it to T3 Connect. Requests that reach the environment directly never join a +sender's trace. Use metrics when the question is: diff --git a/docs/operations/relay-observability.md b/docs/operations/relay-observability.md index 848a676acc94ce071824f50e3fbaba3dfe12551f..37077069053279fa7ff7d61ece6472358fc2314f 100644 --- a/docs/operations/relay-observability.md +++ b/docs/operations/relay-observability.md @@ -2,7 +2,9 @@ > For maintainers. Using T3 Code? See [docs/user](../user/). -The relay Alchemy stack owns a shared Axiom trace setup: +The relay Alchemy stack owns a shared Axiom trace setup. The Knife clients and environment +servers do not export to it; local diagnostics use the configured local or user-owned receiver. +The resources below belong to the upstream relay deployment: - `t3-code-relay-traces-prod`, the OpenTelemetry trace dataset shared by the Worker, mobile app, and first-party relay clients @@ -21,8 +23,7 @@ vp run deploy ``` Alchemy resolves account-level Axiom deployment credentials through its provider. At runtime, the -Worker receives only its scoped ingest token. Mobile and relay clients use their own separately -provisioned scoped ingest tokens. +Worker receives only its scoped ingest token. The Knife does not consume the provisioned mobile or relay-client ingest tokens. The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans. 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 `x-t3-hook-outcome` response header. A held request's delivery records the same on its `relay.inbox.deliver` span. The relay sends its own `traceparent` with each forward and drops any a sender supplied, and signs each forward (`x-t3-relay-delivery`) so the environment trusts the -relay's delivery id, receive time and trace context only from the relay, so on environments that export to T3 Connect, the environment's -`ScheduledTaskService.triggerWebhook` span lands in the same trace. `relay.hook.rate_limit` says which +relay's delivery id, receive time and trace context only from the relay. The environment records +its `ScheduledTaskService.triggerWebhook` span locally. `relay.hook.rate_limit` says which budget ran out: `endpoint` or `hook`. `relay.hook.rate_limiter_failed_open` is set when the Cloudflare rate limiter was unavailable and the request went through unlimited. diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md index 61925062d3e9b05a51ac6c1144955384f4072a89..31c69b9e74721c6598db8bed825003783f625f77 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -1,17 +1,8 @@ -# Product usage data +# Diagnostics and data sharing -The T3 Code server sends product usage events to PostHog, associated with a hashed account or -installation identifier. Events include the provider, model, reasoning effort, permission mode, -turn result, duration, and main-agent token totals when available. - -Events do not include prompts, responses, file contents, authentication tokens, conversation IDs, -raw provider events, or child-agent output. Child-agent token use is excluded from the totals. - -To disable collection, set `T3CODE_TELEMETRY_ENABLED=false` in the server's environment before -starting it. This stops product events from being recorded or sent. - -The desktop app reads the variable from your shell profile (for example `~/.zshrc`) on macOS and -Linux, so export it there and restart the app. On Windows, set it as a user environment variable. +The Knife does not collect or send product analytics. T3 Connect contacts its account and relay +services only when you use that connection option. Local diagnostics stay on your machine unless +you configure an export endpoint. ## Export diagnostics to your own receiver diff --git a/packages/client-runtime/src/authorization/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts index 44dbed210942aa331c49736ffc418615464212df..5bfcdaa507274a65a58419bbd33cfb6c1d2de1d0 100644 --- a/packages/client-runtime/src/authorization/remote.test.ts +++ b/packages/client-runtime/src/authorization/remote.test.ts @@ -304,7 +304,7 @@ describe("remote environment authorization", () => { deviceType: "desktop", os: "unknown", }); - expect(websocketUrl.searchParams.get("clientOs")).toBe("unknown"); + expect(websocketUrl.searchParams.get("clientOs")).toBeNull(); }), ); @@ -532,14 +532,11 @@ describe("remote environment authorization", () => { appVersion: "1.2.3", deviceType: "mobile", os: "Android", - osMajorVersion: 15, - deviceModel: "Pixel 9", }, - connectionMethod: "relay", }).pipe(provideRemoteHttp(fetch.fetchFn)); expect(url).toBe( - "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", + "wss://remote.example.com/ws?wsTicket=ws-ticket&clientSurface=mobile&clientAppVersion=1.2.3", ); }), ); diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 32a3cde3105fe33a42f0112ef392e61d1cc28368..9f19aa4d56bbec7130d10e671be011148e173279 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -3,7 +3,6 @@ import { type AuthClientPresentationMetadata, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, - type ClientConnectionMethod, type AuthEnvironmentScope, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; @@ -42,7 +41,6 @@ const clientMetadataTokenExchangeFields = ( export const appendClientConnectionParams = ( url: URL, clientMetadata: AuthClientPresentationMetadata | undefined, - connectionMethod?: ClientConnectionMethod, ): void => { if (clientMetadata?.surface) { url.searchParams.set("clientSurface", clientMetadata.surface); @@ -50,37 +48,6 @@ export const appendClientConnectionParams = ( if (clientMetadata?.appVersion) { url.searchParams.set("clientAppVersion", clientMetadata.appVersion); } - if (clientMetadata?.deviceType) { - const deviceType = - clientMetadata.deviceType === "mobile" - ? "phone" - : clientMetadata.deviceType === "desktop" || clientMetadata.deviceType === "tablet" - ? clientMetadata.deviceType - : "unknown"; - url.searchParams.set("clientDeviceType", deviceType); - } - if (clientMetadata?.os) { - url.searchParams.set("clientOs", clientMetadata.os); - } - if (clientMetadata?.surface === "web") { - if (clientMetadata.webDeployment) { - url.searchParams.set("clientWebDeployment", clientMetadata.webDeployment); - } - if (clientMetadata.browser) { - url.searchParams.set("clientBrowser", clientMetadata.browser); - } - } - if (clientMetadata?.surface === "mobile") { - if (clientMetadata.osMajorVersion !== undefined) { - url.searchParams.set("clientOsMajorVersion", String(clientMetadata.osMajorVersion)); - } - if (clientMetadata.deviceModel) { - url.searchParams.set("clientDeviceModel", clientMetadata.deviceModel); - } - } - if (connectionMethod) { - url.searchParams.set("connectionMethod", connectionMethod); - } }; export const exchangeRemoteDpopAccessToken = Effect.fn( @@ -205,7 +172,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( readonly httpBaseUrl: string; readonly bearerToken: string; readonly clientMetadata?: AuthClientPresentationMetadata; - readonly connectionMethod?: ClientConnectionMethod; readonly timeoutMs?: number; }) { const issued = yield* issueRemoteWebSocketTicket({ @@ -216,7 +182,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( const url = new URL(environmentWebSocketUrl(input.wsBaseUrl)); url.searchParams.set("wsTicket", issued.ticket); - appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); + appendClientConnectionParams(url, input.clientMetadata); return url.toString(); }); @@ -228,7 +194,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( readonly accessToken: string; readonly dpopProof: string; readonly clientMetadata?: AuthClientPresentationMetadata; - readonly connectionMethod?: ClientConnectionMethod; readonly timeoutMs?: number; }) { const issued = yield* issueRemoteDpopWebSocketTicket({ @@ -239,6 +204,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( }); const url = new URL(environmentWebSocketUrl(input.wsBaseUrl)); url.searchParams.set("wsTicket", issued.ticket); - appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); + appendClientConnectionParams(url, input.clientMetadata); return url.toString(); }); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 780c3a77fdc0d2f7cb3ad53665559f009d2d6b57..97ebb30426e3231697013071bbbbdbc93f1c3e55 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -4,7 +4,6 @@ import { type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { exchangeRemoteDpopAccessToken, type RemoteEnvironmentAuthError, @@ -197,7 +196,6 @@ export const make = Effect.gen(function* () { httpBaseUrl: input.httpBaseUrl, bearerToken: input.bearerToken, clientMetadata: presentation.metadata, - connectionMethod: input.connectionMethod, }).pipe( Effect.mapError(mapRemoteEnvironmentError), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -242,7 +240,6 @@ export const make = Effect.gen(function* () { accessToken: token.accessToken, dpopProof: ticketProof, clientMetadata: presentation.metadata, - connectionMethod: "relay", ...(timeoutMs === undefined ? {} : { timeoutMs }), }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); }, @@ -551,7 +548,6 @@ export const make = Effect.gen(function* () { getDpopToken(input).pipe( Effect.map(({ token }) => httpAuthorization(token)), Effect.withSpan("environment.authorization.http"), - withRelayClientTracing, ), }); }); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 5b73043bcaa02438d7ef095f9d4b3167bf38d88f..18906da992939ec790fbf1ff329f499d72a2e085 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -3,7 +3,6 @@ import { ORCHESTRATION_PROTOCOL_VERSION, type DesktopSshEnvironmentTarget, } from "@t3tools/contracts"; -import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -269,8 +268,7 @@ describe("ConnectionResolver", () => { environmentId: ENVIRONMENT_ID, label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", - socketUrl: - "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=2", + socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web&orchestrationProtocol=2", httpAuthorization: null, target, }); @@ -383,10 +381,9 @@ describe("ConnectionResolver", () => { }), ); - it.effect("exports the complete relay authorization flow through the product tracer", () => + it.effect("records the complete relay authorization flow in local tracing", () => Effect.gen(function* () { const userSpans: Array = []; - const productSpans: Array = []; const target = new RelayConnectionTarget({ environmentId: ENVIRONMENT_ID, label: "Cloud", @@ -409,15 +406,11 @@ describe("ConnectionResolver", () => { yield* broker .prepare(catalogEntry(target)) - .pipe( - Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))), - Effect.withTracer(collectingTracer(userSpans)), - ); + .pipe(Effect.withTracer(collectingTracer(userSpans))); - expect(productSpans).toContain("clientRuntime.connection.broker.relay"); - expect(productSpans).toContain("test.remote.authorizeDpop"); + expect(userSpans).toContain("clientRuntime.connection.broker.relay"); + expect(userSpans).toContain("test.remote.authorizeDpop"); expect(userSpans).toContain("clientRuntime.connection.broker.prepare"); - expect(userSpans).not.toContain("test.remote.authorizeDpop"); }), ); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index 02b4042b4a075331df783aa238b331e5b10ea283..42204d1db28bdfa3e5533e08df0b34be5ec4b674 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -2,7 +2,6 @@ import type { AuthClientPresentationMetadata, ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -77,7 +76,7 @@ function primarySocketUrl( clientMetadata: AuthClientPresentationMetadata | undefined, ): string { const url = new URL(environmentWebSocketUrl(target.wsBaseUrl)); - appendClientConnectionParams(url, clientMetadata, "direct"); + appendClientConnectionParams(url, clientMetadata); return url.toString(); } @@ -224,23 +223,19 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; - return Effect.fnUntraced( - function* (target: RelayConnectionTarget) { - const authorized = yield* remote.authorizeDpop({ - expectedEnvironmentId: target.environmentId, - }); - return { - environmentId: authorized.environmentId, - label: authorized.label, - httpBaseUrl: authorized.httpBaseUrl, - socketUrl: authorized.socketUrl, - httpAuthorization: authorized.httpAuthorization, - target, - } satisfies PreparedConnection; - }, - Effect.withSpan("clientRuntime.connection.broker.relay"), - withRelayClientTracing, - ); + return Effect.fnUntraced(function* (target: RelayConnectionTarget) { + const authorized = yield* remote.authorizeDpop({ + expectedEnvironmentId: target.environmentId, + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }, Effect.withSpan("clientRuntime.connection.broker.relay")); }); const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () { diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index f83598b8095296fa330e6ef62d9a8a5519382b8e..56c8e01b5c79e0e5ba8b53fa793f6a75b1738f83 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1,5 +1,4 @@ import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3tools/contracts"; -import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -295,7 +294,7 @@ describe("retryDelayMs", () => { }); describe("EnvironmentSupervisor", () => { - it.effect("exports each relay setup as a standalone linked trace that ends at readiness", () => + it.effect("records each relay setup as a local linked trace that ends at readiness", () => Effect.gen(function* () { const spans: Array = []; const tracer = Tracer.make({ @@ -315,10 +314,7 @@ describe("EnvironmentSupervisor", () => { }); const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, - }).pipe( - Effect.provide(harness.dependencies), - Effect.provideService(RelayClientTracer, Option.some(tracer)), - ); + }).pipe(Effect.provide(harness.dependencies), Effect.withTracer(tracer)); yield* awaitState( supervisor.state, diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 88a53216541da5c330e11c1ba47e99ba80987e34..eb4a83f121508849be6dcefa9aa364bd40276486 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -1,4 +1,3 @@ -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -484,7 +483,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( "connection.retry.reason": retry.reason, }), ), - }).pipe(withRelayClientTracing); + }); }; const establishTracedConnection = Effect.fnUntraced(function* ( diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 339d28f421e848f8d1f1bfd270b5f5924aa9df4f..d487999259c76083cf1e75ab59a79f8ce250f6ad 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -31,7 +31,6 @@ import { } from "@t3tools/contracts/relay"; import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -725,228 +724,183 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( return ManagedRelayClient.of({ relayUrl, - listEnvironments: Effect.fnUntraced( - function* (input) { - return yield* client.client - .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) - .pipe( - Effect.map((response) => response.environments), - Effect.mapError(relayRequestError("list relay-managed environments")), - timeoutRelayRequest("Relay environment listing"), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), - withRelayClientTracing, - ), - listDevices: Effect.fnUntraced( - function* (input) { - return yield* client.client - .listDevicesV2({ - headers: bearerHeaders(input.clerkToken), - }) - .pipe( - Effect.map((response) => response.devices), - Effect.mapError(relayRequestError("list relay client devices")), - timeoutRelayRequest("Relay client device listing"), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.listDevices"), - withRelayClientTracing, - ), - createEnvironmentLinkChallenge: Effect.fnUntraced( - function* (input) { - return yield* client.client - .createEnvironmentLinkChallenge({ - headers: bearerHeaders(input.clerkToken), - payload: input.payload, - }) - .pipe( - Effect.mapError(relayRequestError("create relay environment link challenge")), - timeoutRelayRequest("Relay environment link challenge"), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), - withRelayClientTracing, - ), - linkEnvironment: Effect.fnUntraced( - function* (input) { - return yield* client.client - .linkEnvironment({ - headers: bearerHeaders(input.clerkToken), - payload: input.payload, - }) - .pipe( - Effect.mapError(relayRequestError("link relay environment")), - timeoutRelayRequest("Relay environment linking"), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), - withRelayClientTracing, - ), - unlinkEnvironment: Effect.fnUntraced( - function* (input) { - return yield* client.client - .unlinkEnvironment({ - headers: bearerHeaders(input.clerkToken), - params: { environmentId: input.environmentId }, - }) - .pipe( - Effect.mapError(relayRequestError("unlink relay environment")), - timeoutRelayRequest("Relay environment unlinking"), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), - withRelayClientTracing, - ), - getEnvironmentStatus: Effect.fnUntraced( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "environment.id": input.environmentId, - }); - return yield* runDpopRequest( - { - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.getEnvironmentStatus(input.environmentId), - }, - (authorization) => - client.dpopClient - .getEnvironmentStatus({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - }) - .pipe( - Effect.mapError(relayRequestError("get relay environment status")), - timeoutRelayRequest("Relay environment status request"), - ), + listEnvironments: Effect.fnUntraced(function* (input) { + return yield* client.client + .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) + .pipe( + Effect.map((response) => response.environments), + Effect.mapError(relayRequestError("list relay-managed environments")), + timeoutRelayRequest("Relay environment listing"), ); - }, - Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"), - withRelayClientTracing, - ), - connectEnvironment: Effect.fnUntraced( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "environment.id": input.environmentId, - }); - return yield* runDpopRequest( - { - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.connectEnvironment(input.environmentId), - }, - (authorization) => { - const payload: RelayEnvironmentConnectRequest = { - ...(input.deviceId ? { deviceId: input.deviceId } : {}), - clientKeyThumbprint: authorization.thumbprint, - }; - return client.dpopClient - .connectEnvironment({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - payload, - }) - .pipe( - Effect.mapError(relayRequestError("connect relay environment")), - timeoutRelayRequest("Relay environment connection"), - ); - }, + }, Effect.withSpan("clientRuntime.managedRelay.listEnvironments")), + listDevices: Effect.fnUntraced(function* (input) { + return yield* client.client + .listDevicesV2({ + headers: bearerHeaders(input.clerkToken), + }) + .pipe( + Effect.map((response) => response.devices), + Effect.mapError(relayRequestError("list relay client devices")), + timeoutRelayRequest("Relay client device listing"), ); - }, - Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"), - withRelayClientTracing, - ), - registerDevice: Effect.fnUntraced( - function* (input) { - return yield* mobileRegistrationRequest( - { - clerkToken: input.clerkToken, - target: dpopProofTargets.registerDevice(), - }, - (authorization) => - client.mobile - .registerDevice({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError(relayRequestError("register relay mobile device")), - timeoutRelayRequest("Relay mobile device registration"), - ), + }, Effect.withSpan("clientRuntime.managedRelay.listDevices")), + createEnvironmentLinkChallenge: Effect.fnUntraced(function* (input) { + return yield* client.client + .createEnvironmentLinkChallenge({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("create relay environment link challenge")), + timeoutRelayRequest("Relay environment link challenge"), ); - }, - Effect.withSpan("clientRuntime.managedRelay.registerDevice"), - withRelayClientTracing, - ), - unregisterDevice: Effect.fnUntraced( - function* (input) { - return yield* mobileRegistrationRequest( - { - clerkToken: input.clerkToken, - target: dpopProofTargets.unregisterDevice(input.deviceId), - }, - (authorization) => - client.mobile - .unregisterDevice({ - headers: dpopHeaders(authorization), - params: { deviceId: input.deviceId }, - }) - .pipe( - Effect.mapError(relayRequestError("unregister relay mobile device")), - timeoutRelayRequest("Relay mobile device unregistration"), - ), + }, Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge")), + linkEnvironment: Effect.fnUntraced(function* (input) { + return yield* client.client + .linkEnvironment({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("link relay environment")), + timeoutRelayRequest("Relay environment linking"), ); - }, - Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), - withRelayClientTracing, - ), - getAgentActivitySnapshot: Effect.fnUntraced( - function* (input) { - return yield* mobileRegistrationRequest( - { - clerkToken: input.clerkToken, - target: dpopProofTargets.getAgentActivitySnapshot(), - }, - (authorization) => - client.mobile - .getAgentActivitySnapshot({ - headers: dpopHeaders(authorization), - }) - .pipe( - Effect.mapError(relayRequestError("read relay agent activity snapshot")), - timeoutRelayRequest("Relay agent activity snapshot"), - ), + }, Effect.withSpan("clientRuntime.managedRelay.linkEnvironment")), + unlinkEnvironment: Effect.fnUntraced(function* (input) { + return yield* client.client + .unlinkEnvironment({ + headers: bearerHeaders(input.clerkToken), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError(relayRequestError("unlink relay environment")), + timeoutRelayRequest("Relay environment unlinking"), ); - }, - Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot"), - withRelayClientTracing, - ), - registerLiveActivity: Effect.fnUntraced( - function* (input) { - return yield* mobileRegistrationRequest( - { - clerkToken: input.clerkToken, - target: dpopProofTargets.registerLiveActivity(), - }, - (authorization) => - client.mobile - .registerLiveActivity({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError(relayRequestError("register relay live activity")), - timeoutRelayRequest("Relay Live Activity registration"), - ), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), - withRelayClientTracing, - ), + }, Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment")), + getEnvironmentStatus: Effect.fnUntraced(function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + return yield* runDpopRequest( + { + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.getEnvironmentStatus(input.environmentId), + }, + (authorization) => + client.dpopClient + .getEnvironmentStatus({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError(relayRequestError("get relay environment status")), + timeoutRelayRequest("Relay environment status request"), + ), + ); + }, Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus")), + connectEnvironment: Effect.fnUntraced(function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + return yield* runDpopRequest( + { + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.connectEnvironment(input.environmentId), + }, + (authorization) => { + const payload: RelayEnvironmentConnectRequest = { + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + clientKeyThumbprint: authorization.thumbprint, + }; + return client.dpopClient + .connectEnvironment({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + payload, + }) + .pipe( + Effect.mapError(relayRequestError("connect relay environment")), + timeoutRelayRequest("Relay environment connection"), + ); + }, + ); + }, Effect.withSpan("clientRuntime.managedRelay.connectEnvironment")), + registerDevice: Effect.fnUntraced(function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerDevice(), + }, + (authorization) => + client.mobile + .registerDevice({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("register relay mobile device")), + timeoutRelayRequest("Relay mobile device registration"), + ), + ); + }, Effect.withSpan("clientRuntime.managedRelay.registerDevice")), + unregisterDevice: Effect.fnUntraced(function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.unregisterDevice(input.deviceId), + }, + (authorization) => + client.mobile + .unregisterDevice({ + headers: dpopHeaders(authorization), + params: { deviceId: input.deviceId }, + }) + .pipe( + Effect.mapError(relayRequestError("unregister relay mobile device")), + timeoutRelayRequest("Relay mobile device unregistration"), + ), + ); + }, Effect.withSpan("clientRuntime.managedRelay.unregisterDevice")), + getAgentActivitySnapshot: Effect.fnUntraced(function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.getAgentActivitySnapshot(), + }, + (authorization) => + client.mobile + .getAgentActivitySnapshot({ + headers: dpopHeaders(authorization), + }) + .pipe( + Effect.mapError(relayRequestError("read relay agent activity snapshot")), + timeoutRelayRequest("Relay agent activity snapshot"), + ), + ); + }, Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot")), + registerLiveActivity: Effect.fnUntraced(function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerLiveActivity(), + }, + (authorization) => + client.mobile + .registerLiveActivity({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("register relay live activity")), + timeoutRelayRequest("Relay Live Activity registration"), + ), + ); + }, Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity")), resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), - withRelayClientTracing, ), }); }); diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index 2e1f6d22aa2bddc3d65453a1786109e8124974df..2265fc3bc118c69804c45dc7ab4c55d09f1d1995 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -12,14 +12,12 @@ import { OrchestrationV2ThreadTranscript, type OrchestrationV2ThreadHistoryPage, } from "@t3tools/contracts"; -import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as Tracer from "effect/Tracer"; import { TestClock } from "effect/testing"; import type { HttpClient } from "effect/http"; @@ -764,36 +762,6 @@ describe("authenticated environment HTTP requests", () => { ); }); -describe("relay request tracing", () => { - it.effect("starts an exported trace for a T3 Connect request", () => - Effect.gen(function* () { - const productSpans: Array<{ readonly name: string; readonly root: boolean }> = []; - const productTracer = Tracer.make({ - span: (options) => { - productSpans.push({ name: options.name, root: Option.isNone(options.parent) }); - return new Tracer.NativeSpan(options); - }, - }); - const harness = makeHarness(() => Response.json(DIFF_RESULT)); - - yield* PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({ - ...harness.input, - diff: DIFF, - }).pipe( - Effect.withSpan("mobile.screen.local"), - Effect.provide(harness.httpLayer), - Effect.provideService(RelayClientTracer, Option.some(productTracer)), - ); - - expect(productSpans[0]).toEqual({ - name: "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest", - root: true, - }); - expect(productSpans.map((span) => span.name)).not.toContain("mobile.screen.local"); - }), - ); -}); - describe("orchestration HTTP compatibility header", () => { it("announces the current protocol without replacing bearer or DPoP authentication", () => { expect( diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index 6e0b389156f37082de1ceca48ee37b1d82942c04..38a2775fdb1e82e379a1f0339a5888e36422d421 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -5,7 +5,6 @@ import { ORCHESTRATION_PROTOCOL_VERSION_TEXT, } from "@t3tools/contracts"; import * as Result from "effect/Result"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { FetchHttpClient, type HttpMethod } from "effect/http"; import { HostedAuthAccess } from "../platform/capabilities.ts"; @@ -106,10 +105,6 @@ const buildEnvironmentAuthHeaders = ( * Resolve relay credentials at request time without replacing the live socket. * A rejected credential gets one refresh and retry, with a new request-bound * proof. Cookie and bearer requests keep their existing authentication behavior. - * - * A DPoP request is T3 Connect work, so its span starts an exported trace that - * the environment continues; its local caller's span would leave that trace - * without a root. */ export const executeAuthenticatedEnvironmentHttpRequest = < Group extends Parameters[1], @@ -118,13 +113,7 @@ export const executeAuthenticatedEnvironmentHttpRequest = < R, >( input: Parameters>[0], -) => - input.prepared.httpAuthorization?._tag === "Dpop" - ? executeEnvironmentRequest(input).pipe( - Effect.withSpan(ENVIRONMENT_REQUEST_SPAN, { root: true }), - withRelayClientTracing, - ) - : executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN)); +) => executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN)); const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest"; diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index ca7581d6b865804c5de32569e0a0132ad2a6c3f4..74c26cca60e8be93cf3dfa1638780576e07ab806 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -5,7 +5,6 @@ import { AuthSessionId, ForwardCompatibleArray, ClientSurface, - ClientWebDeployment, TrimmedNonEmptyString, } from "./baseSchemas.ts"; @@ -297,11 +296,7 @@ export const AuthClientPresentationMetadata = Schema.Struct({ label: Schema.optionalKey(TrimmedNonEmptyString), deviceType: Schema.optionalKey(AuthClientMetadataDeviceType), os: Schema.optionalKey(TrimmedNonEmptyString), - osMajorVersion: Schema.optionalKey(Schema.Int), - deviceModel: Schema.optionalKey(TrimmedNonEmptyString), surface: Schema.optionalKey(ClientSurface), - webDeployment: Schema.optionalKey(ClientWebDeployment), - browser: Schema.optionalKey(TrimmedNonEmptyString), appVersion: Schema.optionalKey(TrimmedNonEmptyString), }); export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type; diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 3ca88df66b19d20956eb386a4c8a4041d9efc7b3..a2bbe22a10b2300f2486d65b32721ced0c5a20ff 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -357,12 +357,6 @@ export const ClientOs = Schema.Literals([ ]); export type ClientOs = typeof ClientOs.Type; -export const ClientDeviceType = Schema.Literals(["desktop", "phone", "tablet", "unknown"]); -export type ClientDeviceType = typeof ClientDeviceType.Type; - -export const ClientWebDeployment = Schema.Literals(["hosted", "server"]); -export type ClientWebDeployment = typeof ClientWebDeployment.Type; - export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]); export type ClientConnectionMethod = typeof ClientConnectionMethod.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index f3a07e22b032c4f704d1d380d658648a7d0bf46b..6454d52e72dbb0c4a4ebc09cb0f965f08d8a826a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -295,10 +295,6 @@ "types": "./src/relayClient.ts", "import": "./src/relayClient.ts" }, - "./relayTracing": { - "types": "./src/relayTracing.ts", - "import": "./src/relayTracing.ts" - }, "./preview": { "types": "./src/preview.ts", "import": "./src/preview.ts" diff --git a/packages/shared/src/relayTracing.test.ts b/packages/shared/src/relayTracing.test.ts deleted file mode 100644 index 980c003328832510c5da4009d0e83ff36d60dfc6..0000000000000000000000000000000000000000 --- a/packages/shared/src/relayTracing.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Tracer from "effect/Tracer"; -import { FetchHttpClient } from "effect/http"; -import { vi } from "vite-plus/test"; - -import { RelayClientTracer, withLocalTracing, withRelayClientTracing } from "./relayTracing.ts"; -import * as RelayTracing from "./relayTracing.ts"; - -function collectingTracer(spans: Array): Tracer.Tracer { - return Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - const end = span.end.bind(span); - span.end = (endTime, exit) => { - end(endTime, exit); - spans.push(span.name); - }; - return span; - }, - }); -} - -describe("withRelayClientTracing", () => { - it.effect("uses the product tracer only for relay operations", () => - Effect.gen(function* () { - const userSpans: Array = []; - const productSpans: Array = []; - const userTracer = collectingTracer(userSpans); - const productTracer = collectingTracer(productSpans); - - yield* Effect.void.pipe(Effect.withSpan("user.operation"), Effect.withTracer(userTracer)); - yield* Effect.void.pipe( - Effect.withSpan("relay.operation"), - withRelayClientTracing, - Effect.provideService(RelayClientTracer, Option.some(productTracer)), - Effect.withTracer(userTracer), - ); - - expect(userSpans).toEqual(["user.operation"]); - expect(productSpans).toEqual(["relay.operation"]); - }), - ); - - it.effect("preserves the active tracer when product tracing is disabled", () => - Effect.gen(function* () { - const userSpans: Array = []; - const userTracer = collectingTracer(userSpans); - - yield* Effect.void.pipe( - Effect.withSpan("relay.operation"), - withRelayClientTracing, - Effect.withTracer(userTracer), - ); - - expect(userSpans).toEqual(["relay.operation"]); - }), - ); - - it.effect("preserves nested error causes in exported relay spans", () => { - const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); - const layerHttpClient = FetchHttpClient.layer.pipe( - Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)), - ); - const layerTracing = RelayTracing.layer( - { - tracesUrl: "https://api.axiom.test/v1/traces", - tracesDataset: "relay-traces", - tracesToken: "public-ingest-token", - }, - { - serviceName: "relay-test", - runtime: "test", - client: "test", - }, - ).pipe(Layer.provide(layerHttpClient)); - const rootCause = new Error("relay socket closed"); - const failure = new Error("relay request failed", { cause: rootCause }); - const layerTracedApplication = Layer.effectDiscard( - Effect.fail(failure).pipe( - Effect.withSpan("relay.failed-operation"), - withRelayClientTracing, - Effect.exit, - ), - ).pipe(Layer.provide(layerTracing)); - - return Layer.build(layerTracedApplication).pipe( - Effect.scoped, - Effect.andThen( - Effect.sync(() => { - expect(fetchFn).toHaveBeenCalledOnce(); - const payload = new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array); - expect(payload).toContain("relay request failed"); - expect(payload).toContain("relay socket closed"); - expect(payload).toContain('"key":"service.name","value":{"stringValue":"relay-test"}'); - expect(payload).toContain('"key":"service.namespace","value":{"stringValue":"t3code"}'); - }), - ), - ); - }); -}); - -describe("withLocalTracing", () => { - it.effect("keeps local work inside a relay span off the product tracer", () => - Effect.gen(function* () { - const localSpans: Array = []; - const productSpans: Array = []; - const localTracer = collectingTracer(localSpans); - const productTracer = collectingTracer(productSpans); - - yield* Effect.void.pipe( - Effect.withSpan("relay.connection.nested"), - Effect.andThen( - Effect.void.pipe( - Effect.withSpan("sql.execute"), - Effect.withSpan("ServerSecretStore.get"), - withLocalTracing, - ), - ), - Effect.withSpan("environment.orchestration.threadSnapshot"), - withRelayClientTracing, - Effect.provideService(RelayClientTracer, Option.some(productTracer)), - Effect.withTracer(localTracer), - ); - - expect(productSpans).toEqual([ - "relay.connection.nested", - "environment.orchestration.threadSnapshot", - ]); - expect(localSpans).toEqual(["sql.execute", "ServerSecretStore.get"]); - }), - ); - - it.effect("leaves the current tracer alone outside relay tracing", () => - Effect.gen(function* () { - const localSpans: Array = []; - - yield* Effect.void.pipe( - Effect.withSpan("sql.execute"), - withLocalTracing, - Effect.withTracer(collectingTracer(localSpans)), - ); - - expect(localSpans).toEqual(["sql.execute"]); - }), - ); -}); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts deleted file mode 100644 index 8f7aa3bc9c244f8804e8e289476709bd9b06fbce..0000000000000000000000000000000000000000 --- a/packages/shared/src/relayTracing.ts +++ /dev/null @@ -1,193 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Tracer from "effect/Tracer"; -import type { HttpClient } from "effect/http"; -import { OtlpSerialization, OtlpTracer } from "effect/observability"; - -export interface RelayClientTracingConfig { - readonly tracesUrl: string; - readonly tracesDataset: string; - readonly tracesToken: string; -} - -export interface RelayClientTracingResource { - readonly serviceName: string; - readonly serviceVersion?: string; - readonly runtime: string; - readonly client: string; - readonly component?: string; -} - -export class RelayClientTracer extends Context.Reference( - "@t3tools/shared/relayTracing/RelayClientTracer", - { - defaultValue: () => Option.none(), - }, -) {} - -/** - * The tracer that was active before relay tracing took over, so work nested - * inside a relay span can return to it. - */ -class LocalTracer extends Context.Reference("@t3tools/shared/relayTracing/LocalTracer", { - defaultValue: () => Option.none(), -}) {} - -/** - * Exports every span `effect` creates through the product tracer. Use it only - * around T3 Connect work; wrap local work inside it with - * {@link withLocalTracing} so it stays off the product tracer. - */ -export const withRelayClientTracing = ( - effect: Effect.Effect, -): Effect.Effect => - RelayClientTracer.pipe( - Effect.flatMap( - Option.match({ - onNone: () => effect, - onSome: (tracer) => - Tracer.Tracer.pipe( - Effect.flatMap((local) => - effect.pipe( - Effect.provideService(Tracer.Tracer, tracer), - Effect.provideService(LocalTracer, Option.some(local)), - ), - ), - ), - }), - ), - ); - -/** - * Runs `effect` on the tracer that was active outside relay tracing. Its spans - * stay in the local trace and are never exported, so a relay span can time the - * work without shipping what the user's machine did to answer it. - */ -export const withLocalTracing = (effect: Effect.Effect): Effect.Effect => - LocalTracer.pipe( - Effect.flatMap( - Option.match({ - onNone: () => effect, - onSome: (local) => effect.pipe(Effect.provideService(Tracer.Tracer, local)), - }), - ), - ); - -function cleanTraceStack(error: Error): string { - const stack = error.stack ?? `${error.name}: ${error.message}`; - const lines = stack.split("\n"); - const effectFrameIndex = lines.findIndex( - (line, index) => index > 0 && /(?:Generator\.next|~effect\/Effect)/.test(line), - ); - return effectFrameIndex < 0 ? stack : lines.slice(0, effectFrameIndex).join("\n"); -} - -function traceSafeError(value: unknown, seen = new WeakSet()): Error { - const message = - value instanceof Error - ? value.message - : typeof value === "object" && - value !== null && - "message" in value && - typeof value.message === "string" - ? value.message - : String(value); - - let cause: Error | undefined; - if (typeof value === "object" && value !== null && !seen.has(value)) { - seen.add(value); - if ("cause" in value && value.cause !== undefined) { - cause = traceSafeError(value.cause, seen); - } - } - - const error = new Error(message, cause ? { cause } : undefined); - if (value instanceof Error) { - error.name = value.name; - error.stack = cleanTraceStack(value); - } else if ( - typeof value === "object" && - value !== null && - "name" in value && - typeof value.name === "string" - ) { - error.name = value.name; - } - if (cause) { - error.stack = `${error.stack ?? `${error.name}: ${error.message}`}\nCaused by: ${cause.stack ?? `${cause.name}: ${cause.message}`}`; - } - return error; -} - -function traceSafeExit(exit: Exit.Exit): Exit.Exit { - if (Exit.isSuccess(exit)) { - return exit; - } - return Exit.failCause( - Cause.fromReasons( - exit.cause.reasons.map((reason) => { - if (Cause.isFailReason(reason)) { - return Cause.makeFailReason(traceSafeError(reason.error)); - } - if (Cause.isDieReason(reason)) { - return Cause.makeDieReason(traceSafeError(reason.defect)); - } - return reason; - }), - ), - ); -} - -function nonInterferingTracer(delegate: Tracer.Tracer): Tracer.Tracer { - return Tracer.make({ - span(options) { - const span = delegate.span(options); - const end = span.end.bind(span); - span.end = (endTime, exit) => { - try { - end(endTime, traceSafeExit(exit)); - } catch { - // Telemetry is best-effort and must never change application behavior. - } - }; - return span; - }, - ...(delegate.context ? { context: delegate.context } : {}), - }); -} - -export function layer( - config: RelayClientTracingConfig | null, - resource: RelayClientTracingResource, -): Layer.Layer { - if (config === null) { - return Layer.succeed(RelayClientTracer, Option.none()); - } - - const layerTracer = OtlpTracer.layer({ - url: config.tracesUrl, - headers: { - Authorization: `Bearer ${config.tracesToken}`, - "X-Axiom-Dataset": config.tracesDataset, - }, - resource: { - serviceName: resource.serviceName, - serviceVersion: resource.serviceVersion, - attributes: { - "service.namespace": "t3code", - "service.runtime": resource.runtime, - "service.component": resource.component ?? "relay-client", - "t3.client.surface": resource.client, - }, - }, - }).pipe(Layer.provide(OtlpSerialization.layerJson)); - - return Layer.effect( - RelayClientTracer, - Tracer.Tracer.pipe(Effect.map(nonInterferingTracer), Effect.asSome), - ).pipe(Layer.provide(layerTracer)); -} diff --git a/scripts/lib/public-config.test.ts b/scripts/lib/public-config.test.ts index 62d383484cf480e8d2fbef8ac54d8e2c612fd1d5..bfb53697db03f3531eebe0ce26d933c53db88808 100644 --- a/scripts/lib/public-config.test.ts +++ b/scripts/lib/public-config.test.ts @@ -27,18 +27,6 @@ describe("loadRepoEnv", () => { expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined(); expect(env.T3CODE_RELAY_URL).toBeUndefined(); expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined(); - expect(env.T3CODE_MOBILE_OTLP_TRACES_URL).toBeUndefined(); - expect(env.T3CODE_MOBILE_OTLP_TRACES_DATASET).toBeUndefined(); - expect(env.T3CODE_MOBILE_OTLP_TRACES_TOKEN).toBeUndefined(); - expect(env.EXPO_PUBLIC_OTLP_TRACES_URL).toBeUndefined(); - expect(env.EXPO_PUBLIC_OTLP_TRACES_DATASET).toBeUndefined(); - expect(env.EXPO_PUBLIC_OTLP_TRACES_TOKEN).toBeUndefined(); - expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL).toBeUndefined(); - expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET).toBeUndefined(); - expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN).toBeUndefined(); - expect(env.VITE_RELAY_OTLP_TRACES_URL).toBeUndefined(); - expect(env.VITE_RELAY_OTLP_TRACES_DATASET).toBeUndefined(); - expect(env.VITE_RELAY_OTLP_TRACES_TOKEN).toBeUndefined(); }); it("applies process, root local, and root precedence in that order", () => { @@ -85,64 +73,12 @@ describe("loadRepoEnv", () => { VITE_CLERK_JWT_TEMPLATE: "template_legacy", T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical", VITE_T3CODE_RELAY_URL: "https://legacy.example.test", - EXPO_PUBLIC_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", - EXPO_PUBLIC_OTLP_TRACES_DATASET: "mobile-traces", - EXPO_PUBLIC_OTLP_TRACES_TOKEN: "mobile-token", }), ).toEqual({ clerkPublishableKey: "pk_legacy", clerkJwtTemplate: "template_legacy", clerkCliOAuthClientId: "oauth_canonical", relayUrl: "https://legacy.example.test", - mobileOtlpTracesUrl: "https://api.axiom.co/v1/traces", - mobileOtlpTracesDataset: "mobile-traces", - mobileOtlpTracesToken: "mobile-token", - relayClientOtlpTracesUrl: undefined, - relayClientOtlpTracesDataset: undefined, - relayClientOtlpTracesToken: undefined, - }); - }); - - it("projects canonical relay client tracing values to web build aliases", () => { - expect( - loadRepoEnv({ - baseEnv: { - T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", - T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "relay-client-traces", - T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "relay-client-token", - }, - repoRoot: makeTemporaryDirectory(), - }), - ).toEqual({ - T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", - T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "relay-client-traces", - T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "relay-client-token", - VITE_RELAY_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", - VITE_RELAY_OTLP_TRACES_DATASET: "relay-client-traces", - VITE_RELAY_OTLP_TRACES_TOKEN: "relay-client-token", - }); - }); - - it("projects canonical mobile tracing values to Expo public aliases", () => { - expect( - loadRepoEnv({ - baseEnv: { - T3CODE_RELAY_URL: "https://relay.example.test", - T3CODE_MOBILE_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", - T3CODE_MOBILE_OTLP_TRACES_DATASET: "mobile-traces", - T3CODE_MOBILE_OTLP_TRACES_TOKEN: "mobile-token", - }, - repoRoot: makeTemporaryDirectory(), - }), - ).toEqual({ - T3CODE_RELAY_URL: "https://relay.example.test", - VITE_T3CODE_RELAY_URL: "https://relay.example.test", - T3CODE_MOBILE_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", - T3CODE_MOBILE_OTLP_TRACES_DATASET: "mobile-traces", - T3CODE_MOBILE_OTLP_TRACES_TOKEN: "mobile-token", - EXPO_PUBLIC_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", - EXPO_PUBLIC_OTLP_TRACES_DATASET: "mobile-traces", - EXPO_PUBLIC_OTLP_TRACES_TOKEN: "mobile-token", }); }); }); diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index 36ca5497f80338ce8db05903ccb6d52b0983431c..75c45e9c821f300e925de17ca720deba7ce6ffee 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -9,12 +9,6 @@ export interface T3CodePublicConfig { readonly clerkJwtTemplate: string | undefined; readonly clerkCliOAuthClientId: string | undefined; readonly relayUrl: string | undefined; - readonly mobileOtlpTracesUrl: string | undefined; - readonly mobileOtlpTracesDataset: string | undefined; - readonly mobileOtlpTracesToken: string | undefined; - readonly relayClientOtlpTracesUrl: string | undefined; - readonly relayClientOtlpTracesDataset: string | undefined; - readonly relayClientOtlpTracesToken: string | undefined; } type Environment = Readonly>; @@ -64,42 +58,6 @@ export function loadRepoEnv({ VITE_T3CODE_RELAY_URL: config.relayUrl, } : {}), - ...(config.mobileOtlpTracesUrl - ? { - T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileOtlpTracesUrl, - EXPO_PUBLIC_OTLP_TRACES_URL: config.mobileOtlpTracesUrl, - } - : {}), - ...(config.mobileOtlpTracesDataset - ? { - T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileOtlpTracesDataset, - EXPO_PUBLIC_OTLP_TRACES_DATASET: config.mobileOtlpTracesDataset, - } - : {}), - ...(config.mobileOtlpTracesToken - ? { - T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken, - EXPO_PUBLIC_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken, - } - : {}), - ...(config.relayClientOtlpTracesUrl - ? { - T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.relayClientOtlpTracesUrl, - VITE_RELAY_OTLP_TRACES_URL: config.relayClientOtlpTracesUrl, - } - : {}), - ...(config.relayClientOtlpTracesDataset - ? { - T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.relayClientOtlpTracesDataset, - VITE_RELAY_OTLP_TRACES_DATASET: config.relayClientOtlpTracesDataset, - } - : {}), - ...(config.relayClientOtlpTracesToken - ? { - T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.relayClientOtlpTracesToken, - VITE_RELAY_OTLP_TRACES_TOKEN: config.relayClientOtlpTracesToken, - } - : {}), }; } @@ -123,36 +81,6 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP "VITE_CLERK_CLI_OAUTH_CLIENT_ID", ), relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"), - mobileOtlpTracesUrl: firstNonEmpty( - sources, - "T3CODE_MOBILE_OTLP_TRACES_URL", - "EXPO_PUBLIC_OTLP_TRACES_URL", - ), - mobileOtlpTracesDataset: firstNonEmpty( - sources, - "T3CODE_MOBILE_OTLP_TRACES_DATASET", - "EXPO_PUBLIC_OTLP_TRACES_DATASET", - ), - mobileOtlpTracesToken: firstNonEmpty( - sources, - "T3CODE_MOBILE_OTLP_TRACES_TOKEN", - "EXPO_PUBLIC_OTLP_TRACES_TOKEN", - ), - relayClientOtlpTracesUrl: firstNonEmpty( - sources, - "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL", - "VITE_RELAY_OTLP_TRACES_URL", - ), - relayClientOtlpTracesDataset: firstNonEmpty( - sources, - "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET", - "VITE_RELAY_OTLP_TRACES_DATASET", - ), - relayClientOtlpTracesToken: firstNonEmpty( - sources, - "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN", - "VITE_RELAY_OTLP_TRACES_TOKEN", - ), }; } -- 2.54.0