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.