diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index f652844a54f..15bc3534cae 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -24,7 +24,6 @@ jobs: env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} - AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} @@ -60,7 +59,6 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} - AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81ef25effc8..64019b61d2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -215,22 +215,13 @@ jobs: - --filter=t3code-relay... - id: relay_state - name: Read production relay tracing config + name: Read production relay config shell: bash run: | vp run --filter t3code-relay deploy \ --stage prod \ --read-state \ - --github-output \ - --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" - - - name: Upload relay client tracing config - uses: actions/upload-artifact@v7 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing.env - if-no-files-found: error - retention-days: 1 + --github-output - id: public_config name: Resolve production relay public config @@ -397,20 +388,6 @@ jobs: with: targets: ${{ matrix.rust_target }} - - name: Download relay client tracing config - uses: actions/download-artifact@v8 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -697,20 +674,6 @@ jobs: - --filter=@t3tools/web... - --filter=@t3tools/scripts... - - name: Download relay client tracing config - uses: actions/download-artifact@v8 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -894,20 +857,6 @@ jobs: - --filter=@t3tools/scripts... - --filter=@t3tools/web... - - name: Download relay client tracing config - uses: actions/download-artifact@v8 - with: - name: relay-client-tracing-config - path: ${{ runner.temp }}/relay-client-tracing - - - name: Load relay client tracing config - shell: bash - run: | - config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" - tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" - echo "::add-mask::$tracing_token" - cat "$config_path" >> "$GITHUB_ENV" - - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" @@ -956,9 +905,6 @@ jobs: --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ --build-env "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=${T3CODE_CLERK_CLI_OAUTH_CLIENT_ID:-}" \ --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ - --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ - --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ - --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \ --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index cd90e7951b8..2f7cce343ec 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -23,19 +23,6 @@ const decodeDesktopBackendChildLogRecord = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendChildLogRecord), ); -const TraceRecordLine = Schema.Struct({ - name: Schema.String, - attributes: Schema.Record(Schema.String, Schema.Unknown), - events: Schema.Array( - Schema.Struct({ - name: Schema.String, - attributes: Schema.Record(Schema.String, Schema.Unknown), - }), - ), -}); - -const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); - const environmentInput = (baseDir: string) => ({ dirname: "/repo/apps/desktop/dist-electron", @@ -84,7 +71,7 @@ describe("DesktopObservability", () => { assert.equal(next.byteLength, maxBufferedBytes); }); - it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => + it.effect("does not persist desktop Effect spans", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -110,22 +97,7 @@ describe("DesktopObservability", () => { ), ); - const records = (yield* fileSystem.readFileString(tracePath)) - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => decodeTraceRecordLine(line)); - const record = records.find((entry) => entry.name === "desktop-observability-test"); - - assert.notEqual(record, undefined); - if (!record) { - return; - } - assert.equal(record.attributes["desktop.test"], true); - assert.equal( - record.events.some((event) => event.name === "desktop trace event"), - true, - ); + assert.isFalse(yield* fileSystem.exists(tracePath)); assert.isFalse(yield* fileSystem.exists(logPath)); }).pipe( Effect.scoped, @@ -195,16 +167,7 @@ describe("DesktopObservability", () => { assert.equal(end.annotations.phase, "END"); assert.equal(end.annotations.details, "code=1"); - const traceRecords = (yield* fileSystem.readFileString(tracePath)) - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => decodeTraceRecordLine(line)); - assert.isFalse( - traceRecords.some( - (record) => record.name === "desktop.observability.backendOutput.writeOutputChunk", - ), - ); + assert.isFalse(yield* fileSystem.exists(tracePath)); }).pipe( Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index c393f4ccd9a..ce406bcd737 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,6 +1,4 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; -import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; -import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -17,14 +15,12 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Tracer from "effect/Tracer"; -import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const DESKTOP_LOG_FILE_MAX_FILES = 10; const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; -const DESKTOP_TRACE_BATCH_WINDOW_MS = 1_000; const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES = 1024 * 1024; const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS = 256; @@ -322,30 +318,6 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio } satisfies RotatingLogFileWriter; }); -const readPersistedOtlpTracesUrl: Effect.Effect< - Option.Option, - never, - FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment -> = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); - if (Option.isNone(raw)) { - return Option.none(); - } - - const parsed = parsePersistedServerObservabilitySettings(raw.value); - return Option.fromNullishOr(parsed.otlpTracesUrl); -}); - -const resolveOtlpTracesUrl = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - if (Option.isSome(environment.otlpTracesUrl)) { - return environment.otlpTracesUrl; - } - return yield* readPersistedOtlpTracesUrl; -}); - const writeDevelopmentConsoleOutput = ( streamName: "stdout" | "stderr", chunk: Uint8Array, @@ -568,47 +540,9 @@ const desktopLoggerLayer = Layer.mergeAll( Layer.succeed(References.MinimumLogLevel, "Info"), ); -const tracerLayer = Layer.unwrap( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const otlpTracesUrl = yield* resolveOtlpTracesUrl; - const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); - const sink = yield* makeTraceSink({ - filePath: tracePath, - maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, - maxFiles: DESKTOP_LOG_FILE_MAX_FILES, - batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, - }); - const delegate = Option.isNone(otlpTracesUrl) - ? undefined - : yield* OtlpTracer.make({ - url: otlpTracesUrl.value, - exportInterval: `${environment.otlpExportIntervalMs} millis`, - resource: { - serviceName: "desktop", - attributes: { - "service.runtime": "desktop", - "service.mode": environment.isDevelopment ? "development" : "packaged", - }, - }, - }); - const tracer = yield* makeLocalFileTracer({ - filePath: tracePath, - maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, - maxFiles: DESKTOP_LOG_FILE_MAX_FILES, - batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, - sink, - ...(delegate ? { delegate } : {}), - }); - - return Layer.succeed(Tracer.Tracer, tracer); - }), -).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(OtlpSerialization.layerJson)); - export const layer = Layer.mergeAll( backendOutputLogFactoryLayer, desktopLoggerLayer, - tracerLayer, - Layer.succeed(Tracer.MinimumTraceLevel, "Info"), - Layer.succeed(References.TracerTimingEnabled, true), + Layer.succeed(Tracer.MinimumTraceLevel, "None"), + Layer.succeed(References.TracerTimingEnabled, false), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 309dbb21d4a..cc673b7b297 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -313,7 +313,7 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolvePrimary surfaces persisted backend observability endpoints", () => + it.effect("resolvePrimary ignores persisted backend observability endpoints", () => withHarness( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -334,8 +334,8 @@ describe("DesktopBackendConfiguration", () => { ); const config = yield* configuration.resolvePrimary; - assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); - assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); + assert.equal(config.bootstrap.otlpTracesUrl, undefined); + assert.equal(config.bootstrap.otlpMetricsUrl, undefined); }), ), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bfb9d6900e5..6ae2e719fe4 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { TELEMETRY_ENABLED } from "@t3tools/shared/telemetryPolicy"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -351,16 +352,19 @@ const isLocalHostIpv4 = (ip: string): boolean => { return false; }; -const buildObservabilityFragment = (observabilitySettings: BackendObservabilitySettings) => ({ - ...Option.match(observabilitySettings.otlpTracesUrl, { - onNone: () => ({}), - onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), - }), - ...Option.match(observabilitySettings.otlpMetricsUrl, { - onNone: () => ({}), - onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), - }), -}); +const buildObservabilityFragment = (observabilitySettings: BackendObservabilitySettings) => + TELEMETRY_ENABLED + ? { + ...Option.match(observabilitySettings.otlpTracesUrl, { + onNone: () => ({}), + onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), + }), + ...Option.match(observabilitySettings.otlpMetricsUrl, { + onNone: () => ({}), + onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), + }), + } + : {}; const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( function* ( diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0616184ec74..c82c078de3f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -165,7 +165,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), Layer.provideMerge(DesktopWslEnvironment.layer), - Layer.provideMerge(DesktopTelemetryPublisher.layer), + Layer.provideMerge(DesktopTelemetryPublisher.layerDisabled), Layer.provideMerge(desktopWindowLayer), ); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 9c17ae514d2..4c90d1ec4ee 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -377,3 +377,15 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { }); export const layer = Layer.effect(DesktopTelemetryPublisher, make()); + +export const layerDisabled = Layer.succeed( + DesktopTelemetryPublisher, + DesktopTelemetryPublisher.of({ + latest: Effect.succeedNone, + changes: Stream.empty, + encoded: Stream.empty, + handleControl: () => Effect.void, + handleControlForSource: () => Effect.void, + removeControlSource: () => Effect.void, + }), +); diff --git a/apps/marketing/src/pages/privacy-policy.astro b/apps/marketing/src/pages/privacy-policy.astro index b85bc0b12b4..3ec6df5f313 100644 --- a/apps/marketing/src/pages/privacy-policy.astro +++ b/apps/marketing/src/pages/privacy-policy.astro @@ -93,12 +93,12 @@ const sections = [ providers may automatically log online identifiers and internet-activity information, including your Internet Protocol (IP) address, device and operating-system type, browser or app type and version, pages viewed, access times, request timestamps, connection - events, latency, error details, and diagnostic traces. The amount of information we - collect depends on the Services you use and your device and browser settings. + events, and error details. The amount of information we collect depends on the Services + you use and your device and browser settings.

- We use operational and diagnostic information to provide, secure, maintain, and - troubleshoot the Services, not for targeted advertising. + We use this information to provide, secure, maintain, and troubleshoot the Services, + not for targeted advertising.

Information We Get From Others

@@ -239,14 +239,6 @@ const sections = [ >https://vercel.com/legal/privacy-notice. -
  • - Axiom (Axiom, Inc.).
    - We use Axiom for operational diagnostics that help us maintain and troubleshoot the - Services.
    - You can view Axiom's privacy policy here:{" "}https://axiom.co/docs/legal/privacy. -
  • Expo (650 Industries, Inc.).
    We use Expo for application updates and related mobile-app services.
    @@ -342,9 +334,8 @@ const sections = [ We retain account, environment-link, device-registration, notification, and security information while your account or the relevant feature is active and for as long as reasonably necessary to provide the Services, protect them from abuse, comply with law, - and resolve disputes. Operational diagnostic traces are configured to expire after 30 - days. Some records may remain longer in backups or where retention is required for - security, fraud prevention, or legal compliance. + and resolve disputes. Some records may remain longer in backups or where retention is + required for security, fraud prevention, or legal compliance.

    To request deletion of your T3 account and associated T3 Connect data, email{" "} ({ }, })); -it.effect("exports spans through the scoped mobile OTLP layer", () => { +it.effect("does not export spans when mobile OTLP is configured", () => { const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); const tracingLayer = makeTracingLayer( { @@ -42,18 +42,13 @@ it.effect("exports spans through the scoped mobile OTLP layer", () => { 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"); + expect(fetchFn).not.toHaveBeenCalled(); }), ), ); }); -it.effect("does not let OTLP serialization failures alter application effects", () => { +it.effect("preserves application failures without starting an exporter", () => { const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); const tracingLayer = makeTracingLayer( { @@ -87,10 +82,7 @@ it.effect("does not let OTLP serialization failures alter application effects", 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", - ); + expect(fetchFn).not.toHaveBeenCalled(); }), ), ); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index def63b61faf..db80ca66122 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -334,8 +334,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Info", ...defaultObservabilityConfig, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", mode: "desktop", port: 4888, cwd: process.cwd(), @@ -488,7 +486,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("falls back to persisted observability settings when env vars are absent", () => + it.effect("ignores persisted observability endpoints", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -531,13 +529,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ), ); - expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); - expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics"); + expect(resolved.otlpTracesUrl).toBeUndefined(); + expect(resolved.otlpMetricsUrl).toBeUndefined(); expect(resolved).toEqual({ logLevel: "Info", ...defaultObservabilityConfig, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", mode: "desktop", port: 4888, cwd: process.cwd(), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 5b05b773b31..7c975d25e67 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,5 +1,6 @@ import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { TELEMETRY_ENABLED } from "@t3tools/shared/telemetryPolicy"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; import * as Duration from "effect/Duration"; @@ -356,14 +357,16 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: - env.otlpTracesUrl ?? - bootstrap?.otlpTracesUrl ?? - persistedObservabilitySettings.otlpTracesUrl, - otlpMetricsUrl: - env.otlpMetricsUrl ?? - bootstrap?.otlpMetricsUrl ?? - persistedObservabilitySettings.otlpMetricsUrl, + otlpTracesUrl: TELEMETRY_ENABLED + ? (env.otlpTracesUrl ?? + bootstrap?.otlpTracesUrl ?? + persistedObservabilitySettings.otlpTracesUrl) + : undefined, + otlpMetricsUrl: TELEMETRY_ENABLED + ? (env.otlpMetricsUrl ?? + bootstrap?.otlpMetricsUrl ?? + persistedObservabilitySettings.otlpMetricsUrl) + : undefined, otlpExportIntervalMs: env.otlpExportIntervalMs, otlpServiceName: env.otlpServiceName, mode, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0da55686b92..8961d83ae5b 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,42 +1,23 @@ import Mime from "@effect/platform-node/Mime"; -import { - AuthOrchestrationOperateScope, - AuthOrchestrationReadScope, - EnvironmentHttpApi, -} from "@t3tools/contracts"; +import { EnvironmentHttpApi } from "@t3tools/contracts"; import { isDevProxiedPath } from "@t3tools/shared/devProxy"; -import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import { cast } from "effect/Function"; import { - HttpBody, - HttpClient, - HttpClientResponse, HttpMiddleware, HttpRouter, HttpServerResponse, HttpServerRequest, - HttpServerRespondable, } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; -import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; -import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; -import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; -import { - annotateEnvironmentRequest, - failEnvironmentScopeRequired, - failEnvironmentAuthInvalid, - failEnvironmentInternal, -} from "./auth/http.ts"; +import { annotateEnvironmentRequest } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; @@ -100,25 +81,6 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } -const authenticateRawRouteWithScope = ( - scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, -) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), - ); - if (!session.scopes.includes(scope)) { - return yield* failEnvironmentScopeRequired(scope); - } - }); - export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "metadata", @@ -134,64 +96,10 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( }), ); -class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ - readonly cause: unknown; - readonly bodyJson: OtlpTracer.TraceData; -}> {} - export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, - Effect.gen(function* () { - yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope); - const request = yield* HttpServerRequest.HttpServerRequest; - const config = yield* ServerConfig.ServerConfig; - const otlpTracesUrl = config.otlpTracesUrl; - const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; - const httpClient = yield* HttpClient.HttpClient; - const bodyJson = cast(yield* request.json); - - yield* Effect.try({ - try: () => decodeOtlpTraceRecords(bodyJson), - catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }), - }).pipe( - Effect.flatMap((records) => browserTraceCollector.record(records)), - Effect.catch((cause) => - Effect.logWarning("Failed to decode browser OTLP traces", { - cause, - bodyJson, - }), - ), - ); - - if (otlpTracesUrl === undefined) { - return HttpServerResponse.empty({ status: 204 }); - } - - return yield* httpClient - .post(otlpTracesUrl, { - body: HttpBody.jsonUnsafe(bodyJson), - }) - .pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.as(HttpServerResponse.empty({ status: 204 })), - Effect.tapError((cause) => - Effect.logWarning("Failed to export browser OTLP traces", { - cause, - otlpTracesUrl, - }), - ), - Effect.orElseSucceed(() => - HttpServerResponse.text("Trace export failed.", { status: 502 }), - ), - ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, - }), - ), + Effect.succeed(HttpServerResponse.empty({ status: 404 })), ); export const assetRouteLayer = HttpRouter.add( diff --git a/apps/server/src/observability/BrowserTraceCollector.ts b/apps/server/src/observability/BrowserTraceCollector.ts index 300a50fe330..9bd200a1f0a 100644 --- a/apps/server/src/observability/BrowserTraceCollector.ts +++ b/apps/server/src/observability/BrowserTraceCollector.ts @@ -10,14 +10,10 @@ export class BrowserTraceCollector extends Context.Service< } >()("t3/observability/BrowserTraceCollector") {} -export const make = (sink: TraceSink): BrowserTraceCollector["Service"] => - BrowserTraceCollector.of({ - record: (records) => - Effect.sync(() => { - for (const record of records) { - sink.push(record); - } - }), - }); +const disabledService = BrowserTraceCollector.of({ record: () => Effect.void }); + +export const make = (_sink: TraceSink): BrowserTraceCollector["Service"] => disabledService; export const layer = (sink: TraceSink) => Layer.succeed(BrowserTraceCollector, make(sink)); + +export const layerDisabled = Layer.succeed(BrowserTraceCollector, disabledService); diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 8aac0927534..5fd9e1cc545 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -1,94 +1,17 @@ -import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; -import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; -import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; import * as Tracer from "effect/Tracer"; -import * as OtlpExporter from "effect/unstable/observability/OtlpExporter"; -import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics"; -import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization"; -import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; -import * as ServerConfig from "../../config.ts"; -import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; import { ServerLoggerLive } from "../../serverLogger.ts"; import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; -const otlpSerializationLayer = OtlpSerialization.layerJson; - -export const ObservabilityLive = Layer.unwrap( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const attribution = yield* ResourceAttribution.ResourceAttribution; - - const traceReferencesLayer = Layer.mergeAll( - Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), - Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), - httpHeaderRedactionLayer, - ); - - const tracerLayer = Layer.unwrap( - Effect.gen(function* () { - const sink = yield* makeTraceSink({ - filePath: config.serverTracePath, - maxBytes: config.traceMaxBytes, - maxFiles: config.traceMaxFiles, - batchWindowMs: config.traceBatchWindowMs, - onFlush: (stats) => - attribution.record({ - component: "server-trace", - operation: "append", - logicalWriteBytes: stats.logicalWriteBytes, - count: stats.count, - durationMs: stats.durationMs, - }), - }); - const delegate = - config.otlpTracesUrl === undefined - ? undefined - : yield* OtlpTracer.make({ - url: config.otlpTracesUrl, - exportInterval: `${config.otlpExportIntervalMs} millis`, - resource: { - serviceName: config.otlpServiceName, - attributes: { - "service.runtime": "t3-server", - "service.mode": config.mode, - }, - }, - }); - - const tracer = yield* makeLocalFileTracer({ - filePath: config.serverTracePath, - maxBytes: config.traceMaxBytes, - maxFiles: config.traceMaxFiles, - batchWindowMs: config.traceBatchWindowMs, - sink, - ...(delegate ? { delegate } : {}), - }); - - return Layer.mergeAll( - Layer.succeed(Tracer.Tracer, tracer), - BrowserTraceCollector.layer(sink), - ); - }), - ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(otlpSerializationLayer)); - - const metricsLayer = - config.otlpMetricsUrl === undefined - ? Layer.empty - : OtlpMetrics.layer({ - url: config.otlpMetricsUrl, - exportInterval: `${config.otlpExportIntervalMs} millis`, - resource: { - serviceName: config.otlpServiceName, - attributes: { - "service.runtime": "t3-server", - "service.mode": config.mode, - }, - }, - }).pipe(Layer.provideMerge(otlpSerializationLayer)); - - return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); - }), +/** + * Keep ordinary application logging while disabling trace collection, local + * trace files, metrics, and every remote observability exporter. + */ +export const ObservabilityLive = Layer.mergeAll( + ServerLoggerLive, + Layer.succeed(Tracer.MinimumTraceLevel, "None"), + Layer.succeed(References.TracerTimingEnabled, false), + BrowserTraceCollector.layerDisabled, ); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 1fca5696ab6..2e3878c5b4f 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -660,3 +660,11 @@ export const layerTest = ( }), ); }; + +export const layerDisabled = layerTest({ + health: Effect.succeed({ + status: "unavailable", + lastSampleAt: Option.none(), + lastError: Option.some("Desktop resource telemetry is disabled by this build."), + }), +}); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index e8d81cc4c1c..a396b3c97a8 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -1022,3 +1022,14 @@ export const layerTest = ( }), ); }; + +export const layerDisabled = layerTest({ + health: Effect.succeed({ + status: "unavailable", + hello: Option.none(), + lastSampleAt: Option.none(), + lastError: Option.some("Resource telemetry is disabled by this build."), + restartCount: 0, + sampleIntervalMs: UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS, + }), +}); diff --git a/apps/server/src/resourceTelemetry/ResourceAttribution.ts b/apps/server/src/resourceTelemetry/ResourceAttribution.ts index 374564bb7d8..d60b8f2f47f 100644 --- a/apps/server/src/resourceTelemetry/ResourceAttribution.ts +++ b/apps/server/src/resourceTelemetry/ResourceAttribution.ts @@ -69,3 +69,11 @@ export const make = Effect.fn("resourceTelemetry.resourceAttribution.make")(func }); export const layer = Layer.effect(ResourceAttribution, make()); + +export const layerDisabled = Layer.succeed( + ResourceAttribution, + ResourceAttribution.of({ + record: () => Effect.void, + snapshot: DateTime.now.pipe(Effect.map((readAt) => ({ readAt, entries: [] }))), + }), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3f63eb4dbef..c1b9a245f46 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4076,7 +4076,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("proxies browser OTLP trace exports through the server", () => + it.effect("rejects browser OTLP trace exports", () => Effect.gen(function* () { const upstreamRequests: Array<{ readonly body: string; @@ -4214,52 +4214,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { body: HttpBody.text(JSON.stringify(payload), "application/json"), }); - assert.equal(response.status, 204); - assert.equal(response.headers["access-control-allow-origin"], "*"); - assert.deepEqual(localTraceRecords, [ - { - type: "otlp-span", - name: "RpcClient.server.getSettings", - traceId: "11111111111111111111111111111111", - spanId: "2222222222222222", - parentSpanId: "3333333333333333", - sampled: true, - kind: "client", - startTimeUnixNano: "1000000", - endTimeUnixNano: "2000000", - durationMs: 1, - attributes: { - "rpc.method": "server.getSettings", - }, - resourceAttributes: { - "service.name": "t3-web", - }, - scope: { - name: "effect", - version: "4.0.0-beta.43", - attributes: {}, - }, - events: [ - { - name: "http.request", - timeUnixNano: "1500000", - attributes: { - "http.status_code": "200", - }, - }, - ], - links: [], - status: { - code: "STATUS_CODE_OK", - }, - }, - ]); - assert.deepEqual(upstreamRequests, [ - { - body: jsonRequestBody(payload), - contentType: "application/json", - }, - ]); + assert.equal(response.status, 404); + assert.deepEqual(localTraceRecords, []); + assert.deepEqual(upstreamRequests, []); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4292,77 +4249,44 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "stores browser OTLP trace exports locally when no upstream collector is configured", - () => - Effect.gen(function* () { - const localTraceRecords: Array = []; - const payload = yield* makeBrowserOtlpPayload("client.test"); - const resourceSpan = payload.resourceSpans[0]; - const scopeSpan = resourceSpan?.scopeSpans[0]; - const span = scopeSpan?.spans[0]; - - assert.notEqual(resourceSpan, undefined); - assert.notEqual(scopeSpan, undefined); - assert.notEqual(span, undefined); - if (!resourceSpan || !scopeSpan || !span) { - return; - } - - yield* buildAppUnderTest({ - layers: { - browserTraceCollector: { - record: (records) => - Effect.sync(() => { - localTraceRecords.push(...records); - }), - }, - }, - }); + it.effect("does not store browser OTLP trace exports locally", () => + Effect.gen(function* () { + const localTraceRecords: Array = []; + const payload = yield* makeBrowserOtlpPayload("client.test"); + const resourceSpan = payload.resourceSpans[0]; + const scopeSpan = resourceSpan?.scopeSpans[0]; + const span = scopeSpan?.spans[0]; + + assert.notEqual(resourceSpan, undefined); + assert.notEqual(scopeSpan, undefined); + assert.notEqual(span, undefined); + if (!resourceSpan || !scopeSpan || !span) { + return; + } - const response = yield* HttpClient.post("/api/observability/v1/traces", { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - "content-type": "application/json", + yield* buildAppUnderTest({ + layers: { + browserTraceCollector: { + record: (records) => + Effect.sync(() => { + localTraceRecords.push(...records); + }), }, - // @effect-diagnostics-next-line preferSchemaOverJson:off - body: HttpBody.text(JSON.stringify(payload), "application/json"), - }); + }, + }); - assert.equal(response.status, 204); - assert.equal(localTraceRecords.length, 1); - const record = localTraceRecords[0] as { - readonly type: string; - readonly name: string; - readonly traceId: string; - readonly spanId: string; - readonly kind: string; - readonly attributes: Readonly>; - readonly events: ReadonlyArray; - readonly links: ReadonlyArray; - readonly scope: { - readonly name?: string; - readonly attributes: Readonly>; - }; - readonly resourceAttributes: Readonly>; - readonly status?: { - readonly code?: string; - }; - }; + const response = yield* HttpClient.post("/api/observability/v1/traces", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + "content-type": "application/json", + }, + // @effect-diagnostics-next-line preferSchemaOverJson:off + body: HttpBody.text(JSON.stringify(payload), "application/json"), + }); - assert.equal(record.type, "otlp-span"); - assert.equal(record.name, span.name); - assert.equal(record.traceId, span.traceId); - assert.equal(record.spanId, span.spanId); - assert.equal(record.kind, "internal"); - assert.deepEqual(record.attributes, {}); - assert.deepEqual(record.events, []); - assert.deepEqual(record.links, []); - assert.equal(record.scope.name, scopeSpan.scope.name); - assert.deepEqual(record.scope.attributes, {}); - assert.equal(record.resourceAttributes["service.name"], "t3-web"); - assert.equal(record.status?.code, String(span.status.code)); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + assert.equal(response.status, 404); + assert.deepEqual(localTraceRecords, []); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect("routes websocket rpc server.upsertKeybinding", () => @@ -4568,11 +4492,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true); - assert.equal(first.config.observability.localTracingEnabled, true); - assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); - assert.equal(first.config.observability.otlpTracesEnabled, true); - assert.equal(first.config.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics"); - assert.equal(first.config.observability.otlpMetricsEnabled, true); + assert.equal(first.config.observability.localTracingEnabled, false); + assert.equal(first.config.observability.otlpTracesUrl, undefined); + assert.equal(first.config.observability.otlpTracesEnabled, false); + assert.equal(first.config.observability.otlpMetricsUrl, undefined); + assert.equal(first.config.observability.otlpMetricsEnabled, false); assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS); } assert.deepEqual(second, { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 32bcaaa8b96..54828f813ad 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -102,7 +102,6 @@ import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; -import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; @@ -122,7 +121,7 @@ import { forkParked, ServerActivation } from "./serverActivation.ts"; // already closes the websocket gracefully. Do not add an artificial drain before // those finalizers get a chance to run. const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; -const ResourceAttributionLayerLive = ResourceAttribution.layer; +const ResourceAttributionLayerLive = ResourceAttribution.layerDisabled; const ApplicationObservabilityLive = ObservabilityLive.pipe( Layer.provideMerge(ResourceAttributionLayerLive), ); @@ -141,12 +140,8 @@ const PtyAdapterLive = Layer.unwrap( const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)); -const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe( - Layer.provide(ResourceMonitorBinary.layer), -); -const DesktopTelemetryReceiverLayerLive = DesktopTelemetryReceiver.layer.pipe( - Layer.provideMerge(ServerSettingsLayerLive), -); +const NativeTelemetryLayerLive = NativeTelemetryClient.layerDisabled; +const DesktopTelemetryReceiverLayerLive = DesktopTelemetryReceiver.layerDisabled; const ResourceTelemetryLayerLive = ResourceTelemetry.layer.pipe( Layer.provideMerge(NativeTelemetryLayerLive), diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index afdd06f4ea9..868414e6c20 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -3,42 +3,19 @@ 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 FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; 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; - }; - }>; - } | null; -} - -interface RecordedBatchBody { - readonly batch: ReadonlyArray<{ - readonly event?: string; - readonly properties?: { - readonly index?: number; - readonly clientType?: string; - }; - }>; -} - it.layer(NodeServices.layer)("AnalyticsService test", (it) => { - it.effect("flush drains all buffered events across multiple batches", () => + it.effect("production telemetry stays disabled when export is explicitly configured", () => Effect.gen(function* () { - const capturedRequests: Array = []; + const capturedRequests: Array = []; const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-base-", }); @@ -55,18 +32,8 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { const batchServerLayer = 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({}); + capturedRequests.push(request.url); + return HttpServerResponse.empty({ status: 204 }); }), ); const runtimeLayer = telemetryLayer.pipe( @@ -76,8 +43,8 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { yield* Effect.gen(function* () { yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); - const telemetryIdentifier = yield* getTelemetryIdentifier; - assert.equal(telemetryIdentifier !== null, true); + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; const analytics = yield* AnalyticsService.AnalyticsService; for (let index = 0; index < 45; index += 1) { @@ -85,38 +52,10 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { } yield* analytics.flush; + assert.isFalse(yield* fileSystem.exists(config.anonymousIdPath)); }).pipe(Effect.provide(runtimeLayer)); - 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.deepEqual(capturedRequests, []); }), ); }); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 5fdc7bdeb19..754bff26c74 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,47 +1,6 @@ -/** - * Anonymous PostHog telemetry service. - * - * Persists an installation-scoped anonymous identifier, buffers events in - * memory, and flushes batches over Effect's HTTP client. - * - * @module AnalyticsService - */ -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Config from "effect/Config"; import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; - -import packageJson from "../../package.json" with { type: "json" }; -import * as ServerConfig from "../config.ts"; -import { getTelemetryIdentifier } from "./Identify.ts"; - -interface BufferedAnalyticsEvent { - readonly event: string; - readonly properties?: Readonly>; - readonly capturedAt: string; -} - -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, @@ -66,121 +25,17 @@ export class AnalyticsService extends Context.Service< ); } -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 bufferRef = yield* Ref.make>([]); - const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; - - const enqueueBufferedEvent = (event: string, properties?: Readonly>) => - Effect.flatMap(DateTime.now, (now) => - Ref.modify(bufferRef, (current) => { - const appended = [ - ...current, - { - 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) => ({ - event: event.event, - distinct_id: identifier, - properties: { - ...event.properties, - $process_person_profile: false, - platform: hostPlatform, - wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), - arch: hostArchitecture, - t3CodeVersion: packageJson.version, - clientType, - }, - timestamp: event.capturedAt, - })), - }; - - yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe( - HttpClientRequest.bodyJson(payload), - Effect.flatMap(httpClient.execute), - Effect.flatMap(HttpClientResponse.filterStatusOk), - ); - }); - - const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { - while (true) { - const batch = yield* Ref.modify(bufferRef, (current) => { - if (current.length === 0) { - return [[] as ReadonlyArray, current] as const; - } - const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); - const remaining = current.slice(nextBatch.length); - return [nextBatch, remaining] as const; - }); - - if (batch.length === 0) { - return; - } - - yield* sendBatch(batch).pipe( - Effect.catch((error) => - Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( - Effect.flatMap(() => Effect.fail(error)), - ), - ), - ); - } - }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); - - const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( - function* (event, properties) { - if (!telemetryConfig.enabled || !identifier) return; - - const enqueueResult = yield* enqueueBufferedEvent(event, properties); - if (enqueueResult.dropped) { - yield* Effect.logDebug("analytics buffer full; dropping oldest event", { - size: enqueueResult.size, - event, - }); - } - }, - ); - - yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), { - disableYield: true, - }).pipe(Effect.forkScoped); - - yield* Effect.addFinalizer(() => flush); - - return AnalyticsService.of({ record, flush }); -}); - -export const layer = Layer.effect(AnalyticsService, make); - +/** + * Preserve the upstream service contract while compiling analytics out of the + * distribution. Keeping this boundary stable minimizes conflicts when + * rebasing new upstream call sites. + */ +export const make = Effect.succeed( + AnalyticsService.of({ + record: () => Effect.void, + flush: Effect.void, + }), +); + +export const layer = AnalyticsService.layerTest; export const layerTest = AnalyticsService.layerTest; diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts deleted file mode 100644 index ab151821789..00000000000 --- a/apps/server/src/telemetry/Identify.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import * as NodeCrypto from "node:crypto"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -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 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 = (value: string) => - NodeCrypto.createHash("sha256").update(value, "utf8").digest("hex"); - -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("preserves exact telemetry identity causes without deriving messages from them", () => { - const decodeCause = new Error("private nested decode details"); - const decodeError = new Identify.TelemetryIdentityDecodeError({ - source: "codex", - filePath: "/tmp/auth.json", - cause: decodeCause, - }); - const readCause = new Error("private nested read details"); - const readError = new Identify.TelemetryIdentityReadError({ - source: "anonymous", - filePath: "/tmp/anonymous-id", - cause: readCause, - }); - - assert.strictEqual(decodeError.cause, decodeCause); - assert.strictEqual(readError.cause, readCause); - assert.notInclude(decodeError.message, decodeCause.message); - assert.notInclude(readError.message, readCause.message); -}); - -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, sha256(anonymousId)); - }).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-identify-anonymous-", - }), - ), - ), - ); - - 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, 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 b6c3d0066df..00000000000 --- a/apps/server/src/telemetry/Identify.ts +++ /dev/null @@ -1,303 +0,0 @@ -import * as NodeOS from "node:os"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; -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 * as ServerConfig from "../config.ts"; - -const CodexAuthJsonSchema = Schema.Struct({ - tokens: 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; - -export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( - "TelemetryIdentityReadError", - { - source: TelemetryIdentitySource, - filePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`; - } -} - -export class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass()( - "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.TaggedErrorClass()( - "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.TaggedErrorClass()( - "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.TaggedErrorClass()( - "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.map(Option.some), - 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(Encoding.encodeHex), - 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 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* fileSystem.writeFileString(anonymousIdPath, 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 - */ -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.map(Option.some), - 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.suspend(() => - getTelemetryIdentifierForHome(NodeOS.homedir()), -); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 126222d214a..2e48d06b564 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -59,6 +59,7 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { TELEMETRY_ENABLED } from "@t3tools/shared/telemetryPolicy"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -1012,13 +1013,15 @@ const makeWsRpcLayer = ( ), observability: { logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined + localTracingEnabled: TELEMETRY_ENABLED, + ...(TELEMETRY_ENABLED && config.otlpTracesUrl !== undefined + ? { otlpTracesUrl: config.otlpTracesUrl } + : {}), + otlpTracesEnabled: TELEMETRY_ENABLED && config.otlpTracesUrl !== undefined, + ...(TELEMETRY_ENABLED && config.otlpMetricsUrl !== undefined ? { otlpMetricsUrl: config.otlpMetricsUrl } : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + otlpMetricsEnabled: TELEMETRY_ENABLED && config.otlpMetricsUrl !== undefined, }, settings, shellResumeCompletionMarker: true, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 521654f3279..5f60f253cab 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -2,6 +2,7 @@ import "vite-plus/test/config"; import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; +import { TELEMETRY_ENABLED } from "@t3tools/shared/telemetryPolicy"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; import packageJson from "./package.json" with { type: "json" }; @@ -53,13 +54,13 @@ export default mergeConfig( 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() ?? "", + TELEMETRY_ENABLED ? (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() ?? "", + TELEMETRY_ENABLED ? (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() ?? "", + TELEMETRY_ENABLED ? (repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() ?? "") : "", ), }, }, diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 2d07e218e85..6c91abf171a 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -1,147 +1,24 @@ -import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import * as ManagedRuntime from "effect/ManagedRuntime"; -import * as Scope from "effect/Scope"; import * as Tracer from "effect/Tracer"; -import { HttpClient } from "effect/unstable/http"; -import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; - -import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; -import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; -import { isElectron } from "../env"; -import { APP_VERSION } from "~/branding"; - -const DEFAULT_EXPORT_INTERVAL_MS = 1_000; -const CLIENT_TRACING_RESOURCE = { - serviceName: "t3-web", - attributes: { - "service.runtime": "t3-web", - "service.mode": isElectron ? "electron" : "browser", - "service.version": APP_VERSION, - }, -} as const; - -const delegateRuntimeLayer = Layer.mergeAll( - primaryEnvironmentHttpLayer, - OtlpExporter.layerFlusher, - OtlpSerialization.layerJson, - Layer.succeed(HttpClient.TracerDisabledWhen, () => true), -); - -let activeDelegate: Tracer.Tracer | null = null; -let activeRuntime: ManagedRuntime.ManagedRuntime | null = null; -let activeScope: Scope.Closeable | null = null; -let activeConfigKey: string | null = null; -let configurationGeneration = 0; -let pendingConfiguration = Promise.resolve(); export interface ClientTracingConfig { readonly exportIntervalMs?: number; } +/** + * Retain native spans for in-process correlation without installing an + * exporter or sending traces outside the application. + */ export const ClientTracingLive = Layer.succeed( Tracer.Tracer, - Tracer.make({ - span(options) { - return activeDelegate?.span(options) ?? new Tracer.NativeSpan(options); - }, - }), + Tracer.make({ span: (options) => new Tracer.NativeSpan(options) }), ); -export function configureClientTracing(config: ClientTracingConfig = {}): Promise { - if (config.exportIntervalMs === undefined && activeConfigKey !== null) { - return pendingConfiguration; - } - pendingConfiguration = pendingConfiguration.finally(() => applyClientTracingConfig(config)); - return pendingConfiguration; -} - -async function applyClientTracingConfig(config: ClientTracingConfig): Promise { - const otlpTracesUrl = resolvePrimaryEnvironmentHttpUrl("/api/observability/v1/traces"); - const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS); - const nextConfigKey = `${otlpTracesUrl}|${exportIntervalMs}`; - - if (activeConfigKey === nextConfigKey && activeDelegate !== null) { - return; - } - - activeConfigKey = nextConfigKey; - const generation = ++configurationGeneration; - - const previousRuntime = activeRuntime; - const previousScope = activeScope; - - activeDelegate = null; - activeRuntime = null; - activeScope = null; - - await disposeTracerRuntime(previousRuntime, previousScope); - - const runtime = ManagedRuntime.make(delegateRuntimeLayer); - const scope = runtime.runSync(Scope.make()); - - const delegateResult = await settleAsyncResult(() => - runtime.runPromiseExit( - Scope.provide(scope)( - OtlpTracer.make({ - url: otlpTracesUrl, - exportInterval: `${exportIntervalMs} millis`, - resource: CLIENT_TRACING_RESOURCE, - }), - ), - ), - ); - if (delegateResult._tag === "Failure") { - await disposeTracerRuntime(runtime, scope); - - if (generation === configurationGeneration) { - const error = squashAtomCommandFailure(delegateResult); - const tracesUrl = new URL(otlpTracesUrl); - console.warn("Failed to configure client tracing exporter", { - scheme: tracesUrl.protocol.replace(/:$/, ""), - host: tracesUrl.hostname, - port: tracesUrl.port || undefined, - exportIntervalMs, - ...safeErrorLogAttributes(error), - }); - } - return; - } - - if (generation !== configurationGeneration) { - await disposeTracerRuntime(runtime, scope); - return; - } - - activeDelegate = delegateResult.value; - activeRuntime = runtime; - activeScope = scope; +/** Stable no-op boundary retained for upstream initialization call sites. */ +export function configureClientTracing(_config: ClientTracingConfig = {}): Promise { + return Promise.resolve(); } -async function disposeTracerRuntime( - runtime: ManagedRuntime.ManagedRuntime | null, - scope: Scope.Closeable | null, -): Promise { - if (runtime === null || scope === null) { - return; - } - - await settleAsyncResult(() => runtime.runPromiseExit(Scope.close(scope, Exit.void))); - runtime.dispose(); -} - -export async function __resetClientTracingForTests() { - configurationGeneration++; - activeConfigKey = null; - activeDelegate = null; - pendingConfiguration = Promise.resolve(); - - const runtime = activeRuntime; - const scope = activeScope; - activeRuntime = null; - activeScope = null; - - await disposeTracerRuntime(runtime, scope); +export function __resetClientTracingForTests(): Promise { + return Promise.resolve(); } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6ee5de587b9..2a1e625cce3 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -11,6 +11,7 @@ import { defineConfig, type Connect, type Plugin } from "vite-plus"; import pkg from "./package.json" with { type: "json" }; import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; +import { TELEMETRY_ENABLED } from "@t3tools/shared/telemetryPolicy"; import { loadRepoEnv } from "../../scripts/lib/public-config"; @@ -35,9 +36,15 @@ 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 configuredRelayTracingUrl = TELEMETRY_ENABLED + ? repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || "" + : ""; +const configuredRelayTracingDataset = TELEMETRY_ENABLED + ? repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || "" + : ""; +const configuredRelayTracingToken = TELEMETRY_ENABLED + ? 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 = (() => { diff --git a/docs/README.md b/docs/README.md index 30653e7d503..d75572badb4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A - [Providers](./internals/providers.md) - [Remote environments](./internals/remote.md) - [Server updates](./internals/server-updates.md) +- [Telemetry-free distribution](./internals/telemetry-free-distribution.md) - [Resource telemetry](./internals/resource-telemetry.md) - [Environment auth](./internals/environment-auth.md) - [T3 Connect](./internals/t3-connect.md) diff --git a/docs/internals/telemetry-free-distribution.md b/docs/internals/telemetry-free-distribution.md new file mode 100644 index 00000000000..f34cc3752c9 --- /dev/null +++ b/docs/internals/telemetry-free-distribution.md @@ -0,0 +1,41 @@ +# Telemetry-free distribution + +This downstream distribution compiles telemetry out at the existing service boundaries. It does +not collect anonymous product analytics, export traces or metrics, persist local trace files, sample +host or process resources, or provision relay observability infrastructure. + +Functional network traffic is unchanged. Provider CLIs, authentication, application updates, relay +connections, and push notifications still contact the services the user chooses to use. Their own +privacy behavior is outside the T3 Code telemetry boundary. + +## Why the upstream interfaces remain + +Telemetry call sites, contracts, and settings shapes intentionally remain type-checked. Their live +layers resolve to no-op services under the compile-time policy in +`packages/shared/src/telemetryPolicy.ts`. This keeps the privacy guarantee concentrated at stable +adapter boundaries and avoids a broad deletion patch that would conflict with routine upstream +work. + +The guarded boundaries are: + +- server analytics and identity creation; +- shared relay-client tracing used by server, web, and mobile; +- browser, server, desktop, and relay trace collectors and exporters; +- server and desktop resource telemetry publishers; +- relay observability infrastructure and build-time client configuration. + +## Keeping the fork current + +Keep the fork's `main` branch as a clean upstream mirror and rebase the telemetry-free branch as a +linear patch series: + +```sh +git fetch upstream main +git rebase upstream/main feat/telemetry-free +git range-diff upstream/main...feat/telemetry-free@{1} upstream/main...feat/telemetry-free +``` + +Enabling `rerere` can reuse recurring conflict resolutions, but every reused resolution should be +reviewed. After each rebase, run the focused telemetry tests and search new runtime/build code for +analytics SDKs, vendor ingest hosts, tracing exporters, and resource samplers that do not pass +through the existing no-op boundaries. diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 7341bfb5eda..64857e316b5 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -2,547 +2,18 @@ > For maintainers. Using T3 Code? See [docs/user](../user/). -T3 Code has one server-side observability model: +This distribution keeps direct application and terminal logs, but compiles out telemetry: -- pretty logs go to stdout for humans -- completed spans go to a local NDJSON trace file -- traces and metrics can also be exported over OTLP to a real backend like Grafana LGTM +- no product analytics or installation identifier; +- no local trace files or browser trace ingestion; +- no OTLP trace or metric exporters; +- no desktop or server resource sampling; +- no relay observability backend. -The local trace file is the persisted source of truth for normal local launches. Those launches do not -write a separate server log file, but SSH-managed launches also persist the remote process's -stdout/stderr at `~/.t3/ssh-launch//server.log`. +Native in-memory span IDs remain only where application and relay error contracts use them for +request correlation. They have no exporter or persistent sink. Provider event files and explicit +terminal output are functional debugging artifacts, not telemetry collected by T3 Code. -## Where To Find Things - -### Logs - -Logs are human-facing: - -- destination: stdout -- format: `Logger.consolePretty()` -- normal local persistence: none -- SSH-managed launch persistence: `~/.t3/ssh-launch//server.log` - -If you want a log message to show up in the trace file, emit it inside an active span with `Effect.log...`. `Logger.tracerLogger` will attach it as a span event. - -### Traces - -Completed spans are written as NDJSON records to `serverTracePath`. The default depends on how the -server starts: production and explicitly configured homes use -`/userdata/logs/server.trace.ndjson` (so `~/.t3/userdata/...` by default, or -`/custom/path/userdata/...` with `--home-dir /custom/path`), a linked worktree dev run uses -`/.t3/userdata/logs/server.trace.ndjson`, and an implicit dev run outside a linked -worktree uses `~/.t3/dev/logs/server.trace.ndjson`. - -Important fields common to both record types: - -- `type`: `effect-span` or `otlp-span` -- `name`: span name -- `traceId`, `spanId`, `parentSpanId`: correlation -- `durationMs`: elapsed time -- `attributes`: structured context -- `events`: embedded logs and custom events - -`effect-span` records also contain `exit` with `Success`, `Failure`, or `Interrupted`. `otlp-span` -records instead carry OTLP resource, scope, and optional status fields. - -The `TraceRecord`, `EffectTraceRecord`, and `OtlpTraceRecord` schemas live in -`packages/shared/src/observability.ts`. - -### Metrics - -Metrics are not written to a local file. - -- local persistence: none -- remote export: OTLP only, when configured -- current definitions: `apps/server/src/observability/Metrics.ts` - -If OTLP is not configured, metrics still exist in-process, but you will not have a local artifact to inspect. - -### Related Artifacts - -Provider event NDJSON files still exist for provider runtime streams. Those are separate from the main server trace file. - -## Run The Server In Instrumented Mode - -There are two useful modes: - -- local-only: stdout + local `server.trace.ndjson` -- full local observability: stdout + local trace file + OTLP export to Grafana/Tempo/Prometheus - -The local trace file is always on. OTLP export is opt-in. - -### Option 1: Local Traces Only - -You do not need any extra env vars. Just run the app normally and inspect `server.trace.ndjson`. - -Examples: - -```bash -npx t3 -``` - -```bash -node --run dev -``` - -```bash -node --run dev:desktop -``` - -### Option 2: Run With A Local LGTM Stack - -#### 1. Start Grafana LGTM - -```bash -docker run --name lgtm \ - -p 3000:3000 \ - -p 4317:4317 \ - -p 4318:4318 \ - --rm -ti \ - grafana/otel-lgtm -``` - -Then open `http://localhost:3000`. - -Default Grafana login: - -- username: `admin` -- password: `admin` - -#### 2. Export OTLP env vars - -```bash -export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces -export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics -export T3CODE_OTLP_SERVICE_NAME=t3-local -``` - -Optional: - -```bash -export T3CODE_TRACE_MIN_LEVEL=Info -export T3CODE_TRACE_TIMING_ENABLED=true -``` - -#### 3. Launch the app from that same shell - -CLI: - -```bash -npx t3 -``` - -Monorepo web/server dev: - -```bash -node --run dev -``` - -Monorepo desktop dev: - -```bash -node --run dev:desktop -``` - -Packaged desktop app: - -Launch the actual app executable from the same shell so the desktop app and embedded backend inherit `T3CODE_OTLP_*`. - -macOS app bundle example: - -```bash -T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ -T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ -T3CODE_OTLP_SERVICE_NAME=t3-desktop \ -"/Applications/T3 Code.app/Contents/MacOS/T3 Code" -``` - -Direct binary example: - -```bash -T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ -T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ -T3CODE_OTLP_SERVICE_NAME=t3-desktop \ -./path/to/your/desktop-app-binary -``` - -Do not rely on launching from Finder, Spotlight, the dock, or the Start menu after setting shell env vars. Those launches usually will not pick them up. - -#### 4. Fully restart after changing env - -The backend reads observability config at process start. If you change OTLP env vars, stop the app completely and start it again. - -## How To Use Traces And Metrics To Debug The Server - -### Start With The Local Trace File - -The trace file is the fastest way to inspect raw span data. - -Resolve the path for the launch mode once. Production and explicitly configured homes store runtime -state under the base directory's `userdata` folder: - -```bash -TRACE_FILE="${T3CODE_HOME:-$HOME/.t3}/userdata/logs/server.trace.ndjson" -``` - -A dev server started from a linked worktree defaults to that worktree's local home: - -```bash -TRACE_FILE="$WORKTREE/.t3/userdata/logs/server.trace.ndjson" -``` - -Only an implicit dev run outside a linked worktree uses the shared dev directory: - -```bash -TRACE_FILE="$HOME/.t3/dev/logs/server.trace.ndjson" -``` - -Tail the selected file: - -```bash -tail -f "$TRACE_FILE" -``` - -Show failed spans: - -```bash -jq -c 'select(.type == "effect-span" and .exit._tag != "Success") | { - name, - durationMs, - exit, - attributes -}' "$TRACE_FILE" -``` - -Show slow spans: - -```bash -jq -c 'select(.durationMs > 1000) | { - name, - durationMs, - traceId, - spanId -}' "$TRACE_FILE" -``` - -Inspect embedded log events: - -```bash -jq -c 'select(any(.events[]?; .attributes["effect.logLevel"] != null)) | { - name, - durationMs, - events: [ - .events[] - | select(.attributes["effect.logLevel"] != null) - | { - message: .name, - level: .attributes["effect.logLevel"] - } - ] -}' "$TRACE_FILE" -``` - -Follow one trace: - -```bash -jq -r 'select(.traceId == "TRACE_ID_HERE") | [ - .name, - .spanId, - (.parentSpanId // "-"), - .durationMs -] | @tsv' "$TRACE_FILE" -``` - -Filter orchestration commands: - -```bash -jq -c 'select(.attributes["orchestration.command_type"] != null) | { - name, - durationMs, - commandType: .attributes["orchestration.command_type"], - aggregateKind: .attributes["orchestration.aggregate_kind"] -}' "$TRACE_FILE" -``` - -Filter git activity: - -```bash -jq -c 'select(.attributes["git.operation"] != null) | { - name, - durationMs, - operation: .attributes["git.operation"], - cwd: .attributes["git.cwd"], - hookEvents: [ - .events[] - | select(.name == "git.hook.started" or .name == "git.hook.finished") - ] -}' "$TRACE_FILE" -``` - -### Use Tempo When You Need A Real Trace Viewer - -Tempo is better than raw NDJSON when you want to: - -- search across many traces -- inspect parent/child relationships visually -- compare many slow traces -- drill into one failing request without hand-joining by `traceId` - -Recommended flow in Grafana: - -1. Open `Explore`. -2. Pick the `Tempo` data source. -3. Set the time range to something recent like `Last 15 minutes`. -4. Start broad. Do not begin with a very narrow query. -5. Look for spans from your configured service name, then narrow by span name or attributes. - -Good first searches: - -- service name such as `t3-local`, `t3-dev`, or `t3-desktop` -- span names like `sendTurn` or a Git operation such as `GitVcsDriver.statusDetails.status` -- Git spans whose `git.operation` attribute identifies the operation -- orchestration spans with attributes like `orchestration.command_type` - -Once you know traces are arriving, narrower TraceQL queries for names such as `sendTurn` or Git -operation names become useful. - -### Use Metrics To See Systemic Problems - -Traces are best for one request. Metrics are best for trends. - -Good metric families to watch: - -- `t3_rpc_request_duration` -- `t3_orchestration_command_duration` -- `t3_orchestration_command_ack_duration` -- `t3_provider_turn_duration` -- `t3_git_command_duration` - -Counters tell you volume and failure rate: - -- `t3_rpc_requests_total` -- `t3_orchestration_commands_total` -- `t3_provider_turns_total` -- `t3_git_commands_total` - -Use metrics when the question is: - -- "is this always slow?" -- "did this get worse after a change?" -- "which command type is failing most often?" - -Use traces when the question is: - -- "what happened in this specific request?" -- "which child span caused this one slow interaction?" -- "what logs were emitted inside the failing flow?" - -### What The New Ack Metric Means - -`t3_orchestration_command_ack_duration` measures: - -- start: command dispatch enters the orchestration engine -- end: the first committed domain event for that command is published by the server - -That is a server-side acknowledgment metric. It does not measure: - -- websocket transit to the browser -- client receipt -- React render time - -If you need those later, add client-side instrumentation or a dedicated server fanout metric. - -## Common Workflows - -### "Why did this request fail?" - -1. Start with the local NDJSON file. -2. Find `effect-span` records where `exit._tag != "Success"`. -3. Group by `traceId`. -4. Inspect sibling spans and span events. -5. If needed, move to Tempo for the full trace tree. - -### "Why is the UI feeling slow?" - -1. Search for slow top-level spans in the trace file or Tempo. -2. Check child spans for sqlite, git, provider, or terminal work. -3. Look at the matching duration metrics to see whether the slowness is systemic. - -### "Did this command take too long to acknowledge?" - -1. Check `t3_orchestration_command_ack_duration` by `commandType`. -2. If it is high, inspect the corresponding orchestration trace. -3. Look at child spans for projection, sqlite, provider, or git work. - -### "Are git hooks causing latency?" - -1. Filter `git.operation` spans. -2. Inspect `git.hook.started` and `git.hook.finished` events. -3. Compare hook timing to the enclosing git span duration. - -### "Why do I have spans locally but nothing in Grafana?" - -Usually one of these is true: - -- `T3CODE_OTLP_TRACES_URL` was not set -- the app was launched from a different environment than the one where you exported the vars -- the app was not fully restarted after changing env -- Grafana is looking at the wrong time range or service name - -If the local NDJSON file is updating, local tracing is working. The problem is almost always OTLP export configuration or process startup. - -## How To Think About Adding Tracing To Future Code - -### Prefer Boundaries Over Tiny Helpers - -Good span boundaries: - -- RPC methods -- orchestration command handling -- provider adapter calls -- external process calls -- persistence writes -- queue handoffs - -Avoid tracing every tiny helper. Most helpers should inherit the active span rather than create a new one. - -### Reuse `Effect.fn(...)` Where It Already Exists - -The codebase already uses `Effect.fn("name")` heavily. That should usually be your first tracing boundary. - -For ad hoc work: - -```ts -import { Effect } from "effect"; - -const runThing = Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - "thing.id": "abc123", - "thing.kind": "example", - }); - - yield* Effect.logInfo("starting thing"); - return yield* doWork(); -}).pipe(Effect.withSpan("thing.run")); -``` - -### Put High-Cardinality Detail On Spans - -Use span annotations for IDs, paths, and other detailed context: - -```ts -yield * - Effect.annotateCurrentSpan({ - "provider.thread_id": input.threadId, - "provider.request_id": input.requestId, - "git.cwd": input.cwd, - }); -``` - -### Keep Metric Labels Low Cardinality - -Good metric labels: - -- operation kind -- method name -- provider kind -- aggregate kind -- outcome - -Bad metric labels: - -- raw thread IDs -- command IDs -- file paths -- cwd -- full prompts -- full model strings when a normalized family label would do - -Detailed context belongs on spans, not metrics. - -### Use Logs As Span Events - -Logs inside a span become part of the trace story: - -```ts -yield * Effect.logInfo("starting provider turn"); -yield * Effect.logDebug("waiting for approval response"); -``` - -Those messages show up as span events because `Logger.tracerLogger` is installed. - -### Use The Pipeable Metrics API - -`withMetrics(...)` is the default way to attach a counter and timer to an effect: - -```ts -import { someCounter, someDuration, withMetrics } from "../observability/Metrics.ts"; - -const program = doWork().pipe( - withMetrics({ - counter: someCounter, - timer: someDuration, - attributes: { - operation: "work", - }, - }), -); -``` - -## Detailed API Reference - -### Runtime Wiring - -The server observability layer is assembled in `apps/server/src/observability/Layers/Observability.ts`. - -It provides: - -- pretty stdout logger -- `Logger.tracerLogger` -- local NDJSON tracer -- optional OTLP trace exporter -- optional OTLP metrics exporter -- Effect trace-level and timing refs - -### Env Vars - -Local trace file: - -- `T3CODE_TRACE_FILE`: override trace file path -- `T3CODE_TRACE_MAX_BYTES`: per-file rotation size, default `10485760` -- `T3CODE_TRACE_MAX_FILES`: rotated file count, default `10` -- `T3CODE_TRACE_BATCH_WINDOW_MS`: flush window, default `200` -- `T3CODE_TRACE_MIN_LEVEL`: minimum trace level, default `Info` -- `T3CODE_TRACE_TIMING_ENABLED`: enable timing metadata, default `true` - -OTLP export: - -- `T3CODE_OTLP_TRACES_URL`: OTLP trace endpoint -- `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint -- `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000` -- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` - -If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. - -### What Is Instrumented Today - -Current high-value span and metric boundaries include: - -- Effect RPC websocket request spans from `effect/rpc` -- RPC request metrics in `apps/server/src/observability/RpcInstrumentation.ts` -- startup phases -- orchestration command processing -- orchestration command acknowledgment latency -- provider session and turn operations -- git command execution and git hook events -- terminal session lifecycle -- sqlite query execution - -### Current Constraints - -- logs outside spans are not persisted in the trace file; SSH-managed launch stdout/stderr is still - captured in its launcher log -- metrics are not snapshotted locally -- the old `serverLogPath` still exists in config for compatibility, but the trace file is the primary - structured persisted artifact +The Diagnostics UI reports trace and resource telemetry as unavailable. See +[Telemetry-free distribution](../internals/telemetry-free-distribution.md) for the complete boundary +and the downstream rebase workflow. diff --git a/docs/operations/relay-observability.md b/docs/operations/relay-observability.md index 2bc697b2ef1..08236c5773c 100644 --- a/docs/operations/relay-observability.md +++ b/docs/operations/relay-observability.md @@ -2,52 +2,13 @@ > For maintainers. Using T3 Code? See [docs/user](../user/). -The relay Alchemy stack owns a shared Axiom trace setup: +This distribution does not provision an observability backend or export relay traces. The relay +keeps native in-memory span IDs only where application error contracts require correlation IDs; +those spans have no exporter or persistent sink. -- `t3-code-relay-traces-prod`, the OpenTelemetry trace dataset shared by the Worker, mobile app, and - first-party relay clients -- `t3-code-relay-otel-ingest-prod`, the dataset-scoped Worker ingest token -- `t3-code-mobile-otel-ingest-prod`, the dataset-scoped mobile ingest token -- `t3-code-relay-client-otel-ingest-prod`, the dataset-scoped first-party relay-client ingest token -- `t3-code-relay-recent-spans-prod`, a view of recent request and endpoint spans +Relay deployments require Cloudflare and PlanetScale credentials only. No analytics or tracing +credential is read, generated, stored, attached to the Worker, or passed to client builds. -Alchemy stages append their sanitized stage name to isolate resources, for example -`t3-code-relay-traces-dev-julius` for a personal stage. - -Deploy from `infra/relay` with the normal Alchemy workflow: - -```sh -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. - -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. -For example: - -```apl -['t3-code-relay-traces-prod'] -| where name startswith 'http.server' -| extend endpoint = column_ifexists('attributes.http.route', ''), - customAttributes = column_ifexists('attributes.custom', dynamic({})) -| project _time, name, trace_id, duration, - ['attributes.http.request.method'], - ['attributes.url.path'], - ['attributes.http.response.status_code'], - endpoint, - relayOperation = customAttributes['relay']['operation'] -| order by _time desc -| limit 200 -``` - -The provisioned view also reads the endpoint from `attributes.http.route`. Relay-specific span -annotations are stored under `attributes.custom`; `relay.operation` is one of the emitted custom -attributes. - -Agents should prefer the provisioned view or APL queries for completed incidents instead of -tailing the Cloudflare Worker. The stack does not provision a separate query token. Responders who -need scripted query access use the authorized account-level `AXIOM_TOKEN` together with -`AXIOM_ORG_ID`; scoped ingest tokens remain write-only credentials for their producers. +For operational debugging, use direct Cloudflare Worker logs and the functional service health +checks. See [Telemetry-free distribution](../internals/telemetry-free-distribution.md) for the +privacy boundary and rebase workflow. diff --git a/docs/operations/release.md b/docs/operations/release.md index 1d8768f59d0..002693cd251 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -58,14 +58,12 @@ Required repository variables shared by relay deployments: - `CLOUDFLARE_ACCOUNT_ID` - `PLANETSCALE_ORGANIZATION` -- `AXIOM_ORG_ID` Required repository secrets shared by relay deployments: - `CLOUDFLARE_API_TOKEN` - `PLANETSCALE_API_TOKEN_ID` - `PLANETSCALE_API_TOKEN` -- `AXIOM_TOKEN` Required `production` environment variables: @@ -89,10 +87,9 @@ Required `production` environment secrets: - `CLERK_SECRET_KEY` - `APNS_PRIVATE_KEY` -The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they -are not bound into the relay Worker. The production deployment uses an Axiom personal access token, -so `AXIOM_ORG_ID` must accompany `AXIOM_TOKEN`. The `prod` stage owns the retained PlanetScale -database. Local personal stages provision isolated branches from it and are never deployed by CI. +The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; +they are not bound into the relay Worker. The `prod` stage owns the retained PlanetScale database. +Local personal stages provision isolated branches from it and are never deployed by CI. Production adopts the configured relay API and tunnel DNS zones as retained Cloudflare resources. Personal stages reference the production-owned zones. diff --git a/infra/relay/README.md b/infra/relay/README.md index 0085c9c5b6b..c0168d1e23b 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -124,14 +124,12 @@ The repository must define these Actions variables shared by relay deployments: - `CLOUDFLARE_ACCOUNT_ID` - `PLANETSCALE_ORGANIZATION` -- `AXIOM_ORG_ID` The repository must define these Actions secrets shared by relay deployments: - `CLOUDFLARE_API_TOKEN` - `PLANETSCALE_API_TOKEN_ID` - `PLANETSCALE_API_TOKEN` -- `AXIOM_TOKEN` The `production` GitHub environment must define these Actions variables: @@ -151,11 +149,10 @@ The `production` GitHub environment must define these Actions secrets: - `CLERK_SECRET_KEY` - `APNS_PRIVATE_KEY` -The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they -are not bound into the relay Worker. The production deployment uses an Axiom personal access token, -so `AXIOM_ORG_ID` must accompany `AXIOM_TOKEN`. The release workflow reads the production relay's -derived public URL and Clerk publishable key from the same environment for downstream desktop, CLI, -and hosted web builds. +The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; +they are not bound into the relay Worker. The release workflow reads the production relay's derived +public URL and Clerk publishable key from the same environment for downstream desktop, CLI, and +hosted web builds. See: diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index 60f73bc3a76..f97fdd7f999 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -1,6 +1,5 @@ // @effect-diagnostics anyUnknownInErrorContext:off layerMergeAllWithDependencies:off - Alchemy provider helpers expose framework-owned any requirements. import * as Alchemy from "alchemy"; -import * as Axiom from "alchemy/Axiom"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Drizzle from "alchemy/Drizzle"; import * as Effect from "effect/Effect"; @@ -8,19 +7,13 @@ import * as Layer from "effect/Layer"; import * as Planetscale from "alchemy/Planetscale"; import * as RelayDb from "./src/db.ts"; -import { RelayObservability } from "./src/observability.ts"; import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts"; import ApiLive, { Api } from "./src/worker.ts"; export default Alchemy.Stack( "T3CodeRelay", { - providers: Layer.mergeAll( - Axiom.providers(), - Cloudflare.providers(), - Drizzle.providers(), - Planetscale.providers(), - ), + providers: Layer.mergeAll(Cloudflare.providers(), Drizzle.providers(), Planetscale.providers()), state: Cloudflare.state(), }, Effect.gen(function* () { @@ -28,7 +21,6 @@ export default Alchemy.Stack( const hyperdrive = yield* RelayDb.RelayHyperdrive; const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie); const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie); - const observability = yield* RelayObservability; const api = yield* Api; return { @@ -39,12 +31,6 @@ export default Alchemy.Stack( url: api.url, relayApiZoneId: relayApiZone.zoneId, managedEndpointZoneId: managedEndpointZone.zoneId, - mobileTracingUrl: observability.traces.otelTracesEndpoint, - mobileTracingDataset: observability.traces.name, - mobileTracingToken: observability.mobileIngestToken.token, - clientTracingUrl: observability.traces.otelTracesEndpoint, - clientTracingDataset: observability.traces.name, - clientTracingToken: observability.clientIngestToken.token, }; }).pipe(Effect.provide(ApiLive)), ); diff --git a/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts index 87098b25daf..90d81641ea7 100644 --- a/infra/relay/scripts/deploy.test.ts +++ b/infra/relay/scripts/deploy.test.ts @@ -11,17 +11,12 @@ import { reconcileRootEnvPublicConfig, reconcileRootEnvRelayUrl, RelayDeployError, - RelayDeployPublicConfigUnavailableError, serializeGithubOutput, - serializeRelayClientTracingEnvironment, } from "./deploy.ts"; describe("RelayDeployError", () => { it("reports the incomplete state source, stage, and missing fields", () => { - const missingFields = missingRelayPublicConfigFields({ - url: "https://relay.example.test", - mobileTracingUrl: "https://api.axiom.co/v1/traces", - }); + const missingFields = missingRelayPublicConfigFields({}); const error = new RelayDeployError({ source: "alchemy_state", stage: "production", @@ -31,28 +26,10 @@ describe("RelayDeployError", () => { expect(error).toMatchObject({ source: "alchemy_state", stage: "production", - missingFields: [ - "mobileTracingDataset", - "mobileTracingToken", - "clientTracingUrl", - "clientTracingDataset", - "clientTracingToken", - ], - }); - expect(error.message).toBe( - "Relay deploy output from 'alchemy_state' for stage 'production' is missing required public config fields: mobileTracingDataset, mobileTracingToken, clientTracingUrl, clientTracingDataset, clientTracingToken", - ); - }); - - it("distinguishes deploy results that do not produce public config", () => { - const error = new RelayDeployPublicConfigUnavailableError({ - result: "dry-run", - stage: "production", - outputPath: "/tmp/relay-client.env", + missingFields: ["url"], }); - expect(error.message).toBe( - "Relay deploy result 'dry-run' for stage 'production' did not produce public config required by GitHub environment output '/tmp/relay-client.env'.", + "Relay deploy output from 'alchemy_state' for stage 'production' is missing required public config fields: url", ); }); }); @@ -107,59 +84,22 @@ describe("reconcileRootEnvRelayUrl", () => { }); describe("reconcileRootEnvPublicConfig", () => { - const config = { - relayUrl: "https://relay.example.test", - mobileTracingUrl: "https://api.axiom.co/v1/traces", - mobileTracingDataset: "t3-code-mobile-traces-dev", - mobileTracingToken: "xaat-public-ingest", - clientTracingUrl: "https://api.axiom.co/v1/traces", - clientTracingDataset: "t3-code-relay-client-traces-dev", - clientTracingToken: "xaat-relay-client-ingest", - } as const; + const config = { relayUrl: "https://relay.example.test" } as const; - it("adds the complete local client config", () => { + it("adds the relay URL without tracing credentials", () => { expect(reconcileRootEnvPublicConfig("", config)).toBe( - [ - "T3CODE_RELAY_URL=https://relay.example.test", - "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", - "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev", - "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=t3-code-relay-client-traces-dev", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=xaat-relay-client-ingest", - "", - ].join("\n"), + "T3CODE_RELAY_URL=https://relay.example.test\n", ); }); - it("replaces stale values while preserving unrelated entries", () => { + it("replaces stale relay URLs while preserving unrelated entries", () => { expect( reconcileRootEnvPublicConfig( - [ - "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example", - "T3CODE_RELAY_URL=https://old.example.test", - "T3CODE_MOBILE_OTLP_TRACES_URL=https://old.example.test/v1/traces", - "T3CODE_MOBILE_OTLP_TRACES_DATASET=old-dataset", - "T3CODE_MOBILE_OTLP_TRACES_TOKEN=old-token", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://old.example.test/v1/traces", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=old-client-dataset", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=old-client-token", - "", - ].join("\n"), + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example\nT3CODE_RELAY_URL=https://old.example.test\n", config, ), ).toBe( - [ - "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example", - "T3CODE_RELAY_URL=https://relay.example.test", - "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", - "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev", - "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=t3-code-relay-client-traces-dev", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=xaat-relay-client-ingest", - "", - ].join("\n"), + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example\nT3CODE_RELAY_URL=https://relay.example.test\n", ); }); }); @@ -176,31 +116,8 @@ describe("serializeGithubOutput", () => { }); }); -describe("serializeRelayClientTracingEnvironment", () => { - it("serializes tracing config for downstream GITHUB_ENV loading", () => { - expect( - serializeRelayClientTracingEnvironment({ - relayUrl: "https://relay.example.test", - mobileTracingUrl: "https://api.axiom.co/v1/traces", - mobileTracingDataset: "mobile", - mobileTracingToken: "mobile-token", - clientTracingUrl: "https://api.axiom.co/v1/traces", - clientTracingDataset: "relay", - clientTracingToken: "client-token", - }), - ).toBe( - [ - "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=relay", - "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-token", - "", - ].join("\n"), - ); - }); -}); - -describe("release workflow tracing config propagation", () => { - it.effect("uses an artifact instead of a masked cross-job token output", () => +describe("release workflow telemetry policy", () => { + it.effect("does not propagate tracing credentials", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -209,39 +126,20 @@ describe("release workflow tracing config propagation", () => { ); const workflow = yield* fileSystem.readFileString(workflowPath); - expect(workflow).not.toContain("client_tracing_token:"); - expect(workflow).not.toContain("needs.relay_public_config.outputs.client_tracing_token"); - expect(workflow).toContain('--github-env-file "$RUNNER_TEMP/relay-client-tracing.env"'); - expect(workflow).toContain("name: relay-client-tracing-config"); - expect(workflow).toContain('cat "$config_path" >> "$GITHUB_ENV"'); + expect(workflow).not.toContain("relay-client-tracing-config"); + expect(workflow).not.toContain("T3CODE_RELAY_CLIENT_OTLP_TRACES_"); }).pipe(Effect.provide(NodeServices.layer)), ); }); describe("publicConfigFromOutput", () => { - it("reads the complete public tracing config from persisted Alchemy output", () => { - expect( - publicConfigFromOutput({ - url: "https://relay.example.test", - mobileTracingUrl: "https://api.axiom.co/v1/traces", - mobileTracingDataset: "mobile", - mobileTracingToken: "mobile-token", - clientTracingUrl: "https://api.axiom.co/v1/traces", - clientTracingDataset: "relay", - clientTracingToken: "client-token", - }), - ).toEqual({ + it("reads the relay URL from persisted Alchemy output", () => { + expect(publicConfigFromOutput({ url: "https://relay.example.test" })).toEqual({ relayUrl: "https://relay.example.test", - mobileTracingUrl: "https://api.axiom.co/v1/traces", - mobileTracingDataset: "mobile", - mobileTracingToken: "mobile-token", - clientTracingUrl: "https://api.axiom.co/v1/traces", - clientTracingDataset: "relay", - clientTracingToken: "client-token", }); }); it("rejects incomplete stack output", () => { - expect(publicConfigFromOutput({ url: "https://relay.example.test" })).toBeNull(); + expect(publicConfigFromOutput({})).toBeNull(); }); }); diff --git a/infra/relay/scripts/deploy.ts b/infra/relay/scripts/deploy.ts index 1942b8735e3..d7e314f04c0 100644 --- a/infra/relay/scripts/deploy.ts +++ b/infra/relay/scripts/deploy.ts @@ -14,7 +14,6 @@ import { LoggingCli } from "alchemy/Cli/LoggingCli"; import * as Plan from "alchemy/Plan"; import * as Stage from "alchemy/Stage"; import * as State from "alchemy/State/State"; -import { TelemetryLive } from "alchemy/Telemetry/Layer"; import { PlatformServices } from "alchemy/Util/PlatformServices"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -24,22 +23,13 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { Command, Flag, Prompt } from "effect/unstable/cli"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import RelayStack from "../alchemy.run.ts"; -const relayDeployOutputFields = [ - "url", - "mobileTracingUrl", - "mobileTracingDataset", - "mobileTracingToken", - "clientTracingUrl", - "clientTracingDataset", - "clientTracingToken", -] as const; +const relayDeployOutputFields = ["url"] as const; export const RelayDeployOutputField = Schema.Literals(relayDeployOutputFields); export type RelayDeployOutputField = typeof RelayDeployOutputField.Type; @@ -66,19 +56,6 @@ export class RelayDeployError extends Schema.TaggedErrorClass( } } -export class RelayDeployPublicConfigUnavailableError extends Schema.TaggedErrorClass()( - "RelayDeployPublicConfigUnavailableError", - { - result: RelayDeployResult, - stage: Schema.String, - outputPath: Schema.String, - }, -) { - override get message(): string { - return `Relay deploy result '${this.result}' for stage '${this.stage}' did not produce public config required by GitHub environment output '${this.outputPath}'.`; - } -} - export interface RelayDeployOptions { readonly dryRun: boolean; readonly force: boolean; @@ -87,29 +64,16 @@ export interface RelayDeployOptions { readonly yes: boolean; readonly adopt: boolean; readonly githubOutput: boolean; - readonly githubEnvFile: Option.Option; readonly readState: boolean; } export interface RelayPublicConfig { readonly relayUrl: string; - readonly mobileTracingUrl: string; - readonly mobileTracingDataset: string; - readonly mobileTracingToken: string; - readonly clientTracingUrl: string; - readonly clientTracingDataset: string; - readonly clientTracingToken: string; } const publicConfigEnvEntries = (config: RelayPublicConfig) => ({ T3CODE_RELAY_URL: config.relayUrl, - T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl, - T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset, - T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileTracingToken, - T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl, - T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset, - T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.clientTracingToken, }) as const; export function reconcileRootEnvPublicConfig(contents: string, config: RelayPublicConfig): string { @@ -131,15 +95,7 @@ export function reconcileRootEnvPublicConfig(contents: string, config: RelayPubl } export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string { - return reconcileRootEnvPublicConfig(contents, { - relayUrl, - mobileTracingUrl: "", - mobileTracingDataset: "", - mobileTracingToken: "", - clientTracingUrl: "", - clientTracingDataset: "", - clientTracingToken: "", - }) + return reconcileRootEnvPublicConfig(contents, { relayUrl }) .split("\n") .filter((line) => !line.startsWith("T3CODE_MOBILE_OTLP_TRACES_")) .filter((line) => !line.startsWith("T3CODE_RELAY_CLIENT_OTLP_TRACES_")) @@ -168,14 +124,6 @@ export function serializeGithubOutput(entries: Readonly path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -214,8 +162,8 @@ const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* ( const rootEnvPath = path.join(root, ".env"); const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : ""; - yield* fs.writeFileString(rootEnvPath, reconcileRootEnvPublicConfig(contents, config)); - yield* Console.log(`Updated ${rootEnvPath} with relay public client configuration`); + yield* fs.writeFileString(rootEnvPath, reconcileRootEnvRelayUrl(contents, config.relayUrl)); + yield* Console.log(`Updated ${rootEnvPath} with the relay URL`); }); const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* ( @@ -238,26 +186,6 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* ); }); -const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* ( - outcome: RelayDeployOutcome, - outputPath: string, - stage: string, -) { - if (Option.isNone(outcome.publicConfig)) { - return yield* new RelayDeployPublicConfigUnavailableError({ - result: outcome.result, - stage, - outputPath, - }); - } - const fs = yield* FileSystem.FileSystem; - yield* Console.log(`::add-mask::${outcome.publicConfig.value.clientTracingToken}`); - yield* fs.writeFileString( - outputPath, - serializeRelayClientTracingEnvironment(outcome.publicConfig.value), - ); -}); - const deployBaseServices = Layer.mergeAll( Layer.succeed(AuthProviders, {}), Layer.succeed(ArtifactStore, createArtifactStore()), @@ -265,7 +193,6 @@ const deployBaseServices = Layer.mergeAll( Layer.provide(ProfileLive, PlatformServices), Layer.provide(CredentialsStoreLive, PlatformServices), FetchHttpClient.layer, - TelemetryLive, LoggingCli, ); const deployServices = deployBaseServices; @@ -274,38 +201,14 @@ function relayPublicConfigValues( output: unknown, ): Readonly> { if (typeof output !== "object" || output === null) { - return { - url: undefined, - mobileTracingUrl: undefined, - mobileTracingDataset: undefined, - mobileTracingToken: undefined, - clientTracingUrl: undefined, - clientTracingDataset: undefined, - clientTracingToken: undefined, - }; + return { url: undefined }; } const value = output as Record; const text = (name: string) => { const candidate = value[name]; return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined; }; - const secret = (name: string): string | undefined => { - const candidate = value[name]; - if (!Redacted.isRedacted(candidate)) { - return text(name); - } - const redacted = Redacted.value(candidate); - return typeof redacted === "string" && redacted.length > 0 ? redacted : undefined; - }; - return { - url: text("url"), - mobileTracingUrl: text("mobileTracingUrl"), - mobileTracingDataset: text("mobileTracingDataset"), - mobileTracingToken: secret("mobileTracingToken"), - clientTracingUrl: text("clientTracingUrl"), - clientTracingDataset: text("clientTracingDataset"), - clientTracingToken: secret("clientTracingToken"), - }; + return { url: text("url") }; } export function missingRelayPublicConfigFields( @@ -326,15 +229,7 @@ export function publicConfigFromOutput(output: unknown): RelayPublicConfig | nul if (!hasCompleteRelayPublicConfigValues(values)) { return null; } - return { - relayUrl: values.url, - mobileTracingUrl: values.mobileTracingUrl, - mobileTracingDataset: values.mobileTracingDataset, - mobileTracingToken: values.mobileTracingToken, - clientTracingUrl: values.clientTracingUrl, - clientTracingDataset: values.clientTracingDataset, - clientTracingToken: values.clientTracingToken, - }; + return { relayUrl: values.url }; } const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) { @@ -440,9 +335,6 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO if (options.githubOutput) { yield* writeGithubOutput(outcome); } - if (Option.isSome(options.githubEnvFile)) { - yield* writeGithubEnvFile(outcome, options.githubEnvFile.value, stage); - } }); export const relayDeployCommand = Command.make( @@ -478,12 +370,6 @@ export const relayDeployCommand = Command.make( Flag.withDescription("Append relay deployment metadata to GITHUB_OUTPUT."), Flag.withDefault(false), ), - githubEnvFile: Flag.string("github-env-file").pipe( - Flag.withDescription( - "Write relay client tracing variables to a file suitable for GITHUB_ENV.", - ), - Flag.optional, - ), readState: Flag.boolean("read-state").pipe( Flag.withDescription("Read the deployed stack output without planning or applying changes."), Flag.withDefault(false), diff --git a/infra/relay/src/observability.test.ts b/infra/relay/src/observability.test.ts index 3958b28b95d..1f8f088be07 100644 --- a/infra/relay/src/observability.test.ts +++ b/infra/relay/src/observability.test.ts @@ -1,54 +1,19 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { expect, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; -import * as Schema from "effect/Schema"; -import * as HttpServer from "effect/unstable/http/HttpServer"; -import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import type { OtlpTracer } from "effect/unstable/observability"; import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts"; import { makeRelayTraceLayer } from "./observability.ts"; -interface ExportedRequest { - readonly authorization: string | undefined; - readonly body: string; - readonly dataset: string | undefined; -} - -const otlpAttributeValue = (value: { - readonly stringValue?: string | null; - readonly boolValue?: boolean | null; - readonly intValue?: string | number | null; - readonly doubleValue?: number | null; -}) => value.stringValue ?? value.boolValue ?? value.intValue ?? value.doubleValue; - -const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); - -it.effect("exports schema error fields as span attributes", () => +it.effect("keeps relay error effects intact without exporting spans", () => Effect.gen(function* () { - const exportedRequest = yield* Deferred.make(); - yield* HttpServer.serveEffect( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - yield* Deferred.succeed(exportedRequest, { - authorization: request.headers.authorization, - body: yield* request.text, - dataset: request.headers["x-axiom-dataset"], - }); - return HttpServerResponse.empty({ status: 204 }); - }), - ); - - yield* Effect.fail( - new EnvironmentConnector.EnvironmentConnectNotAuthorized({ - environmentId: "environment-1", - operation: "connect", - reason: "managed_endpoint_allocation_not_ready", - }), - ).pipe( + const error = new EnvironmentConnector.EnvironmentConnectNotAuthorized({ + environmentId: "environment-1", + operation: "connect", + reason: "managed_endpoint_allocation_not_ready", + }); + const exit = yield* Effect.fail(error).pipe( Effect.withSpan("relay.test.schema_error"), Effect.exit, Effect.provide( @@ -60,26 +25,6 @@ it.effect("exports schema error fields as span attributes", () => ), ); - const request = yield* Deferred.await(exportedRequest).pipe(Effect.timeout("1 second")); - const payload = (yield* decodeJson(request.body)) as OtlpTracer.TraceData; - const span = payload.resourceSpans - .flatMap((resourceSpan) => resourceSpan.scopeSpans) - .flatMap((scopeSpan) => scopeSpan.spans) - .find((candidate) => candidate.name === "relay.test.schema_error"); - const attributes = Object.fromEntries( - (span?.attributes ?? []).map((attribute) => [ - attribute.key, - otlpAttributeValue(attribute.value), - ]), - ); - - expect(request.authorization).toBe("Bearer test-token"); - expect(request.dataset).toBe("relay-test-traces"); - expect(attributes).toMatchObject({ - "error.type": "EnvironmentConnectNotAuthorized", - "error.environmentId": "environment-1", - "error.operation": "connect", - "error.reason": "managed_endpoint_allocation_not_ready", - }); + expect(exit._tag).toBe("Failure"); }).pipe(Effect.provide(NodeHttpServer.layerTest), Effect.scoped), ); diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index ca091d8cce6..cdda134f8d1 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -1,72 +1,7 @@ -import * as Alchemy from "alchemy"; -import * as Axiom from "alchemy/Axiom"; -import * as Output from "alchemy/Output"; -import * as Cause from "effect/Cause"; 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 Redacted from "effect/Redacted"; -import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; -import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; - -import { relayResourceNameForStage } from "./deploymentConfig.ts"; - -const relayRecentSpansQuery = (dataset: string) => - [ - `['${dataset}']`, - `| where isnotnull(span_id) or isnotnull(trace_id)`, - `| extend requestMethod = column_ifexists('attributes.http.request.method', ''), path = column_ifexists('attributes.url.path', ''), endpoint = column_ifexists('attributes.http.route', ''), statusCode = column_ifexists('attributes.http.response.status_code', 0), customAttributes = column_ifexists('attributes.custom', dynamic({}))`, - `| extend userId = customAttributes['user']['id']`, - `| project _time, name, trace_id, span_id, duration, requestMethod, path, statusCode, endpoint, userId`, - `| order by _time desc`, - `| limit 200`, - ].join("\n"); - -export const RelayObservability = Effect.gen(function* () { - const { stage } = yield* Alchemy.Stack; - const traces = yield* Axiom.Dataset("RelayTracesDataset", { - name: relayResourceNameForStage("t3-code-relay-traces", stage), - kind: "otel:traces:v1", - description: "T3 Code relay Worker HTTP request spans.", - retentionDays: 30, - useRetentionPeriod: true, - }); - - const workerIngestToken = yield* Axiom.ApiToken("RelayWorkerAxiomIngestToken", { - name: relayResourceNameForStage("t3-code-relay-otel-ingest", stage), - description: "Owned by Alchemy. Scoped OTLP ingest token for relay HTTP spans.", - datasetCapabilities: Output.map(traces.name, (dataset) => ({ - [dataset]: { ingest: ["create" as const] }, - })), - }); - - const mobileIngestToken = yield* Axiom.ApiToken("RelayMobileAxiomIngestToken", { - name: relayResourceNameForStage("t3-code-mobile-otel-ingest", stage), - description: "Owned by Alchemy. Scoped OTLP ingest token for T3 Code mobile spans.", - datasetCapabilities: Output.map(traces.name, (dataset) => ({ - [dataset]: { ingest: ["create" as const] }, - })), - }); - - const clientIngestToken = yield* Axiom.ApiToken("RelayClientAxiomIngestToken", { - name: relayResourceNameForStage("t3-code-relay-client-otel-ingest", stage), - description: "Owned by Alchemy. Scoped OTLP ingest token for first-party relay client spans.", - datasetCapabilities: Output.map(traces.name, (dataset) => ({ - [dataset]: { ingest: ["create" as const] }, - })), - }); - - yield* Axiom.View("RelayRecentSpansView", { - name: relayResourceNameForStage("t3-code-relay-recent-spans", stage), - description: "Recent relay HTTP request spans.", - datasets: [traces.name], - aplQuery: Output.map(traces.name, relayRecentSpansQuery), - }); - - return { traces, workerIngestToken, mobileIngestToken, clientIngestToken } as const; -}); export const withSpanAttributes = (attributes: Record) => @@ -75,163 +10,13 @@ export const withSpanAttributes = Effect.andThen(effect.pipe(Effect.annotateSpans(attributes))), ); -const appendEncodedAttributes = ( - attributes: Record, - prefix: string, - value: unknown, -): void => { - if ( - value === null || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" || - typeof value === "bigint" || - Array.isArray(value) - ) { - attributes[prefix] = value; - return; - } - if (typeof value !== "object") { - return; - } - for (const [key, child] of Object.entries(value)) { - appendEncodedAttributes(attributes, `${prefix}.${key}`, child); - } -}; - -const schemaErrorAttributes = (error: unknown): Record | undefined => { - if (typeof error !== "object" || error === null) { - return undefined; - } - const constructor = error.constructor; - if (!Schema.isSchema(constructor)) { - return undefined; - } - const encoded = Schema.encodeUnknownOption(constructor as unknown as Schema.Encoder)( - error, - ); - if (Option.isNone(encoded) || typeof encoded.value !== "object" || encoded.value === null) { - return undefined; - } - const tag = Reflect.get(encoded.value, "_tag"); - if (typeof tag !== "string") { - return undefined; - } - - const attributes: Record = { - "error.type": tag, - }; - for (const [key, value] of Object.entries(encoded.value)) { - if (key !== "_tag") { - appendEncodedAttributes(attributes, `error.${key}`, value); - } - } - return attributes; -}; - -const annotateSchemaError = (span: Tracer.Span, exit: Exit.Exit): void => { - if (Exit.isSuccess(exit)) { - return; - } - for (const reason of exit.cause.reasons) { - const error = Cause.isFailReason(reason) - ? reason.error - : Cause.isDieReason(reason) - ? reason.defect - : undefined; - const attributes = schemaErrorAttributes(error); - if (attributes) { - for (const [key, value] of Object.entries(attributes)) { - span.attribute(key, value); - } - return; - } - } -}; - -class RelayTraceSpan implements Tracer.Span { - readonly _tag = "Span"; - private readonly delegate: Tracer.Span; - - constructor(delegate: Tracer.Span) { - this.delegate = delegate; - } - - get name() { - return this.delegate.name; - } - get spanId() { - return this.delegate.spanId; - } - get traceId() { - return this.delegate.traceId; - } - get parent() { - return this.delegate.parent; - } - get annotations() { - return this.delegate.annotations; - } - get status() { - return this.delegate.status; - } - get attributes() { - return this.delegate.attributes; - } - get links() { - return this.delegate.links; - } - get sampled() { - return this.delegate.sampled; - } - get kind() { - return this.delegate.kind; - } - - end(endTime: bigint, exit: Exit.Exit): void { - annotateSchemaError(this.delegate, exit); - this.delegate.end(endTime, exit); - } - - attribute(key: string, value: unknown): void { - this.delegate.attribute(key, value); - } - - event(name: string, startTime: bigint, attributes?: Record): void { - this.delegate.event(name, startTime, attributes); - } - - addLinks(links: ReadonlyArray): void { - this.delegate.addLinks(links); - } -} - -const withSchemaErrorAttributes = (delegate: Tracer.Tracer): Tracer.Tracer => - Tracer.make({ - span: (options) => new RelayTraceSpan(delegate.span(options)), - ...(delegate.context ? { context: delegate.context } : {}), - }); +export const relayTraceLayerDisabled = Layer.succeed( + Tracer.Tracer, + Tracer.make({ span: (options) => new Tracer.NativeSpan(options) }), +); -export const makeRelayTraceLayer = (input: { +export const makeRelayTraceLayer = (_input: { readonly tracesEndpoint: string; readonly tracesDatasetName: string; readonly ingestToken: Redacted.Redacted; -}) => - Layer.effect( - Tracer.Tracer, - OtlpTracer.make({ - url: input.tracesEndpoint, - resource: { - serviceName: "t3-code-relay-worker", - attributes: { - "service.runtime": "cloudflare-worker", - "service.component": "relay", - }, - }, - headers: { - Authorization: `Bearer ${Redacted.value(input.ingestToken)}`, - "X-Axiom-Dataset": input.tracesDatasetName, - }, - exportInterval: "1 second", - }).pipe(Effect.map(withSchemaErrorAttributes)), - ).pipe(Layer.provideMerge(OtlpExporter.layerFlusher), Layer.provide(OtlpSerialization.layerJson)); +}) => relayTraceLayerDisabled; diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 77dfd845c5b..46b801dbc17 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -33,7 +33,7 @@ import { withoutCapturedParentSpan, } from "./http/Api.ts"; import { ManagedEndpointZone, RelayApiZone, RelayDeploymentConfig } from "./zone.ts"; -import { makeRelayTraceLayer, RelayObservability } from "./observability.ts"; +import { relayTraceLayerDisabled } from "./observability.ts"; import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts"; import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts"; import * as Devices from "./agentActivity/Devices.ts"; @@ -121,7 +121,6 @@ export const ApiLive = Api.make( const relayApiZone = yield* RelayApiZone; const managedEndpointZone = yield* ManagedEndpointZone; const randomApnsDeliveryJobSigningSecret = yield* ApnsDeliveryJobSigningSecret; - const observability = yield* RelayObservability; // // 2. Create bindings @@ -137,10 +136,6 @@ export const ApiLive = Api.make( const apnsDeliveryJobSigningSecret = yield* randomApnsDeliveryJobSigningSecret; const apnsDeliveryQueueSender = yield* Cloudflare.Queues.WriteQueue(apnsDeliveryQueue); - const axiomDatasetName = yield* observability.traces.name; - const axiomIngestToken = yield* observability.workerIngestToken.token; - const axiomTracesEndpoint = yield* observability.traces.otelTracesEndpoint; - const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY"); const clerkPublishableKey = yield* Config.string("CLERK_PUBLISHABLE_KEY"); const clerkJwtAudience = yield* Config.string("CLERK_JWT_AUDIENCE"); @@ -182,14 +177,6 @@ export const ApiLive = Api.make( }); }); - const relayTraceLayer = Layer.unwrap( - Effect.all({ - tracesDatasetName: axiomDatasetName, - tracesEndpoint: axiomTracesEndpoint, - ingestToken: axiomIngestToken, - }).pipe(Effect.map(makeRelayTraceLayer)), - ); - const runtimeLayer = Layer.empty.pipe( Layer.provideMerge(MobileRegistrations.layer), Layer.provideMerge(AgentActivityPublisher.layer), @@ -291,7 +278,9 @@ export const ApiLive = Api.make( ).pipe( HttpRouter.toHttpEffect, withoutCapturedParentSpan, - Effect.flatMap((httpEffect) => traceRelayHttpRequestWith(httpEffect, relayTraceLayer)), + Effect.flatMap((httpEffect) => + traceRelayHttpRequestWith(httpEffect, relayTraceLayerDisabled), + ), ); return { fetch }; diff --git a/packages/shared/package.json b/packages/shared/package.json index f669bd0a452..35763051119 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -35,6 +35,10 @@ "types": "./src/observability.ts", "import": "./src/observability.ts" }, + "./telemetryPolicy": { + "types": "./src/telemetryPolicy.ts", + "import": "./src/telemetryPolicy.ts" + }, "./httpObservability": { "types": "./src/httpObservability.ts", "import": "./src/httpObservability.ts" diff --git a/packages/shared/src/relayTracing.test.ts b/packages/shared/src/relayTracing.test.ts index 3bb7f1ea1ac..9b5eae94518 100644 --- a/packages/shared/src/relayTracing.test.ts +++ b/packages/shared/src/relayTracing.test.ts @@ -62,7 +62,7 @@ describe("withRelayClientTracing", () => { }), ); - it.effect("preserves nested error causes in exported relay spans", () => { + it.effect("does not export configured product relay spans", () => { const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); const httpClientLayer = FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)), @@ -93,10 +93,7 @@ describe("withRelayClientTracing", () => { 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(fetchFn).not.toHaveBeenCalled(); }), ), ); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 1259984ea3c..217b6e2f3a6 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -1,12 +1,8 @@ -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/unstable/http"; -import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; export interface RelayClientTracingConfig { readonly tracesUrl: string; @@ -41,116 +37,9 @@ export const withRelayClientTracing = ( ), ); -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 makeRelayClientTracingLayer( - config: RelayClientTracingConfig | null, - resource: RelayClientTracingResource, -): Layer.Layer { - if (config === null) { - return Layer.succeed(RelayClientTracer, Option.none()); - } - - const tracerLayer = OtlpTracer.layer({ - url: config.tracesUrl, - headers: { - Authorization: `Bearer ${config.tracesToken}`, - "X-Axiom-Dataset": config.tracesDataset, - }, - resource: { - serviceName: resource.serviceName, - serviceVersion: resource.serviceVersion, - attributes: { - "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.map(Option.some)), - ).pipe(Layer.provide(tracerLayer)); + _config: RelayClientTracingConfig | null, + _resource: RelayClientTracingResource, +) { + return Layer.succeed(RelayClientTracer, Option.none()); } diff --git a/packages/shared/src/telemetryPolicy.test.ts b/packages/shared/src/telemetryPolicy.test.ts new file mode 100644 index 00000000000..9b1ce1f9b1c --- /dev/null +++ b/packages/shared/src/telemetryPolicy.test.ts @@ -0,0 +1,132 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { TELEMETRY_ENABLED } from "./telemetryPolicy.ts"; + +it("keeps telemetry disabled at compile time", () => { + expect(TELEMETRY_ENABLED).toBe(false); +}); + +const productionExtensions = new Set([ + ".astro", + ".cjs", + ".js", + ".json", + ".mjs", + ".rs", + ".ts", + ".tsx", + ".yaml", + ".yml", +]); + +function productionFiles( + directory: string, +): Effect.Effect< + ReadonlyArray, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const files: Array = []; + + for (const entry of yield* fileSystem.readDirectory(directory)) { + const absolutePath = path.join(directory, entry); + const info = yield* fileSystem.stat(absolutePath); + if (info.type === "Directory") { + if (!["dist", "dist-electron", "node_modules"].includes(entry)) { + files.push(...(yield* productionFiles(absolutePath))); + } + } else if ( + productionExtensions.has(path.extname(entry)) && + !/\.(?:spec|test)\.[^.]+$/u.test(entry) + ) { + files.push(absolutePath); + } + } + + return files; + }); +} + +it.effect("does not reintroduce telemetry vendors in production code or workflows", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = yield* path.fromFileUrl(new URL("../../..", import.meta.url)); + const files = yield* Effect.forEach( + [".github", "apps", "infra", "native", "packages"], + (directory) => productionFiles(path.join(repoRoot, directory)), + { concurrency: "unbounded" }, + ).pipe(Effect.map((groups) => groups.flat())); + const prohibited = [ + /us\.i\.posthog\.com/iu, + /api\.axiom\.co/iu, + /X-Axiom-Dataset/iu, + /@sentry\//iu, + /sentry\.io/iu, + /crashlytics/iu, + /bugsnag/iu, + /@rollbar\//iu, + ]; + + const violations = yield* Effect.forEach(files, (filePath) => + fileSystem + .readFileString(filePath) + .pipe( + Effect.map((contents) => + prohibited.some((pattern) => pattern.test(contents)) + ? filePath.slice(repoRoot.length + 1) + : undefined, + ), + ), + ).pipe(Effect.map((paths) => paths.filter((path) => path !== undefined))); + + expect(violations).toEqual([]); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("keeps every exporter boundary free of exporter implementations", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = yield* path.fromFileUrl(new URL("../../..", import.meta.url)); + const boundaryFiles = [ + ".github/workflows/release.yml", + "apps/desktop/src/app/DesktopObservability.ts", + "apps/server/src/observability/BrowserTraceCollector.ts", + "apps/server/src/observability/Layers/Observability.ts", + "apps/server/src/telemetry/AnalyticsService.ts", + "apps/web/src/observability/clientTracing.ts", + "infra/relay/scripts/deploy.ts", + "infra/relay/src/observability.ts", + "packages/shared/src/relayTracing.ts", + ]; + const prohibited = [ + /OtlpExporter/u, + /OtlpMetrics/u, + /OtlpTracer/u, + /TelemetryLive/u, + /T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN/u, + /makeLocalFileTracer/u, + /makeTraceSink/u, + ]; + const violations = yield* Effect.forEach(boundaryFiles, (relativePath) => + fileSystem + .readFileString(path.join(repoRoot, relativePath)) + .pipe( + Effect.map((contents) => + prohibited.some((pattern) => pattern.test(contents)) ? relativePath : undefined, + ), + ), + ).pipe(Effect.map((paths) => paths.filter((path) => path !== undefined))); + + expect(violations).toEqual([]); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/packages/shared/src/telemetryPolicy.ts b/packages/shared/src/telemetryPolicy.ts new file mode 100644 index 00000000000..a5402b2b210 --- /dev/null +++ b/packages/shared/src/telemetryPolicy.ts @@ -0,0 +1,8 @@ +/** + * Downstream privacy policy for the telemetry-free distribution. + * + * Keep this as a compile-time constant so telemetry integrations can retain + * their upstream-compatible interfaces without ever starting collectors or + * exporters in a shipped build. + */ +export const TELEMETRY_ENABLED: boolean = false;