From 0834dcf651613ecce07789654826c8a52af90296 Mon Sep 17 00:00:00 2001 From: pseudo <27822378+pseudoseed@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:37:32 -0700 Subject: [PATCH] fix: send no analytics, traces, or crash reports off the machine The fork shipped upstream's outside sinks: server usage events to PostHog through ingest.t3.codes, relay traces to Axiom from server, web, and mobile, and mobile crash reports through expo-observe. Each is now disabled at one point in code, so no build config or baked key can turn it back on. Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 - apps/mobile/app.config.ts | 10 +- .../SettingsDiagnosticsRouteScreen.tsx | 56 +-- .../src/features/observability/observe.ts | 64 +-- .../features/observability/tracing.test.ts | 7 +- apps/server/src/config.ts | 2 +- .../src/telemetry/AnalyticsService.test.ts | 368 ------------------ apps/server/src/telemetry/AnalyticsService.ts | 323 +-------------- apps/server/src/telemetry/Identify.ts | 5 - .../components/onboarding/WelcomeWizard.tsx | 13 +- .../components/settings/SettingsPanels.tsx | 2 +- apps/web/src/lib/runtime.test.ts | 33 +- docs/internals/product-analytics.md | 128 +----- docs/user/faq.md | 2 +- docs/user/telemetry.md | 33 +- packages/shared/src/relayTracing.ts | 118 +----- 16 files changed, 53 insertions(+), 1115 deletions(-) delete mode 100644 apps/server/src/telemetry/AnalyticsService.test.ts diff --git a/.env.example b/.env.example index fc67dcef94780..cc71615aae577 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,3 @@ T3CODE_RELAY_URL=https://relay.t3.codes # Defaults to https://app.t3.codes; override to test against a staging deployment. # T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes -# Public, ingest-only mobile OpenTelemetry configuration. -# 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-... diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ac86fe3237841..57cc512ed4e68 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -31,11 +31,9 @@ const iosTeamIdOverride = ( )?.trim(); const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; -// Compiled into the native project. Without a relay, Observe's native side could dispatch to -// Expo's own ingest before observe.ts turns it off; `.invalid` never resolves (RFC 6761). -const observeEndpointUrl = repoEnv.T3CODE_RELAY_URL?.trim() - ? new URL("/v1/mobile/observe", repoEnv.T3CODE_RELAY_URL.trim()).toString() - : "https://observe.invalid"; +// PseudoCode sends no crash reports. Compiled into the native project so Observe can never reach +// Expo's ingest or the relay; `.invalid` never resolves (RFC 6761). +const observeEndpointUrl = "https://observe.invalid"; const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; const androidAdaptiveForeground = "./assets/android-icon-foreground.png"; @@ -498,7 +496,7 @@ const config: ExpoConfig = { EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID, EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME: repoEnv.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME, observability: { - tracesUrl: repoEnv.EXPO_PUBLIC_OTLP_TRACES_URL ?? "https://api.axiom.co/v1/traces", + tracesUrl: repoEnv.EXPO_PUBLIC_OTLP_TRACES_URL ?? null, tracesDataset: repoEnv.EXPO_PUBLIC_OTLP_TRACES_DATASET ?? null, tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, }, diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx index 01532c826f847..275d9328712f2 100644 --- a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -3,9 +3,8 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScroll import * as Application from "expo-application"; import Constants from "expo-constants"; import * as Updates from "expo-updates"; -import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { useAtomValue } from "@effect/atom-react"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; import { View } from "react-native"; @@ -14,12 +13,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; -import { crashReportingAvailable, setCrashReportsEnabled } from "../observability/observe"; import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { SettingsSection } from "../settings/components/SettingsSection"; -import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow"; import { formatStartupCrashReport, parseStartupCrashRecords, @@ -90,8 +86,6 @@ export function SettingsDiagnosticsRouteScreen() { className="flex-1" contentContainerClassName="gap-6 px-5 pt-4 pb-[18px]" > - {crashReportingAvailable ? : null} - {state.status === "loading" ? ( @@ -137,54 +131,6 @@ export function SettingsDiagnosticsRouteScreen() { ); } -function CrashReportsSetting() { - const preferences = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom, { mode: "promiseExit" }); - // One change at a time, so a slow save never turns sending back on after a later opt-out. - const [saving, setSaving] = useState(false); - const ready = AsyncResult.isSuccess(preferences) && !preferences.waiting; - const enabled = AsyncResult.isSuccess(preferences) - ? preferences.value.crashReportsEnabled !== false - : true; - - const change = (value: boolean) => { - setSaving(true); - // Sending stops before the save and starts only after it. - if (!value) void setCrashReportsEnabled(false); - savePreferences({ crashReportsEnabled: value }) - .then((exit) => { - if (Exit.isFailure(exit)) { - console.warn("[diagnostics] could not save the crash report setting", exit.cause); - return value ? undefined : setCrashReportsEnabled(true); - } - return value ? setCrashReportsEnabled(true) : undefined; - }) - .catch((error: unknown) => { - console.warn("[diagnostics] could not apply the crash report setting", error); - }) - .finally(() => setSaving(false)); - }; - - return ( - - - - - - When the app crashes or hits an error, it sends the error message, the stack trace, the app - version, and the device model and OS version to the T3 Code team. Each launch also sends - startup timings. Reports carry a random ID for this install. - - - ); -} - function EmptyState(props: { readonly icon: "exclamationmark.triangle" | "checkmark.circle"; readonly title: string; diff --git a/apps/mobile/src/features/observability/observe.ts b/apps/mobile/src/features/observability/observe.ts index fbbb5e22b8339..e70b8683c7cca 100644 --- a/apps/mobile/src/features/observability/observe.ts +++ b/apps/mobile/src/features/observability/observe.ts @@ -1,63 +1,5 @@ -import Constants from "expo-constants"; import { Observe } from "expo-observe"; -// Importing expo-observe installs its global JS error handler; reports leave only through the relay. -const relayUrl: unknown = Constants.expoConfig?.extra?.relay?.url; -const appVariant: unknown = Constants.expoConfig?.extra?.appVariant; - -export const crashReportingAvailable = typeof relayUrl === "string" && relayUrl.trim() !== ""; - -interface PromiseRejectionTracker { - readonly enablePromiseRejectionTracker?: (options: { - readonly allRejections: boolean; - readonly onUnhandled: (id: number, rejection: unknown) => void; - }) => void; -} - -// React Native tracks unhandled rejections only in development, for LogBox; Observe has no hook. -if (!__DEV__) { - const hermes: PromiseRejectionTracker | undefined = Reflect.get(globalThis, "HermesInternal"); - hermes?.enablePromiseRejectionTracker?.({ - allRejections: true, - onUnhandled: (_id, rejection) => Observe.reportError(rejection), - }); -} - -let latestConfiguration = 0; - -// While sending is off, a dispatch pass drops what it finds, so one runs before turning it back on. -// Best effort: Observe skips the pass while it is backing off from a failed send. -export async function setCrashReportsEnabled(enabled: boolean) { - const configuration = ++latestConfiguration; - const dispatchingEnabled = crashReportingAvailable && enabled; - if (dispatchingEnabled) { - await Observe.dispatchEvents(); - if (configuration !== latestConfiguration) return; - } - Observe.configure({ - dispatchingEnabled, - ...(typeof appVariant === "string" ? { environment: appVariant } : {}), - }); -} - -if (!crashReportingAvailable) { - void setCrashReportsEnabled(false); -} else { - // Observe keeps its last configuration across launches; configuring it off while the setting - // loads would drop the last session's native crash. Imported lazily so the persistence graph - // never runs ahead of the imports `index.ts` orders first. - const changedSinceLoad = () => latestConfiguration !== 0; - void import("../../persistence/imperative") - .then(({ loadPreferences }) => loadPreferences()) - .then( - (preferences) => - changedSinceLoad() - ? undefined - : setCrashReportsEnabled(preferences.crashReportsEnabled !== false), - // Observe keeps the last choice it was given, which stands in for the unreadable one. - (error: unknown) => console.warn("[observe] could not read the crash report setting", error), - ) - .catch((error: unknown) => { - console.warn("[observe] could not apply the crash report setting", error); - }); -} +// PseudoCode sends no crash reports. Importing expo-observe installs its global error handler, +// and Observe keeps its last configuration across launches, so sending is switched off here. +Observe.configure({ dispatchingEnabled: false }); diff --git a/apps/mobile/src/features/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts index a0fa9e85f6de9..8f8890b01e8d7 100644 --- a/apps/mobile/src/features/observability/tracing.test.ts +++ b/apps/mobile/src/features/observability/tracing.test.ts @@ -17,7 +17,7 @@ vi.mock("expo-constants", () => ({ }, })); -it.effect("does not let OTLP serialization failures alter application effects", () => { +it.effect("sends no spans even when a build carries trace credentials", () => { const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); const layerTracing = Tracing.layerFromConfig( { @@ -52,10 +52,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/config.ts b/apps/server/src/config.ts index 83bd88f3ddb6d..4383dd687221b 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -23,7 +23,7 @@ import packageJson from "../package.json" with { type: "json" }; // Official release workflows set this telemetry channel; other builds report dev. // Unlike __T3CODE_BUILD_CHANNEL__, this is not the sign-in page's latest/nightly channel. declare const __T3CODE_TELEMETRY_CHANNEL__: ReleaseChannel | undefined; -export const telemetryChannel: ReleaseChannel = +const telemetryChannel: ReleaseChannel = typeof __T3CODE_TELEMETRY_CHANNEL__ === "undefined" ? "dev" : __T3CODE_TELEMETRY_CHANNEL__; export const DEFAULT_PORT = 3773; diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts deleted file mode 100644 index 417841cb53e39..0000000000000 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as Clock from "effect/Clock"; -import * as ConfigProvider from "effect/ConfigProvider"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import * as HttpServer from "effect/http/HttpServer"; -import * as HttpServerRequest from "effect/http/HttpServerRequest"; -import * as HttpServerResponse from "effect/http/HttpServerResponse"; -import * as HostProcess from "@t3tools/shared/HostProcess"; - -import * as ServerConfig from "../config.ts"; -import { getTelemetryIdentity } from "./Identify.ts"; -import * as AnalyticsService from "./AnalyticsService.ts"; - -interface RecordedBatchRequest { - readonly path: string; - readonly body: { - readonly batch?: ReadonlyArray<{ - readonly event?: string; - readonly properties?: { - readonly index?: number; - readonly clientType?: string; - readonly serverOs?: string; - readonly serverArch?: string; - readonly serverAppVersion?: string; - readonly serverMode?: string; - readonly t3CodeVersion?: string; - }; - }>; - } | null; -} - -interface RecordedBatchBody { - readonly batch: ReadonlyArray<{ - readonly event?: string; - readonly properties?: { - readonly index?: number; - readonly clientType?: string; - readonly serverOs?: string; - readonly serverArch?: string; - readonly serverAppVersion?: string; - readonly serverMode?: string; - readonly t3CodeVersion?: string; - }; - }>; -} - -const SentBatch = Schema.fromJsonString( - Schema.Struct({ - batch: Schema.Array( - Schema.Struct({ - uuid: Schema.String, - event: Schema.String, - distinct_id: Schema.String, - properties: Schema.Struct({ - telemetryChannel: Schema.String, - installId: Schema.optionalKey(Schema.String), - ownerSource: Schema.optionalKey(Schema.String), - observedBy: Schema.optionalKey(Schema.String), - }), - }), - ), - }), -); -const decodeSentBatch = Schema.decodeEffect(SentBatch); -type SentEvent = (typeof SentBatch.Type)["batch"][number]; -type Respond = ( - request: HttpClientRequest.HttpClientRequest, -) => Effect.Effect; - -const respondWithStatus = - (status: number): Respond => - (request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))); - -/** - * Answers 503 for `minutes` after the first send, then 204. `answers` holds each - * answer with its time in ms after the first send (the test clock runs on across tests). - */ -const failingFor = (minutes: number) => { - const answers: Array<{ readonly at: number; readonly status: number }> = []; - let firstSend: number | undefined; - const respond: Respond = (request) => - Effect.flatMap(Clock.currentTimeMillis, (now) => { - firstSend ??= now; - const at = now - firstSend; - const status = at < minutes * 60_000 ? 503 : 204; - answers.push({ at, status }); - return respondWithStatus(status)(request); - }); - return { respond, answers }; -}; - -const recordTwentyEvents = Effect.gen(function* () { - const analytics = yield* AnalyticsService.AnalyticsService; - for (let index = 0; index < 20; index += 1) { - yield* analytics.record("test.retry", { index }); - } -}); - -/** - * Runs `record` (20 events by default), then lets `minutes` of background - * flushes run against a client that answers every send with `respond`. - * Returns the sent batches. - */ -const sendFor = ( - minutes: number, - respond: Respond, - record: Effect.Effect< - void, - never, - AnalyticsService.AnalyticsService | Effect.Services - > = recordTwentyEvents, -) => - Effect.gen(function* () { - const batches: Array> = []; - const client = HttpClient.make((request) => - Effect.gen(function* () { - if (request.body._tag === "Uint8Array") { - const body = yield* decodeSentBatch(new TextDecoder().decode(request.body.body)).pipe( - Effect.orDie, - ); - batches.push(body.batch); - } - return yield* respond(request); - }), - ); - const runtimeLayer = AnalyticsService.layer.pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-retry-" }), - ), - Layer.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - T3CODE_TELEMETRY_ENABLED: true, - T3CODE_POSTHOG_KEY: "phc_test_key", - T3CODE_POSTHOG_HOST: "http://localhost", - }), - ), - ), - Layer.provide( - Layer.mergeAll( - Layer.succeed(HostProcess.Platform, "win32"), - Layer.succeed(HostProcess.Architecture, "x64"), - Layer.succeed(HttpClient.HttpClient, client), - ), - ), - ); - - yield* Effect.gen(function* () { - yield* record; - for (let second = 0; second < minutes * 60; second += 1) { - yield* TestClock.adjust("1 second"); - } - }).pipe(Effect.provide(runtimeLayer)); - return batches; - }); - -it.layer(NodeServices.layer)("AnalyticsService test", (it) => { - it.effect("client owner reports cannot use up the server's owner cap", () => - Effect.gen(function* () { - const batches = yield* sendFor( - 10, - respondWithStatus(204), - Effect.gen(function* () { - const analytics = yield* AnalyticsService.AnalyticsService; - yield* analytics.recordOwners( - Array.from({ length: 1_001 }, (_, index) => ({ - source: "device" as const, - ownerId: index.toString(16).padStart(64, "0"), - })), - "client", - ); - // The event buffer also holds 1,000, so send the client events first. - yield* analytics.flush; - yield* analytics.recordOwners([{ source: "connect", ownerId: "a".repeat(64) }], "server"); - }), - ); - const owners = batches.flat().map((event) => event.properties.observedBy); - - assert.equal(owners.filter((observedBy) => observedBy === "client").length, 1_000); - assert.equal(owners.filter((observedBy) => observedBy === "server").length, 1); - }), - ); - - it.effect("keeps a failed batch through a 20-minute outage and sends it once after", () => - Effect.gen(function* () { - const ingest = failingFor(20); - const batches = yield* sendFor(40, ingest.respond); - const statuses = ingest.answers.map(({ status }) => status); - assert.equal(statuses.at(-1), 204); - assert.equal(statuses.filter((status) => status === 204).length, 1); - const sentUuids = new Set(batches.map((batch) => batch.map(({ uuid }) => uuid).join())); - assert.equal(sentUuids.size, 1); - assert.equal(batches[0]?.length, 20); - }), - ); - - it.effect("drops a batch that keeps failing for 30 minutes", () => - Effect.gen(function* () { - const ingest = failingFor(Infinity); - yield* sendFor(60, ingest.respond); - const lastSend = ingest.answers.at(-1)?.at ?? 0; - assert.isAtLeast(lastSend, 30 * 60_000); - assert.isBelow(lastSend, 36 * 60_000); - }), - ); - - it.effect("drops a batch the endpoint rejects without sending it again", () => - Effect.gen(function* () { - const batches = yield* sendFor(10, respondWithStatus(400)); - assert.equal(batches.length, 1); - }), - ); - - it.effect("flush drains all buffered events across multiple batches", () => - Effect.gen(function* () { - const capturedRequests: Array = []; - const layerServerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-base-", - }); - - const layerTelemetry = AnalyticsService.layer.pipe(Layer.provideMerge(layerServerConfig)); - const layerConfig = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - T3CODE_TELEMETRY_ENABLED: true, - T3CODE_POSTHOG_KEY: "phc_test_key", - T3CODE_POSTHOG_HOST: "http://localhost", - T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20, - }), - ); - const layerBatchServer = HttpServer.serve( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.method !== "POST") { - return HttpServerResponse.empty({ status: 404 }); - } - - const payload = yield* request.json.pipe( - Effect.map((body) => body as RecordedBatchRequest["body"]), - Effect.orElseSucceed(() => null), - ); - - capturedRequests.push({ path: request.url, body: payload }); - - return HttpServerResponse.jsonUnsafe({}); - }), - ); - const layerRuntime = layerTelemetry.pipe( - Layer.provide(layerConfig), - Layer.provide( - Layer.mergeAll( - Layer.succeed(HostProcess.Platform, "linux"), - Layer.succeed(HostProcess.Architecture, "arm64"), - ), - ), - Layer.provideMerge(NodeHttpServer.layerTest), - ); - - yield* Effect.gen(function* () { - yield* Layer.launch(layerBatchServer).pipe(Effect.forkScoped); - const { distinctId } = yield* getTelemetryIdentity; - assert.equal(distinctId !== null, true); - const analytics = yield* AnalyticsService.AnalyticsService; - - for (let index = 0; index < 45; index += 1) { - yield* analytics.record("test.flush.drain", { index }); - } - - yield* analytics.flush; - }).pipe(Effect.provide(layerRuntime)); - - const batchRequests = capturedRequests.filter( - (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => - Array.isArray(request.body?.batch), - ); - assert.equal(batchRequests.length, 3); - assert.equal( - batchRequests.every( - (request) => request.path.endsWith("/batch/") || request.path.endsWith("/batch"), - ), - true, - ); - const deliveredIndexes = batchRequests.flatMap((request) => - request.body.batch - .filter((event) => event.event === "test.flush.drain") - .map((event) => event.properties?.index) - .filter((index): index is number => typeof index === "number"), - ); - - const sorted = deliveredIndexes.toSorted((a, b) => a - b); - assert.equal(sorted.length, 45); - assert.deepEqual( - sorted, - Array.from({ length: 45 }, (_, index) => index), - ); - assert.equal( - batchRequests.every((request) => - request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"), - ), - true, - ); - assert.equal( - batchRequests.every((request) => - request.body.batch.every( - (event) => - event.properties?.serverOs === "Linux" && - event.properties.serverArch === "arm64" && - event.properties.serverAppVersion === event.properties.t3CodeVersion && - event.properties.serverMode === "web", - ), - ), - true, - ); - }), - ); - - it.effect("does not send batch requests when telemetry is disabled", () => - Effect.gen(function* () { - const capturedPaths: Array = []; - const layerServerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-disabled-", - }); - const layerTelemetry = AnalyticsService.layer.pipe(Layer.provideMerge(layerServerConfig)); - const layerConfig = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - T3CODE_TELEMETRY_ENABLED: false, - T3CODE_POSTHOG_KEY: "phc_test_key", - T3CODE_POSTHOG_HOST: "http://localhost", - }), - ); - const layerBatchServer = HttpServer.serve( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - capturedPaths.push(request.url); - return HttpServerResponse.jsonUnsafe({}); - }), - ); - const layerRuntime = layerTelemetry.pipe( - Layer.provide(layerConfig), - Layer.provide( - Layer.mergeAll( - Layer.succeed(HostProcess.Platform, "linux"), - Layer.succeed(HostProcess.Architecture, "arm64"), - ), - ), - Layer.provideMerge(NodeHttpServer.layerTest), - ); - - yield* Effect.gen(function* () { - yield* Layer.launch(layerBatchServer).pipe(Effect.forkScoped); - const analytics = yield* AnalyticsService.AnalyticsService; - yield* analytics.record("test.disabled", { index: 1 }); - yield* analytics.flush; - }).pipe(Effect.provide(layerRuntime)); - - assert.deepEqual(capturedPaths, []); - }), - ); -}); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 4edc6621b0c3e..2e002a272b1c2 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,79 +1,9 @@ -// T3 ingest retains events and forwards to PostHog; UUIDs deduplicate retries there. -import * as HostProcess from "@t3tools/shared/HostProcess"; -import type { ClientOs } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Config from "effect/Config"; +// PseudoCode sends no analytics anywhere; every call is a no-op. import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Random from "effect/Random"; -import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; -import * as Semaphore from "effect/Semaphore"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import packageJson from "../../package.json" with { type: "json" }; -import * as ServerConfig from "../config.ts"; -import { getTelemetryIdentity, type TelemetryOwner } from "./Identify.ts"; - -interface BufferedAnalyticsEvent { - readonly uuid: string; - readonly event: string; - readonly properties?: Readonly>; - readonly capturedAt: string; -} - -interface DeliveryState { - readonly failedBatch: ReadonlyArray; - readonly batchAttempts: number; - readonly firstFailedAt: number; - readonly failures: number; - readonly retryAt: number; -} - -// Retry delays shorter than this still wait for the next background check. -const FLUSH_INTERVAL_MS = 10_000; -// A hung send would hold the flush lock, and with it the shutdown flush. -const SEND_TIMEOUT = "10 seconds"; -// A failed batch is tried again until it has failed for this long. Ingest answers 503 when it -// cannot store a batch, and a broken Cloudflare location can take about 5 minutes to fail over. -// The waiting batch holds newer events back; `maxBufferedEvents` bounds them meanwhile. -const MAX_BATCH_RETRY_MS = 30 * 60_000; -const RETRY_BASE_DELAY_MS = 2_000; -// Clients can report any number of owner ids; past this many per observer, new ones -// are not sent. Each observer has its own cap, so client reports never crowd out the -// server's own. -const MAX_RECORDED_OWNERS = 1_000; -const RETRY_MAX_DELAY_MS = 300_000; -// These statuses reject the batch itself; unlike 408/429, retrying cannot help. -const REJECTED_BATCH_STATUSES = new Set([400, 401, 413]); - -/** `random` is in [0, 1). */ -function retryDelayMs(failures: number, random: number): number { - const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1)); - return Math.round(ceiling / 2 + (ceiling / 2) * random); -} - -const TelemetryEnvConfig = Config.all({ - posthogKey: Config.String("T3CODE_POSTHOG_KEY").pipe( - Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), - ), - posthogHost: Config.String("T3CODE_POSTHOG_HOST").pipe( - Config.withDefault("https://ingest.t3.codes"), - ), - 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), -}); +import type { TelemetryOwner } from "./Identify.ts"; export class AnalyticsService extends Context.Service< AnalyticsService, @@ -85,11 +15,6 @@ export class AnalyticsService extends Context.Service< readonly flush: Effect.Effect; - /** - * Records `install.owner_observed` for each owner of this install that the - * server read itself or a connecting client reported. Each owner is sent once - * per server process, so reconnects and repeated reads are free. - */ readonly recordOwners: ( owners: ReadonlyArray, observedBy: "server" | "client", @@ -97,239 +22,11 @@ export class AnalyticsService extends Context.Service< } >()("t3/telemetry/AnalyticsService") {} -function serverOsFromNodePlatform(platform: string): ClientOs { - switch (platform) { - case "darwin": - return "macOS"; - case "win32": - return "Windows"; - case "linux": - return "Linux"; - case "android": - return "Android"; - default: - return "other"; - } -} - -/** @public */ -/** @public */ -export const make = Effect.gen(function* () { - const telemetryConfig = yield* TelemetryEnvConfig; - const httpClient = yield* HttpClient.HttpClient; - const serverConfig = yield* ServerConfig.ServerConfig; - const { distinctId, installId } = yield* getTelemetryIdentity; - const crypto = yield* Crypto.Crypto; - const bufferRef = yield* Ref.make>([]); - const deliveryRef = yield* Ref.make({ - failedBatch: [], - batchAttempts: 0, - firstFailedAt: 0, - failures: 0, - retryAt: 0, - }); - const recordedOwnersRef = yield* Ref.make< - Readonly>> - >({ server: new Set(), client: new Set() }); - // The background flush and the shutdown flush must not send the same batch at once. - const flushLock = yield* Semaphore.make(1); - const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - const hostPlatform = yield* HostProcess.Platform; - const hostArchitecture = yield* HostProcess.Architecture; - - const enqueueBufferedEvent = ( - uuid: string, - event: string, - properties?: Readonly>, - ) => - Effect.flatMap(DateTime.now, (now) => - Ref.modify(bufferRef, (current) => { - const appended = [ - ...current, - { - uuid, - event, - ...(properties ? { properties } : {}), - capturedAt: DateTime.formatIso(now), - } satisfies BufferedAnalyticsEvent, - ]; - - const next = - appended.length > telemetryConfig.maxBufferedEvents - ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents) - : appended; - - return [ - { - size: next.length, - dropped: next.length !== appended.length, - } as const, - next, - ] as const; - }), - ); - - const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* ( - events: ReadonlyArray, - ) { - if (!telemetryConfig.enabled || !distinctId) return; - - const payload = { - api_key: telemetryConfig.posthogKey, - batch: events.map((event) => ({ - uuid: event.uuid, - event: event.event, - distinct_id: distinctId, - properties: { - ...event.properties, - ...(installId === null ? {} : { installId }), - $process_person_profile: false, - platform: hostPlatform, - wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), - arch: hostArchitecture, - t3CodeVersion: packageJson.version, - telemetryChannel: ServerConfig.telemetryChannel, - clientType, - serverOs: serverOsFromNodePlatform(hostPlatform), - serverArch: hostArchitecture, - serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName), - serverAppVersion: packageJson.version, - serverMode: serverConfig.mode, - }, - timestamp: event.capturedAt, - })), - }; - - yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe( - HttpClientRequest.bodyJson(payload), - Effect.flatMap(httpClient.execute), - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.timeout(SEND_TIMEOUT), - ); - }); - - const takeBatch = Ref.modify(bufferRef, (current) => { - const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); - return [nextBatch, current.slice(nextBatch.length)] as const; - }); - - // Back off even after dropping rejected batches so bad API keys cannot drain the queue in a tight loop. - const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { - while (true) { - const delivery = yield* Ref.get(deliveryRef); - const batch = delivery.failedBatch.length > 0 ? delivery.failedBatch : yield* takeBatch; - if (batch.length === 0) { - return; - } - - const sent = yield* Effect.result(sendBatch(batch)); - if (Result.isSuccess(sent)) { - yield* Ref.set(deliveryRef, { - failedBatch: [], - batchAttempts: 0, - firstFailedAt: 0, - failures: 0, - retryAt: 0, - }); - continue; - } - - const now = yield* Clock.currentTimeMillis; - const failures = delivery.failures + 1; - const batchAttempts = delivery.batchAttempts + 1; - const firstFailedAt = delivery.batchAttempts === 0 ? now : delivery.firstFailedAt; - const retryAt = now + retryDelayMs(failures, yield* Random.next); - const error = sent.failure; - const rejected = - error._tag === "HttpClientError" && - error.reason._tag === "StatusCodeError" && - REJECTED_BATCH_STATUSES.has(error.reason.response.status); - if (!rejected && now - firstFailedAt < MAX_BATCH_RETRY_MS) { - yield* Ref.set(deliveryRef, { - failedBatch: batch, - batchAttempts, - firstFailedAt, - failures, - retryAt, - }); - yield* Effect.logDebug("Failed to send telemetry batch; will retry", { - attempt: batchAttempts, - cause: error, - }); - return; - } - yield* Ref.set(deliveryRef, { - failedBatch: [], - batchAttempts: 0, - firstFailedAt: 0, - failures, - retryAt, - }); - yield* Effect.logWarning( - rejected - ? "Dropped telemetry batch the endpoint rejected" - : "Dropped telemetry batch after repeated send failures", - { events: batch.length, attempts: batchAttempts, cause: error }, - ); - return; - } - }).pipe(flushLock.withPermit); - - const flushWhenDue = Effect.gen(function* () { - const { retryAt } = yield* Ref.get(deliveryRef); - if ((yield* Clock.currentTimeMillis) >= retryAt) { - yield* flush; - } - }); - - const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( - function* (event, properties) { - if (!telemetryConfig.enabled || !distinctId) return; - - // Catch Node UUID defects too; best-effort telemetry drops events without UUIDs. - const uuid = yield* Effect.exit(crypto.randomUUIDv7()); - if (Exit.isFailure(uuid)) return; - - const enqueueResult = yield* enqueueBufferedEvent(uuid.value, event, properties); - if (enqueueResult.dropped) { - yield* Effect.logDebug("analytics buffer full; dropping oldest event", { - size: enqueueResult.size, - event, - }); - } - }, - ); - - const recordOwners: AnalyticsService["Service"]["recordOwners"] = Effect.fn( - "AnalyticsService.recordOwners", - )((owners, observedBy) => - Effect.forEach( - owners, - ({ source, ownerId }) => - Ref.modify(recordedOwnersRef, (recorded) => { - const key = `${source}:${ownerId}`; - const seen = recorded[observedBy]; - return seen.has(key) || seen.size >= MAX_RECORDED_OWNERS - ? [false, recorded] - : [true, { ...recorded, [observedBy]: new Set(seen).add(key) }]; - }).pipe( - Effect.flatMap((isNew) => - isNew - ? record("install.owner_observed", { ownerSource: source, ownerId, observedBy }) - : Effect.void, - ), - ), - { discard: true }, - ), - ); - - yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.andThen(flushWhenDue)), { - disableYield: true, - }).pipe(Effect.forkScoped); - - yield* Effect.addFinalizer(() => flush); - - return AnalyticsService.of({ record, flush, recordOwners }); -}); - -export const layer = Layer.effect(AnalyticsService, make); +export const layer = Layer.succeed( + AnalyticsService, + AnalyticsService.of({ + record: Effect.fn("AnalyticsService.record")(() => Effect.void), + flush: Effect.void, + recordOwners: Effect.fn("AnalyticsService.recordOwners")(() => Effect.void), + }), +); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 0baebc1e1803d..8abb1f1db5ce9 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -294,11 +294,6 @@ export const getTelemetryIdentityForHome = Effect.fn("getTelemetryIdentityForHom }; }); -export const getTelemetryIdentity = Effect.flatMap( - HostProcess.HomeDirectory, - getTelemetryIdentityForHome, -); - /** * Owners the server can read without a client: the linked T3 Connect account * and the provider logins under `homeDirectory`. Clients add `connect` and diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 6e3c5abe16c04..cd4e2b7771cfe 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -38,7 +38,6 @@ import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; import { APP_BASE_NAME } from "../../branding"; import { useLocalStorage } from "../../hooks/useLocalStorage"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; -import { PRIVACY_POLICY_URL } from "../../legalLinks"; import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; import { completeOnboarding } from "../../onboarding/firstRun"; import { @@ -434,17 +433,7 @@ function ConnectionStep({ ) : null}

- T3 Code collects anonymous usage data to help us improve it. To read more about how your - data is used and how to opt out, see our{" "} - - privacy policy - - . + {APP_BASE_NAME} sends no usage data, traces, or crash reports off your machine.