From 7000b9fe6a8f93a30f060cc7df944002a01528b4 Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+oldbettie@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:59:45 +0800 Subject: [PATCH 1/2] fix: Ada no longer reports to upstream T3 services Delete the server's PostHog analytics (ingest.t3.codes, embedded key, identity read from Codex and Claude logins), the client device-hash reporting, the mobile expo-observe SDK and the app.t3.codes hosted-app default. Point the model manifest, npm launcher and native-client dispatch at jayleaton/ada, and extend check-removed-connect so upstream syncs cannot bring any of it back. Co-Authored-By: Claude Opus 5.5 --- .github/triage/PLAYBOOK.md | 2 +- .github/workflows/mobile-eas-preview-fork.yml | 2 +- .github/workflows/mobile-eas-preview.yml | 2 +- .../desktop/src/app/CodexAuthCallback.test.ts | 5 +- .../src/app/DesktopLaunchLinks.test.ts | 2 +- .../DesktopBackendConfiguration.test.ts | 3 - .../backend/DesktopBackendConfiguration.ts | 1 - .../src/shell/DesktopShellEnvironment.ts | 2 - apps/marketing/src/pages/llms.txt.ts | 1 - apps/mobile/app.config.ts | 14 - apps/mobile/index.ts | 2 - .../T3ComposerEditorModule.kt | 2 +- .../ios/T3ComposerEditorModule.swift | 2 +- apps/mobile/package.json | 1 - apps/mobile/src/App.tsx | 2 - .../src/components/RenderErrorBoundary.tsx | 5 +- apps/mobile/src/connection/platform.ts | 19 +- .../src/features/connection/pairing.test.ts | 4 +- .../src/features/observability/observe.ts | 5 - .../prompt-links/prompt-link-actions.ts | 9 +- .../src/features/threads/ThreadFeed.tsx | 26 +- apps/mobile/src/lib/appLinking.test.ts | 2 +- .../src/lib/composerContextClipboard.test.ts | 2 +- apps/server/src/auth/McpOAuth.ts | 9 +- apps/server/src/auth/RpcAuthorization.ts | 9 +- apps/server/src/cli/policy.ts | 2 +- .../src/environment/hostedAppUrl.test.ts | 8 +- apps/server/src/environment/hostedAppUrl.ts | 4 +- apps/server/src/http.test.ts | 2 +- apps/server/src/mcp/McpHttpServer.ts | 232 +-------- apps/server/src/mcp/toolkits/core.test.ts | 57 --- .../ProviderEventIngestor.test.ts | 99 ---- .../orchestration-v2/ProviderEventIngestor.ts | 35 -- .../ProviderTurnStartService.test.ts | 10 - .../ProviderTurnStartService.ts | 13 +- .../RunExecutionService.test.ts | 41 -- .../orchestration-v2/RunExecutionService.ts | 61 --- apps/server/src/policy/ManagedPolicy.test.ts | 5 +- apps/server/src/policy/ManagedPolicy.ts | 14 +- apps/server/src/policy/policyTemplates.ts | 8 +- .../src/provider/CodexChatGptAuth.test.ts | 50 +- apps/server/src/provider/CodexChatGptAuth.ts | 141 +---- .../src/provider/CodexChatGptHandoff.ts | 1 - apps/server/src/provider/ModelManifest.ts | 5 +- apps/server/src/server.ts | 7 - .../src/serverRuntimeStartup.autoPull.test.ts | 7 - apps/server/src/serverRuntimeStartup.test.ts | 5 - apps/server/src/serverRuntimeStartup.ts | 41 -- .../src/telemetry/AnalyticsService.test.ts | 484 ------------------ apps/server/src/telemetry/AnalyticsService.ts | 365 ------------- .../src/telemetry/DelegatedTaskAnalytics.ts | 85 --- .../server/src/telemetry/FeatureUsage.test.ts | 96 ---- apps/server/src/telemetry/FeatureUsage.ts | 90 ---- apps/server/src/telemetry/Identify.test.ts | 267 ---------- apps/server/src/telemetry/Identify.ts | 328 ------------ .../src/telemetry/ProviderDimensions.test.ts | 78 --- .../src/telemetry/ProviderDimensions.ts | 250 --------- .../telemetry/ProviderUsageAnalytics.test.ts | 350 ------------- .../src/telemetry/ProviderUsageAnalytics.ts | 427 --------------- apps/server/src/triage/TriageService.ts | 2 +- apps/server/src/triage/triagePrompt.ts | 2 +- apps/server/src/ws.test.ts | 7 - apps/server/src/ws.ts | 151 +----- apps/server/vite.config.ts | 5 +- .../components/chat/MarkdownThreadLink.tsx | 8 +- .../composerInlineTokenPaste.test.ts | 2 +- .../desktop/DesktopWebLinkCoordinator.tsx | 3 +- .../src/components/onboarding/WelcomePage.tsx | 8 +- .../components/promptLinks/copyPromptLink.ts | 11 +- .../components/settings/SettingsPanels.tsx | 21 +- .../components/settings/pairingUrls.test.ts | 9 +- apps/web/src/connection/platform.ts | 19 +- apps/web/src/hostedPairing.test.ts | 8 +- apps/web/src/hostedPairing.ts | 22 +- apps/web/src/state/managedPolicy.ts | 7 +- apps/web/vite/warmBundledDev.ts | 3 +- docs/README.md | 3 +- docs/internals/product-analytics.md | 152 ------ docs/user/appearance.md | 2 +- docs/user/background-service.md | 4 +- docs/user/default-browser.md | 3 +- docs/user/faq.md | 4 +- docs/user/managed-policy.md | 8 +- docs/user/prompt-links.md | 30 +- docs/user/providers-acp.md | 2 +- docs/user/remote-access.md | 5 +- docs/user/telemetry.md | 26 +- docs/user/welcome-page.md | 2 +- .../src/authorization/remote.test.ts | 27 - .../src/authorization/remote.ts | 27 +- .../client-runtime/src/connection/resolver.ts | 11 +- .../src/platform/capabilities.ts | 11 - .../src/threadReference.test.ts | 8 +- .../client-runtime/src/threadReference.ts | 17 +- packages/contracts/src/managedPolicy.ts | 3 - packages/contracts/src/resourceTelemetry.ts | 1 - packages/shared/package.json | 8 - packages/shared/src/codexAuthHandoff.test.ts | 4 +- .../src/composerContextReferences.test.ts | 15 +- packages/shared/src/hostedApp.ts | 1 - packages/shared/src/managedPolicyFile.test.ts | 19 +- packages/shared/src/managedPolicyFile.ts | 31 +- packages/shared/src/promptLinks.test.ts | 37 +- packages/shared/src/promptLinks.ts | 13 +- packages/shared/src/providerAuthReturnUrl.ts | 4 +- packages/shared/src/remote.test.ts | 2 +- packages/shared/src/telemetryId.test.ts | 11 - packages/shared/src/telemetryId.ts | 12 - packages/shared/src/threadLinks.test.ts | 57 +-- packages/shared/src/threadLinks.ts | 42 +- pnpm-lock.yaml | 45 -- scripts/check-removed-connect.test.ts | 30 +- scripts/check-removed-connect.ts | 70 ++- scripts/lib/cli-external-packages.test.ts | 1 - scripts/lib/cli-external-packages.ts | 1 - scripts/lib/desktop-external-packages.ts | 1 - scripts/mobile-native-client.ts | 3 +- scripts/npm-launcher/t3.mjs | 4 +- scripts/removed-connect-paths.json | 8 +- scripts/upstream-install-commands.test.ts | 9 +- 120 files changed, 353 insertions(+), 4510 deletions(-) delete mode 100644 apps/mobile/src/features/observability/observe.ts delete mode 100644 apps/server/src/telemetry/AnalyticsService.test.ts delete mode 100644 apps/server/src/telemetry/AnalyticsService.ts delete mode 100644 apps/server/src/telemetry/DelegatedTaskAnalytics.ts delete mode 100644 apps/server/src/telemetry/FeatureUsage.test.ts delete mode 100644 apps/server/src/telemetry/FeatureUsage.ts delete mode 100644 apps/server/src/telemetry/Identify.test.ts delete mode 100644 apps/server/src/telemetry/Identify.ts delete mode 100644 apps/server/src/telemetry/ProviderDimensions.test.ts delete mode 100644 apps/server/src/telemetry/ProviderDimensions.ts delete mode 100644 apps/server/src/telemetry/ProviderUsageAnalytics.test.ts delete mode 100644 apps/server/src/telemetry/ProviderUsageAnalytics.ts delete mode 100644 docs/internals/product-analytics.md delete mode 100644 packages/shared/src/hostedApp.ts delete mode 100644 packages/shared/src/telemetryId.test.ts delete mode 100644 packages/shared/src/telemetryId.ts diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 7db3ed55b8009..aa417f0aa3062 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -67,7 +67,7 @@ different code depending on it: - How is Ada running on this machine: `ada serve` in a terminal, the background service, or the desktop app? -- Which surface is the user connecting from: the website (app.t3.codes), the +- Which surface is the user connecting from: a web browser, the desktop app against a local server, the desktop app against a remote server, or the mobile app? diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml index a8bf5725af70c..8c69b07d56faa 100644 --- a/.github/workflows/mobile-eas-preview-fork.yml +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -201,7 +201,7 @@ jobs: else link="$(eas build:list --platform android --build-profile preview:dev --runtime-version "$hash" --status finished --limit 5 --json --non-interactive \ | sed -n '/^\[/,$p' \ - | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/pingdotgg/projects/t3-code/builds/\(.id)"')" + | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/jayleaton/projects/t3-code-personal/builds/\(.id)"')" fi echo "link=$link" >> "$GITHUB_OUTPUT" if [ -z "$link" ]; then echo "build=true" >> "$GITHUB_OUTPUT"; fi diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 5989d7a0c0cc8..8b7a1991b2ad1 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -243,7 +243,7 @@ jobs: # A client EAS built before this job existed; expired ones are gone. link="$(eas build:list --platform android --build-profile preview:dev --runtime-version "$hash" --status finished --limit 5 --json --non-interactive \ | sed -n '/^\[/,$p' \ - | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/pingdotgg/projects/t3-code/builds/\(.id)"')" + | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/jayleaton/projects/t3-code-personal/builds/\(.id)"')" fi echo "link=$link" >> "$GITHUB_OUTPUT" if [ -z "$link" ]; then echo "build=true" >> "$GITHUB_OUTPUT"; fi diff --git a/apps/desktop/src/app/CodexAuthCallback.test.ts b/apps/desktop/src/app/CodexAuthCallback.test.ts index 6d8a905885aca..ef8f9131c1f9c 100644 --- a/apps/desktop/src/app/CodexAuthCallback.test.ts +++ b/apps/desktop/src/app/CodexAuthCallback.test.ts @@ -51,12 +51,13 @@ describe("desktop Codex callback helper", () => { }); expect(received).toBe(expected); }); - it("returns hosted web to the exact instance and environment without putting the code in its query", async () => { + it("returns web to the exact instance and environment without putting the code in its query", async () => { const authorizationUrl = request(await freePort()); const expected = callback(authorizationUrl); const input = { authorizationUrl, - returnUrl: "https://app.t3.codes/settings/providers?environmentId=remote-one&instanceId=work", + returnUrl: + "http://localhost:5733/settings/providers?environmentId=remote-one&instanceId=work", environmentId: EnvironmentId.make("remote-one"), instanceId: ProviderInstanceId.make("work"), flowId: "flow-one", diff --git a/apps/desktop/src/app/DesktopLaunchLinks.test.ts b/apps/desktop/src/app/DesktopLaunchLinks.test.ts index cc3b16be50ad5..67ade6a80e41b 100644 --- a/apps/desktop/src/app/DesktopLaunchLinks.test.ts +++ b/apps/desktop/src/app/DesktopLaunchLinks.test.ts @@ -363,7 +363,7 @@ it.effect.each(["startup", "open-url", "early-second-instance"] as const)( }).toString(); const request = { authorizationUrl: authorize.toString(), - returnUrl: "https://app.t3.codes/welcome#agents:remote-one", + returnUrl: "http://localhost:5733/welcome#agents:remote-one", environmentId: EnvironmentId.make("remote-one"), instanceId: ProviderInstanceId.make("work"), flowId: "flow-one", diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index c1b9c060d547e..cfec6e2dda9d2 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -692,8 +692,6 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.env.OTEL_SDK_DISABLED, "true"); assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); - assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); - assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); }).pipe( @@ -714,7 +712,6 @@ describe("DesktopBackendConfiguration", () => { ), Effect.provideService(HostProcess.Environment, { OTEL_SDK_DISABLED: "true", - T3CODE_TELEMETRY_ENABLED: "false", OTEL_RESOURCE_ATTRIBUTES: "deployment.environment.name=staging", }), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 4d2ef55040f5e..7ff11489961c1 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -86,7 +86,6 @@ const DESKTOP_BACKEND_ENV_NAMES = [ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", - "T3CODE_TELEMETRY_ENABLED", "T3CODE_GITHUB_APP_CLIENT_ID", // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 9d9e390c95496..294774c3d8ca1 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -107,7 +107,6 @@ const LOGIN_SHELL_ENV_NAMES = [ "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", - "T3CODE_TELEMETRY_ENABLED", "T3CODE_GITHUB_APP_CLIENT_ID", "__MISE_DIFF", "JAVA_HOME", @@ -484,7 +483,6 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "JAVA_HOME", - "T3CODE_TELEMETRY_ENABLED", "T3CODE_GITHUB_APP_CLIENT_ID", ] as const) { if (!config.env[name] && shellEnvironment[name]) { diff --git a/apps/marketing/src/pages/llms.txt.ts b/apps/marketing/src/pages/llms.txt.ts index edabf7e88aa2b..8b2a874ee4f21 100644 --- a/apps/marketing/src/pages/llms.txt.ts +++ b/apps/marketing/src/pages/llms.txt.ts @@ -15,7 +15,6 @@ Official builds come from ${absoluteUrl("/download")} and ${GITHUB_REPOSITORY_UR ## Official links - [Download ${APP_NAME}](${absoluteUrl("/download")}): macOS, Windows, and Linux installers -- [Web app](https://app.t3.codes): connect to an ${APP_NAME} server from a browser - [Source code](${GITHUB_REPOSITORY_URL}): the MIT-licensed repository - [Releases](${GITHUB_REPOSITORY_URL}/releases): release builds and changelogs diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 39ee36e124687..454e3a31a8fc5 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -21,9 +21,6 @@ const runtimeVersionPolicy = const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; -// Observe must never send this personal client to Expo ingestion. -const observeEndpointUrl = "https://observe.invalid"; - if ( isIosPersonalTeamBuild && (!personalTeamBundleIdentifier || @@ -99,8 +96,6 @@ function resolveAppVariant(value: string | undefined): AppVariant { } const variant = VARIANT_CONFIG[APP_VARIANT]; -// Copied thread links open the app. app.t3.codes/.well-known lists every variant's identifiers. -const HOSTED_APP_HOST = "app.t3.codes"; const iosBundleIdentifier = isIosPersonalTeamBuild ? personalTeamBundleIdentifier! : variant.iosBundleIdentifier; @@ -276,14 +271,6 @@ const config: ExpoConfig = { predictiveBackGestureEnabled: true, // expo-sensors declares this for its pedometer, which the app does not use. blockedPermissions: ["android.permission.ACTIVITY_RECOGNITION"], - intentFilters: [ - { - action: "VIEW", - autoVerify: true, - data: [{ scheme: "https", host: HOSTED_APP_HOST, pathPrefix: "/threads/" }], - category: ["BROWSABLE", "DEFAULT"], - }, - ], }, web: { favicon: variant.assets.appIcon, @@ -421,7 +408,6 @@ const config: ExpoConfig = { extra: { eas: { projectId: "263247b1-0330-46c5-ac6a-7efcba44c359", - observe: { endpointUrl: observeEndpointUrl }, }, appVariant: APP_VARIANT, personalClient: isPersonalClient, diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 9367ade408e95..b21e452f9c6dc 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,8 +1,6 @@ // Must stay first: the render counter and behavior probe wrap the DevTools hook before any React commit. import "./src/features/development/render-counter-entry"; import "./src/features/development/behavior-probe-entry"; -// Next, so errors thrown while the rest of the app loads are recorded too. -import "./src/features/observability/observe"; import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; import { LogBox } from "react-native"; diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt index 46b7da38c0b86..5fd2e4784e27e 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -11,7 +11,7 @@ import org.json.JSONArray internal object T3ComposerClipboard { // Pasted thread links become chips in JS; packages/shared/src/threadLinks.ts parses them. fun hasThreadLink(text: String): Boolean = - text.contains("t3-thread://v2/") || text.contains("https://app.t3.codes/threads/") + text.contains("t3-thread://v2/") fun write(context: Context, text: String, fragment: String) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index c54a1a84e9644..02ce557385996 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -6,7 +6,7 @@ enum T3ComposerClipboard { // Pasted thread links become chips in JS; packages/shared/src/threadLinks.ts parses them. static func hasThreadLink(_ text: String) -> Bool { - text.contains("t3-thread://v2/") || text.contains("https://app.t3.codes/threads/") + text.contains("t3-thread://v2/") } static func write(text: String, fragment: String) { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 564d35a49c35a..80981e4a92042 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -101,7 +101,6 @@ "expo-linking": "~58.0.16", "expo-network": "58.0.4", "expo-notifications": "~58.1.5", - "expo-observe": "~58.0.18", "expo-paste-input": "^0.2.2", "expo-quick-actions": "^6.0.2", "expo-secure-store": "~58.0.2", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 4e9aa0ef24b8c..50b419df0321f 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -21,7 +21,6 @@ import { type NavigationState, } from "@react-navigation/native"; import { pipe } from "effect/Function"; -import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/hostedApp"; import { RegistryContext } from "@effect/atom-react"; import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; @@ -131,7 +130,6 @@ const appLinking = { "t3code-personal://", "t3code-personal-dev://", "t3code-personal-preview://", - DEFAULT_HOSTED_APP_URL, ], config: { initialRouteName: "Home" }, filter: shouldHandleAppLink, diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx index f4a6035b09704..16f9439a2cd38 100644 --- a/apps/mobile/src/components/RenderErrorBoundary.tsx +++ b/apps/mobile/src/components/RenderErrorBoundary.tsx @@ -1,4 +1,3 @@ -import { Observe } from "expo-observe"; import { Component, type ReactNode } from "react"; import { ScrollView, View } from "react-native"; @@ -61,9 +60,7 @@ export class RenderErrorBoundary extends Component< return { failed: true, error }; } - override componentDidCatch(error: unknown, info: { componentStack?: string }) { - // A caught render error never reaches Observe's global handler. - Observe.reportError(error); + override componentDidCatch(_error: unknown, info: { componentStack?: string }) { this.setState({ componentStack: info.componentStack }); } diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index d233e09e4d2b8..a29983be84032 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -16,7 +16,6 @@ import { AppState } from "react-native"; import { authClientMetadata } from "../lib/authClientMetadata"; import * as Runtime from "../lib/runtime"; -import * as MobileStorage from "../persistence/mobile-storage"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error"; @@ -138,18 +137,13 @@ const layerWakeups = Wakeups.layer({ }); const layerCapabilities = Layer.effectContext( - Effect.gen(function* () { - const storage = yield* MobileStorage.MobileStorage; - return Context.make( + Effect.sync(() => + Context.make( ClientCapabilities.PrimaryEnvironmentAuth, ClientCapabilities.PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeedNone, }), ).pipe( - // The device id persists in secure storage; analytics only sends its hash. - Context.add(ClientCapabilities.AnalyticsDevice, { - id: storage.loadOrCreateAgentAwarenessDeviceId.pipe(Effect.option), - }), Context.add( ClientCapabilities.ClientPresentation, ClientCapabilities.ClientPresentation.of({ @@ -180,8 +174,8 @@ const layerCapabilities = Layer.effectContext( ), }), ), - ); - }), + ), + ), ); const layerPlatformConnectionSource = Layer.succeed( @@ -192,7 +186,6 @@ const layerPlatformConnectionSource = Layer.succeed( ); const layerProvidedConnectionStorage = ConnectionStorage.layer.pipe(Layer.provide(Runtime.layer)); -const layerProvidedCapabilities = layerCapabilities.pipe(Layer.provide(Runtime.layer)); const layerEnvironmentOwnedDataCleanup = Layer.succeed( Persistence.EnvironmentOwnedDataCleanup, @@ -215,7 +208,7 @@ type ConnectionPlatformLayerSource = | typeof Runtime.layer | typeof layerConnectivity | typeof layerWakeups - | typeof layerProvidedCapabilities + | typeof layerCapabilities | typeof layerPlatformConnectionSource | typeof layerEnvironmentOwnedDataCleanup; @@ -228,7 +221,7 @@ export const layer: Layer.Layer< Runtime.layer, layerConnectivity, layerWakeups, - layerProvidedCapabilities, + layerCapabilities, layerPlatformConnectionSource, layerEnvironmentOwnedDataCleanup, ); diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 831bace2ab4d4..c111459fa39a5 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -44,7 +44,7 @@ describe("parsePairingUrl", () => { it("reads hosted pairing links into backend host fields", () => { expect( parsePairingUrl( - "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%2F#token=pairing-token", + "https://hosted.example/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%2F#token=pairing-token", ), ).toEqual({ host: "https://desktop.tailnet.ts.net", @@ -72,7 +72,7 @@ describe("mobile pairing address validation", () => { it("validates the backend inside a hosted pairing link", () => { expect(() => validateMobilePairingUrl( - "https://app.t3.codes/pair?host=http%3A%2F%2Flocalhost%3A3773#token=secret", + "https://hosted.example/pair?host=http%3A%2F%2Flocalhost%3A3773#token=secret", ), ).toThrow("Localhost points to this phone"); }); diff --git a/apps/mobile/src/features/observability/observe.ts b/apps/mobile/src/features/observability/observe.ts deleted file mode 100644 index 28c644b81fcf5..0000000000000 --- a/apps/mobile/src/features/observability/observe.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Observe } from "expo-observe"; - -// Crash reports could only leave through T3 Connect. Observe keeps its last configuration across -// launches, so a build that once sent them must turn sending off. -Observe.configure({ dispatchingEnabled: false }); diff --git a/apps/mobile/src/features/prompt-links/prompt-link-actions.ts b/apps/mobile/src/features/prompt-links/prompt-link-actions.ts index ced7ff7001b81..b4506c229a121 100644 --- a/apps/mobile/src/features/prompt-links/prompt-link-actions.ts +++ b/apps/mobile/src/features/prompt-links/prompt-link-actions.ts @@ -1,7 +1,6 @@ import * as Dialog from "../../components/ui/Dialog"; -import { buildNewThreadLink, type LinkedPrompt } from "@t3tools/shared/promptLinks"; +import type { LinkedPrompt } from "@t3tools/shared/promptLinks"; -import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { getComposerDraftSnapshot, setComposerDraftText, @@ -35,9 +34,3 @@ export async function applyLinkedPromptToThreadDraft( { text: "Replace", onPress: () => setComposerDraftText(draftKey, prompt.text) }, ]); } - -export async function copyPromptLink(prompt: string): Promise { - if (!(await tryCopyTextWithHaptic(buildNewThreadLink({ prompt }), { target: "prompt link" }))) { - Dialog.alert("Could not copy", "Try again."); - } -} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 80424808b4f90..9fbb11056b2f4 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -63,8 +63,7 @@ import { import type { ToolCallVisibility } from "@t3tools/contracts/settings"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; -import { hostedThreadLink, parseThreadLinkHref } from "@t3tools/shared/threadLinks"; -import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks"; +import { formatThreadLink, parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { parseComposerContextHref, collectComposerContextReferences, @@ -169,10 +168,8 @@ import { attachmentVideoPreviewSource } from "../../lib/videoPreviewSource"; import { CopyTextButton } from "./CopyTextButton"; import { getReviewSectionIdForCheckpoint } from "../review/reviewModel"; import { setReviewSelectedSectionId } from "../review/reviewState"; -import { Menu } from "../../components/ui/Menu"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { useBarInset, useContentTopInset } from "../../components/ui/useBarInset"; -import { copyPromptLink } from "../prompt-links/prompt-link-actions"; import { parseReviewCommentMessageSegments } from "../review/reviewCommentSelection"; import { ReviewCommentCard, @@ -348,16 +345,12 @@ export interface ThreadFeedProps { readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; } -const USER_MESSAGE_COPY_MENU_ACTIONS = [ - { id: "copy-prompt-link", title: "Copy prompt link", image: "link" }, -]; - function UserMessageCopyButton(props: { readonly text: string; readonly onCopy: (() => Promise) | undefined; readonly tintColor: ColorValue; }) { - const button = ( + return ( ); - const linkText = replaceComposerContextReferences(props.text, (reference) => reference.label); - if (linkText.length > PROMPT_LINK_MAX_PROMPT_LENGTH) return button; - return ( - { - if (nativeEvent.event === "copy-prompt-link") void copyPromptLink(linkText); - }} - shouldOpenOnLongPress - > - {button} - - ); } function AssistantForkButton(props: { @@ -3034,7 +3014,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const thread = resolveLinkedThread(href); if (thread) { if (actionId === "open-thread") onMarkdownLinkPress(href); - else if (actionId === "copy-thread-link") copyTextWithHaptic(hostedThreadLink(thread)); + else if (actionId === "copy-thread-link") copyTextWithHaptic(formatThreadLink(thread)); return; } const target = resolveFileChipTarget(href, props.workspaceRoot); diff --git a/apps/mobile/src/lib/appLinking.test.ts b/apps/mobile/src/lib/appLinking.test.ts index 9da848a5ae12c..91fede15511b6 100644 --- a/apps/mobile/src/lib/appLinking.test.ts +++ b/apps/mobile/src/lib/appLinking.test.ts @@ -23,7 +23,7 @@ describe("shouldHandleAppLink", () => { "t3code-personal://threads/env-1/thread-1", "t3code-personal://pair?pairingUrl=x", "t3code-personal-dev://settings/usage?tab=limits", - "https://app.t3.codes/threads/env/thread?prompt=Fix%20expo-development-client%20and%20t3code-personal://expo-sharing", + "t3code-personal://threads/env/thread?prompt=Fix%20expo-development-client%20and%20t3code-personal://expo-sharing", ])("handles path-bearing URL %s", (url) => { expect(shouldHandleAppLink(url)).toBe(true); }); diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index 77305cce84439..d849c1ad1a518 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -342,7 +342,7 @@ describe("mobile context clipboard imports", () => { it("pastes a thread link as a chip only where the destination's agent reads its machine", async () => { const text = [ - "https://app.t3.codes/threads/destination/local-thread", + "", "", ].join(" "); const result = await importComposerContextClipboard( diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 186e405731151..ec8147374679a 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -19,7 +19,6 @@ import { parseMcpApprovalCode, type RuntimeMode, } from "@t3tools/contracts"; -import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/hostedApp"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -615,8 +614,8 @@ export const layerMcpClientAuthenticator = Layer.effect( const environmentId = yield* environment.getEnvironmentId; const hostedAppUrl = yield* hostedAppUrlConfig.pipe( Effect.catch((cause) => - Effect.logWarning("Outside MCP clients get app.t3.codes thread links", { cause }).pipe( - Effect.as(DEFAULT_HOSTED_APP_URL), + Effect.logWarning("Ignoring invalid T3CODE_HOSTED_APP_URL", { cause }).pipe( + Effect.as(Option.none()), ), ), ); @@ -636,7 +635,9 @@ export const layerMcpClientAuthenticator = Layer.effect( access: client.access, linked: client.peerLink, peerEnvironmentId: client.peerEnvironmentId, - ...(client.peerLink ? {} : { hostedAppUrl }), + ...(client.peerLink || Option.isNone(hostedAppUrl) + ? {} + : { hostedAppUrl: hostedAppUrl.value }), }, capabilities: new Set([ "orchestration", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 26c114042873f..1232f5ee7184c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -313,17 +313,12 @@ const requiredScopesForRpcCall = ( * RPC, that is when the stream completes. Every RPC passes through here, so it * is the one place to observe requests without wrapping each handler. */ -export const layer = ( - scopes: ReadonlyArray, - onAuthorized?: (method: string, payload: unknown) => Effect.Effect, -) => +export const layer = (scopes: ReadonlyArray) => Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => { const requiredScopes = requiredScopesForRpcCall(rpc._tag, payload); const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope)); if (requiredScope !== undefined) return Effect.fail(rpcAuthorizationError(requiredScope)); - return onAuthorized === undefined - ? effect - : Effect.tap(effect, () => onAuthorized(rpc._tag, payload)); + return effect; }); /** Retrying can install or restart tools even though ordinary listing is readable. */ diff --git a/apps/server/src/cli/policy.ts b/apps/server/src/cli/policy.ts index 85dcccedcda03..7ce6f5f802cb7 100644 --- a/apps/server/src/cli/policy.ts +++ b/apps/server/src/cli/policy.ts @@ -111,7 +111,7 @@ const checkCommand = Command.make("check", { const SAMPLE_POLICY: ManagedPolicyDocument = { schemaVersion: 1, organization: "Example Corp", - limits: { maxRuntimeMode: "auto-accept-edits", clients: ["desktop"], telemetry: false }, + limits: { maxRuntimeMode: "auto-accept-edits", clients: ["desktop"] }, }; const templatesCommand = Command.make("templates", { diff --git a/apps/server/src/environment/hostedAppUrl.test.ts b/apps/server/src/environment/hostedAppUrl.test.ts index 4709dd232c0d7..8f003e65d932a 100644 --- a/apps/server/src/environment/hostedAppUrl.test.ts +++ b/apps/server/src/environment/hostedAppUrl.test.ts @@ -11,10 +11,10 @@ const provideEnv = (env: Readonly>) => it.effect("rejects malformed or insecure hosted app URLs", () => Effect.gen(function* () { for (const value of [ - "app.t3.codes", - "http://app.t3.codes", - "https://app.t3.codes/nested", - "https://app.t3.codes?alias=true", + "hosted.example", + "http://hosted.example", + "https://hosted.example/nested", + "https://hosted.example?alias=true", ]) { const result = yield* hostedAppUrlConfig.pipe( provideEnv({ T3CODE_HOSTED_APP_URL: value }), diff --git a/apps/server/src/environment/hostedAppUrl.ts b/apps/server/src/environment/hostedAppUrl.ts index aa3eb2988eba5..1274bcef21f72 100644 --- a/apps/server/src/environment/hostedAppUrl.ts +++ b/apps/server/src/environment/hostedAppUrl.ts @@ -1,4 +1,3 @@ -import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/hostedApp"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -32,8 +31,9 @@ function validateHostedAppUrl(value: string) { } } +/** A hosted web app is opt-in: without it, thread links stay `t3-thread://` links. */ export const hostedAppUrlConfig = Config.NonEmptyString("T3CODE_HOSTED_APP_URL").pipe( - Config.withDefault(DEFAULT_HOSTED_APP_URL), Config.map((value) => value.trim()), Config.mapEffect(validateHostedAppUrl), + Config.option, ); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 5f7df20c738c8..7bb3bc06eda9b 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -115,7 +115,7 @@ describe("browser API CORS", () => { new Request("https://backend.example/api/environment", { method: "OPTIONS", headers: { - origin: "https://app.t3.codes", + origin: "https://hosted.example", "access-control-request-method": "GET", "access-control-request-headers": [ ORCHESTRATION_PROTOCOL_HEADER, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 62f22a2a8b6a7..aa55de60e8020 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -18,9 +18,7 @@ import { OrchestratorMcpFailure, PreviewAutomationError, ProviderInteractionMode, - type RunId, RuntimeMode, - ThreadId, } from "@t3tools/contracts"; import { compactJson } from "@t3tools/shared/compactJson"; @@ -33,15 +31,6 @@ import * as DeviceService from "../device/DeviceService.ts"; import * as HtmlRender from "../htmlRender/HtmlRender.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; -import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; -import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; -import { - type AgentThread, - agentToolProperties, - handoffSettings, - MAX_REPORTED_DEPTH, - toolOutcome, -} from "../telemetry/ProviderDimensions.ts"; import { advertisedJsonSchema } from "./advertisedJsonSchema.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import { leanToolResult, wantsLeanResults } from "./McpResultShape.ts"; @@ -1019,212 +1008,6 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct ); }); -/** - * Tools that hand work to another thread's agent: they create it, message it, - * steer it, or move context into it. Their events name the receiving agent too. - */ -const HANDOFF_TOOLS: ReadonlySet = new Set([ - "delegate_task", - "create_threads", - "t3_thread_launch", - "t3_thread_send", - "t3_thread_send_attachments", - "schedule_task", - "run_scheduled_task_now", -]); - -/** Tool actions that hand work over with only a `{ sequence }` result; the input names the thread. */ -const INPUT_TARGET_ACTIONS: Readonly>> = { - t3_queue: new Set(["edit", "promote_to_steer"]), - t3_pending_request: new Set(["respond"]), -}; - -/** Tool actions that hand work over and name the receiving thread in their result. */ -const RESULT_TARGET_ACTIONS: Readonly>> = { - t3_thread_transfer: new Set(["fork", "merge_back"]), -}; - -const hasAction = ( - actions: Readonly>>, - tool: string, - args: unknown, -) => - actions[tool]?.has(String((args as { readonly action?: unknown } | null | undefined)?.action)) === - true; - -const isInputTargetHandoff = (tool: string, args: unknown) => - hasAction(INPUT_TARGET_ACTIONS, tool, args); - -const resultThreadKeys = ["childThreadId", "targetThreadId", "boundThreadId", "threadId"] as const; - -/** Thread ids a handoff tool result names: the thread that received the work. */ -const resultThreadIds = (content: unknown): ReadonlyArray => { - if (typeof content !== "object" || content === null) return []; - const record = content as Readonly>; - if (Array.isArray(record.threads)) return record.threads.flatMap(resultThreadIds); - const key = resultThreadKeys.find((candidate) => typeof record[candidate] === "string"); - return key === undefined ? [] : [record[key] as string]; -}; - -/** - * Thread ids that received a handoff. The input's `threadId` is used only after - * the call succeeded, so it is one the handler already resolved and authorized. - */ -const handoffTargetIds = ( - tool: string, - args: unknown, - result: McpSchema.CallToolResult | undefined, -): ReadonlyArray => { - if (!isInputTargetHandoff(tool, args)) return resultThreadIds(result?.structuredContent); - if (result === undefined || toolOutcome(result).outcome !== "ok") return []; - const threadId = (args as { readonly threadId?: unknown } | null | undefined)?.threadId; - return typeof threadId === "string" ? [threadId] : []; -}; - -/** Tool analytics must never include IDs, prompts or free text. */ -const withToolAnalytics = - /* Request instrumentation already owns the span; keep this helper in that context. */ - Effect.fnUntraced(function* (registration: Effect.Effect) { - const server = yield* McpServer.McpServer; - const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService); - if (Option.isNone(analytics)) return yield* registration; - const registry = yield* Effect.serviceOption(ProviderRegistry.ProviderRegistry); - const threads = yield* Effect.serviceOption(ThreadManagementService.ThreadManagementService); - const shellOf = (threadId: string) => - Option.isNone(threads) - ? Effect.succeed(null) - : threads.value - .getThreadShell(ThreadId.make(threadId)) - .pipe(Effect.orElseSucceed(() => null)); - // Delegation depth: parents walked from the caller, capped. - const depthOf = - /* Request instrumentation already owns the span; keep this helper in that context. */ - Effect.fnUntraced(function* (thread: AgentThread) { - let depth = 0; - let parentId = - thread.lineage.relationshipToParent === "subagent" ? thread.lineage.parentThreadId : null; - while (parentId !== null && depth < MAX_REPORTED_DEPTH) { - depth += 1; - const parent = yield* shellOf(parentId); - parentId = - parent?.lineage.relationshipToParent === "subagent" - ? parent.lineage.parentThreadId - : null; - } - return depth; - }); - // Whether the caller's active run was started by a scheduled task. - const scheduledRunOf = (threadId: string, runId: RunId | null) => - Option.isNone(threads) || runId === null - ? Effect.succeed(false) - : threads.value - .getThreadRecords(ThreadId.make(threadId), ["runs", "messages"], { - runIds: [runId], - messageRoles: ["user"], - }) - .pipe( - Effect.map((records) => { - const run = records.runs.find((candidate) => candidate.id === runId); - return records.messages.some( - (message) => - message.id === run?.userMessageId && message.scheduledTaskId !== undefined, - ); - }), - Effect.orElseSucceed(() => false), - ); - const record = - /* Request instrumentation already owns the span; keep this helper in that context. */ - Effect.fnUntraced( - function* ( - tool: string, - args: unknown, - result: McpSchema.CallToolResult | undefined, - durationMs: number, - ) { - const handoff = - HANDOFF_TOOLS.has(tool) || - isInputTargetHandoff(tool, args) || - hasAction(RESULT_TARGET_ACTIONS, tool, args); - const outcome = toolOutcome(result); - // Agents call status, read, and preview tools in tight loops, so an - // event per successful call floods analytics. Only failures count there. - if (!handoff && outcome.outcome === "ok") return; - // Client credentials (no calling thread) report an unknown caller. - const invocation = Option.getOrUndefined( - yield* Effect.serviceOption(McpInvocationContext.McpInvocationContext), - )?.thread; - const providers = Option.isSome(registry) ? yield* registry.value.getProviders : []; - // Only handoff tools pay for thread lookups; the rest report the - // caller's provider from the credential. - const caller = - handoff && invocation !== undefined - ? ((yield* shellOf(invocation.threadId)) ?? undefined) - : undefined; - const targetIds = handoff - ? [...new Set(handoffTargetIds(tool, args, result))].filter( - (threadId) => threadId !== invocation?.threadId, - ) - : []; - const targets = yield* Effect.forEach(targetIds, shellOf); - for (const properties of agentToolProperties({ - tool, - providers, - caller, - ...(invocation === undefined - ? {} - : { callerProviderInstanceId: invocation.providerInstanceId }), - ...(caller === undefined || invocation === undefined - ? {} - : { - callerDepth: yield* depthOf(caller), - callerScheduledRun: yield* scheduledRunOf( - invocation.threadId, - caller.activeRunId, - ), - }), - targets: targets.filter((thread) => thread !== null), - outcome, - ...(handoff - ? { settings: handoffSettings(tool, args, result?.structuredContent) } - : {}), - durationMs, - })) { - yield* analytics.value.record("mcp.tool.invoked", properties); - } - }, - (effect, _tool, _args, _result, _durationMs) => effect.pipe(Effect.ignoreCause), - ); - const recordingServer = McpServer.McpServer.of({ - ...server, - addTool: Effect.fn("McpServer.addTool")(function (options) { - return server.addTool({ - ...options, - handle: - /* Request instrumentation already owns the span; keep this helper in that context. */ - Effect.fnUntraced(function* (payload) { - const startedAt = yield* Clock.currentTimeMillis; - return yield* options.handle(payload).pipe( - Effect.onExit((exit) => { - const result = exit._tag === "Success" ? exit.value : undefined; - // A call that asks for input is retried with it, and that call is the one counted. - if (result instanceof McpSchema.InputRequired) return Effect.void; - // Handoff tools look up threads to record; the response must not wait on them. - return Clock.currentTimeMillis.pipe( - Effect.flatMap((endedAt) => - record(options.tool.name, payload, result, endedAt - startedAt), - ), - Effect.forkDetach, - Effect.asVoid, - ); - }), - ); - }), - }); - }), - }); - return yield* registration.pipe(Effect.provideService(McpServer.McpServer, recordingServer)); - }); - /** Builds `toolkits`, then registers all their tools in name order. */ export const layerInToolNameOrder = (toolkits: Layer.Layer) => Layer.effectDiscard(McpToolCatalog.use((catalog) => catalog.registerStaged)).pipe( @@ -1320,15 +1103,10 @@ export const toolkitRegistration = , EX, ) => Layer.effectDiscard( stagedInCatalog( - withToolAnalytics( - withToolSpans( - withResultShape( - withCompactResults( - withAdvertisedSchemas( - toolkit, - McpServer.registerToolkit(decodeAsAdvertised(toolkit)), - ), - ), + withToolSpans( + withResultShape( + withCompactResults( + withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))), ), ), ), @@ -1347,7 +1125,7 @@ const imageToolRegistration = , A, E, R, register: Effect.Effect, handlers: McpToolAccess.HandlersLayer, ) => - Layer.effectDiscard(stagedInCatalog(withToolAnalytics(withToolSpans(register)))).pipe( + Layer.effectDiscard(stagedInCatalog(withToolSpans(register))).pipe( Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)), ); diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 810190c0aa237..eccdf50f8a9fa 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -22,7 +22,6 @@ import { type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; @@ -55,7 +54,6 @@ import * as ServerSettings from "../../serverSettings.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; import * as McpInvocationContext from "../McpInvocationContext.ts"; import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; -import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { dispatchFailure } from "../threadAccess.ts"; import * as EnvironmentHandlers from "./environment/handlers.ts"; import * as AttachmentHandlers from "./attachment/handlers.ts"; @@ -332,61 +330,6 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both ), ); -it.effect("records only failed calls of tools that hand off no work", () => { - // Recording runs detached from the tool response, so the first event is awaited. - const recorded = Deferred.makeUnsafe<{ event: string; properties: object }>(); - return Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const organize = (capabilities: McpInvocationContext.McpInvocationScope["capabilities"]) => - server.callTool({ name: "t3_thread_organize", arguments: { action: "pin" } }).pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...scope, - capabilities, - }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect((yield* organize(scope.capabilities)).isError).not.toBe(true); - expect(declaredFailure(yield* organize(new Set()))).toMatchObject({ - code: "capability_denied", - }); - // The successful call ran first, so it would be the first event if it were recorded. - expect(yield* Deferred.await(recorded)).toMatchObject({ - event: "mcp.tool.invoked", - properties: { tool: "t3_thread_organize", outcome: "error" }, - }); - }).pipe( - Effect.provide( - layerThreadToolkit.pipe( - Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), - Layer.provide(NodeCrypto.layer), - Layer.provide( - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), - getProjectThreadRecords: ({ threadId: id }) => - Effect.succeed( - McpToolAccessTestkit.idleThreadProjection(McpToolAccessTestkit.liveThreadShell(id)), - ), - dispatch: () => Effect.succeed({ sequence: 1 } as never), - }), - ), - Layer.provide( - Layer.succeed( - AnalyticsService.AnalyticsService, - AnalyticsService.AnalyticsService.of({ - record: (event, properties) => - Deferred.succeed(recorded, { event, properties: properties ?? {} }).pipe( - Effect.asVoid, - ), - flush: Effect.void, - recordOwners: () => Effect.void, - }), - ), - ), - ), - ), - ); -}); - it.effect("rejects an unknown organize target instead of archiving the calling thread", () => { const dispatched: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 520fea7c0575b..407de95a0ae9e 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -46,7 +46,6 @@ import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; -import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -326,104 +325,6 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit ), ); -it.effect("records accepted billed turn usage once without billing the context window", () => { - const recorded: Array = []; - const layerAnalytics = Layer.succeed(ProviderUsageAnalytics.ProviderTurnAnalytics, { - runStarted: () => Effect.void, - turnCompleted: (report) => - Effect.sync(() => { - recorded.push(report); - }), - runSettled: () => Effect.void, - }); - return Effect.gen(function* () { - const now = yield* DateTime.now; - const eventSink = yield* EventSink.EventSinkV2; - const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const threadEvent = yield* threadCreatedEvent(now); - yield* eventSink.write({ events: [threadEvent] }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId: threadEvent.threadId, - }); - const providerThreadId = idAllocator.derive.providerThread({ - driver: CODEX_DRIVER, - nativeThreadId: "billing-thread", - }); - const providerTurn = { - id: idAllocator.derive.providerTurn({ driver: CODEX_DRIVER, nativeTurnId: "billing-turn" }), - providerThreadId, - nodeId: NodeId.make("node:billing-turn"), - runAttemptId: null, - nativeTurnRef: null, - ordinal: 1, - status: "completed" as const, - startedAt: now, - completedAt: DateTime.makeUnsafe(DateTime.toEpochMillis(now) + 120), - tokenUsage: { - inputTokens: 4000, - cachedInputTokens: 3000, - outputTokens: 100, - usedTokens: 4100, - updatedAt: DateTime.formatIso(now), - }, - turnTokenUsage: { - usageStatus: "complete" as const, - usageScope: "main_agent" as const, - hasSubagents: false, - inputTokens: 40, - cachedInputTokens: 30, - outputTokens: 10, - }, - }; - const input = { - providerSessionId, - providerInstanceId: modelSelection.instanceId, - threadId: threadEvent.threadId, - analyticsContext: { - modelSelection, - runtimeMode: "full-access" as const, - interactionMode: "default" as const, - origin: { - createdBy: "agent" as const, - creationSource: "provider" as const, - scheduled: false, - subagent: true, - }, - }, - event: { type: "provider_turn.updated" as const, driver: CODEX_DRIVER, providerTurn }, - }; - yield* ingestor.ingestNormalized(input); - yield* ingestor.ingestNormalized(input); - const ignored = yield* ingestor.ingestNormalized({ - ...input, - event: { - ...input.event, - providerTurn: { - ...providerTurn, - id: idAllocator.derive.providerTurn({ - driver: CODEX_DRIVER, - nativeTurnId: "stale-billing-turn", - }), - }, - }, - writeIfRunCurrent: { - runId: RunId.make("missing-run"), - activeAttemptId: RunAttemptId.make("stale-attempt"), - expectedStatus: "running", - }, - }); - assert.isEmpty(ignored); - assert.lengthOf(recorded, 1); - assert.deepEqual(recorded[0], { - driver: CODEX_DRIVER, - providerTurn, - context: input.analyticsContext, - }); - }).pipe(Effect.provide(layerTest.pipe(Layer.provide(layerAnalytics)))); -}); - layer("ProviderEventIngestorV2", (it) => { it.effect("normalizes provider events through the real event log and projection store", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 623fddb491108..b85ae97992639 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -27,10 +27,6 @@ import * as SqlError from "effect/sql/SqlError"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { modelSelectionsEqual } from "@t3tools/shared/model"; -import { - ProviderTurnAnalytics, - type ProviderTurnAnalyticsContext, -} from "../telemetry/ProviderUsageAnalytics.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as EventSink from "./EventSink.ts"; @@ -200,7 +196,6 @@ export interface ProviderEventIngestInput { readonly nodeId?: NodeId; readonly rawEventId?: RawEventId; readonly event: ProviderAdapter.ProviderAdapterV2Event; - readonly analyticsContext?: ProviderTurnAnalyticsContext; } export interface ProviderEventIngestorV2Shape { @@ -252,11 +247,9 @@ export const layer: Layer.Layer< const projections = yield* ProjectionStore.ProjectionStoreV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; - const analytics = yield* ProviderTurnAnalytics; const agentScope = yield* AgentScope; const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const scope = yield* Effect.scope; - const completedTurnAnalytics = new Set(); const makeDomainEvent = /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ @@ -818,34 +811,6 @@ export const layer: Layer.Layer< ), ), ), - Effect.tap((storedEvents) => - Effect.gen(function* () { - if (storedEvents.length === 0 || input.event.type !== "provider_turn.updated") - return; - const providerTurn = input.event.providerTurn; - if ( - providerTurn.status !== "completed" && - providerTurn.status !== "failed" && - providerTurn.status !== "interrupted" && - providerTurn.status !== "cancelled" - ) - return; - const key = `${input.providerInstanceId}:${providerTurn.id}`; - if (completedTurnAnalytics.has(key)) return; - completedTurnAnalytics.add(key); - if (completedTurnAnalytics.size > 4096) { - const oldest = completedTurnAnalytics.values().next().value; - if (oldest !== undefined) completedTurnAnalytics.delete(oldest); - } - yield* analytics.turnCompleted({ - driver: input.event.driver, - providerTurn, - ...(input.analyticsContext === undefined - ? {} - : { context: input.analyticsContext }), - }); - }), - ), (effect) => input.event.type === "app_thread.created" ? threadCommands.withLock(input.event.appThread.id, effect) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 0370d9fd27be3..abf937a81ca17 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -59,7 +59,6 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter" import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnStart from "./ProviderTurnStartService.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; -import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; const isDomainEvent = Schema.is(OrchestrationV2DomainEvent); @@ -329,7 +328,6 @@ function makeLocalCommandHarness(input: { readonly threadRuntimeMode?: OrchestrationV2ThreadProjection["thread"]["runtimeMode"]; }; }) { - const settledRuns: Array = []; const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); const runId = RunId.make("run-native-account-command"); @@ -1058,11 +1056,6 @@ function makeLocalCommandHarness(input: { }), Layer.mock(ProviderAuthService.ProviderAuthService)({ tryHandlePromptCommand }), Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }), - Layer.succeed(ProviderUsageAnalytics.ProviderTurnAnalytics, { - runStarted: () => Effect.void, - turnCompleted: () => Effect.void, - runSettled: (report) => Effect.sync(() => void settledRuns.push(report.status)), - }), Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: resolveRuntimePolicy, }), @@ -1070,7 +1063,6 @@ function makeLocalCommandHarness(input: { ), ); return { - settledRuns, getThreadRecords, getTurnStartContext, getRuntimeRecoveryProjection, @@ -1872,7 +1864,6 @@ effectIt.effect("terminalizes a starting run when its provider session cannot op ); const projection = harness.projection(); expect(projection.runs.at(-1)).toMatchObject({ status: "failed", startedAt: null }); - expect(harness.settledRuns).toEqual(["failed"]); expect(projection.attempts[0]).toMatchObject({ status: "failed", startedAt: null }); expect(projection.nodes[0]).toMatchObject({ status: "failed", startedAt: null }); expect(projection.turnItems).toMatchObject([ @@ -1962,7 +1953,6 @@ effectIt.effect("does not overwrite a run interrupted while its provider session expect(harness.startRootRun).not.toHaveBeenCalled(); const projection = harness.projection(); expect(projection.runs.at(-1)?.status).toBe("interrupted"); - expect(harness.settledRuns).toEqual([]); expect(projection.attempts[0]?.status).toBe("pending"); expect(projection.nodes[0]?.status).toBe("pending"); expect(projection.turnItems).toEqual([]); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index e20ef039d9d7a..e2f0e47a53457 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -41,7 +41,6 @@ import * as ServerSettings from "../serverSettings.ts"; import { providerUserText } from "./providerUserText.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; import * as EventSink from "./EventSink.ts"; -import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import { isActiveRun } from "./CommandPolicy.ts"; import { @@ -157,7 +156,6 @@ export const layer: Layer.Layer< ProviderTurnStartServiceV2, Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; - const turnAnalytics = yield* ProviderUsageAnalytics.ProviderTurnAnalytics; const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const fileSystem = yield* FileSystem.FileSystem; @@ -522,22 +520,13 @@ export const layer: Layer.Layer< } satisfies OrchestrationV2DomainEvent; }), ); - const written = yield* eventSink.writeIfRunCurrent({ + yield* eventSink.writeIfRunCurrent({ threadId: projection.thread.id, runId, activeAttemptId: attempt.id, expectedStatus: "starting", events, }); - // A steering restart that cannot start still ends a run whose earlier turns counted. - if (written.committed) { - yield* turnAnalytics.runSettled({ - runId, - status, - completedAt: now, - childrenRunning: false, - }); - } }, ); const settleStartFailure = Effect.fn("ProviderTurnStartService.settleStartFailure")( diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index e25b4c7ab556a..55a5099dce56a 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -55,7 +55,6 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter" import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderCommitBatch from "./ProviderCommitBatch.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; -import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; import * as PreviewAgentActivity from "../preview/PreviewAgentActivity.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; @@ -1433,21 +1432,9 @@ it.effect("ingests new member threads of an owned child after the root turn term const subagentNodeId = NodeId.make("node:run-execution-late-child:subagent"); const childMessageIngested = yield* Deferred.make(); const order = yield* Ref.make>([]); - const origins = yield* Ref.make>([]); - const requests = yield* Ref.make>([]); const layerTest = RunExecutionService.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(ProviderUsageAnalytics.ProviderTurnAnalytics, { - runStarted: (input) => - Ref.update(requests, (current) => [...current, `started:${input.runId}`]), - turnCompleted: () => Effect.void, - runSettled: (input) => - Ref.update(requests, (current) => [ - ...current, - `settled:${input.runId}:${input.status}:children-running=${input.childrenRunning}`, - ]), - }), McpAppModelContext.layerEmpty, PreviewAgentActivity.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), @@ -1471,15 +1458,6 @@ it.effect("ingests new member threads of an owned child after the root turn term Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ ingestNormalized: (input) => Effect.gen(function* () { - const eventType = input.event.type; - yield* Ref.update(origins, (current) => [ - ...current, - [ - eventType, - input.analyticsContext?.origin, - input.analyticsContext?.segment, - ] as const, - ]); if ( input.event.type === "app_thread.created" && input.event.appThread.id === memberThreadId @@ -1669,25 +1647,6 @@ it.effect("ingests new member threads of an owned child after the root turn term ); assert.isTrue(Option.isSome(observed), "child message was not ingested after root terminal"); assert.deepEqual(yield* Ref.get(order), ["root-finalized", "member-thread", "child-message"]); - // The run's message started the root; the parent agent started the child's turn. - const recorded = yield* Ref.get(origins); - assert.deepEqual(recorded[0]?.slice(0, 2), [ - "app_thread.created", - { createdBy: "user", creationSource: "web", scheduled: false, subagent: false }, - ]); - assert.deepEqual( - recorded.find(([type]) => type === "provider_turn.updated"), - [ - "provider_turn.updated", - { createdBy: "agent", creationSource: "provider", scheduled: false, subagent: true }, - "native_child", - ], - ); - // The child outlives the root, so the request summary says its later turns are missing. - assert.deepEqual(yield* Ref.get(requests), [ - `started:${runId}`, - `settled:${runId}:completed:children-running=true`, - ]); }), ); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index e981ab7362d2d..0091da4daef07 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -47,7 +47,6 @@ import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; -import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts"; import type { ProjectionStoreV2Error } from "./ProjectionStore.ts"; import { knownProviderFailureMessage, @@ -99,14 +98,6 @@ function isSettledSubagentStatus(status: OrchestrationV2Subagent["status"]): boo return !isOrchestrationV2WorkActive(status); } -/** A provider-native subagent's turns arrive on its parent's run, but the parent agent started them. */ -const NATIVE_SUBAGENT_TURN_ORIGIN: ProviderUsageAnalytics.ProviderTurnOrigin = { - createdBy: "agent", - creationSource: "provider", - scheduled: false, - subagent: true, -}; - const backgroundCapableTurnItemTypes: ReadonlySet = new Set([ "command_execution", "dynamic_tool", @@ -626,7 +617,6 @@ export const layer: Layer.Layer< const previewAgentActivity = yield* PreviewAgentActivity.PreviewAgentActivity; const turnItemPositions = yield* TurnItemPositionStore.TurnItemPositionStoreV2; const finalizationObserver = yield* RunFinalizationService.RunFinalizationObserver; - const turnAnalytics = yield* ProviderUsageAnalytics.ProviderTurnAnalytics; const writeFinalRunEvents = Effect.fn("RunExecutionService.writeFinalRunEvents")( function* (input: { @@ -883,24 +873,12 @@ export const layer: Layer.Layer< } else { yield* eventSink.writeWithEffects(finalization); } - yield* turnAnalytics.runSettled({ - runId: input.run.id, - status: input.terminal.status, - completedAt, - childrenRunning: hasOpenSubagentProjection && cascadedSubagentEvents.length === 0, - }); yield* input.refreshAfterTurn; }, ); return RunExecutionServiceV2.of({ startRootRun: Effect.fn("RunExecutionService.startRootRun")(function* (input) { - const rootTurnOrigin: ProviderUsageAnalytics.ProviderTurnOrigin = { - createdBy: input.message.createdBy, - creationSource: input.message.creationSource, - scheduled: input.message.scheduledTaskId !== undefined, - subagent: input.appThread.lineage.relationshipToParent === "subagent", - }; // Startup failure and stream shutdown can report the same attempt. const refreshAfterTurn = yield* Effect.cached( finalizationObserver.refreshAfterTurn(input.appThread.projectId).pipe( @@ -1015,25 +993,6 @@ export const layer: Layer.Layer< const latestTurnItemOrdinal = yield* Ref.make(input.providerTurnOrdinal * 100); const latestProviderThread = yield* Ref.make(input.providerThread); const latestRootProviderTurn = yield* Ref.make(null); - // Later root turns in one attempt are provider continuations, such as Codex goal turns. - let firstRootProviderTurnId: ProviderTurnId | undefined; - const analyticsContext = { - runId: input.run.id, - modelSelection: input.modelSelection, - runtimeMode: input.runtimePolicy.runtimeMode, - interactionMode: input.runtimePolicy.interactionMode, - origin: rootTurnOrigin, - ...(input.run.restartContinuationOfRunId === undefined - ? {} - : { restartContinuation: true }), - } satisfies ProviderUsageAnalytics.ProviderTurnAnalyticsContext; - if (input.attempt.reason === "initial") { - yield* turnAnalytics.runStarted({ - runId: input.run.id, - driver: input.providerThread.driver, - context: { ...analyticsContext, segment: "initial" }, - }); - } const routeIdentity: ProviderEventRouteIdentity = { threadId: input.run.threadId, runId: input.run.id, @@ -1427,27 +1386,7 @@ export const layer: Layer.Layer< const isRootProviderThreadUpdate = event.type === "provider_thread.updated" && event.providerThread.id === input.providerThread.id; - const nativeChildTurn = - event.type === "provider_turn.updated" && - event.providerTurn.providerThreadId !== input.providerThread.id; - if (event.type === "provider_turn.updated" && !nativeChildTurn) { - firstRootProviderTurnId ??= event.providerTurn.id; - } const storedEvents = yield* providerEventIngestor.ingestNormalized({ - analyticsContext: nativeChildTurn - ? { - ...analyticsContext, - origin: NATIVE_SUBAGENT_TURN_ORIGIN, - segment: "native_child", - } - : { - ...analyticsContext, - segment: - event.type === "provider_turn.updated" && - event.providerTurn.id !== firstRootProviderTurnId - ? "continuation" - : input.attempt.reason, - }, providerSessionId: input.providerSessionId, providerInstanceId: input.run.providerInstanceId, threadId: input.run.threadId, diff --git a/apps/server/src/policy/ManagedPolicy.test.ts b/apps/server/src/policy/ManagedPolicy.test.ts index d6c7710b57ead..265da6e361c50 100644 --- a/apps/server/src/policy/ManagedPolicy.test.ts +++ b/apps/server/src/policy/ManagedPolicy.test.ts @@ -60,12 +60,11 @@ it.layer(NodeServices.layer)("ManagedPolicy.load", (it) => { JSON.stringify({ schemaVersion: 1, organization: "Example Corp", - limits: { webhookTasks: false, telemetry: true }, + limits: { webhookTasks: false, browserCookieImport: true }, }), ); - assert.deepEqual(policy.summary?.limits, { webhookTasks: false, telemetry: true }); + assert.deepEqual(policy.summary?.limits, { webhookTasks: false, browserCookieImport: true }); assert.equal(ManagedPolicy.deniedBy(policy, "webhookTasks"), "Example Corp"); - assert.isUndefined(ManagedPolicy.deniedBy(policy, "telemetry")); }), ); diff --git a/apps/server/src/policy/ManagedPolicy.ts b/apps/server/src/policy/ManagedPolicy.ts index 7da8f3c625b4c..912d505eeb099 100644 --- a/apps/server/src/policy/ManagedPolicy.ts +++ b/apps/server/src/policy/ManagedPolicy.ts @@ -69,7 +69,7 @@ export const loadedPolicy = Effect.fn("ManagedPolicy.loadedPolicy")(function* ( */ export function deniedBy( policy: Pick, - capability: "webhookTasks" | "telemetry", + capability: "webhookTasks", ): string | undefined { return policy.document?.limits?.[capability] === false ? policy.document.organization : undefined; } @@ -128,20 +128,12 @@ function summarizeLimits( document: ManagedPolicyDocument, lockedInstanceFields: Readonly>>, ): LimitsSummary { - const { - maxRuntimeMode, - clients, - webhookTasks, - telemetry, - browserCookieImport, - providers, - updates, - } = document.limits ?? {}; + const { maxRuntimeMode, clients, webhookTasks, browserCookieImport, providers, updates } = + document.limits ?? {}; return { ...(maxRuntimeMode === undefined ? {} : { maxRuntimeMode }), ...(clients === undefined ? {} : { clients }), ...(webhookTasks === undefined ? {} : { webhookTasks }), - ...(telemetry === undefined ? {} : { telemetry }), ...(browserCookieImport === undefined ? {} : { browserCookieImport }), ...(providers?.allowedDrivers === undefined && Object.keys(lockedInstanceFields).length === 0 ? {} diff --git a/apps/server/src/policy/policyTemplates.ts b/apps/server/src/policy/policyTemplates.ts index d83b68ca9ebf4..89e6e7d109147 100644 --- a/apps/server/src/policy/policyTemplates.ts +++ b/apps/server/src/policy/policyTemplates.ts @@ -97,7 +97,7 @@ const policies = (): ReadonlyArray => [ kind: "checkbox", list: "Clients", name: "remote", - label: "Remote: phones, other computers, app.t3.codes", + label: "Remote: phones, other computers, browsers", defaultChecked: false, }, ], @@ -114,12 +114,6 @@ const policies = (): ReadonlyArray => [ help: `Disabled stops users from importing cookies from another browser into ${APP_NAME}'s browser.`, toggle: "BrowserCookieImport", }, - { - id: "Telemetry", - title: "Allow usage data", - help: `Disabled stops ${APP_NAME} from sending product usage data. Enabled cannot turn them on for a user who turned them off.`, - toggle: "Telemetry", - }, { id: "AllowedDrivers", title: "Allowed providers", diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index 4e84553dca26a..b4b5ea8ca5d33 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -22,7 +22,6 @@ import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; -import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; const assertSameCallback = (actual: string | null, expected: string | null) => { @@ -37,7 +36,6 @@ const instanceId = ProviderInstanceId.make("managed-codex-test"); const makeHarnessFor = Effect.fnUntraced(function* ( instanceId: ProviderInstanceId, bytes: Map = new Map(), - failAnalytics = false, ) { const environmentId = environmentIds.get(bytes) ?? EnvironmentId.make(NodeCrypto.randomUUID()); environmentIds.set(bytes, environmentId); @@ -224,26 +222,11 @@ const makeHarnessFor = Effect.fnUntraced(function* ( const address = server.address(); if (!address || typeof address === "string") throw new Error("mock address"); origin = `http://127.0.0.1:${address.port}`; - const analyticsEvents: { - event: string; - properties: Readonly> | undefined; - }[] = []; - const analytics = AnalyticsService.AnalyticsService.of({ - record: (event, properties) => - failAnalytics - ? Effect.die("analytics unavailable") - : Effect.sync(() => { - analyticsEvents.push({ event, properties }); - }), - flush: Effect.void, - recordOwners: () => Effect.void, - }); const auth = yield* makeCodexChatGptAuth({ instanceId, discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1`, }).pipe( - Effect.provideService(AnalyticsService.AnalyticsService, analytics), Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment), ); @@ -299,7 +282,6 @@ const makeHarnessFor = Effect.fnUntraced(function* ( rotateSigningKey: () => { signingKey = signingKeyFor("rotated"); }, - analyticsEvents, secrets, environmentId, bytes, @@ -314,7 +296,7 @@ const makeHarnessFor = Effect.fnUntraced(function* ( }, "owner", { discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1` }, - ).pipe(Stream.provideService(AnalyticsService.AnalyticsService, analytics)), + ), finishCallback: (state: { authorizationUrl: string | null }) => Effect.promise(() => fetch(prepareCallback(state))), destination: Effect.gen(function* () { @@ -336,7 +318,6 @@ const makeHarnessFor = Effect.fnUntraced(function* ( discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1`, }).pipe( - Effect.provideService(AnalyticsService.AnalyticsService, analytics), Effect.provideService(ServerSecretStore.ServerSecretStore, destinationStore), Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment), ); @@ -1572,24 +1553,6 @@ it.effect("primary completes OAuth and destination imports and owns the refresh ); assert.isTrue(stopped); assert.strictEqual(imported.phase, "succeeded"); - assert.deepStrictEqual( - h.analyticsEvents.map(({ event }) => event), - [ - "chatgpt.auth.started", - "chatgpt.auth.completed", - "chatgpt.transfer.started", - "chatgpt.transfer.completed", - ], - ); - assert.isTrue( - h.analyticsEvents.every(({ properties }) => properties?.flow === "primary_handoff"), - ); - assert.strictEqual(h.analyticsEvents[1]?.properties?.outcome, "succeeded"); - assert.strictEqual(h.analyticsEvents[3]?.properties?.outcome, "succeeded"); - assert.notProperty(h.analyticsEvents[1]?.properties ?? {}, "connectedAccountCount"); - assert.strictEqual(h.analyticsEvents[1]?.properties?.intent, "different_account"); - assert.strictEqual(h.analyticsEvents[3]?.properties?.connectedAccountCount, 1); - assert.strictEqual(h.analyticsEvents[3]?.properties?.savedConnectionCount, 1); const saved = Option.getOrThrow(yield* destination.auth.read); assert.strictEqual(saved.clientId, result.profile.registration.clientId); assert.strictEqual(saved.refreshToken, "initial-refresh"); @@ -1716,17 +1679,6 @@ it.effect( ), ); -it.effect("telemetry failures do not fail a verified ChatGPT sign-in", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarnessFor(instanceId, new Map(), true); - yield* h.signIn; - const state = yield* h.phase("succeeded"); - assert.strictEqual(state.phase, "succeeded"); - assert.isTrue(Option.isSome(yield* h.auth.read)); - }), - ), -); it.effect("a running session renews its bound profile but refuses a replacement sign-in", () => provision( Effect.gen(function* () { diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index ad06cdc001ae6..ec086694a83c6 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -7,11 +7,7 @@ import { type ProviderInstanceId, } from "@t3tools/contracts"; import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; -import { hashTelemetryId } from "@t3tools/shared/telemetryId"; import * as Clock from "effect/Clock"; -import * as Cause from "effect/Cause"; -import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; -import { openAiUserId } from "../telemetry/Identify.ts"; import * as Digest from "effect/crypto/Digest"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Signature from "effect/crypto/Signature"; @@ -32,10 +28,8 @@ import { HttpClient, HttpClientRequest } from "effect/http"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ServerSettings from "../serverSettings.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -const isSetupError = Schema.is(ProviderSetupError); const RESOURCE = "https://api.openai.com/v1"; const REQUIRED_SCOPE = "chatgpt.tokens.use.direct"; const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration"; @@ -119,53 +113,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* readonly resource?: string; readonly defaultReturnUrl?: string; readonly reconnectProfile?: ChatGptReconnectProfile | null; - readonly telemetryFlow?: "direct" | "primary_handoff"; readonly expectedProfileKey?: string; }) { - const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService); - const settings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); - const track = Effect.fn("CodexChatGptAuth.track")(function* ( - event: "auth" | "transfer", - properties: Readonly>, - task: Effect.Effect, - expiresAt?: number, - ) { - if (Option.isNone(analytics)) return yield* task; - const record = (name: string, properties: Readonly>) => - analytics.value.record(name, properties).pipe(Effect.ignoreCause); - const startedAt = yield* Clock.currentTimeMillis; - yield* record(`chatgpt.${event}.started`, properties); - return yield* task.pipe( - Effect.onExit((result) => - Effect.gen(function* () { - const endedAt = yield* Clock.currentTimeMillis; - const error = Exit.isFailure(result) - ? Cause.findErrorOption(result.cause) - : Option.none(); - const counts = - Exit.isSuccess(result) && - (event === "transfer" || options.telemetryFlow !== "primary_handoff") - ? yield* accountCounts.pipe(Effect.catchCause(() => Effect.succeed({}))) - : {}; - yield* record(`chatgpt.${event}.completed`, { - ...counts, - ...properties, - outcome: Exit.isSuccess(result) - ? "succeeded" - : Cause.hasInterruptsOnly(result.cause) - ? expiresAt !== undefined && endedAt >= expiresAt - ? "expired" - : "cancelled" - : "failed", - durationMs: Math.max(0, endedAt - startedAt), - ...(Option.isSome(error) && isSetupError(error.value) - ? { failureStage: error.value.operation } - : {}), - }); - }), - ), - ); - }); const http = yield* HttpClient.HttpClient; const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId); const registrationStore = yield* ProviderCredentialStore.make( @@ -286,18 +235,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* saved.sessions.find((session) => session.clientId === saved.activeClientId), ), ), - // The OpenAI account is the person, so it marks who owns this install. - Effect.tap((credentials) => { - const userId = Option.flatMap(credentials, (saved) => - Option.flatMap(Option.fromUndefinedOr(saved.idToken), openAiUserId), - ); - return Option.isSome(userId) && Option.isSome(analytics) - ? analytics.value.recordOwners( - [{ source: "openai", ownerId: hashTelemetryId(userId.value) }], - "server", - ) - : Effect.void; - }), ); const writeSessions = (saved: typeof Sessions.Type) => encodeSessions(saved).pipe( @@ -338,53 +275,6 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* ), ), ); - const accountCounts = Effect.gen(function* () { - const instanceIds = new Set([options.instanceId]); - if (Option.isSome(settings)) { - const current = yield* settings.value.getSettings; - instanceIds.add("codex"); - for (const [id, instance] of Object.entries(current.providerInstances)) { - if (instance.driver === "codex") instanceIds.add(id); - } - } - const accounts = new Set(); - const connections = new Set(); - const savedConnections = new Set(); - let unidentifiedConnectedConnectionCount = 0; - for (const id of instanceIds) { - const registrations = yield* ProviderCredentialStore.make("codex-chatgpt-registration", id); - const registrationBytes = yield* registrations.get; - if (Option.isSome(registrationBytes)) { - const saved = yield* decodeRegistration(new TextDecoder().decode(registrationBytes.value)); - for (const profile of "profiles" in saved ? saved.profiles : [saved]) { - savedConnections.add(profile.clientId); - } - } - const credentials = yield* ProviderCredentialStore.make("codex-chatgpt", id); - const credentialBytes = yield* credentials.get; - if (Option.isNone(credentialBytes)) continue; - const saved = yield* decodeSessions(new TextDecoder().decode(credentialBytes.value)); - for (const session of "sessions" in saved ? saved.sessions : [saved]) { - if (!session.scopes.includes(REQUIRED_SCOPE) || connections.has(session.clientId)) continue; - connections.add(session.clientId); - // Client-scoped subjects need verified email for local counting only; never export email or merge profiles. - const email = session.email?.trim().toLowerCase(); - if (email) accounts.add(email); - else unidentifiedConnectedConnectionCount++; - } - } - return { - accountCountScope: Option.isSome(settings) ? "environment" : "provider_instance", - connectedAccountCount: accounts.size, - connectedConnectionCount: connections.size, - savedConnectionCount: savedConnections.size, - unidentifiedConnectedConnectionCount, - }; - }).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - Effect.provideService(SecureRandom.SecureRandom, random), - Effect.provideService(Digest.Digest, digest), - ); const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) { const saved = yield* readRegistrations; let connectionNumber = saved.profiles.length + 1; @@ -949,28 +839,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* })), ]; }), - authenticate: (method, context) => - lock.withPermit( - track( - "auth", - { - flow: options.telemetryFlow ?? "direct", - intent: - options.telemetryFlow === "primary_handoff" - ? options.reconnectProfile - ? "saved_profile" - : "different_account" - : method === "chatgpt-change-account" - ? "different_account" - : method.startsWith("chatgpt-profile:") - ? "saved_profile" - : "default", - callbackMode: context.callbackMode ?? "server", - }, - authenticate(method, context), - context.expiresAt, - ), - ), + authenticate: (method, context) => lock.withPermit(authenticate(method, context)), removeMethod: Effect.fn("CodexChatGptAuth.removeMethod")(function (methodId) { return lock.withPermit( withSessionLock( @@ -1093,12 +962,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* yield* save(profile.credentials, true); }); return lock.withPermit( - track( - "transfer", - { flow: "primary_handoff" }, - validate.pipe( - Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)), - ), + validate.pipe( + Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)), ), ); }; diff --git a/apps/server/src/provider/CodexChatGptHandoff.ts b/apps/server/src/provider/CodexChatGptHandoff.ts index 9d00a1b966e50..8bb93c76c02e1 100644 --- a/apps/server/src/provider/CodexChatGptHandoff.ts +++ b/apps/server/src/provider/CodexChatGptHandoff.ts @@ -42,7 +42,6 @@ export function subscribeChatGptHandoff( ...endpoints, instanceId: input.instanceId, reconnectProfile: input.profile, - telemetryFlow: "primary_handoff", defaultReturnUrl: input.returnUrl, }).pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, store), diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index bd1e08af91be5..cd662dbc0d517 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -15,6 +15,7 @@ import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/http"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { CLI_RELEASE_REPOSITORY } from "@t3tools/shared/cliRelease"; import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; @@ -22,8 +23,8 @@ import bundledManifestJson from "./model-manifest.json" with { type: "json" }; import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; -const MODEL_MANIFEST_URL = - "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; +// The fork's own copy, which upstream syncs keep current; upstream's repository is never fetched. +const MODEL_MANIFEST_URL = `https://raw.githubusercontent.com/${CLI_RELEASE_REPOSITORY}/main/apps/server/src/provider/model-manifest.json`; const MANIFEST_TTL_MS = 60 * 60 * 1000; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e14e5d484473a..deb697a542077 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -54,9 +54,6 @@ import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts"; import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; -import * as DelegatedTaskAnalytics from "./telemetry/DelegatedTaskAnalytics.ts"; -import * as ProviderUsageAnalytics from "./telemetry/ProviderUsageAnalytics.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts"; @@ -530,7 +527,6 @@ const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe( // Remote settlement settles children over this server's own links first. Layer.provide(layerPeerLinks), Layer.provide(layerScheduledTaskWebhookOrigin), - Layer.provide(ProviderUsageAnalytics.layer), Layer.provide(layerCheckpointStore), Layer.provide(layerGitWorkflow), Layer.provide( @@ -631,7 +627,6 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provide(ProjectionStoreV2.layer), Layer.provide(ProcessRunner.layer), ), - DelegatedTaskAnalytics.layer, Layer.effectDiscard( Effect.gen(function* () { const service = yield* PullRequestSyncReactor.PullRequestSyncReactor; @@ -734,8 +729,6 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provideMerge(layerResourceDiagnostics), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(TriageService.layer.pipe(Layer.provide(GitVcsDriver.layer))), - // Layers memoize by reference, so this is the same policy the settings layer reads. - Layer.provideMerge(AnalyticsService.layer.pipe(Layer.provide(ManagedPolicy.layer))), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), Layer.provideMerge(DirectEndpoints.layer), diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 25daf5b858b43..46958f4e9f0ec 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -5,7 +5,6 @@ import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { HttpServer } from "effect/http"; @@ -31,8 +30,6 @@ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ThreadTaskService from "./threadTask/ThreadTaskService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; -import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; it.effect("parks automatic pull until activation without delaying command readiness", () => @@ -163,10 +160,6 @@ it.effect("parks automatic pull until activation without delaying command readin Effect.onInterrupt(() => Deferred.succeed(statusInterrupted, undefined)), ), }), - Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void }), - // No provider logins or Connect link, so startup records no owners. - FileSystem.layerNoop({}), - Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.succeedNone }), NodeCrypto.layer, Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), Layer.mock(ExternalLauncher.ExternalLauncher)({}), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 7d0f7a06c26d0..023ecd9e999fd 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -43,8 +43,6 @@ import * as ProjectService from "./project/ProjectService.ts"; import { ServerActivation } from "./serverActivation.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; -import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ThreadTaskService from "./threadTask/ThreadTaskService.ts"; it.effect.each([ @@ -209,10 +207,7 @@ it.effect.each([ prepareTrial: Effect.undefined, }), Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(AnalyticsService.AnalyticsService)({}), - // No provider logins or Connect link, so startup records no owners. FileSystem.layerNoop({}), - Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.succeedNone }), Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), Layer.mock(ExternalLauncher.ExternalLauncher)({}), Layer.mock(HttpServer.HttpServer)({ diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 5cf08e78944cf..68bfe493b0f2a 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -46,8 +46,6 @@ import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerSettings from "./serverSettings.ts"; import { forkParked, forkParkedFiber } from "./serverActivation.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; -import { getServerOwners } from "./telemetry/Identify.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { formatHeadlessServeOutput, issueHeadlessServeAccessInfo } from "./startupAccess.ts"; @@ -144,38 +142,6 @@ export const makeCommandGate = Effect.gen(function* () { } satisfies CommandGate; }); -const recordStartupHeartbeat = Effect.gen(function* () { - const analytics = yield* AnalyticsService.AnalyticsService; - const projects = yield* ProjectService.ProjectService; - const threads = yield* ThreadManagement.ThreadManagementService; - - const { threadCount, projectCount } = yield* Effect.all({ - projects: projects.snapshot, - threadCount: threads.countThreads(), - }).pipe( - Effect.map(({ projects: projectSnapshot, threadCount }) => ({ - projectCount: projectSnapshot.projects.length, - threadCount, - })), - Effect.catch((cause) => - Effect.logWarning("failed to gather V2 startup counts for telemetry", { - cause, - }).pipe( - Effect.as({ - threadCount: 0, - projectCount: 0, - }), - ), - ), - ); - - yield* analytics.record("server.boot.heartbeat", { - threadCount, - projectCount, - }); - yield* analytics.recordOwners(yield* getServerOwners, "server"); -}); - const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, @@ -591,13 +557,6 @@ const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: Startup yield* forkParked( Effect.gen(function* () { - yield* Effect.logDebug("startup phase: recording startup heartbeat"); - yield* recordStartupHeartbeat.pipe( - Effect.withSpan("server.startup.heartbeat.record", { - attributes: { "startup.phase": "heartbeat.record" }, - }), - Effect.ignoreCause({ log: true }), - ); if (serverConfig.startupPresentation === "headless") { yield* Effect.logDebug("startup phase: headless access info"); const accessInfo = yield* issueHeadlessServeAccessInfo(); diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts deleted file mode 100644 index d1574935b9876..0000000000000 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ /dev/null @@ -1,484 +0,0 @@ -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -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 Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -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 type { ManagedPolicyDocument } from "@t3tools/contracts"; - -import * as ServerConfig from "../config.ts"; -import * as ManagedPolicy from "../policy/ManagedPolicy.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 }); - } -}); - -const OPTED_IN = { - T3CODE_TELEMETRY_ENABLED: true, - T3CODE_POSTHOG_KEY: "phc_test_key", - T3CODE_POSTHOG_HOST: "http://localhost", -}; - -const analyticsLayerWith = ( - client: HttpClient.HttpClient, - policy?: ManagedPolicyDocument, - config: Record = OPTED_IN, -) => - AnalyticsService.layer.pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-retry-" }), - ), - Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(config))), - Layer.provide( - Layer.mergeAll( - Layer.succeed(HostProcess.Platform, "win32"), - Layer.succeed(HostProcess.Architecture, "x64"), - Layer.succeed(HttpClient.HttpClient, client), - ManagedPolicy.layerTest(policy), - ), - ), - ); - -/** - * 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, - policy?: ManagedPolicyDocument, - config?: Record, -) => - 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 = analyticsLayerWith(client, policy, config); - - 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(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AnalyticsService test", (it) => { - it.effect("waits for desktop shell consent and honors a late telemetry opt-out", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1" }; - const batches = yield* sendFor( - 0, - respondWithStatus(204), - Effect.gen(function* () { - const analytics = yield* AnalyticsService.AnalyticsService; - yield* analytics.record("test.before-discovery"); - yield* analytics.flush; - env.T3CODE_TELEMETRY_ENABLED = "false"; - delete env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING; - yield* analytics.record("test.opted-out"); - yield* analytics.flush; - env.T3CODE_TELEMETRY_ENABLED = "true"; - yield* analytics.record("test.allowed"); - yield* analytics.flush; - }), - ).pipe(Effect.provideService(HostProcess.Environment, env)); - assert.deepEqual( - batches.flat().map((event) => event.event), - ["test.allowed"], - ); - }), - ); - - it.effect("sends nothing unless T3CODE_TELEMETRY_ENABLED opts in", () => - Effect.gen(function* () { - const { T3CODE_TELEMETRY_ENABLED: _optIn, ...unset } = OPTED_IN; - const batches = yield* sendFor( - 10, - respondWithStatus(204), - recordTwentyEvents, - undefined, - unset, - ).pipe(Effect.provideService(HostProcess.Environment, {})); - assert.deepEqual(batches, []); - }), - ); - - it.effect("lets the desktop's login shell opt in after discovery", () => - Effect.gen(function* () { - const { T3CODE_TELEMETRY_ENABLED: _optIn, ...unset } = OPTED_IN; - const env: NodeJS.ProcessEnv = { T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1" }; - const batches = yield* sendFor( - 0, - respondWithStatus(204), - Effect.gen(function* () { - const analytics = yield* AnalyticsService.AnalyticsService; - delete env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING; - yield* analytics.record("test.shell-unset"); - yield* analytics.flush; - env.T3CODE_TELEMETRY_ENABLED = "true"; - yield* analytics.record("test.shell-opted-in"); - yield* analytics.flush; - }), - undefined, - unset, - ).pipe(Effect.provideService(HostProcess.Environment, env)); - assert.deepEqual( - batches.flat().map((event) => event.event), - ["test.shell-opted-in"], - ); - }), - ); - - 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( - "sends nothing when a managed policy turns telemetry off, despite the environment", - () => - Effect.gen(function* () { - const policy = { schemaVersion: 1, organization: "Example Corp" } as const; - const allowed = yield* sendFor(1, respondWithStatus(204), recordTwentyEvents, { - ...policy, - limits: { telemetry: true }, - }); - assert.equal(allowed.length, 1); - const denied = yield* sendFor(1, respondWithStatus(204), recordTwentyEvents, { - ...policy, - limits: { telemetry: false }, - }); - assert.deepEqual(denied, []); - }), - ); - - 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("shutdown stops waiting for a telemetry endpoint that never answers", () => - Effect.gen(function* () { - const scope = yield* Scope.make(); - const context = yield* Layer.buildWithScope( - analyticsLayerWith(HttpClient.make(() => Effect.never)), - scope, - ); - yield* recordTwentyEvents.pipe(Effect.provide(context)); - - const shutdown = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild); - yield* TestClock.adjust("1 second"); - yield* Fiber.join(shutdown); - }), - ); - - 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"), - ManagedPolicy.layerTest(), - ), - ), - 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"), - ManagedPolicy.layerTest(), - ), - ), - Layer.provideMerge(NodeHttpServer.layerTest), - ); - - yield* Effect.gen(function* () { - yield* Layer.launch(layerBatchServer).pipe(Effect.forkScoped); - const analytics = yield* AnalyticsService.AnalyticsService; - yield* analytics.record("test.disabled", { index: 1 }); - yield* analytics.flush; - }).pipe(Effect.provide(layerRuntime)); - - assert.deepEqual(capturedPaths, []); - }), - ); -}); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts deleted file mode 100644 index 2de4634ed7781..0000000000000 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ /dev/null @@ -1,365 +0,0 @@ -// 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"; -import * as ConfigProvider from "effect/ConfigProvider"; -import * as Context from "effect/Context"; -import * as SecureRandom from "effect/crypto/SecureRandom"; -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 * as ManagedPolicy from "../policy/ManagedPolicy.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 for the next background flush. -const SEND_TIMEOUT = "10 seconds"; -// Quit and restart wait for this flush; events it cannot send in time are dropped. -const SHUTDOWN_FLUSH_TIMEOUT = "1 second"; -// 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"), - ), - // Off unless opted in: the fork does not report to upstream by default. - enabled: Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(false)), - 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, - { - readonly record: ( - event: string, - properties?: Readonly>, - ) => Effect.Effect; - - 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", - ) => Effect.Effect; - } ->()("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 hostEnvironment = yield* HostProcess.Environment; - const awaitingShellConsent = hostEnvironment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING === "1"; - // A managed policy can switch analytics off over the environment, never on. - const policyAllows = - ManagedPolicy.deniedBy(yield* ManagedPolicy.ManagedPolicy, "telemetry") === undefined; - // A cached PATH does not establish consent from the current login shell. - const telemetryEnabled = Effect.suspend(() => { - if (!policyAllows) return Effect.succeed(false); - if (!awaitingShellConsent) return Effect.succeed(telemetryConfig.enabled); - if (hostEnvironment.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING === "1") - return Effect.succeed(false); - const preference = hostEnvironment.T3CODE_TELEMETRY_ENABLED; - return Config.Boolean("T3CODE_TELEMETRY_ENABLED").pipe( - Config.withDefault(false), - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnv({ - env: preference === undefined ? {} : { T3CODE_TELEMETRY_ENABLED: preference }, - }), - ), - Effect.orElseSucceed(() => false), - ); - }); - const httpClient = yield* HttpClient.HttpClient; - const serverConfig = yield* ServerConfig.ServerConfig; - const { distinctId, installId } = yield* getTelemetryIdentity; - const crypto = yield* SecureRandom.SecureRandom; - 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 (!(yield* telemetryEnabled) || !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 (!(yield* telemetryEnabled) || !distinctId) return; - - // Catch Node UUID defects too; best-effort telemetry drops events without UUIDs. - const uuid = yield* Effect.exit(crypto.uuidV7()); - 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.pipe(Effect.timeout(SHUTDOWN_FLUSH_TIMEOUT), Effect.ignore), - ); - - return AnalyticsService.of({ record, flush, recordOwners }); -}); - -export const layer = Layer.effect(AnalyticsService, make); diff --git a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts deleted file mode 100644 index b3fb782ae8343..0000000000000 --- a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Record one anonymous `mcp.delegated_task.finished` event per app-owned task reaching final status. - * - * @module DelegatedTaskAnalytics - */ -import type { OrchestrationV2DomainEvent } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; - -import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; -import { forkParked } from "../serverActivation.ts"; -import * as AnalyticsService from "./AnalyticsService.ts"; -import { crossProvider, providerDimensions } from "./ProviderDimensions.ts"; - -type SubagentEvent = Extract; - -const FINAL_STATUSES: ReadonlySet = new Set([ - "completed", - "failed", - "cancelled", - "interrupted", -]); - -const make = Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const analytics = yield* AnalyticsService.AnalyticsService; - const registry = yield* ProviderRegistry.ProviderRegistry; - // A task can be re-reported with the same final status; count it once per process. - const reported = new Set(); - - // Runs for every subagent update; most return early, so avoid a span per event. - const onSubagent = Effect.fnUntraced( - function* (event: SubagentEvent) { - const task = event.payload; - if (task.origin !== "app_owned" || !FINAL_STATUSES.has(task.status)) return; - if (reported.has(task.id)) return; - const parent = yield* orchestrator.getThreadShell(task.threadId); - const providers = yield* registry.getProviders; - const parentDimensions = - parent === null - ? { provider: "unknown" } - : providerDimensions(providers, parent.modelSelection); - const child = providerDimensions(providers, { - instanceId: task.providerInstanceId, - model: task.model ?? "", - }); - const cross = crossProvider(parentDimensions.provider, child.provider); - const durationSeconds = - task.startedAt !== null && task.completedAt !== null - ? Math.max( - 0, - Math.round( - (DateTime.toEpochMillis(task.completedAt) - - DateTime.toEpochMillis(task.startedAt)) / - 1000, - ), - ) - : undefined; - yield* analytics.record("mcp.delegated_task.finished", { - status: task.status, - callerProvider: parentDimensions.provider, - ...(parentDimensions.model === undefined ? {} : { callerModel: parentDimensions.model }), - targetProvider: child.provider, - ...(child.model === undefined ? {} : { targetModel: child.model }), - ...(cross === undefined ? {} : { crossProvider: cross }), - ...(task.completionWake === undefined ? {} : { completionWake: task.completionWake }), - ...(durationSeconds === undefined ? {} : { durationSeconds }), - }); - // Only after recording: a failed lookup leaves the next final update to report it. - reported.add(task.id); - }, - (effect) => effect.pipe(Effect.ignoreCause), - ); - - yield* forkParked( - Stream.runForEach(orchestrator.streamDomainEvents, (event) => - event.type === "subagent.updated" ? onSubagent(event) : Effect.void, - ), - ); -}); - -export const layer = Layer.effectDiscard(make); diff --git a/apps/server/src/telemetry/FeatureUsage.test.ts b/apps/server/src/telemetry/FeatureUsage.test.ts deleted file mode 100644 index 623538e92432f..0000000000000 --- a/apps/server/src/telemetry/FeatureUsage.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - ORCHESTRATION_V2_WS_METHODS, - OrchestrationV2Command, - WS_METHODS, -} from "@t3tools/contracts"; -import { expect, it } from "@effect/vitest"; -import * as Schema from "effect/Schema"; - -import { commandFeatureUsage, featureUsage } from "./FeatureUsage.ts"; - -const command = Schema.decodeUnknownSync(OrchestrationV2Command); -const ids = { commandId: "command-private", threadId: "thread-private" }; - -it("ignores messages, housekeeping commands, and untracked RPCs", () => { - expect( - commandFeatureUsage( - command({ - ...ids, - type: "message.dispatch", - messageId: "message-private", - text: "secret prompt", - attachments: [], - createdBy: "user", - creationSource: "web", - dispatchMode: { type: "start_immediately" }, - }), - ), - ).toBe(undefined); - expect( - commandFeatureUsage( - command({ ...ids, type: "thread.visit", visitedAt: "2026-10-24T00:00:00.000Z" }), - ), - ).toBe(undefined); - expect(featureUsage(WS_METHODS.terminalWrite, { data: "rm -rf" })).toBe(undefined); -}); - -it("leaves thread launches to client.thread.started", () => { - expect( - featureUsage(ORCHESTRATION_V2_WS_METHODS.launchThread, { - title: "Private title", - workspaceStrategy: { type: "worktree", baseRef: "main", branch: "secret-branch" }, - }), - ).toBe(undefined); -}); - -it("tells watching a pull request apart from unwatching it", () => { - const watch = (watching: boolean) => - commandFeatureUsage( - command({ - ...ids, - type: "thread.pull-request.watch", - host: "github.com", - repository: "owner/private", - number: 7, - watching, - }), - ); - expect(watch(true)).toEqual({ feature: "thread.pull_request.watch", variant: "on" }); - expect(watch(false)).toEqual({ feature: "thread.pull_request.watch", variant: "off" }); -}); - -it("reads RPC variants from enums, never from paths or text", () => { - expect( - featureUsage(WS_METHODS.shellOpenInEditor, { cwd: "/Users/someone/secret", editor: "cursor" }), - ).toEqual({ feature: "editor.open", variant: "cursor" }); - expect(featureUsage(WS_METHODS.terminalOpen, { cwd: "/Users/someone/secret" })).toEqual({ - feature: "terminal.open", - }); -}); - -it("counts the owner's guest share actions, and only guest message sends", () => { - const send = (guestMessageId?: string) => - commandFeatureUsage( - command({ - ...ids, - type: "message.dispatch", - messageId: "message-private", - text: "secret prompt", - attachments: [], - createdBy: "user", - creationSource: "web", - dispatchMode: { type: "start_immediately" }, - ...(guestMessageId === undefined ? {} : { guestMessageId }), - }), - ); - expect(send("guest-message-private")).toEqual({ feature: "guest_message.send" }); - expect(send()).toBe(undefined); - expect( - commandFeatureUsage( - command({ ...ids, type: "thread.guest-message.dismiss", guestMessageId: "guest-private" }), - ), - ).toEqual({ feature: "guest_message.dismiss" }); - expect(commandFeatureUsage(command({ ...ids, type: "thread.guest-share.close" }))).toEqual({ - feature: "guest_share.close", - }); -}); diff --git a/apps/server/src/telemetry/FeatureUsage.ts b/apps/server/src/telemetry/FeatureUsage.ts deleted file mode 100644 index e8f5a94993729..0000000000000 --- a/apps/server/src/telemetry/FeatureUsage.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { type OrchestrationV2Command, WS_METHODS } from "@t3tools/contracts"; - -export interface FeatureUsage { - readonly feature: string; - readonly variant?: string; -} - -type Payload = Readonly>; - -const literal = (payload: Payload, key: string) => - typeof payload[key] === "string" ? (payload[key] as string) : undefined; - -const use = (feature: string, variant?: string): FeatureUsage => - variant === undefined ? { feature } : { feature, variant }; - -/** - * Exclude housekeeping. `client.turn.requested` counts every `message.dispatch`; only sending a - * guest's message is also a feature. Record at command dispatch so retries do not count twice. - */ -const COMMAND_FEATURES: { - readonly [Type in OrchestrationV2Command["type"]]?: ( - command: Extract, - ) => FeatureUsage | undefined; -} = { - "message.dispatch": (command) => - command.guestMessageId === undefined ? undefined : { feature: "guest_message.send" }, - "checkpoint.rollback": (command) => ({ - feature: "checkpoint.rollback", - variant: command.restoreFiles === false ? "conversation_only" : "with_files", - }), - "thread.fork": () => ({ feature: "thread.fork" }), - "thread.merge_back": () => ({ feature: "thread.merge_back" }), - "provider.switch": () => ({ feature: "provider.switch" }), - "thread.archive": () => ({ feature: "thread.archive" }), - "thread.snooze": () => ({ feature: "thread.snooze" }), - "thread.pin": () => ({ feature: "thread.pin" }), - "thread.settle": () => ({ feature: "thread.settle" }), - "thread.pull-request.link": () => ({ feature: "thread.pull_request.link" }), - "thread.pull-request.watch": (command) => - use("thread.pull_request.watch", command.watching ? "on" : "off"), - "thread.runtime-mode.set": (command) => use("thread.runtime_mode.set", command.runtimeMode), - "thread.interaction-mode.set": (command) => - use("thread.interaction_mode.set", command.interactionMode), - "run.interrupt": () => ({ feature: "run.interrupt" }), - "queued-message.promote-to-steer": () => ({ feature: "queue.steer" }), - "queued-run.edit": () => ({ feature: "queue.edit" }), - "thread.guest-share.close": () => ({ feature: "guest_share.close" }), - "thread.guest-message.dismiss": () => ({ feature: "guest_message.dismiss" }), -}; - -/** - * RPCs that represent a user starting a feature. Variants come only from - * closed enums in the contracts, so no path, id, or free text can reach analytics. - * Thread launches are left out because `client.thread.started` counts them. - */ -const METHOD_FEATURES: Readonly FeatureUsage>> = { - [WS_METHODS.terminalOpen]: () => ({ feature: "terminal.open" }), - [WS_METHODS.previewOpen]: () => ({ feature: "preview.open" }), - [WS_METHODS.deviceOpen]: () => ({ feature: "device.open" }), - [WS_METHODS.shellOpenInEditor]: (payload) => use("editor.open", literal(payload, "editor")), - [WS_METHODS.vcsCreateWorktree]: () => ({ feature: "worktree.create" }), - [WS_METHODS.gitRunStackedAction]: (payload) => - use("git.stacked_action", literal(payload, "action")), - [WS_METHODS.pullRequestsRunAction]: (payload) => - use("pull_request.action", literal(payload, "action")), - [WS_METHODS.pullRequestsSubmitReview]: () => ({ feature: "pull_request.review" }), - [WS_METHODS.pullRequestsComment]: () => ({ feature: "pull_request.comment" }), - [WS_METHODS.scheduledTasksUpsert]: () => ({ feature: "scheduled_task.save" }), - [WS_METHODS.scheduledTasksRunNow]: () => ({ feature: "scheduled_task.run_now" }), - [WS_METHODS.agentSessionsImport]: () => ({ feature: "agent_session.import" }), - [WS_METHODS.projectCloneStart]: () => ({ feature: "project.clone" }), - [WS_METHODS.sourceControlPublishRepository]: () => ({ feature: "repository.publish" }), - [WS_METHODS.sourceControlSignInStart]: () => ({ feature: "source_control.sign_in" }), - [WS_METHODS.serverUpsertKeybinding]: () => ({ feature: "keybinding.customize" }), -}; - -/** The feature a dispatched command uses, or undefined when it is not a tracked feature. */ -export function commandFeatureUsage(command: OrchestrationV2Command): FeatureUsage | undefined { - // TypeScript cannot pair a union's tag with its own entry, so widen once here. - const mapping = COMMAND_FEATURES[command.type] as - | ((command: OrchestrationV2Command) => FeatureUsage | undefined) - | undefined; - return mapping?.(command); -} - -/** The feature a WebSocket request uses, or undefined when it is not a tracked feature. */ -export function featureUsage(method: string, payload: unknown): FeatureUsage | undefined { - if (typeof payload !== "object" || payload === null) return undefined; - return METHOD_FEATURES[method]?.(payload as Payload); -} diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts deleted file mode 100644 index ae781135a97ef..0000000000000 --- a/apps/server/src/telemetry/Identify.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as Digest from "effect/crypto/Digest"; -import * as Effect from "effect/Effect"; -import * as Hex from "effect/encoding/Hex"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as References from "effect/References"; - -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ServerConfig from "../config.ts"; -import * as Identify from "./Identify.ts"; - -interface CapturedLog { - readonly message: unknown; - readonly annotations: Readonly>; -} - -const sha256 = Effect.fn("test.sha256")(function* (value: string) { - const digest = yield* Digest.Digest; - return Hex.encode( - yield* digest.digest(Digest.sha256, new TextEncoder().encode(value)).pipe(Effect.orDie), - ); -}); - -const makeCaptureLogger = (logs: CapturedLog[]) => - Logger.make(({ fiber, message }) => { - logs.push({ - message, - annotations: fiber.getRef(References.CurrentLogAnnotations), - }); - }); - -const findIdentityLog = ( - logs: ReadonlyArray, - source: Identify.TelemetryIdentitySource, - errorTag: string, -) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); - -const unsignedJwt = (claims: Readonly>) => - ["e30", Buffer.from(JSON.stringify(claims)).toString("base64url"), "signature"].join("."); - -const layerIdentity = (prefix: string, logs?: CapturedLog[]) => - Layer.provideMerge( - ServerSecretStore.layer, - ServerConfig.layerTest(process.cwd(), { prefix }), - ).pipe( - Layer.merge( - logs === undefined - ? Layer.empty - : Logger.layer([makeCaptureLogger(logs)], { mergeWithExisting: false }), - ), - ); - -const writeHomeFile = Effect.fn("test.writeHomeFile")(function* ( - homeDirectory: string, - relativePath: string, - contents: string, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const filePath = path.join(homeDirectory, relativePath); - yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); - yield* fileSystem.writeFileString(filePath, contents); - return filePath; -}); - -it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("telemetry identity", (it) => { - it.effect("keeps the old distinct_id rule and a fixed install id as logins change", () => - 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 anonymousId = "persisted-anonymous-id"; - yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); - const installId = yield* sha256(anonymousId); - const identity = () => Identify.getTelemetryIdentityForHome(homeDirectory); - - assert.deepEqual(yield* identity(), { distinctId: installId, installId }); - - yield* writeHomeFile(homeDirectory, ".claude.json", `{"userID":"claude-machine"}`); - assert.deepEqual(yield* identity(), { - distinctId: yield* sha256("claude-machine"), - installId, - }); - - yield* writeHomeFile(homeDirectory, ".codex/auth.json", `{"auth_mode":"apikey"}`); - assert.equal((yield* identity()).distinctId, yield* sha256("claude-machine")); - - yield* writeHomeFile(homeDirectory, ".codex/auth.json", `{"tokens":{"account_id":"ws-1"}}`); - assert.deepEqual(yield* identity(), { distinctId: yield* sha256("ws-1"), installId }); - }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-install-"))), - ); - - it.effect("leaves no torn anonymous id behind when its write fails midway", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tornWrites = FileSystem.FileSystem.of({ - ...fileSystem, - writeFileString: (filePath, data, options) => - fileSystem - .writeFileString(filePath, data.slice(0, Math.floor(data.length / 2)), options) - .pipe( - Effect.andThen( - Effect.fail( - PlatformError.systemError({ - _tag: "WriteZero", - module: "FileSystem", - method: "writeFileString", - pathOrDescriptor: filePath, - }), - ), - ), - ), - }); - - const identity = yield* Identify.getTelemetryIdentityForHome( - path.join(config.baseDir, "home"), - ).pipe(Effect.provideService(FileSystem.FileSystem, tornWrites)); - - assert.deepEqual(identity, { distinctId: null, installId: null }); - assert.isFalse(yield* fileSystem.exists(config.anonymousIdPath)); - }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-torn-"))), - ); - - it.effect("does not overwrite the anonymous id path after a non-NotFound read failure", () => { - const logs: CapturedLog[] = []; - - return Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fileSystem.makeDirectory(config.anonymousIdPath); - - const identity = yield* Identify.getTelemetryIdentityForHome( - path.join(config.baseDir, "home"), - ); - assert.deepEqual(identity, { distinctId: null, installId: null }); - assert.deepEqual(yield* fileSystem.readDirectory(config.anonymousIdPath), []); - - const readLog = findIdentityLog(logs, "anonymous", "TelemetryIdentityReadError"); - assert.equal(readLog?.annotations.filePath, config.anonymousIdPath); - assert.notEqual(readLog?.annotations.platformReason, "NotFound"); - assert.notProperty(readLog?.annotations ?? {}, "cause"); - assert.isUndefined( - findIdentityLog(logs, "anonymous", "TelemetryAnonymousIdPersistenceError"), - ); - }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-read-", logs))); - }); - - it.effect("reads each provider account as an owner", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const path = yield* Path.Path; - const homeDirectory = path.join(config.baseDir, "home"); - yield* writeHomeFile( - homeDirectory, - ".codex/auth.json", - JSON.stringify({ - tokens: { - account_id: "team-workspace", - id_token: unsignedJwt({ - sub: "google-oauth2|login-method", - "https://api.openai.com/auth": { user_id: "user-openai-person" }, - }), - }, - }), - ); - yield* writeHomeFile( - homeDirectory, - ".claude.json", - JSON.stringify({ - userID: "claude-machine", - oauthAccount: { accountUuid: "claude-person" }, - }), - ); - - const owners = yield* Identify.getServerOwnersForHome(homeDirectory); - - assert.deepEqual(owners, [ - { source: "openai", ownerId: yield* sha256("user-openai-person") }, - { source: "claude", ownerId: yield* sha256("claude-person") }, - ]); - }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-owners-"))), - ); - - it.effect.each([ - { - login: "an API key", - secret: "sk-private-openai-api-key", - authJson: (secret: string) => `{"auth_mode":"apikey","OPENAI_API_KEY":"${secret}"}`, - }, - { - login: "an agent identity", - secret: "private-agent-identity-jwt", - authJson: (secret: string) => - `{"auth_mode":"agentIdentity","OPENAI_API_KEY":null,"agent_identity":"${secret}"}`, - }, - { - login: "a personal access token", - secret: "private-personal-access-token", - authJson: (secret: string) => `{"OPENAI_API_KEY":null,"personal_access_token":"${secret}"}`, - }, - ])( - "adds no owner and logs nothing when Codex authenticates with $login", - ({ secret, authJson }) => { - const logs: CapturedLog[] = []; - - return Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const path = yield* Path.Path; - const homeDirectory = path.join(config.baseDir, "home"); - yield* writeHomeFile(homeDirectory, ".codex/auth.json", authJson(secret)); - - assert.deepEqual(yield* Identify.getServerOwnersForHome(homeDirectory), []); - assert.deepEqual(logs, []); - }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-tokenless-", logs))); - }, - ); - - it.effect( - "warns about malformed Codex auth without its tokens and keeps the other owners", - () => { - const logs: CapturedLog[] = []; - - return Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const path = yield* Path.Path; - const homeDirectory = path.join(config.baseDir, "home"); - const privateAccessToken = "private-codex-access-token"; - const codexAuthPath = yield* writeHomeFile( - homeDirectory, - ".codex/auth.json", - `{"tokens":{"access_token":"${privateAccessToken}"}}`, - ); - yield* writeHomeFile( - homeDirectory, - ".claude.json", - `{"oauthAccount":{"accountUuid":"claude-person"}}`, - ); - - assert.deepEqual(yield* Identify.getServerOwnersForHome(homeDirectory), [ - { source: "claude", ownerId: yield* sha256("claude-person") }, - ]); - const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"); - assert.equal( - decodeLog?.message, - `Failed to decode codex telemetry identity at '${codexAuthPath}'.`, - ); - assert.equal(decodeLog?.annotations.causeKind, "schema"); - assert.notInclude( - Object.values(decodeLog?.annotations ?? {}) - .map(String) - .join("\n"), - privateAccessToken, - ); - }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-decode-", logs))); - }, - ); -}); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts deleted file mode 100644 index 4f4cd00cc0991..0000000000000 --- a/apps/server/src/telemetry/Identify.ts +++ /dev/null @@ -1,328 +0,0 @@ -import * as SecureRandom from "effect/crypto/SecureRandom"; -import * as Effect from "effect/Effect"; -import * as Base64Url from "effect/encoding/Base64Url"; -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 Result from "effect/Result"; -import * as Schema from "effect/Schema"; - -import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import { hashTelemetryId } from "@t3tools/shared/telemetryId"; -import * as ServerConfig from "../config.ts"; - -/** - * Codex writes `tokens` only for ChatGPT logins and omits the key for API-key, - * agent-identity, and personal-access-token logins, so its absence is a - * supported install and not a malformed file. `account_id` is the ChatGPT - * workspace, which a whole Team shares; the `id_token` names the person. - */ -const CodexAuthJsonSchema = Schema.Struct({ - tokens: Schema.optionalKey( - Schema.Struct({ - account_id: Schema.String, - id_token: Schema.optionalKey(Schema.String), - }), - ), -}); - -/** `userID` is random per machine. `oauthAccount` is present only while Claude Code is logged in. */ -const ClaudeJsonSchema = Schema.Struct({ - userID: Schema.optionalKey(Schema.String), - oauthAccount: Schema.optionalKey( - Schema.Struct({ - accountUuid: Schema.optionalKey(Schema.String), - }), - ), -}); - -// `sub` names the login method (`google-oauth2|…`), so one person can have several. -// `user_id` (`user-…`) is the OpenAI account and is the same for every login. -const IdTokenClaims = Schema.Struct({ - "https://api.openai.com/auth": Schema.Struct({ user_id: Schema.NonEmptyString }), -}); -const decodeIdTokenClaims = Schema.decodeUnknownOption(Schema.fromJsonString(IdTokenClaims)); - -/** - * Who an install belongs to, from strongest to weakest. `connect` was the T3 - * Connect account, which older clients may still report. `openai` and `claude` - * are provider accounts. `device` is a client device that connected. - */ -export const TelemetryOwnerSource = Schema.Literals(["connect", "openai", "claude", "device"]); -export type TelemetryOwnerSource = typeof TelemetryOwnerSource.Type; - -export interface TelemetryOwner { - readonly source: TelemetryOwnerSource; - /** `hashTelemetryId` of the source's raw id. */ - readonly ownerId: string; -} - -export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); -export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; - -class TelemetryIdentityReadError extends Schema.TaggedError()( - "TelemetryIdentityReadError", - { - source: TelemetryIdentitySource, - filePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`; - } -} - -class TelemetryIdentityDecodeError extends Schema.TaggedError()( - "TelemetryIdentityDecodeError", - { - source: Schema.Literals(["codex", "claude"]), - filePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to decode ${this.source} telemetry identity at '${this.filePath}'.`; - } -} - -export class TelemetryAnonymousIdGenerationError extends Schema.TaggedError()( - "TelemetryAnonymousIdGenerationError", - { - source: Schema.Literal("anonymous"), - filePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to generate anonymous telemetry identity for '${this.filePath}'.`; - } -} - -export class TelemetryAnonymousIdPersistenceError extends Schema.TaggedError()( - "TelemetryAnonymousIdPersistenceError", - { - source: Schema.Literal("anonymous"), - filePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to persist anonymous telemetry identity at '${this.filePath}'.`; - } -} - -type TelemetryIdentityError = - | TelemetryIdentityReadError - | TelemetryIdentityDecodeError - | TelemetryAnonymousIdGenerationError - | TelemetryAnonymousIdPersistenceError; - -const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)); -const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)); - -function isNotFoundError(error: PlatformError.PlatformError): boolean { - return error.reason._tag === "NotFound"; -} - -const getTelemetryIdentityCauseAnnotations = (cause: unknown) => { - if (cause instanceof PlatformError.PlatformError) { - return { - causeKind: "platform", - platformReason: cause.reason._tag, - }; - } - if (cause instanceof Schema.SchemaError) { - return { causeKind: "schema" }; - } - return { causeKind: "other" }; -}; - -const logTelemetryIdentityError = (error: TelemetryIdentityError) => - Effect.logWarning(error.message).pipe( - Effect.annotateLogs({ - errorTag: error._tag, - source: error.source, - ...("filePath" in error ? { filePath: error.filePath } : {}), - ...getTelemetryIdentityCauseAnnotations(error.cause), - ...(error.stack === undefined ? {} : { errorStack: error.stack }), - }), - ); - -const readIdentityFile = ( - fileSystem: FileSystem.FileSystem, - source: TelemetryIdentitySource, - filePath: string, -) => - fileSystem.readFileString(filePath).pipe( - Effect.asSome, - Effect.catchTags({ - PlatformError: (cause) => - isNotFoundError(cause) - ? Effect.succeed(Option.none()) - : Effect.fail( - new TelemetryIdentityReadError({ - source, - filePath, - cause, - }), - ), - }), - ); - -/** - * The OpenAI account id in a ChatGPT id_token, read without verifying it: - * analytics needs only a stable id. Codex CLI and T3's own sign-in both use it. - */ -export const openAiUserId = (idToken: string) => { - const payload = Base64Url.decodeString(idToken.split(".")[1] ?? ""); - return Result.isSuccess(payload) - ? Option.map( - decodeIdTokenClaims(payload.success), - (claims) => claims["https://api.openai.com/auth"].user_id, - ) - : Option.none(); -}; - -const readJsonIdentityFile = Effect.fn("TelemetryIdentity.readJsonIdentityFile")(function* ( - source: "codex" | "claude", - filePath: string, - decode: (encoded: string) => Effect.Effect, -) { - const fileSystem = yield* FileSystem.FileSystem; - const encoded = yield* readIdentityFile(fileSystem, source, filePath); - if (Option.isNone(encoded)) { - return Option.none(); - } - return yield* decode(encoded.value).pipe( - Effect.asSome, - Effect.mapError((cause) => new TelemetryIdentityDecodeError({ source, filePath, cause })), - ); -}); - -// A missing or unreadable file only removes that owner; it never blocks the others. -const skipOnError = ( - effect: Effect.Effect< - Option.Option, - TelemetryIdentityReadError | TelemetryIdentityDecodeError, - R - >, -) => - effect.pipe( - Effect.catch((error) => logTelemetryIdentityError(error).pipe(Effect.as(Option.none()))), - ); - -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* SecureRandom.SecureRandom.pipe( - Effect.flatMap((crypto) => crypto.uuidV4()), - Effect.mapError( - (cause) => - new TelemetryAnonymousIdGenerationError({ - source: "anonymous", - filePath: anonymousIdPath, - cause, - }), - ), - ); - yield* writeFileStringAtomically({ filePath: anonymousIdPath, contents: anonymousId }).pipe( - Effect.mapError( - (cause) => - new TelemetryAnonymousIdPersistenceError({ - source: "anonymous", - filePath: anonymousIdPath, - cause, - }), - ), - ); - - return anonymousId; -}); - -const readProviderLogins = Effect.fn("TelemetryIdentity.readProviderLogins")(function* ( - homeDirectory: string, -) { - const path = yield* Path.Path; - const codexTokens = yield* readJsonIdentityFile( - "codex", - path.join(homeDirectory, ".codex", "auth.json"), - decodeCodexAuthJson, - ).pipe( - skipOnError, - Effect.map(Option.flatMap((authJson) => Option.fromUndefinedOr(authJson.tokens))), - ); - const claudeJson = yield* readJsonIdentityFile( - "claude", - path.join(homeDirectory, ".claude.json"), - decodeClaudeJson, - ).pipe(skipOnError); - return { codexTokens, claudeJson }; -}); - -/** - * `distinctId` preserves legacy priority: hashed Codex account_id, Claude userID, then anonymous id. - * It can change on login or be shared by a Codex Team; hashed `installId` stays stable per install. - * A null `distinctId` disables telemetry. Missing files and API-key-only Codex auth fall through; - * only unreadable or malformed files warn. - */ -export const getTelemetryIdentityForHome = Effect.fn("getTelemetryIdentityForHome")(function* ( - homeDirectory: string, -) { - const { codexTokens, claudeJson } = yield* readProviderLogins(homeDirectory); - const installId = yield* upsertAnonymousId.pipe( - Effect.map(hashTelemetryId), - Effect.catch((error) => logTelemetryIdentityError(error).pipe(Effect.as(null))), - ); - const providerId = Option.orElse( - Option.map(codexTokens, (tokens) => tokens.account_id), - () => Option.flatMap(claudeJson, (json) => Option.fromUndefinedOr(json.userID)), - ); - return { - distinctId: Option.isSome(providerId) ? hashTelemetryId(providerId.value) : installId, - installId, - }; -}); - -export const getTelemetryIdentity = Effect.flatMap( - HostProcess.HomeDirectory, - getTelemetryIdentityForHome, -); - -/** - * Owners the server can read without a client: the provider logins under - * `homeDirectory`. Clients add `device` owners when they connect. - */ -export const getServerOwnersForHome = Effect.fn("getServerOwnersForHome")(function* ( - homeDirectory: string, -) { - const { codexTokens, claudeJson } = yield* readProviderLogins(homeDirectory); - - const raw: ReadonlyArray]> = [ - [ - "openai", - Option.flatMap(codexTokens, (tokens) => - Option.flatMap(Option.fromUndefinedOr(tokens.id_token), openAiUserId), - ), - ], - [ - "claude", - Option.flatMap(claudeJson, (json) => Option.fromUndefinedOr(json.oauthAccount?.accountUuid)), - ], - ]; - return raw.flatMap(([source, value]): ReadonlyArray => - Option.isSome(value) && value.value.trim() !== "" - ? [{ source, ownerId: hashTelemetryId(value.value) }] - : [], - ); -}); - -export const getServerOwners = Effect.flatMap(HostProcess.HomeDirectory, getServerOwnersForHome); diff --git a/apps/server/src/telemetry/ProviderDimensions.test.ts b/apps/server/src/telemetry/ProviderDimensions.test.ts deleted file mode 100644 index d88010f600e35..0000000000000 --- a/apps/server/src/telemetry/ProviderDimensions.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ProviderInstanceId, ThreadId, type ServerProvider } from "@t3tools/contracts"; -import { expect, it } from "@effect/vitest"; - -import { type AgentThread, agentToolProperties, handoffSettings } from "./ProviderDimensions.ts"; - -const provider = ( - instanceId: string, - driver: string, - models: ReadonlyArray<{ slug: string; isCustom: boolean }>, -) => - ({ - instanceId: ProviderInstanceId.make(instanceId), - driver, - models, - }) as unknown as ServerProvider; - -const providers = [ - provider("work-codex", "codex", [{ slug: "gpt-5.5", isCustom: false }]), - provider("claudeAgent", "claudeAgent", [ - { slug: "claude-opus-5-5", isCustom: false }, - { slug: "my-private-finetune", isCustom: true }, - ]), - provider("opencode", "opencode", [{ slug: "ollama/acme-internal", isCustom: false }]), -]; - -const thread = ( - instanceId: string, - model: string, - overrides: Partial = {}, -): AgentThread => ({ - modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model }, - runtimeMode: "full-access", - interactionMode: "default", - createdBy: "user", - creationSource: "web", - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: ThreadId.make("t") }, - ...overrides, -}); - -it("omits custom, user-configured, and unknown models and instance names", () => { - expect( - agentToolProperties({ - tool: "t3_thread_send", - providers, - caller: thread("claudeAgent", "my-private-finetune"), - targets: [thread("opencode", "ollama/acme-internal"), thread("removed-instance", "gpt-5.5")], - outcome: { outcome: "ok" }, - }).map(({ callerProvider, callerModel, targetProvider, targetModel }) => ({ - callerProvider, - callerModel, - targetProvider, - targetModel, - })), - ).toEqual([ - { - callerProvider: "claudeAgent", - callerModel: undefined, - targetProvider: "opencode", - targetModel: undefined, - }, - { - callerProvider: "claudeAgent", - callerModel: undefined, - targetProvider: "unknown", - targetModel: undefined, - }, - ]); -}); - -it("drops input strings outside the schema's literals, since rejected calls are recorded too", () => { - expect(handoffSettings("delegate_task", { mode: "/home/me/secret" }, undefined)).toEqual({ - targetChosen: false, - mode: "async", - }); - expect( - handoffSettings("t3_thread_launch", { workspaceStrategy: { type: "/home/me/secret" } }, {}), - ).toEqual({ targetChosen: false, workspace: "root" }); -}); diff --git a/apps/server/src/telemetry/ProviderDimensions.ts b/apps/server/src/telemetry/ProviderDimensions.ts deleted file mode 100644 index f81a22e844aef..0000000000000 --- a/apps/server/src/telemetry/ProviderDimensions.ts +++ /dev/null @@ -1,250 +0,0 @@ -import type { - ModelSelection, - OrchestrationV2ThreadShell, - ServerProvider, -} from "@t3tools/contracts"; - -import { BUNDLED_MODEL_MANIFEST } from "../provider/ModelManifest.ts"; - -/** - * Drivers whose model catalogs are published by the vendor. Other drivers - * (OpenCode, Pi, ACP agents) list models from the user's own configuration, - * such as local Ollama tags, so their model names stay out of analytics. - */ -const VENDOR_CATALOG_DRIVERS: ReadonlySet = new Set([ - "codex", - "claudeAgent", - "cursor", - "grok", - "antigravity", -]); - -export interface ProviderDimensions { - readonly provider: string; - readonly model?: string; -} - -/** - * Anonymous provider and model for a model selection. Instance ids are - * user-defined, so only the driver kind is reported. The model is reported - * only when it comes from a vendor catalog and is not a user-added custom model. - */ -export function providerDimensions( - providers: ReadonlyArray, - selection: Pick, -): ProviderDimensions { - const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId); - if (provider === undefined) return { provider: "unknown" }; - const model = provider.models.find((candidate) => candidate.slug === selection.model); - return VENDOR_CATALOG_DRIVERS.has(provider.driver) && model !== undefined && !model.isCustom - ? { provider: provider.driver, model: model.slug } - : { provider: provider.driver }; -} - -const DATED_SNAPSHOT = /-\d{8}$/u; - -// The bundled manifest is reviewed in this repo. Codex lists whatever its configured -// model provider serves, so only the manifest vouches for Codex, Claude, and Antigravity. -const MANIFEST_LABELS: ReadonlyMap = new Map( - Object.values(BUNDLED_MODEL_MANIFEST.providers ?? {}).flatMap((catalog) => - catalog.models.flatMap((model) => - [model.slug, ...(model.aliases ?? [])].map((name) => [name, model.slug] as const), - ), - ), -); -const REGISTRY_LABEL_DRIVERS: ReadonlySet = new Set(["cursor", "grok"]); - -/** - * The vendor catalog slug that a native or routed model id names, such as - * `anthropic/claude-opus-5-5` or a dated snapshot. Models outside the bundled - * manifest and the Cursor and Grok catalogs, including user-added ones, return undefined. - */ -export function catalogModelLabel( - providers: ReadonlyArray, - model: string, -): string | undefined { - const leaf = model.slice(model.lastIndexOf("/") + 1); - const candidates = [model, leaf, leaf.replace(DATED_SNAPSHOT, "")]; - for (const candidate of candidates) { - const label = MANIFEST_LABELS.get(candidate); - if (label !== undefined) return label; - } - for (const provider of providers) { - if (!REGISTRY_LABEL_DRIVERS.has(provider.driver)) continue; - const match = provider.models.find( - (entry) => !entry.isCustom && candidates.includes(entry.slug), - ); - if (match !== undefined) return match.slug; - } - return undefined; -} - -/** The thread fields agent analytics reads. Ids are used for lookups, never reported. */ -export type AgentThread = Pick< - OrchestrationV2ThreadShell, - "modelSelection" | "runtimeMode" | "interactionMode" | "createdBy" | "creationSource" | "lineage" ->; - -type Fields = Readonly>; - -const asFields = (value: unknown): Fields => - typeof value === "object" && value !== null ? (value as Fields) : {}; - -const stringField = (fields: Fields, key: string) => - typeof fields[key] === "string" ? (fields[key] as string) : undefined; - -/** A string from the tool input only when it is one of the schema's literals. */ -const oneOf = (value: string | undefined, allowed: ReadonlyArray) => - value !== undefined && allowed.includes(value) ? value : undefined; - -/** - * Whether two resolved providers differ. Unresolved selections compare as - * unknown rather than as a cross-provider handoff. - */ -export const crossProvider = (caller: string, target: string) => - caller === "unknown" || target === "unknown" ? undefined : caller !== target; - -const defined = (entries: Readonly>) => - Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined)); - -/** - * How deep a thread sits in a delegation tree: 0 for a top-level thread, 1 for - * its subagent, and so on. Forks are top-level threads of their own. Depth is - * counted by walking parents, capped so a corrupt lineage cannot loop. - */ -export const MAX_REPORTED_DEPTH = 5; - -/** - * Who started the work that made this call. Scheduled runs keep the task - * creator's provenance, so the run's message marks them instead. - */ -function callerOrigin(input: { - readonly thread: Pick; - readonly scheduledRun: boolean; -}) { - if (input.scheduledRun) return "scheduler"; - if (input.thread.createdBy === "agent") return "agent"; - return input.thread.createdBy === "system" ? "system" : "user"; -} - -/** - * Settings the agent chose for a handoff, read only from closed enums and - * booleans in the tool's arguments and result. Prompts, titles, and ids are - * never read. - */ -export function handoffSettings(tool: string, args: unknown, result: unknown): Fields { - const input = asFields(args); - const output = asFields(result); - switch (tool) { - case "delegate_task": - return defined({ - targetChosen: input.target !== undefined, - // Rejected calls are recorded too, so input strings must match a literal. - mode: oneOf(stringField(input, "mode"), ["async", "wait"]) ?? "async", - ...(output.waitTimedOut === true ? { waitTimedOut: true } : {}), - }); - case "create_threads": { - const threads = Array.isArray(input.threads) ? input.threads : []; - return { - batchSize: threads.length, - targetChosen: threads.some((thread) => asFields(thread).target !== undefined), - }; - } - case "t3_thread_launch": - return defined({ - targetChosen: input.modelSelection !== undefined, - workspace: - input.scratch === true - ? "scratch" - : (oneOf(stringField(asFields(input.workspaceStrategy), "type"), [ - "root", - "worktree", - "existing_worktree", - ]) ?? "root"), - }); - case "t3_thread_send": - return defined({ delivery: stringField(output, "delivery") }); - case "t3_thread_send_attachments": - return { delivery: "auto" }; - case "schedule_task": - return { bindToCurrentThread: input.bindToCurrentThread !== false }; - default: - return {}; - } -} - -/** Outcome of a tool call, with the stable failure code when the call failed. */ -export function toolOutcome( - result: - | { readonly isError?: boolean | undefined; readonly structuredContent?: unknown } - | undefined, -) { - if (result === undefined) return { outcome: "error", errorCode: "exception" }; - const content = asFields(result.structuredContent); - // T3 tool failures are declared errors whose structured content carries a - // closed `code`; the MCP layer does not always set isError for them. - const code = stringField(content, "code") ?? stringField(asFields(content.error), "_tag"); - if (result.isError === true || (content._tag !== undefined && code !== undefined)) { - return { outcome: "error", errorCode: code ?? "unknown" }; - } - return { outcome: "ok" }; -} - -/** - * Event properties for an agent tool call: the calling agent, and when the call - * handed work to other threads, the agents running them. Each target counts - * once, so a create_threads batch on two providers reports both. - */ -export function agentToolProperties(input: { - readonly tool: string; - readonly providers: ReadonlyArray; - readonly caller: AgentThread | undefined; - readonly callerProviderInstanceId?: ModelSelection["instanceId"]; - readonly callerDepth?: number; - readonly callerScheduledRun?: boolean; - readonly targets: ReadonlyArray; - readonly outcome: Fields; - readonly settings?: Fields; - readonly durationMs?: number; -}): ReadonlyArray>> { - const caller = - input.caller !== undefined - ? providerDimensions(input.providers, input.caller.modelSelection) - : input.callerProviderInstanceId !== undefined - ? { - provider: providerDimensions(input.providers, { - instanceId: input.callerProviderInstanceId, - model: "", - }).provider, - } - : { provider: "unknown" }; - const base = defined({ - tool: input.tool, - callerProvider: caller.provider, - callerModel: caller.model, - callerOrigin: - input.caller === undefined - ? undefined - : callerOrigin({ - thread: input.caller, - scheduledRun: input.callerScheduledRun === true, - }), - callerDepth: - input.callerDepth === undefined ? undefined : Math.min(input.callerDepth, MAX_REPORTED_DEPTH), - durationMs: input.durationMs, - ...input.outcome, - ...input.settings, - }); - if (input.targets.length === 0) return [base]; - return input.targets.map((thread) => { - const target = providerDimensions(input.providers, thread.modelSelection); - return defined({ - ...base, - targetProvider: target.provider, - targetModel: target.model, - targetRuntimeMode: thread.runtimeMode, - targetInteractionMode: thread.interactionMode, - crossProvider: crossProvider(caller.provider, target.provider), - }); - }); -} diff --git a/apps/server/src/telemetry/ProviderUsageAnalytics.test.ts b/apps/server/src/telemetry/ProviderUsageAnalytics.test.ts deleted file mode 100644 index bd0a7c7da64b9..0000000000000 --- a/apps/server/src/telemetry/ProviderUsageAnalytics.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { - NodeId, - ProviderInstanceId, - ProviderThreadId, - ProviderTurnId, - RunId, - type OrchestrationV2ProviderTurn, - type ServerProvider, - type TurnTokenUsage, -} from "@t3tools/contracts"; -import { describe, expect, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; -import * as AnalyticsService from "./AnalyticsService.ts"; -import { catalogModelLabel } from "./ProviderDimensions.ts"; -import { - ProviderTurnAnalytics, - RequestUsageLedger, - layer, - providerTurnProperties, - type ProviderTurnAnalyticsContext, - type ProviderTurnSegment, -} from "./ProviderUsageAnalytics.ts"; - -const providers = [ - // Codex lists whatever its configured model provider serves, including private deployments. - { driver: "codex", models: [{ slug: "gpt-6-sol" }, { slug: "acme-azure-deployment" }] }, - { driver: "claudeAgent", models: [{ slug: "claude-opus-5-5" }, { slug: "claude-haiku-4-5" }] }, - { driver: "opencode", models: [{ slug: "ollama/acme-internal" }] }, -].map( - ({ driver, models }) => - ({ - instanceId: ProviderInstanceId.make(driver), - driver, - models: models.map((model) => ({ ...model, isCustom: false })), - }) as unknown as ServerProvider, -); -const label = (model: string) => catalogModelLabel(providers, model); - -const at = (ms: number) => DateTime.makeUnsafe(ms); -const complete = (input: number, output: number, extra: Partial = {}) => - ({ - usageStatus: "complete", - usageScope: "main_agent", - hasSubagents: false, - inputTokens: input, - outputTokens: output, - ...extra, - }) as TurnTokenUsage; - -const turn = ( - id: string, - usage: TurnTokenUsage | undefined, - status: OrchestrationV2ProviderTurn["status"] = "completed", -): OrchestrationV2ProviderTurn => ({ - id: ProviderTurnId.make(id), - providerThreadId: ProviderThreadId.make("provider-thread"), - nodeId: NodeId.make(`node:${id}`), - runAttemptId: null, - nativeTurnRef: null, - ordinal: 1, - status, - startedAt: at(1_000), - completedAt: at(4_000), - ...(usage === undefined ? {} : { turnTokenUsage: usage }), -}); - -const context = ( - runId: string, - segment: ProviderTurnSegment, - model = "gpt-6-sol", -): ProviderTurnAnalyticsContext => ({ - runId: RunId.make(runId), - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model, - options: [ - { id: "reasoningEffort", value: "xhigh" }, - { id: "serviceTier", value: "priority" }, - ], - }, - runtimeMode: "full-access", - interactionMode: "default", - origin: { createdBy: "user", creationSource: "web", scheduled: false, subagent: false }, - segment, -}); - -const settled = (runId: string, childrenRunning = false) => ({ - runId: RunId.make(runId), - status: "completed" as const, - completedAt: at(9_000), - childrenRunning, -}); - -describe("provider usage reporting", () => { - it.effect( - "records one request across a continuation, a child, a replay, and a late child", - () => { - const recorded: Array = []; - const layerTest = layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(AnalyticsService.AnalyticsService)({ - record: (event, properties) => - Effect.sync(() => void recorded.push([event, properties])), - }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed(providers), - }), - ), - ), - ); - return Effect.gen(function* () { - const analytics = yield* ProviderTurnAnalytics; - const root = context("run-a", "initial"); - yield* analytics.runStarted({ runId: RunId.make("run-a"), driver: "codex", context: root }); - const first = { driver: "codex", context: root }; - const firstTurn = turn("root", complete(100, 10, { models: ["gpt-6-sol"] })); - yield* analytics.turnCompleted({ ...first, providerTurn: firstTurn }); - yield* analytics.turnCompleted({ ...first, providerTurn: firstTurn }); - yield* analytics.turnCompleted({ - driver: "codex", - context: context("run-a", "continuation"), - providerTurn: turn("goal", complete(50, 5, { models: ["gpt-6-sol"] })), - }); - yield* analytics.turnCompleted({ - driver: "codex", - context: context("run-a", "native_child"), - providerTurn: turn("child", complete(20, 2)), - }); - yield* analytics.runSettled(settled("run-a")); - yield* analytics.turnCompleted({ - driver: "codex", - context: context("run-a", "native_child"), - providerTurn: turn("late-child", complete(7, 1)), - }); - yield* analytics.runSettled(settled("run-a")); - yield* analytics.runStarted({ - runId: RunId.make("run-b"), - driver: "codex", - context: context("run-b", "initial"), - }); - yield* analytics.runSettled(settled("run-b")); - - const requests = recorded.filter(([event]) => event === "provider.request.completed"); - expect(recorded.filter(([event]) => event === "provider.turn.completed")).toHaveLength(5); - expect(requests).toHaveLength(2); - // The whole payload: no run, turn, or thread ids, prompts, or raw model names. - expect(requests[0]?.[1]).toEqual({ - reportVersion: 2, - provider: "codex", - requestKind: "user", - terminalStatus: "completed", - lifecycleObserved: true, - model: "gpt-6-sol", - effort: "xhigh", - serviceTier: "fast", - runtimeMode: "full-access", - interactionMode: "default", - createdBy: "user", - creationSource: "web", - scheduled: false, - subagent: false, - providerTurns: 2, - continuations: 1, - steeringRestarts: 0, - usageStatus: "complete", - inputTokens: 150, - outputTokens: 15, - nativeChildTurns: 1, - childUsageStatus: "complete", - childInputTokens: 20, - childOutputTokens: 2, - durationMs: 8_000, - }); - expect(requests[1]?.[1]).toMatchObject({ providerTurns: 0, usageStatus: "unavailable" }); - }).pipe(Effect.provide(layerTest)); - }, - ); - - it("keeps a request partial when its start, a steered segment, or a counter is missing", () => { - const ledger = new RequestUsageLedger(); - ledger.start(RunId.make("restarted"), "codex", { - ...context("restarted", "initial"), - restartContinuation: true, - }); - ledger.add({ - driver: "codex", - context: context("restarted", "initial"), - providerTurn: turn("after-restart", complete(10, 1)), - }); - expect(ledger.settle(settled("restarted"), label)).toMatchObject({ - restartContinuation: true, - lifecycleObserved: false, - usageStatus: "partial", - inputTokens: 10, - }); - - ledger.start(RunId.make("steered"), "claudeAgent", context("steered", "initial")); - ledger.add({ - driver: "claudeAgent", - context: context("steered", "initial"), - providerTurn: turn( - "steered-away", - { usageStatus: "partial", usageScope: "main_agent", hasSubagents: false, outputTokens: 4 }, - "interrupted", - ), - }); - ledger.add({ - driver: "claudeAgent", - context: context("steered", "steering_restart"), - providerTurn: turn("steered-to", complete(30, 6)), - }); - expect(ledger.settle(settled("steered"), label)).toMatchObject({ - usageStatus: "partial", - steeringRestarts: 1, - inputTokens: 30, - outputTokens: 10, - }); - - ledger.start(RunId.make("silent"), "grok", context("silent", "initial")); - ledger.add({ - driver: "grok", - context: context("silent", "initial"), - providerTurn: turn("silent", undefined), - }); - const silent = ledger.settle(settled("silent", true), label); - expect(silent).toMatchObject({ - usageStatus: "unavailable", - childrenRunning: true, - childUsageStatus: "unavailable", - }); - expect(silent).not.toHaveProperty("outputTokens"); - }); - - it("confirms only the models a provider reported", () => { - const opus = context("models", "initial", "claude-opus-5-5"); - const properties = (usage: TurnTokenUsage | undefined) => - providerTurnProperties( - { driver: "claudeAgent", context: opus, providerTurn: turn("t", usage) }, - label, - ); - expect(properties(complete(1, 1, { models: ["claude-haiku-4-5-20251001"] }))).toMatchObject({ - model: "claude-opus-5-5", - observedModel: "claude-haiku-4-5", - mixedModels: false, - }); - const unconfirmed = properties(complete(1, 1)); - expect(unconfirmed).toMatchObject({ model: "claude-opus-5-5" }); - expect(unconfirmed).not.toHaveProperty("mixedModels"); - expect(properties(complete(1, 1, { models: ["a", "b"] }))).toMatchObject({ mixedModels: true }); - - const ledger = new RequestUsageLedger(); - ledger.start(RunId.make("mixed"), "codex", context("mixed", "initial")); - ledger.add({ - driver: "codex", - context: context("mixed", "initial"), - providerTurn: turn("root", complete(1, 1, { models: ["gpt-6-sol"] })), - }); - ledger.add({ - driver: "codex", - context: context("mixed", "native_child"), - providerTurn: turn("child", complete(1, 1, { models: ["gpt-6-luna"] })), - }); - expect(ledger.settle(settled("mixed"), label)).toMatchObject({ mixedModels: true }); - }); - - it("keeps zero, absent, local-command, and ledger usage distinct", () => { - const properties = (usage: TurnTokenUsage | undefined) => - providerTurnProperties( - { driver: "claudeAgent", context: context("z", "initial"), providerTurn: turn("t", usage) }, - label, - ); - expect(properties(complete(0, 0))).toMatchObject({ - usageStatus: "complete", - usageReported: true, - inputTokens: 0, - outputTokens: 0, - }); - const absent = properties(undefined); - expect(absent).toMatchObject({ usageStatus: "unavailable", usageReported: false }); - expect(absent).not.toHaveProperty("outputTokens"); - expect( - properties( - complete(0, 0, { localCommand: true, otherUsage: { inputTokens: 900, outputTokens: 80 } }), - ), - ).toMatchObject({ localCommand: true, otherInputTokens: 900, otherOutputTokens: 80 }); - expect(properties(complete(74_167, 236, { reasoningTokens: 30 }))).toMatchObject({ - outputTokens: 236, - reasoningTokens: 30, - }); - }); - - it("reports routed catalog models and closed efforts only", () => { - const routed = (model: string, variant: string) => - providerTurnProperties( - { - driver: "opencode", - context: { - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model, - options: [{ id: "variant", value: variant }], - }, - }, - providerTurn: turn("t", complete(1, 1, { models: [model] })), - }, - label, - ); - expect(routed("my-proxy/claude-opus-5-5", "high")).toMatchObject({ - model: "claude-opus-5-5", - observedModel: "claude-opus-5-5", - effort: "high", - }); - for (const local of [ - routed("ollama/acme-internal", "secret-variant"), - routed("acme-azure-deployment", "high"), - ]) { - expect(local).not.toHaveProperty("model"); - expect(local).not.toHaveProperty("observedModel"); - } - expect(routed("ollama/acme-internal", "secret-variant")).not.toHaveProperty("effort"); - }); - - it("reports closed service tiers from a tier option or a fast mode toggle", () => { - const tier = (option: { id: string; value: string | boolean }) => - providerTurnProperties( - { - driver: "codex", - context: { - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-6-sol", - options: [option], - }, - }, - providerTurn: turn("t", complete(1, 1)), - }, - label, - ).serviceTier; - expect(tier({ id: "serviceTier", value: "ultrafast" })).toBe("ultrafast"); - expect(tier({ id: "serviceTier", value: "default" })).toBe("standard"); - expect(tier({ id: "serviceTier", value: "/home/me/secret" })).toBeUndefined(); - expect(tier({ id: "fastMode", value: true })).toBe("fast"); - expect(tier({ id: "fastMode", value: false })).toBe("standard"); - }); -}); diff --git a/apps/server/src/telemetry/ProviderUsageAnalytics.ts b/apps/server/src/telemetry/ProviderUsageAnalytics.ts deleted file mode 100644 index ce7f29ba1f8d5..0000000000000 --- a/apps/server/src/telemetry/ProviderUsageAnalytics.ts +++ /dev/null @@ -1,427 +0,0 @@ -/** - * Provider token reporting: one `provider.turn.completed` per provider turn and one - * `provider.request.completed` per run, built from the same turns so each counts once. - * - * @module ProviderUsageAnalytics - */ -import type { - ModelSelection, - OrchestrationV2Actor, - OrchestrationV2CreationSource, - OrchestrationV2ProviderTurn, - OrchestrationV2RunAttempt, - ProviderInteractionMode, - RunId, - RuntimeMode, - TurnTokenUsage, -} from "@t3tools/contracts"; -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 { - getModelSelectionBooleanOptionValue, - getModelSelectionStringOptionValue, -} from "@t3tools/shared/model"; -import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; -import * as AnalyticsService from "./AnalyticsService.ts"; -import { catalogModelLabel } from "./ProviderDimensions.ts"; - -// Bump when a field changes meaning, so reports can split old and new rows. -const REPORT_VERSION = 2; - -/** - * Where the run's starting message came from. Most turns start without a - * person (provider continuations, agent sends, delegated subagents), so - * reports need this to tell user turns apart. - */ -export interface ProviderTurnOrigin { - readonly createdBy: OrchestrationV2Actor; - readonly creationSource: OrchestrationV2CreationSource; - readonly scheduled: boolean; - readonly subagent: boolean; -} - -/** What started this provider turn inside its run. */ -export type ProviderTurnSegment = - | OrchestrationV2RunAttempt["reason"] - | "continuation" - | "native_child"; - -export interface ProviderTurnAnalyticsContext { - readonly runId?: RunId; - readonly modelSelection: ModelSelection; - readonly runtimeMode?: RuntimeMode; - readonly interactionMode?: ProviderInteractionMode; - readonly origin?: ProviderTurnOrigin; - readonly segment?: ProviderTurnSegment; - readonly restartContinuation?: boolean; -} - -export interface ProviderTurnReport { - readonly driver: string; - readonly providerTurn: OrchestrationV2ProviderTurn; - readonly context?: ProviderTurnAnalyticsContext; -} - -export interface RunSettledReport { - readonly runId: RunId; - readonly status: OrchestrationV2ProviderTurn["status"]; - readonly completedAt: DateTime.Utc; - // Run-owned children were still open when the run settled; their later turns are not added. - readonly childrenRunning: boolean; -} - -type Properties = Readonly>; -/** Maps a native model id to a vendor catalog label, or undefined to keep it out. */ -export type ModelLabeler = (model: string) => string | undefined; - -const TERMINAL_STATUSES: ReadonlySet = new Set([ - "completed", - "failed", - "interrupted", - "cancelled", -]); - -const REPORTED_EFFORTS: ReadonlySet = new Set([ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", - "ultrathink", - "ultracode", - "brutal", -]); - -const defined = (entries: Properties): Properties => - Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined)); - -// OpenCode names its effort option `variant`, and variants can be user-defined. -function selectedEffort(selection: ModelSelection): string | undefined { - const effort = - getModelSelectionStringOptionValue(selection, "reasoningEffort") ?? - getModelSelectionStringOptionValue(selection, "effort") ?? - getModelSelectionStringOptionValue(selection, "variant"); - return effort !== undefined && REPORTED_EFFORTS.has(effort) ? effort : undefined; -} - -// Codex sends a tier id (its "fast" config shows up as "priority"); Claude has a fastMode toggle. -const REPORTED_SERVICE_TIERS: ReadonlyMap = new Map([ - ["default", "standard"], - ["flex", "flex"], - ["fast", "fast"], - ["priority", "fast"], - ["ultrafast", "ultrafast"], -]); - -function selectedServiceTier(selection: ModelSelection): string | undefined { - const tier = getModelSelectionStringOptionValue(selection, "serviceTier"); - if (tier !== undefined) return REPORTED_SERVICE_TIERS.get(tier); - const fastMode = getModelSelectionBooleanOptionValue(selection, "fastMode"); - return fastMode === undefined ? undefined : fastMode ? "fast" : "standard"; -} - -/** - * Confirmed models across counted work. Unknown when any piece of work has no - * reported model, unless two distinct models were already seen. - */ -function modelObservation( - models: ReadonlyArray | undefined>, - label: ModelLabeler, -): Properties { - const distinct = new Set(models.flatMap((entry) => entry ?? [])); - if (distinct.size > 1) return { mixedModels: true }; - if (distinct.size === 0 || models.some((entry) => entry === undefined || entry.length === 0)) { - return {}; - } - return defined({ mixedModels: false, observedModel: label([...distinct][0]!) }); -} - -function selectionProperties( - context: ProviderTurnAnalyticsContext | undefined, - label: ModelLabeler, -) { - const selection = context?.modelSelection; - return defined({ - model: selection === undefined ? undefined : label(selection.model), - effort: selection === undefined ? undefined : selectedEffort(selection), - serviceTier: selection === undefined ? undefined : selectedServiceTier(selection), - runtimeMode: context?.runtimeMode, - interactionMode: context?.interactionMode, - ...context?.origin, - }); -} - -function childKind(context: ProviderTurnAnalyticsContext | undefined) { - if (context?.segment === "native_child") return "native"; - return context?.origin?.subagent === true ? "t3" : undefined; -} - -function counters(usage: TurnTokenUsage | undefined): Properties { - return defined({ - inputTokens: usage?.inputTokens, - cachedInputTokens: usage?.cachedInputTokens, - cacheCreationTokens: usage?.cacheCreationTokens, - outputTokens: usage?.outputTokens, - reasoningTokens: usage?.reasoningTokens, - }); -} - -export function providerTurnProperties( - report: ProviderTurnReport, - label: ModelLabeler, -): Properties { - const { providerTurn, context } = report; - const usage = providerTurn.turnTokenUsage; - return defined({ - reportVersion: REPORT_VERSION, - provider: report.driver, - terminalStatus: providerTurn.status, - usageStatus: usage?.usageStatus ?? "unavailable", - usageScope: usage?.usageScope ?? "main_agent", - usageReported: usage !== undefined, - hasSubagents: usage?.hasSubagents, - ...counters(usage), - localCommand: usage?.localCommand === true ? true : undefined, - otherInputTokens: usage?.otherUsage?.inputTokens, - otherOutputTokens: usage?.otherUsage?.outputTokens, - ...selectionProperties(context, label), - ...modelObservation([usage?.models], label), - segment: context?.segment, - childKind: childKind(context), - durationMs: - providerTurn.startedAt && providerTurn.completedAt - ? Math.max( - 0, - DateTime.toEpochMillis(providerTurn.completedAt) - - DateTime.toEpochMillis(providerTurn.startedAt), - ) - : undefined, - }); -} - -/** How the request reached the provider; only `user` counts as a person asking. */ -function requestKind(origin: ProviderTurnOrigin | undefined) { - if (origin === undefined) return "unknown"; - if (origin.scheduled) return "scheduled"; - if (origin.subagent) return "t3_child"; - if (origin.createdBy === "user") return "user"; - if (origin.creationSource === "provider") return "provider_continuation"; - return origin.createdBy; -} - -function sumOf( - usages: ReadonlyArray, - field: - | "inputTokens" - | "cachedInputTokens" - | "cacheCreationTokens" - | "outputTokens" - | "reasoningTokens", - requireAll: boolean, -): number | undefined { - const values = usages.map((usage) => usage[field]); - const known = values.filter((value) => value !== undefined); - if (known.length === 0 || (requireAll && known.length < values.length)) return undefined; - return known.reduce((sum, value) => sum + value, 0); -} - -/** complete only when every turn is complete; partial when any turn has counts. */ -function combinedStatus(usages: ReadonlyArray) { - if (usages.length > 0 && usages.every((usage) => usage?.usageStatus === "complete")) { - return "complete"; - } - return usages.some( - (usage) => usage?.inputTokens !== undefined || usage?.outputTokens !== undefined, - ) - ? "partial" - : "unavailable"; -} - -interface RequestTurn { - readonly child: boolean; - readonly segment: ProviderTurnSegment | undefined; - readonly providerTurn: OrchestrationV2ProviderTurn; -} - -interface RequestEntry { - driver: string | undefined; - context: ProviderTurnAnalyticsContext | undefined; - readonly lifecycleObserved: boolean; - readonly turns: Map; -} - -const MAX_TRACKED_RUNS = 2048; - -/** - * Collects terminal provider turns per run in this process. A replayed turn - * replaces its earlier copy, a settled run ignores late turns, and a run whose - * initial attempt started before this process reports `lifecycleObserved: false`. - */ -export class RequestUsageLedger { - private readonly open = new Map(); - private readonly settled = new Set(); - - start(runId: RunId, driver: string, context: ProviderTurnAnalyticsContext): void { - if (this.settled.has(runId) || this.open.has(runId)) return; - // A restart continuation carries on a run whose earlier turns this process never saw. - const lifecycleObserved = context.restartContinuation !== true; - this.open.set(runId, { driver, context, lifecycleObserved, turns: new Map() }); - evictOldest(this.open); - } - - add(report: ProviderTurnReport): void { - const runId = report.context?.runId; - if (runId === undefined || this.settled.has(runId)) return; - if (!TERMINAL_STATUSES.has(report.providerTurn.status)) return; - let entry = this.open.get(runId); - if (entry === undefined) { - entry = { driver: undefined, context: undefined, lifecycleObserved: false, turns: new Map() }; - this.open.set(runId, entry); - evictOldest(this.open); - } - const child = report.context?.segment === "native_child"; - if (!child) { - entry.driver ??= report.driver; - entry.context ??= report.context; - } - entry.turns.set(String(report.providerTurn.id), { - child, - segment: report.context?.segment, - providerTurn: report.providerTurn, - }); - } - - settle(report: RunSettledReport, label: ModelLabeler): Properties | undefined { - if (this.settled.has(report.runId)) return undefined; - this.settled.add(report.runId); - evictOldest(this.settled); - const entry = this.open.get(report.runId); - this.open.delete(report.runId); - if (entry === undefined) return undefined; - - const turns = [...entry.turns.values()]; - const root = turns.filter((turn) => !turn.child); - const children = turns.filter((turn) => turn.child); - const rootUsage = root.map((turn) => turn.providerTurn.turnTokenUsage); - const childUsage = children.map((turn) => turn.providerTurn.turnTokenUsage); - const { lifecycleObserved } = entry; - const measuredRoot = rootUsage.filter((usage) => usage !== undefined); - const status = combinedStatus(rootUsage); - const rootComplete = status === "complete"; - const otherUsage = measuredRoot.map((usage) => usage.otherUsage); - const allOtherKnown = otherUsage.length > 0 && otherUsage.every((usage) => usage !== undefined); - const anyRootSubagents = measuredRoot.some((usage) => usage.hasSubagents); - const measuredChildren = childUsage.filter((usage) => usage !== undefined); - const childStatus = - children.length === 0 && !anyRootSubagents && !report.childrenRunning - ? "none" - : children.length === 0 - ? "unavailable" - : report.childrenRunning && combinedStatus(childUsage) === "complete" - ? "partial" - : combinedStatus(childUsage); - const startedAt = turns - .map((turn) => turn.providerTurn.startedAt) - .filter((value) => value !== null) - .map(DateTime.toEpochMillis); - - return defined({ - reportVersion: REPORT_VERSION, - provider: entry.driver ?? "unknown", - requestKind: requestKind(entry.context?.origin), - restartContinuation: entry.context?.restartContinuation === true ? true : undefined, - terminalStatus: report.status, - lifecycleObserved, - ...selectionProperties(entry.context, label), - providerTurns: root.length, - continuations: root.filter((turn) => turn.segment === "continuation").length, - steeringRestarts: root.filter((turn) => turn.segment === "steering_restart").length, - usageStatus: lifecycleObserved || status === "unavailable" ? status : "partial", - inputTokens: sumOf(measuredRoot, "inputTokens", rootComplete), - cachedInputTokens: sumOf(measuredRoot, "cachedInputTokens", true), - cacheCreationTokens: sumOf(measuredRoot, "cacheCreationTokens", true), - outputTokens: sumOf(measuredRoot, "outputTokens", rootComplete), - reasoningTokens: sumOf(measuredRoot, "reasoningTokens", true), - localCommand: measuredRoot.some((usage) => usage.localCommand === true) ? true : undefined, - otherInputTokens: allOtherKnown - ? otherUsage.reduce((sum, usage) => sum + (usage?.inputTokens ?? 0), 0) - : undefined, - otherOutputTokens: allOtherKnown - ? otherUsage.reduce((sum, usage) => sum + (usage?.outputTokens ?? 0), 0) - : undefined, - nativeChildTurns: children.length, - childUsageStatus: childStatus, - childInputTokens: sumOf(measuredChildren, "inputTokens", false), - childOutputTokens: sumOf(measuredChildren, "outputTokens", false), - childrenRunning: report.childrenRunning ? true : undefined, - ...modelObservation( - [...rootUsage, ...childUsage].map((usage) => usage?.models), - label, - ), - durationMs: - startedAt.length === 0 - ? undefined - : Math.max(0, DateTime.toEpochMillis(report.completedAt) - Math.min(...startedAt)), - }); - } -} - -function evictOldest(collection: Map | Set) { - if (collection.size <= MAX_TRACKED_RUNS) return; - const oldest = collection.keys().next().value; - if (oldest !== undefined) collection.delete(oldest); -} - -export class ProviderTurnAnalytics extends Context.Reference<{ - readonly runStarted: (input: { - readonly runId: RunId; - readonly driver: string; - readonly context: ProviderTurnAnalyticsContext; - }) => Effect.Effect; - readonly turnCompleted: (report: ProviderTurnReport) => Effect.Effect; - readonly runSettled: (report: RunSettledReport) => Effect.Effect; -}>("t3/telemetry/ProviderTurnAnalytics", { - defaultValue: () => ({ - runStarted: () => Effect.void, - turnCompleted: () => Effect.void, - runSettled: () => Effect.void, - }), -}) {} - -export const layer = Layer.effect( - ProviderTurnAnalytics, - Effect.gen(function* () { - const analytics = yield* AnalyticsService.AnalyticsService; - const registry = yield* ProviderRegistry.ProviderRegistry; - const ledger = new RequestUsageLedger(); - const labeler = Effect.map( - registry.getProviders, - (providers): ModelLabeler => - (model) => - catalogModelLabel(providers, model), - ); - return { - runStarted: Effect.fn("ProviderTurnAnalytics.runStarted")((input) => - Effect.sync(() => ledger.start(input.runId, input.driver, input.context)), - ), - turnCompleted: Effect.fn("ProviderTurnAnalytics.turnCompleted")(function* (report) { - ledger.add(report); - yield* analytics.record( - "provider.turn.completed", - providerTurnProperties(report, yield* labeler), - ); - }), - runSettled: Effect.fn("ProviderTurnAnalytics.runSettled")(function* (report) { - const properties = ledger.settle(report, yield* labeler); - if (properties !== undefined) { - yield* analytics.record("provider.request.completed", properties); - } - }), - }; - }), -); diff --git a/apps/server/src/triage/TriageService.ts b/apps/server/src/triage/TriageService.ts index 0e4de123ac03a..dbbd239f50add 100644 --- a/apps/server/src/triage/TriageService.ts +++ b/apps/server/src/triage/TriageService.ts @@ -44,7 +44,7 @@ const describeClient = (client: AuthClientPresentationMetadata) => client.appVersion === undefined ? undefined : `version ${client.appVersion}`, client.os === undefined ? undefined : `on ${client.os}`, client.browser === undefined ? undefined : `in ${client.browser}`, - client.webDeployment === "hosted" ? "(app.t3.codes)" : undefined, + client.webDeployment === "hosted" ? "(hosted web app)" : undefined, ] .filter((part) => part !== undefined) .join(" "); diff --git a/apps/server/src/triage/triagePrompt.ts b/apps/server/src/triage/triagePrompt.ts index 23cf540230695..2cae69de16533 100644 --- a/apps/server/src/triage/triagePrompt.ts +++ b/apps/server/src/triage/triagePrompt.ts @@ -73,7 +73,7 @@ different code depending on it: - How is Ada running on this machine: \`ada serve\` in a terminal, the background service, or the desktop app? -- Which surface is the user connecting from: the website (app.t3.codes), the +- Which surface is the user connecting from: a web browser, the desktop app against a local server, the desktop app against a remote server, or the mobile app? diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2b97b2f27d55a..8d97bd4728a55 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -58,7 +58,6 @@ import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/s import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as AgentSessionImporter from "./project/AgentSessionImporter.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts"; import * as ProviderInstallation from "./provider/providerInstallation.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -802,7 +801,6 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(AcpRegistrySessions.AcpRegistrySessions)({}), Layer.mock(AgentSessionImporter.AgentSessionImporter)({}), Layer.mock(AgentSessionScanner.AgentSessionScanner)({}), - Layer.mock(AnalyticsService.AnalyticsService)({}), Layer.mock(AntigravityInstallation.AntigravityInstallation)({ managedDirectory: "/unused" }), Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({}), @@ -1025,7 +1023,6 @@ it.effect.each([ method: "browser-session-cookie", scopes: [], }, - {}, yield* ServerBrowser.ServerBrowser, yield* McpGatewayBroker.McpGatewayBroker, yield* ClientFocusBroker.ClientFocusBroker, @@ -1279,7 +1276,6 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( method: "browser-session-cookie", scopes: [], }, - {}, yield* ServerBrowser.ServerBrowser, yield* McpGatewayBroker.McpGatewayBroker, yield* ClientFocusBroker.ClientFocusBroker, @@ -1533,7 +1529,6 @@ it.effect("a missing thread worktree gets its project's skills in the cwd snapsh method: "browser-session-cookie", scopes: [], }, - {}, yield* ServerBrowser.ServerBrowser, {} as McpGatewayBroker.McpGatewayBroker["Service"], {} as ClientFocusBroker.ClientFocusBroker["Service"], @@ -1622,7 +1617,6 @@ it.effect("a publish whose push fails still refreshes the new remote", () => method: "browser-session-cookie", scopes: [AuthSourceControlWriteScope], }, - {}, yield* ServerBrowser.ServerBrowser, {} as McpGatewayBroker.McpGatewayBroker["Service"], {} as ClientFocusBroker.ClientFocusBroker["Service"], @@ -2035,7 +2029,6 @@ it.effect("ACP session RPCs list a thread missing its history and repair it on i method: "browser-session-cookie", scopes: [], }, - {}, yield* ServerBrowser.ServerBrowser, {} as McpGatewayBroker.McpGatewayBroker["Service"], {} as ClientFocusBroker.ClientFocusBroker["Service"], diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a9bc5c76d852f..a751e90199729 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -36,15 +36,9 @@ import { type AuthEnvironmentScope, type ScheduledTaskListResult, AuthSessionId, - ClientConnectionMethod, - ClientDeviceType, - ClientOs, - ClientSurface, - ClientWebDeployment, type DiscoveredLocalServerList, type EditorId, type FileManagerRevealKind, - type OrchestrationV2Command, type GitActionProgressEvent, type GitManagerServiceError, type MessageId, @@ -243,9 +237,6 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; -import { commandFeatureUsage, featureUsage } from "./telemetry/FeatureUsage.ts"; -import type { TelemetryOwner } from "./telemetry/Identify.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as TriageService from "./triage/TriageService.ts"; @@ -271,7 +262,6 @@ import { failEnvironmentAuthCredential, failEnvironmentInternal } from "./auth/h import { isNetworkPath } from "@t3tools/shared/path"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { socketFrameSplitterFor } from "@t3tools/shared/socketFrameParts"; -import { isTelemetryIdHash } from "@t3tools/shared/telemetryId"; import { sameUsageLimitCommandCoverage, withUsageLimitsCommands, @@ -732,15 +722,6 @@ function toAuthAccessStreamEvent( } } -const isClientSurface = Schema.is(ClientSurface); -const isClientConnectionMethod = Schema.is(ClientConnectionMethod); -const isClientDeviceType = Schema.is(ClientDeviceType); -const isClientOs = Schema.is(ClientOs); -const isClientWebDeployment = Schema.is(ClientWebDeployment); -const MAX_CLIENT_APP_VERSION_LENGTH = 64; -const MAX_CLIENT_BROWSER_LENGTH = 64; -const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; - function hasCompatibleOrchestrationProtocol(url: URL): boolean { return ( url.searchParams.get(ORCHESTRATION_PROTOCOL_QUERY_PARAM) === @@ -760,73 +741,6 @@ function shouldUseBoundedThreadSnapshot(input: { return input.acceptBoundedSnapshot === true; } -// Keep telemetry per socket; several client types can connect simultaneously. -function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { - const url = requestUrl(request); - if (Option.isNone(url)) { - return {}; - } - - const surface = url.value.searchParams.get("clientSurface"); - const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; - const deviceType = url.value.searchParams.get("clientDeviceType"); - const os = url.value.searchParams.get("clientOs"); - const webDeployment = url.value.searchParams.get("clientWebDeployment"); - const browser = url.value.searchParams.get("clientBrowser")?.trim() ?? ""; - const connectionMethod = url.value.searchParams.get("connectionMethod"); - const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? ""; - const osMajorVersion = Number(rawOsMajorVersion); - const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? ""; - const isMobile = surface === "mobile"; - const hasOsMajorVersion = - isMobile && rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0; - const hasDeviceModel = - isMobile && deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH; - - return { - ...(isClientSurface(surface) ? { surface } : {}), - ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH - ? { appVersion, clientAppVersion: appVersion } - : {}), - ...(isClientOs(os) - ? { - clientOs: os, - ...(isMobile && (os === "iOS" || os === "Android") ? { os } : {}), - } - : {}), - ...(isClientDeviceType(deviceType) ? { clientDeviceType: deviceType } : {}), - ...(surface === "web" && isClientWebDeployment(webDeployment) ? { webDeployment } : {}), - ...(surface === "web" && browser !== "" && browser.length <= MAX_CLIENT_BROWSER_LENGTH - ? { clientBrowser: browser } - : {}), - ...(hasOsMajorVersion ? { osMajorVersion, clientOsMajorVersion: osMajorVersion } : {}), - ...(hasDeviceModel ? { deviceModel, clientDeviceModel: deviceModel } : {}), - ...(isClientConnectionMethod(connectionMethod) ? { connectionMethod } : {}), - }; -} - -/** - * Owners a connecting client reports: its hashed T3 Connect account and its - * hashed device id. Only hashes are accepted, so a raw id never reaches analytics. - */ -function readClientOwners( - request: HttpServerRequest.HttpServerRequest, -): ReadonlyArray { - const url = requestUrl(request); - if (Option.isNone(url)) { - return []; - } - const params = url.value.searchParams; - return ( - [ - ["connect", params.get("clientOwner")], - ["device", params.get("clientDevice")], - ] as const - ).flatMap(([source, ownerId]) => - ownerId !== null && isTelemetryIdHash(ownerId) ? [{ source, ownerId }] : [], - ); -} - const canReplayPersistedRange = Effect.fnUntraced(function* ( afterSequence: number, headSequence: number, @@ -1399,7 +1313,6 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc export const layerWsRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, - clientAnalyticsProps: Readonly>, serverBrowser: ServerBrowser.ServerBrowser["Service"], mcpGatewayBroker: McpGatewayBroker.McpGatewayBroker["Service"], clientFocusBroker: ClientFocusBroker.ClientFocusBroker["Service"], @@ -1440,25 +1353,6 @@ export const layerWsRpc = ( const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2; const mcpAppRequests = yield* McpAppRequests.McpAppRequests; - const analytics = yield* AnalyticsService.AnalyticsService; - const originProps = clientAnalyticsProps; - const recordClientCommandAnalytics = (command: OrchestrationV2Command) => { - const usage = commandFeatureUsage(command); - return Effect.all( - [ - command.type === "message.dispatch" - ? analytics.record("client.turn.requested", originProps) - : Effect.void, - usage === undefined - ? Effect.void - : analytics.record("feature.used", { - ...usage, - ...(originProps.surface === undefined ? {} : { surface: originProps.surface }), - }), - ], - { discard: true }, - ).pipe(Effect.ignore); - }; const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const commandOutputHub = yield* CommandOutputHub.CommandOutputHub; const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; @@ -2012,9 +1906,6 @@ export const layerWsRpc = ( ).pipe(Effect.provide(intakeContext)), ) .pipe( - Effect.tap((result) => - result.replayed ? Effect.void : recordClientCommandAnalytics(command), - ), Effect.map((result) => ({ sequence: result.sequence })), Effect.mapError((cause) => { const detail = userFacingDispatchErrorMessage(cause); @@ -2216,24 +2107,6 @@ export const layerWsRpc = ( }).pipe(Effect.provide(intakeContext)), ) .pipe( - // A resumed launch is a retry of one already counted. - Effect.tap((result) => - result.resumed - ? Effect.void - : analytics - .record("client.thread.started", { - ...originProps, - workspaceStrategy: input.workspaceStrategy.type, - }) - .pipe( - Effect.andThen( - input.initialMessage === undefined - ? Effect.void - : analytics.record("client.turn.requested", originProps), - ), - Effect.ignore, - ), - ), Effect.map((result) => ({ ...result, projection: projectThreadProjectionForWire(result.projection), @@ -3619,7 +3492,6 @@ export const makeInProcessWsRpcLayer = (session: EnvironmentAuth.AuthenticatedSe provideWsRpcDependencies( layerWsRpc( session, - {}, serverBrowser, yield* McpGatewayBroker.McpGatewayBroker, yield* ClientFocusBroker.ClientFocusBroker, @@ -3673,7 +3545,6 @@ export const layer = Layer.unwrap( } const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; - const analytics = yield* AnalyticsService.AnalyticsService; const session = yield* serverAuth.authenticateWebSocketUpgrade(request, "rpc").pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthCredential(error), @@ -3682,7 +3553,6 @@ export const layer = Layer.unwrap( failEnvironmentInternal("internal_error", error), ), ); - const clientAnalyticsProps = readClientAnalyticsProps(request); const transport = NodeHttpServerRequest.toIncomingMessage(request).socket; const liveness = yield* WsClientLiveness.make(() => ({ pendingBytes: transport.writableLength, @@ -3713,7 +3583,6 @@ export const layer = Layer.unwrap( Effect.andThen(Effect.sync(() => transport.resetAndDestroy())), Effect.as(HttpServerResponse.empty()), ); - yield* analytics.recordOwners(readClientOwners(request), "client"); const reportProfiles = yield* serverBrowser.profileReporter; const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { const { protocol, httpEffect } = @@ -3725,24 +3594,7 @@ export const layer = Layer.unwrap( yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe( Effect.provideService(RpcServer.Protocol, withOutputWindow(liveness.observe(protocol))), Effect.provide( - Layer.merge( - RpcAuthorization.layer(session.scopes, (method, payload) => { - // Anonymous product signal: the feature and its enum variant, - // credited to the connecting client's surface only. - const usage = featureUsage(method, payload); - return usage === undefined - ? Effect.void - : analytics - .record("feature.used", { - ...usage, - ...(clientAnalyticsProps.surface === undefined - ? {} - : { surface: clientAnalyticsProps.surface }), - }) - .pipe(Effect.ignore); - }), - rpcInstrumentationLayer, - ), + Layer.merge(RpcAuthorization.layer(session.scopes), rpcInstrumentationLayer), ), Effect.forkScoped, ); @@ -3752,7 +3604,6 @@ export const layer = Layer.unwrap( provideWsRpcDependencies( layerWsRpc( session, - clientAnalyticsProps, serverBrowser, mcpGatewayBroker, clientFocusBroker, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index cf495d2fbdc4c..d9f7f85694e56 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -20,9 +20,8 @@ export { shouldBundleCliDependency }; const repoEnv = loadRepoEnv(); const buildVersion = repoEnv.APP_VERSION?.trim() || packageJson.version; -// Sent with every analytics event. Release workflows set it for pingdotgg/t3code -// builds only, so local builds and forks report "dev". A typo fails the build -// instead of mislabeling events. +// Release workflows set it for official builds only, so local builds and forks +// report "dev". A typo fails the build instead of mislabeling the release. const TELEMETRY_CHANNELS = ["stable", "nightly", "preview", "dev"]; const telemetryChannel = process.env.T3CODE_TELEMETRY_CHANNEL?.trim() || "dev"; if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) { diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index a88535167db7d..7636c1fa3ac63 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -1,6 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { formatHostedThreadLink, + formatThreadLink, guessThreadLinkRef, threadLinkCandidates, type ThreadLink, @@ -56,11 +57,16 @@ export function MarkdownThreadLink(props: { } const ref = resolved?.ref ?? writtenRef; const title = resolved?.metadata.title.trim() || props.label || "Thread"; + const hostedAppUrl = configuredHostedAppUrl(); return ( ); } diff --git a/apps/web/src/components/composerInlineTokenPaste.test.ts b/apps/web/src/components/composerInlineTokenPaste.test.ts index 1ab28a5b7ff51..1a9ba2d78b26b 100644 --- a/apps/web/src/components/composerInlineTokenPaste.test.ts +++ b/apps/web/src/components/composerInlineTokenPaste.test.ts @@ -51,7 +51,7 @@ describe("importPastedComposerText", () => { }); it("keeps the link when the composer cannot take the thread", () => { - const pasted = "https://app.t3.codes/threads/env-1/thread-1"; + const pasted = "[Login](t3-thread://v2/env-1/thread-1)"; expect(importPastedComposerText(clipboard({ "text/plain": pasted }), () => new Map())).toBe( pasted, ); diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx index b138a8232bdb6..a185d0503c106 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx @@ -17,6 +17,7 @@ import { openFileInPreview, openUrlInPreview } from "../../browser/openFileInPre import { isPreviewAvailableFor } from "../../browser/previewRuntime"; import { requestConfirmDialog } from "../../confirmDialog"; import { connectPairing } from "../../connection/onboarding"; +import { configuredHostedAppUrl } from "../../hostedPairing"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { useComposerDraftStore } from "../../composerDraftStore"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; @@ -206,7 +207,7 @@ export function DesktopWebLinkCoordinator() { await pairFromLink(pairingUrl); return; } - const appPath = promptLinkAppPath(url); + const appPath = promptLinkAppPath(url, configuredHostedAppUrl()); if (appPath !== null) { await router.navigate({ href: appPath }); return; diff --git a/apps/web/src/components/onboarding/WelcomePage.tsx b/apps/web/src/components/onboarding/WelcomePage.tsx index ff876b6774a1a..b71f880972fde 100644 --- a/apps/web/src/components/onboarding/WelcomePage.tsx +++ b/apps/web/src/components/onboarding/WelcomePage.tsx @@ -77,7 +77,7 @@ import { newProjectId, randomUUID } from "../../lib/utils"; import { agentSessionImport } from "../../state/agentSessions"; import { readProjects, useProjects, waitForProject } from "../../state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; -import { usageDataNotice, useManagedPolicy } from "../../state/managedPolicy"; +import { USAGE_DATA_NOTICE, useManagedPolicy } from "../../state/managedPolicy"; import { useProjectScans } from "../../onboarding/useProjectScans"; import { projectEnvironment } from "../../state/projects"; import { serverEnvironment } from "../../state/server"; @@ -309,7 +309,7 @@ export function WelcomePage({ - + @@ -317,10 +317,10 @@ export function WelcomePage({ ); } -function TelemetryNotice({ environmentId }: { readonly environmentId: EnvironmentId }) { +function UsageDataNotice() { return (

- {usageDataNotice(useManagedPolicy(environmentId))} + {USAGE_DATA_NOTICE}

); } diff --git a/apps/web/src/components/promptLinks/copyPromptLink.ts b/apps/web/src/components/promptLinks/copyPromptLink.ts index 6f2a6762fa4a6..04637102debaf 100644 --- a/apps/web/src/components/promptLinks/copyPromptLink.ts +++ b/apps/web/src/components/promptLinks/copyPromptLink.ts @@ -18,7 +18,10 @@ import { configuredHostedAppUrl } from "~/hostedPairing"; import { stackedThreadToast, toastManager } from "../ui/toast"; -/** A shareable link to a prompt on the hosted app, or null when the prompt cannot fit in one. */ +/** + * A shareable link to a prompt on the hosted app, or null when the build has no hosted app or the + * prompt cannot fit in one. + */ export function promptLinkFor( prompt: string, context?: { @@ -27,9 +30,11 @@ export function promptLinkFor( readonly model: string | null; }, ): string | null { + const origin = configuredHostedAppUrl(); const trimmed = isAttachmentOnlyBootstrapPrompt(prompt) ? "" : prompt.trim(); - if (trimmed.length === 0 || trimmed.length > PROMPT_LINK_MAX_PROMPT_LENGTH) return null; - return buildNewThreadLink({ prompt: trimmed, ...context, origin: configuredHostedAppUrl() }); + if (origin === null || trimmed.length === 0 || trimmed.length > PROMPT_LINK_MAX_PROMPT_LENGTH) + return null; + return buildNewThreadLink({ prompt: trimmed, ...context, origin }); } export function promptLinkRepositoryOf( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2304bd4dc7788..cec3a1490c55c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -55,7 +55,11 @@ import { import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { isElectron } from "../../env"; -import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; +import { + buildHostedChannelSelectionUrl, + configuredHostedAppUrl, + type HostedAppChannel, +} from "../../hostedPairing"; import { readAppearanceModePreference, readThemeHalves, @@ -74,7 +78,7 @@ import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { ThreadTitleInstructionsSettings } from "./ThreadTitleInstructionsSettings"; import { SnoozePresetsRow } from "./SnoozePresetsSettings"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; -import { usageDataNotice, usePrimaryManagedPolicy } from "../../state/managedPolicy"; +import { USAGE_DATA_NOTICE, usePrimaryManagedPolicy } from "../../state/managedPolicy"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, @@ -186,7 +190,8 @@ function AboutVersionSection() { hasDesktopBridge && primaryPolicy?.limits?.updates?.allowNightly === false ? primaryPolicy.organization : undefined; - const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; + const selectedHostedAppChannel = + hasDesktopBridge || configuredHostedAppUrl() === null ? null : HOSTED_APP_CHANNEL; // A switch that failed part way leaves this app or a server on the other track. // With its updater off, a switch cannot move this app, so it is not counted. const appOffTrack = @@ -420,9 +425,8 @@ function AboutVersionSection() { value={selectedHostedAppChannel} onValueChange={(value) => { if (value === selectedHostedAppChannel) return; - window.location.assign( - buildHostedChannelSelectionUrl({ channel: value as HostedAppChannel }), - ); + const url = buildHostedChannelSelectionUrl({ channel: value as HostedAppChannel }); + if (url !== null) window.location.assign(url); }} > @@ -2395,10 +2399,7 @@ export function AboutSettingsPanel() { - + { ); }); - it("uses hosted pairing URLs for HTTPS endpoints", () => { - vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes"); + it("uses hosted pairing URLs for HTTPS endpoints only when the build has a hosted app", () => { + expect( + resolveHostedPairingUrl("https://host.tailnet.example.ts.net:3773", "PAIRCODE"), + ).toBeNull(); + vi.stubEnv("VITE_HOSTED_APP_URL", "https://hosted.example"); expect(resolveHostedPairingUrl("https://host.tailnet.example.ts.net:3773", "PAIRCODE")).toBe( - "https://preview.t3.codes/pair?host=https%3A%2F%2Fhost.tailnet.example.ts.net%3A3773#token=PAIRCODE", + "https://hosted.example/pair?host=https%3A%2F%2Fhost.tailnet.example.ts.net%3A3773#token=PAIRCODE", ); }); }); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 44dee24f77aa8..d616f334d0db3 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -51,7 +51,6 @@ import { import { clearComposerDraftsEnvironment } from "../composerDraftStore"; import { isHostedStaticApp } from "../hostedPairing"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; -import { randomUUID } from "../lib/utils"; import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; import { desktopLocalConnectionId, @@ -306,21 +305,6 @@ export const prepareDesktopSshEnvironment = Effect.fn("web.connectionPlatform.ss }, ); -const ANALYTICS_DEVICE_ID_KEY = "t3code:analytics-device-id"; - -// Lives as long as this browser profile's site data. Storage can be blocked, which only drops the id. -const analyticsDevice = { - id: Effect.sync( - Option.liftThrowable(() => { - const existing = window.localStorage.getItem(ANALYTICS_DEVICE_ID_KEY); - if (existing) return existing; - const created = randomUUID(); - window.localStorage.setItem(ANALYTICS_DEVICE_ID_KEY, created); - return created; - }), - ), -}; - const layerCapabilities = Layer.effectContext( Effect.sync(() => { const presentation = ClientCapabilities.ClientPresentation.of({ @@ -376,8 +360,7 @@ const layerCapabilities = Layer.effectContext( }), }); - return Context.make(ClientCapabilities.AnalyticsDevice, analyticsDevice).pipe( - Context.add(ClientCapabilities.PrimaryEnvironmentAuth, primaryAuth), + return Context.make(ClientCapabilities.PrimaryEnvironmentAuth, primaryAuth).pipe( Context.add(ClientCapabilities.ClientPresentation, presentation), Context.add(ClientCapabilities.SshEnvironmentGateway, ssh), ); diff --git a/apps/web/src/hostedPairing.test.ts b/apps/web/src/hostedPairing.test.ts index 7e4a5b0390c21..fb26b04c4f26b 100644 --- a/apps/web/src/hostedPairing.test.ts +++ b/apps/web/src/hostedPairing.test.ts @@ -12,7 +12,7 @@ describe("hostedPairing", () => { }); it("reads hosted pairing host and query token parameters", () => { - const url = new URL("https://app.t3.codes/pair?host=100.64.1.2:3773&token=ABCD1234"); + const url = new URL("https://hosted.example/pair?host=100.64.1.2:3773&token=ABCD1234"); expect(readHostedPairingRequest(url)).toEqual({ host: "100.64.1.2:3773", @@ -23,17 +23,17 @@ describe("hostedPairing", () => { }); it("prefers hash tokens so generated hosted links do not put credentials in search params", () => { - vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes"); + vi.stubEnv("VITE_HOSTED_APP_URL", "https://hosted.example"); const url = new URL( buildHostedPairingUrl({ host: "https://backend.example.com:3773", token: "pairing-token", label: "Workstation", - }), + })!, ); - expect(url.origin).toBe("https://preview.t3.codes"); + expect(url.origin).toBe("https://hosted.example"); expect(url.pathname).toBe("/pair"); expect(url.searchParams.get("host")).toBe("https://backend.example.com:3773"); expect(url.searchParams.get("label")).toBe("Workstation"); diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index 9c9882b392f03..83eee025c1df7 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -1,5 +1,3 @@ -import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/hostedApp"; - import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "@t3tools/shared/remote"; export interface HostedPairingRequest { @@ -10,8 +8,9 @@ export interface HostedPairingRequest { export type HostedAppChannel = "latest" | "nightly"; -export function configuredHostedAppUrl(): string { - return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; +/** This build's hosted web app, which only a hosted deploy configures. */ +export function configuredHostedAppUrl(): string | null { + return import.meta.env.VITE_HOSTED_APP_URL?.trim() || null; } function configuredBackendUrl(): string { @@ -44,7 +43,8 @@ export function isHostedStaticApp(url?: URL): boolean { return false; } - const hostedOrigin = originFromUrl(configuredHostedAppUrl()); + const hostedAppUrl = configuredHostedAppUrl(); + const hostedOrigin = hostedAppUrl === null ? null : originFromUrl(hostedAppUrl); return hostedOrigin !== null && (url ?? new URL(window.location.href)).origin === hostedOrigin; } @@ -72,8 +72,10 @@ export function buildHostedPairingUrl(input: { readonly host: string; readonly token: string; readonly label?: string | null; -}): string { - const url = new URL("/pair", configuredHostedAppUrl()); +}): string | null { + const hostedAppUrl = configuredHostedAppUrl(); + if (hostedAppUrl === null) return null; + const url = new URL("/pair", hostedAppUrl); url.searchParams.set("host", input.host); const label = input.label?.trim(); @@ -86,8 +88,10 @@ export function buildHostedPairingUrl(input: { export function buildHostedChannelSelectionUrl(input: { readonly channel: HostedAppChannel; -}): string { - const url = new URL("/__t3code/channel", configuredHostedAppUrl()); +}): string | null { + const hostedAppUrl = configuredHostedAppUrl(); + if (hostedAppUrl === null) return null; + const url = new URL("/__t3code/channel", hostedAppUrl); url.searchParams.set("channel", input.channel); return url.toString(); } diff --git a/apps/web/src/state/managedPolicy.ts b/apps/web/src/state/managedPolicy.ts index 1ca30648b46a5..cc814a46adbb4 100644 --- a/apps/web/src/state/managedPolicy.ts +++ b/apps/web/src/state/managedPolicy.ts @@ -31,12 +31,7 @@ export function firstManagedDenial( return undefined; } -/** What usage-data notices say; a policy can only turn product analytics off. */ -export function usageDataNotice(policy: ManagedPolicySummary | undefined): string { - return policy?.limits?.telemetry === false - ? `${policy.organization} turned off anonymous usage data.` - : `${APP_NAME} doesn't collect usage data unless you turn it on with T3CODE_TELEMETRY_ENABLED.`; -} +export const USAGE_DATA_NOTICE = `${APP_NAME} doesn't collect usage data.`; export interface ManagedInstanceLocks { /** Keys inside the instance's `config` users cannot change. */ diff --git a/apps/web/vite/warmBundledDev.ts b/apps/web/vite/warmBundledDev.ts index bf0137fa4ca0a..424e062623dbb 100644 --- a/apps/web/vite/warmBundledDev.ts +++ b/apps/web/vite/warmBundledDev.ts @@ -1,10 +1,9 @@ import type { Plugin, ViteDevServer } from "vite-plus"; -// What the first page load pulls in: the app, the managed-auth shell, the chat +// What the first page load pulls in: the app, the chat // layout and its landing routes, and the sidebar's project icons. const STARTUP_LAZY_ENTRIES = [ "src/main.tsx", - "src/components/clerk/BrowserManagedAuthShell.tsx", "src/routes/_chat.tsx", "src/routes/_chat.index.tsx", "src/routes/_chat.$environmentId.$threadId.tsx", diff --git a/docs/README.md b/docs/README.md index 8215bc584c599..a795b3eab6dcc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ - [Devices](./user/devices.md) - [Usage and limits](./user/usage.md) - [Secrets](./user/secrets.md) -- [Product usage data](./user/telemetry.md) +- [Usage data and diagnostics](./user/telemetry.md) - [Voice and chat control (Workspace MCP)](./user/workspace-mcp.md) - [Remote access](./user/remote-access.md) - [Outside agents (MCP)](./user/outside-agents.md) @@ -50,7 +50,6 @@ source alone does not explain. Most code changes do not need an internal documen - [Remote environments](./internals/remote.md) - [Server updates](./internals/server-updates.md) - [Resource telemetry](./internals/resource-telemetry.md) -- [Product analytics](./internals/product-analytics.md) - [Environment auth](./internals/environment-auth.md) - [Assistant citations](./internals/assistant-citations.md) - [Mobile layout](./internals/mobile-layout.md) diff --git a/docs/internals/product-analytics.md b/docs/internals/product-analytics.md deleted file mode 100644 index 2ea2c863d71bf..0000000000000 --- a/docs/internals/product-analytics.md +++ /dev/null @@ -1,152 +0,0 @@ -# Product analytics - -The server owns analytics delivery, opt-out, and identity for every connected client. -Clients do not load the PostHog browser SDK. Client-use events require an -authenticated connection, so visiting the hosted app without connecting does not -count as product use. - -## Installs and owners - -`distinct_id` keeps the rule every build has used: the hashed Codex workspace, -else the Claude machine id, else the install's anonymous id. It can change when -someone logs in, and a Codex Team shares one, but keeping it means PostHog charts -and history never break. Do not change it; add a field instead. - -Every event also carries `installId` when the server can save its state: the hash -of a random id in the state directory, which never changes for the install. If -that file cannot be written, events still go out under `distinct_id`, as before. Count installs -with it. Who an install belongs to is a separate fact, sent as -`install.owner_observed`. Owners are resolved at query time in our own store, so -a rule can change and apply to all history. - -[Owner sources](../../apps/server/src/telemetry/Identify.ts), strongest first: -the OpenAI and Claude person ids, then the client device. The server reports the -provider logins it can read itself. A connecting client reports its device, which -links every environment opened from one phone, browser, or desktop. Use the -OpenAI `user_id` claim, not the Codex `account_id` (the ChatGPT workspace, which a -whole Team shares) or the token `sub` (one per login method). - -Every id is SHA-256 hashed with [one helper](../../packages/shared/src/telemetryId.ts) -on both sides, so an id hashes the same on the client and the server. -The server accepts only hashes from clients. - -## Attribution boundaries - -Client dimensions belong to the event's WebSocket connection. A server-global -"current client" would misattribute simultaneous web, desktop, and mobile use. -Provider execution has its own events because a turn can outlive the requesting -connection. - -Keep client and server dimensions separate. A desktop host can serve a phone or a -remote browser, and a direct connection can cross a network. Older clients omit -metadata. Missing client values must stay unknown rather than being backfilled -from server properties. The legacy `clientType` property describes how the server -runs; use `surface` for the connected client. - -## Interpreting events - -Use `client.turn.requested` for active-use reports. There is no connection event, -because clients reconnect often and each reconnect would count again. -A command whose id was already accepted is a retry, so it records nothing again. -One identity can appear in several client groups during a period; adding those -groups double-counts users. - -Provider send and completion counts need not match. `provider.turn.completed` -is one provider turn, and `segment` says what started it: the run's first -attempt (`initial`), a `steering_restart`, a provider `continuation` such as a -Codex goal turn, or a `native_child` subagent. `createdBy`, `creationSource`, -`scheduled`, and `subagent` describe the run's starting message, and `childKind` -separates native subagents from T3-managed child threads. Use this event for -per-turn measurements, not for counting requests. - -`provider.request.completed` is one run, recorded when the run settles in the -server process that ran it. It adds each root turn once, so continuations, -steering, and replayed provider events do not add rows. `requestKind: "user"` -counts what people asked for. Native subagent turns are summed apart from the -root (`childUsageStatus`, `childInputTokens`); T3-managed child threads report -their own requests as `t3_child`. A child still running at settlement is never -added later, and the summary sets `childrenRunning`. A server restart loses the -summary of a run in progress. A run that continues after a restart reports its -own usage with `restartContinuation` and `lifecycleObserved: false`; it cannot -recover the original request's count or kind. A run whose provider session never -opens has no summary, and a start the provider rejects reports -`providerTurns: 0`. A Stop that lands while a steering restart is starting ends -the run without a summary. Collection is best effort, with no scan or backfill of -provider history. - -Counts cover the main agent. `otherInputTokens` and `otherOutputTokens` are what -a provider's own ledger recorded beyond it during the turn: Claude's per-process -`modelUsage` growth, which covers native subagents, compaction, and helper -calls. They need a baseline from the same process, so they stay absent when it -is unknown or the ledger goes backwards, and background work that ends between -turns lands in the next one. Claude subagents have no turns of their own, so -this never overlaps child turns. - -`model`, `effort`, and `serviceTier` are what the run selected. `serviceTier` -is `standard`, `flex`, `fast`, or `ultrafast`, from Codex's tier option or -Claude's fast mode, and is absent when the selection does not set one. `observedModel` and -`mixedModels` appear only when the provider reported the models for the counted -work (Codex thread settings and reroutes, Claude root messages, OpenCode steps). -`mixedModels: false` without `reportVersion` was never checked. Model labels -come from the bundled model manifest and the Cursor and Grok catalogs, also for -routed ids such as `anthropic/claude-opus-5-5`, and efforts come from a closed -list, so user-configured names stay out. - -Status and counters answer different questions. `usageReported: false` means -the adapter attached no usage; a harness where every row says so is unsupported, -not idle. `partial` keeps valid counts that cannot establish a whole-turn total, -such as an OpenCode step that ended without a report, an OpenCode stream -reconnect, or an interrupted segment. -Explicit zeros are sent and unknown counts stay absent. `localCommand` marks a -provider-local command such as Claude's `/compact`, whose result counters are not -model work. Reasoning tokens are a subset of output. - -Charts built on these events should say "output tokens per measured provider -completion", compare matching provider, observed model, effort, interaction mode, -and terminal status, and show eligible and measured counts by harness, origin, -and status so unknown models and unsupported adapters stay in the denominator. -Divide summed totals for output/input ratios; averaging per-turn ratios lets -small-input turns dominate. Rows without `reportVersion` keep their missing -fields unknown; a missing origin is never a person. - -`feature.used` counts WebSocket requests that start a feature. RPCs are observed in -the RPC authorization middleware after the request succeeds; dispatched commands -are recorded by the dispatch handler, which can tell a retry apart. -[Feature mapping](../../apps/server/src/telemetry/FeatureUsage.ts) allowlists -the methods and commands, and takes variants only from closed contract enums, -so paths, ids, and text never reach analytics. Guests of a shared thread have no -WebSocket connection, so the guest share service records their joins and messages -itself, with no `surface`. - -Agent tool events report driver kinds, never instance ids: users name -instances, so an instance id can identify an account or employer. -[Provider dimensions](../../apps/server/src/telemetry/ProviderDimensions.ts) -report a model only when it is a non-custom entry in a vendor catalog. OpenCode, -Pi, and ACP agents list models from user configuration, such as local Ollama -tags, so their models stay out. Handoff settings come from closed enums and -booleans in tool arguments; prompts, titles, and ids are never read. Thread ids -are used only to look up the threads on each side and never leave the server, -which is also why tool events carry no per-thread sequence. A handoff to several -threads, such as `create_threads`, records one `mcp.tool.invoked` per target, each -repeating the call's outcome and duration, so event counts are not call counts. - -## Delivery - -The server sends batches to the ingest endpoint at `ingest.t3.codes`, which archives -every event and forwards some of them to PostHog. A send can fail after the endpoint -has stored the batch, so every retry is a copy. -[Delivery](../../apps/server/src/telemetry/AnalyticsService.ts) gives each event a -uuid when it is recorded (the endpoint rejects events without one), backs off after -a failed send, and drops a batch after a few tries. Without these limits, one stuck -batch was sent every second for days. - -## Collection boundary - -Keep analytics payloads to product metadata and normalized measurements. Do not -send prompts, authentication material, raw provider payloads, user-assigned device -names, or conversation identifiers. Client metadata is best effort; invalid values -must not reject a connection. PostHog person profiles remain disabled. - -Dev-runner servers and the CI archive smoke test turn telemetry off, because each -would count as a new install. Set `T3CODE_TELEMETRY_ENABLED=true` to test analytics -from a dev server. diff --git a/docs/user/appearance.md b/docs/user/appearance.md index fc2eda2692a13..462514cf837c5 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -59,7 +59,7 @@ change. Export your theme as JSON to share it. Environment themes and defaults come from the server serving your web app or the desktop app's main local environment. With the desktop app's local environment off, they come from the [background service](./background-service.md) on the same machine while the app is connected to -it. app.t3.codes and other connections do not use them. +it. Other connections do not use them. Select a published theme in **Settings → Appearance** to follow its palette as the server updates it. **Duplicate** makes an independent copy you can edit. A saved custom theme with the same ID diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 0971a35bda176..4a9cfc33927a8 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -48,8 +48,8 @@ A new service answers only on this machine. Other devices reach it once you choose the local network in `ada up` (or run `ada up --lan`, which sets `T3CODE_LAN=true` in `service.env`). It then listens on your local network and Tailscale addresses. It still does not answer on public addresses. Anyone on the -same network can reach the port, so a pairing code is all that keeps them out. The -hosted web app at app.t3.codes needs an HTTPS address, such as +same network can reach the port, so a pairing code is all that keeps them out. A +web app served over HTTPS needs an HTTPS address for the service, such as [Tailscale HTTPS](./remote-access.md#tailscale-https), because browsers block plain HTTP from an HTTPS page. diff --git a/docs/user/default-browser.md b/docs/user/default-browser.md index b807859c2e95f..8021d97e6d09b 100644 --- a/docs/user/default-browser.md +++ b/docs/user/default-browser.md @@ -11,7 +11,6 @@ choose another browser there. Pull request links for a connected project open in the pull request view when opening pull request links in the app is enabled. Other links still open in the browser panel. -HTML files you open from Finder open the same way. [Prompt links](prompt-links.md) to -app.t3.codes open in the app as a draft. +HTML files you open from Finder open the same way. A link that launches Ada waits until the app has connected to its environment, then opens. diff --git a/docs/user/faq.md b/docs/user/faq.md index d96a64001b899..98d1ad7f2b48e 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -33,7 +33,7 @@ Ada drives Claude through Anthropic's Agent SDK. This is a known bug on Anthropi ## How do I use Ada remotely? -Your agents run wherever Ada runs: your desktop, a server, a VPS. You connect to it from anywhere. Run `ada up` on that machine, then pair over your LAN or Tailscale. [app.t3.codes](https://app.t3.codes) is the web client. [Remote access](./remote-access.md) +Your agents run wherever Ada runs: your desktop, a server, a VPS. You connect to it from anywhere. Run `ada up` on that machine, then pair over your LAN or Tailscale. [Remote access](./remote-access.md) ## How do I use Ada on my phone? @@ -59,7 +59,7 @@ No. Ada is a serious developer tool. Locally runnable models are not capable of ## Does my code go to Ada servers? -No. Your code and prompts go from your machine to your provider (Anthropic, OpenAI, etc.), not to us. Your devices connect straight to your machine over your LAN or Tailscale. Ada doesn't collect usage data unless you turn it on with `T3CODE_TELEMETRY_ENABLED=true`. [Product usage data](./telemetry.md) +No. Your code and prompts go from your machine to your provider (Anthropic, OpenAI, etc.), not to us. Your devices connect straight to your machine over your LAN or Tailscale. Ada doesn't collect usage data. [Usage data and diagnostics](./telemetry.md) ## Worktrees are eating my disk. How do I clean them up? diff --git a/docs/user/managed-policy.md b/docs/user/managed-policy.md index 56a78bc579bc8..acf1cf2360936 100644 --- a/docs/user/managed-policy.md +++ b/docs/user/managed-policy.md @@ -30,8 +30,7 @@ The desktop app includes the `ada` command; see [The `ada` command](./install.md }, "limits": { "maxRuntimeMode": "auto-accept-edits", - "clients": ["desktop", "local-browser"], - "telemetry": false + "clients": ["desktop", "local-browser"] } } ``` @@ -168,7 +167,6 @@ A few settings need care: "clients": ["desktop"], "webhookTasks": false, "browserCookieImport": false, - "telemetry": false, "providers": { "allowedDrivers": ["claudeAgent", "codex"], "instances": { @@ -217,7 +215,7 @@ The limit decides which modes Ada asks the provider for. It is not a sandbox; se - `desktop`: as the desktop app's own server. - `local-browser`: as a server only browsers on this machine reach, such as a plain `ada`. -- `remote`: reachable from phones, other computers, and app.t3.codes over the network. +- `remote`: reachable from phones, other computers, and browsers over the network. `remote` is the line that matters. A server reachable from the network also accepts browsers on this machine, and the two local kinds differ only in how Ada was started. On machines with the @@ -248,8 +246,6 @@ so restart Ada after that. but do not run, and cannot be turned back on. Users can still edit or delete them. - `browserCookieImport: false`: users cannot import cookies from another browser into Ada's browser. -- `telemetry: false`: the server and desktop app send no [product usage data](./telemetry.md). A - policy cannot turn it on. ### Providers diff --git a/docs/user/prompt-links.md b/docs/user/prompt-links.md index 19e4b451be8b3..bc8b518613e2a 100644 --- a/docs/user/prompt-links.md +++ b/docs/user/prompt-links.md @@ -8,18 +8,18 @@ change your permission mode, branch, or worktree. Your own defaults apply. ## Make a link -Open the command palette and choose **Copy prompt link**. It copies the draft in the composer. -When the project is on GitHub, **Copy prompt link with repo and model** also adds the repository -and the model you picked. Sent messages have a **Copy prompt link** button too. - -You can also write a link by hand: +Write a link by hand: ``` -https://app.t3.codes/new?prompt=Rate%20my%20swearing -https://app.t3.codes/new?prompt=Fix%20the%20build&repo=owner/name&provider=codex&model=gpt-5.5 -https://app.t3.codes/new?pr=owner/name/123&prompt=Fix%20the%20failing%20check +t3code://new?prompt=Rate%20my%20swearing +t3code://new?prompt=Fix%20the%20build&repo=owner/name&provider=codex&model=gpt-5.5 +t3code://new?pr=owner/name/123&prompt=Fix%20the%20failing%20check ``` +These links open the desktop app. **Copy prompt link** in the command palette and on sent +messages appears only when your Ada web app is set up with a hosted address that links can point +to. + | Parameter | Meaning | | ---------- | ----------------------------------------------------------------------------- | | `prompt` | The prompt. Required, except on pull request links. At most 8,000 characters. | @@ -32,10 +32,6 @@ Encode the prompt for a URL. In JavaScript, use `encodeURIComponent`. ## Open a link -On app.t3.codes, the link first asks where to open it: the desktop or mobile app, this browser -when it is connected to a machine, or the download page. Select **Remember my choice** to skip -this step next time. - - **No `repo`**: the draft opens without a project, on the machine you used last. - **`repo`**: the draft opens in your most recently used project for that repository. A fork counts only for its own owner. When you do not have the repository, choose **Clone** or **Use @@ -47,7 +43,7 @@ this step next time. worktree. Ada checks the code out only after you confirm, because the checkout runs your setup script on it. -A thread link with a prompt, such as `https://app.t3.codes//?prompt=…`, +A thread link with a prompt, such as `t3code://threads//?prompt=…`, puts the prompt in that thread's composer. If you already typed a draft there, Ada asks before it replaces it. @@ -57,8 +53,6 @@ malicious. ## Share a thread -**Copy thread** copies a link such as `https://app.t3.codes/threads//`. Paste -it anywhere, such as Slack, a GitHub comment, or your notes. On a phone with Ada installed, it -opens the thread in the app. Elsewhere, opening it asks where to open it, like a prompt link, then -opens the thread. Only someone connected to the machine that runs the thread can see it. In Ada, -a thread link in a message opens the thread in the app. +**Copy thread** copies a Markdown link such as `[Title](t3-thread://v2//)`. +Paste it into an Ada composer to attach the thread, or into a message, where it opens the thread +in the app. Only someone connected to the machine that runs the thread can see it. diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index 2702ce1576127..2c7bfa803d7bf 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -42,7 +42,7 @@ without expanding shell expressions. ## Where agents run Registry agents always run on the machine that hosts your Ada server. That stays true when you -connect through `app.t3.codes` or from another device. +connect from a browser or from another device. Agents install under `tools///` inside Ada home. Ada verifies SHA-256 when the Registry entry provides one; entries without a checksum retain the Registry's HTTPS distribution guarantee. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 5f424c6da01a7..c5bb4eab0d736 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -173,7 +173,8 @@ If that port is already in use, choose another with ### Hosted web app -[app.t3.codes](https://app.t3.codes) needs an HTTPS endpoint. It connects directly +Ada does not use a hosted web app unless one is set up. A hosted web app, served +over HTTPS, needs an HTTPS endpoint. It connects directly to your server; a hosted pairing link does not make an unreachable backend reachable or convert HTTP to HTTPS. @@ -326,7 +327,7 @@ is for machines outside the network. A machine in the network is switched off here or forgotten from the network instead. A phone that is not on your tailnet shows network machines as **Can't reach from -this phone**. On app.t3.codes, a machine reachable only over plain HTTP shows +this phone**. On a hosted web app, a machine reachable only over plain HTTP shows **Can't reach from this browser**. Open the app from a machine on your network, or use Tailscale HTTPS. diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md index ac052c5b6c6c4..ab480d189cba3 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -1,26 +1,8 @@ -# Product usage data +# Usage data and diagnostics -Ada doesn't collect usage data unless you turn it on with `T3CODE_TELEMETRY_ENABLED=true`. -When it is on, the server sends product usage events to the endpoint at `ingest.t3.codes`, -associated with a hashed account or installation identifier. To count how many installations one person -uses, the server also sends hashed account identifiers: your OpenAI and -Claude accounts when you are signed in, and a random identifier for each device that connects. The endpoint keeps a copy of each -event and forwards events to PostHog. Events include the provider, model, reasoning effort, -permission mode, turn and request results, duration, release channel, and token counts when -available. Child-agent token counts are reported separately from the main agent's. Model names -are sent only when they appear in a provider's public model list. - -Product events do not include prompts, responses, file contents, authentication tokens, conversation IDs, -raw provider events, or child-agent output. - -## Turn it on - -To send product events from the server, set `T3CODE_TELEMETRY_ENABLED=true` in its -environment before starting it. An administrator can keep them off in the server and desktop -app with a [managed policy](./managed-policy.md#other-capabilities), which a variable cannot override. - -The desktop app reads `T3CODE_TELEMETRY_ENABLED` from your shell profile (for example `~/.zshrc`) on macOS and -Linux, so export it there and restart the app. On Windows, set it as a user environment variable. +Ada doesn't collect product usage data. It has no analytics or crash reporting, and nothing on your +machines reports to the upstream project. Diagnostics leave a server only when you send +them to your own receiver. ## Export diagnostics to your own receiver diff --git a/docs/user/welcome-page.md b/docs/user/welcome-page.md index d930834d85d05..98972e54a6882 100644 --- a/docs/user/welcome-page.md +++ b/docs/user/welcome-page.md @@ -8,7 +8,7 @@ and the phone app. Only an agent is needed to start chatting. To see the page again, go to **Settings → About → Welcome page** and select **Open**. Direct links, such as Settings, open normally before setup is done. -The hosted app at app.t3.codes does not open the page. With no computer connected, +A hosted web app does not open the page. With no computer connected, it asks you to add one with a pairing link. The computer must be reachable from that browser at an HTTPS address, such as its [Tailscale HTTPS](./remote-access.md#tailscale-https) address. To add more computers later, use **Settings → Environments**. diff --git a/packages/client-runtime/src/authorization/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts index f85d9b96043f4..0daaef6f1ca35 100644 --- a/packages/client-runtime/src/authorization/remote.test.ts +++ b/packages/client-runtime/src/authorization/remote.test.ts @@ -3,13 +3,11 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; -import { hashTelemetryId } from "@t3tools/shared/telemetryId"; import { bootstrapRemoteBearerSession, issueRemoteWebSocketTicket, resolveRemoteWebSocketConnectionUrl, } from "./remote.ts"; -import * as ClientCapabilities from "../platform/capabilities.ts"; import * as RpcHttp from "../rpc/http.ts"; const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); @@ -160,29 +158,4 @@ describe("remote environment authorization", () => { ); }), ); - - it.effect("names this device by hash only", () => - Effect.gen(function* () { - const fetch = recordedFetch( - Response.json({ ticket: "ws-ticket", expiresAt: "2026-05-01T12:05:00.000Z" }), - ); - - const url = new URL( - yield* resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: "wss://remote.example.com/", - httpBaseUrl: "https://remote.example.com/", - bearerToken: "bearer-token", - }).pipe( - provideRemoteHttp(fetch.fetchFn), - Effect.provideService(ClientCapabilities.AnalyticsDevice, { - id: Effect.succeedSome("device-1"), - }), - ), - ); - - expect(url.searchParams.has("clientOwner")).toBe(false); - expect(url.searchParams.get("clientDevice")).toBe(hashTelemetryId("device-1")); - expect(url.toString()).not.toContain("device-1"); - }), - ); }); diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index ffc94419360f9..5593083b61c01 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -9,13 +9,10 @@ import { type AuthWebSocketTicketUse, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; -import { hashTelemetryId } from "@t3tools/shared/telemetryId"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; -import * as ClientCapabilities from "../platform/capabilities.ts"; import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiGroupClient, @@ -42,24 +39,10 @@ const clientMetadataTokenExchangeFields = ( }; }; -/** Hashes of who is connecting. Only hashes leave the client. */ -export interface ClientOwnerParams { - readonly device?: string; -} - -/** Tells the environment, for analytics, which device is connecting. */ -export const readClientOwnerParams = Effect.gen(function* () { - const device = yield* (yield* ClientCapabilities.AnalyticsDevice).id; - return ( - Option.isSome(device) ? { device: hashTelemetryId(device.value) } : {} - ) satisfies ClientOwnerParams; -}); - export const appendClientConnectionParams = ( url: URL, clientMetadata: AuthClientPresentationMetadata | undefined, connectionMethod?: ClientConnectionMethod, - owners?: ClientOwnerParams, ): void => { if (clientMetadata?.surface) { url.searchParams.set("clientSurface", clientMetadata.surface); @@ -98,9 +81,6 @@ export const appendClientConnectionParams = ( if (connectionMethod) { url.searchParams.set("connectionMethod", connectionMethod); } - if (owners?.device) { - url.searchParams.set("clientDevice", owners.device); - } }; export const bootstrapRemoteBearerSession = Effect.fn( @@ -221,11 +201,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( const url = rpcSocketUrl(input.wsBaseUrl); url.searchParams.set("wsTicket", Redacted.value(issued.ticket)); - appendClientConnectionParams( - url, - input.clientMetadata, - input.connectionMethod, - yield* readClientOwnerParams, - ); + appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); return url.toString(); }); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index b6be34bbb56fd..7ba5e1c290e52 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -9,11 +9,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; -import { - appendClientConnectionParams, - type ClientOwnerParams, - readClientOwnerParams, -} from "../authorization/remote.ts"; +import { appendClientConnectionParams } from "../authorization/remote.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; import { @@ -76,13 +72,12 @@ const isBearerCredential = Schema.is(BearerConnectionCredential); function primarySocketUrl( target: PrimaryConnectionTarget, clientMetadata: AuthClientPresentationMetadata | undefined, - owners: ClientOwnerParams, ): string { const url = new URL(target.wsBaseUrl); if (url.pathname === "" || url.pathname === "/") { url.pathname = "/ws"; } - appendClientConnectionParams(url, clientMetadata, "direct", owners); + appendClientConnectionParams(url, clientMetadata, "direct"); return url.toString(); } @@ -100,7 +95,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary environmentId: target.environmentId, label: target.label, httpBaseUrl: target.httpBaseUrl, - socketUrl: primarySocketUrl(target, presentation.metadata, yield* readClientOwnerParams), + socketUrl: primarySocketUrl(target, presentation.metadata), httpAuthorization: null, target, } satisfies PreparedConnection; diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index eba050d404bd6..13cf54e0e78f4 100644 --- a/packages/client-runtime/src/platform/capabilities.ts +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -20,17 +20,6 @@ export interface ProvisionedSshEnvironment extends PreparedSshEnvironment { readonly label: string; } -/** - * A random id this client keeps across launches. Environments it connects to - * record its hash, so two environments opened from one device share an owner - * in analytics even when nobody is signed in. - */ -export class AnalyticsDevice extends Context.Reference<{ - readonly id: Effect.Effect>; -}>("@t3tools/client-runtime/platform/capabilities/AnalyticsDevice", { - defaultValue: () => ({ id: Effect.succeedNone }), -}) {} - export class ClientPresentation extends Context.Service< ClientPresentation, { diff --git a/packages/client-runtime/src/threadReference.test.ts b/packages/client-runtime/src/threadReference.test.ts index 88905f9cec532..20f3cd7ca6304 100644 --- a/packages/client-runtime/src/threadReference.test.ts +++ b/packages/client-runtime/src/threadReference.test.ts @@ -98,8 +98,8 @@ describe("resolveThreadReferenceCopyTarget", () => { }), ], }); - // The plain text opens the thread anywhere and pastes back as the copied record's chip. - expect(target!.value).toBe("https://app.t3.codes/threads/env-1/thread-1"); + // The plain text links the thread and pastes back as the copied record's chip. + expect(target!.value).toBe("[Fix the login flow](t3-thread://v2/env-1/thread-1)"); expect(threadLinkContextReferences(target!.value, pasted!.records, () => undefined)).toEqual({ text: `[Fix the login flow](t3-context://v1/thread/${pasted!.records[0]!.contextId})`, records: pasted!.records, @@ -108,8 +108,8 @@ describe("resolveThreadReferenceCopyTarget", () => { it("links through the build's hosted app", () => { expect( - resolveThreadReferenceCopyTarget({ ...thread, hostedAppUrl: "https://preview.t3.codes" }) + resolveThreadReferenceCopyTarget({ ...thread, hostedAppUrl: "https://hosted.example" }) ?.value, - ).toBe("https://preview.t3.codes/threads/env-1/thread-1"); + ).toBe("https://hosted.example/threads/env-1/thread-1"); }); }); diff --git a/packages/client-runtime/src/threadReference.ts b/packages/client-runtime/src/threadReference.ts index 17fa5df458935..3fb3cb292a6b4 100644 --- a/packages/client-runtime/src/threadReference.ts +++ b/packages/client-runtime/src/threadReference.ts @@ -4,13 +4,13 @@ import type { ThreadPullRequestLink, } from "@t3tools/contracts"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; -import { hostedThreadLink } from "@t3tools/shared/threadLinks"; +import { formatThreadLink, hostedThreadLink } from "@t3tools/shared/threadLinks"; import { resolveThreadCurrentPullRequestLink } from "./threadPullRequests.ts"; export interface ThreadReferenceCopyTarget { readonly kind: "pull-request" | "thread"; - /** The plain text: a PR URL, or a link that opens the thread from anywhere. */ + /** The plain text: a PR URL, or a link to the thread. */ readonly value: string; /** What a toast or palette row shows for the copy. */ readonly description: string; @@ -22,18 +22,21 @@ export interface ThreadReferenceCopyTarget { } /** - * A link that opens the thread from anywhere, through `hostedAppUrl`, this build's hosted app. It - * pastes back as a chip into any composer whose agent reads that thread's machine. + * A link to the thread: through `hostedAppUrl`, this build's hosted app, when it has one, else a + * Markdown `t3-thread://` link. It pastes back as a chip into any composer whose agent reads that + * thread's machine. */ export function threadChipCopyTarget( threadRef: ScopedThreadRef, title: string, - hostedAppUrl?: string, + hostedAppUrl?: string | null, ): ThreadReferenceCopyTarget { const record = threadContextRecord(threadRef, title); return { kind: "thread", - value: hostedThreadLink(threadRef, hostedAppUrl), + value: hostedAppUrl + ? hostedThreadLink(threadRef, hostedAppUrl) + : formatThreadLink(threadRef, record.title), description: record.title, fragment: { version: 1, source: { environmentId: threadRef.environmentId }, records: [record] }, clipboardTarget: "thread", @@ -49,7 +52,7 @@ export function resolveThreadReferenceCopyTarget(input: { readonly openPanelPullRequestUrl?: string | null | undefined; readonly pullRequests?: ReadonlyArray | undefined; readonly linkedPullRequestUrl?: string | null; - readonly hostedAppUrl?: string; + readonly hostedAppUrl?: string | null; }): ThreadReferenceCopyTarget | null { if (input.openPanelPullRequestUrl === null) return null; const pullRequestUrl = diff --git a/packages/contracts/src/managedPolicy.ts b/packages/contracts/src/managedPolicy.ts index 4a647b8b53bdb..edf457bb14f51 100644 --- a/packages/contracts/src/managedPolicy.ts +++ b/packages/contracts/src/managedPolicy.ts @@ -76,8 +76,6 @@ export const ManagedPolicyLimits = Schema.Struct({ /** Clients the server accepts. Without `remote`, nothing off this machine can pair. */ clients: Schema.optionalKey(Schema.NonEmptyArray(ManagedPolicyClient)), webhookTasks: Schema.optionalKey(Schema.Boolean), - /** Can only turn product analytics off, never force it on. */ - telemetry: Schema.optionalKey(Schema.Boolean), browserCookieImport: Schema.optionalKey(Schema.Boolean), providers: Schema.optionalKey(ManagedProviderLimits), updates: Schema.optionalKey(ManagedUpdatesPolicy), @@ -91,7 +89,6 @@ export const ManagedPolicySummaryLimits = Schema.Struct({ maxRuntimeMode: Schema.optionalKey(RuntimeMode), clients: Schema.optionalKey(Schema.NonEmptyArray(ManagedPolicyClient)), webhookTasks: Schema.optionalKey(Schema.Boolean), - telemetry: Schema.optionalKey(Schema.Boolean), browserCookieImport: Schema.optionalKey(Schema.Boolean), providers: Schema.optionalKey( Schema.Struct({ diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 5e7045515f1f4..2add775b9acf1 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -316,7 +316,6 @@ export const ShellEnvironmentPatch = Schema.Struct({ SSL_CERT_FILE: Schema.optionalKey(Schema.NullOr(Schema.String)), SSL_CERT_DIR: Schema.optionalKey(Schema.NullOr(Schema.String)), NODE_EXTRA_CA_CERTS: Schema.optionalKey(Schema.NullOr(Schema.String)), - T3CODE_TELEMETRY_ENABLED: Schema.optionalKey(Schema.NullOr(Schema.String)), T3CODE_GITHUB_APP_CLIENT_ID: Schema.optionalKey(Schema.NullOr(Schema.String)), __MISE_DIFF: Schema.optionalKey(Schema.NullOr(Schema.String)), JAVA_HOME: Schema.optionalKey(Schema.NullOr(Schema.String)), diff --git a/packages/shared/package.json b/packages/shared/package.json index 3ab1b37f0fcee..5d845994813d0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -199,10 +199,6 @@ "types": "./src/dpopCommon.ts", "import": "./src/dpopCommon.ts" }, - "./telemetryId": { - "types": "./src/telemetryId.ts", - "import": "./src/telemetryId.ts" - }, "./oauthScope": { "types": "./src/oauthScope.ts", "import": "./src/oauthScope.ts" @@ -219,10 +215,6 @@ "types": "./src/cliArgs.ts", "import": "./src/cliArgs.ts" }, - "./hostedApp": { - "types": "./src/hostedApp.ts", - "import": "./src/hostedApp.ts" - }, "./symlink": { "types": "./src/symlink.ts", "import": "./src/symlink.ts" diff --git a/packages/shared/src/codexAuthHandoff.test.ts b/packages/shared/src/codexAuthHandoff.test.ts index 30693b60e5c87..5c945436c0bb8 100644 --- a/packages/shared/src/codexAuthHandoff.test.ts +++ b/packages/shared/src/codexAuthHandoff.test.ts @@ -23,7 +23,7 @@ const authorizationUrl = () => { }; const input = { authorizationUrl: authorizationUrl(), - returnUrl: "https://app.t3.codes/welcome#agents:remote-environment", + returnUrl: "http://localhost:5733/welcome#agents:remote-environment", environmentId: EnvironmentId.make("remote-environment"), instanceId: ProviderInstanceId.make("work-codex"), flowId: "flow-one", @@ -31,7 +31,7 @@ const input = { const callbackUrl = `http://127.0.0.1:54213/auth/callback?state=${"a".repeat(43)}&code=one-time-code&client_id=oaiapp_test`; describe("Codex desktop handoff", () => { - it("keeps the hosted return route, account, and environment with the code in a fragment", () => { + it("keeps the web return route, account, and environment with the code in a fragment", () => { expect(readCodexAuthHandoff(codexAuthHandoffUrl(input), false)).toEqual(input); const delivery = codexAuthDeliveryUrl(input, callbackUrl); expect(new URL(delivery).search).toBe(""); diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index b57940d27003c..2dfc5be71e7ba 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -585,12 +585,12 @@ describe("threadLinkContextReferences", () => { const reference = (environmentId: string, threadId: string, label: string) => `[${label}](t3-context://v1/thread/${threadContextRecord(thread(environmentId, threadId), label).contextId})`; - it("makes a chip of a copied link, a v2 link, and a labeled link, titled here", () => { + it("makes a chip of a v2 link and a labeled link, titled here", () => { const pasted = threadLinkContextReferences( [ - "https://app.t3.codes/threads/env-1/t1", + "", "", - "[Written](https://app.t3.codes/threads/env-2/t3)", + "[Written](t3-thread://v2/env-2/t3)", ].join(" "), [], ({ threadId }) => (threadId === "t3" ? undefined : `Title ${threadId}`), @@ -612,22 +612,21 @@ describe("threadLinkContextReferences", () => { it("prefers the record the copy carried, and names an unknown thread plainly", () => { const copied = threadContextRecord(thread("env-1", "t1"), "Copied title"); const pasted = threadLinkContextReferences( - "https://app.t3.codes/threads/env-1/t1 https://app.t3.codes/threads/env-9/t9", + " ", [copied], () => "Here", ); expect(pasted.records).toEqual([copied, threadContextRecord(thread("env-9", "t9"), "Here")]); expect( - threadLinkContextReferences("https://app.t3.codes/threads/env-9/t9", [], () => undefined) - .records, + threadLinkContextReferences("", [], () => undefined).records, ).toEqual([threadContextRecord(thread("env-9", "t9"), "Thread")]); }); it("keeps links it may not chip, v1 links, and links in code as written", () => { const text = [ - "https://app.t3.codes/threads/elsewhere/t1", + "", "[Old](t3-thread://v1/t2)", - "`https://app.t3.codes/threads/env-1/t3`", + "``", ].join(" "); const pasted = threadLinkContextReferences( text, diff --git a/packages/shared/src/hostedApp.ts b/packages/shared/src/hostedApp.ts deleted file mode 100644 index e9a124dedd7ef..0000000000000 --- a/packages/shared/src/hostedApp.ts +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; diff --git a/packages/shared/src/managedPolicyFile.test.ts b/packages/shared/src/managedPolicyFile.test.ts index f328762e0838c..d6807832aaf3b 100644 --- a/packages/shared/src/managedPolicyFile.test.ts +++ b/packages/shared/src/managedPolicyFile.test.ts @@ -138,7 +138,7 @@ describe("readManagedPolicy", () => { ); // Keys beside the wrapper, or a second entry, are refused rather than dropped or merged. for (const ambiguous of [ - { Forced: [{ mcx_preference_settings: settings }], limits: { telemetry: false } }, + { Forced: [{ mcx_preference_settings: settings }], limits: { webhookTasks: false } }, { Forced: [{ mcx_preference_settings: settings }, { mcx_preference_settings: settings }] }, ]) { assert.include( @@ -152,10 +152,10 @@ describe("readManagedPolicy", () => { yield* read("darwin", { [managedPolicyPath("darwin")]: typo }), '["limits"]["maxRuntimeMod"]', ); - // Policies written while T3 Connect existed still load; the retired key changes nothing. - const retiredConnect = JSON.stringify({ ...settings, limits: { connect: false } }); + // Policies naming retired features (T3 Connect, product analytics) still load. + const retired = JSON.stringify({ ...settings, limits: { connect: false, telemetry: false } }); assert.equal( - yield* read("darwin", { [managedPolicyPath("darwin")]: retiredConnect }), + yield* read("darwin", { [managedPolicyPath("darwin")]: retired }), managedPolicyPath("darwin"), ); assert.isUndefined(yield* read("darwin", {})); @@ -237,9 +237,9 @@ describe("readManagedPolicy", () => { text("SupportUrl", ""), // Registry names ignore case. text("maxRuntimeMode", "auto-accept-edits"), - dword("Telemetry", 0), - // Retired with T3 Connect: older templates still write it, and it is ignored. + // Retired controls: older templates still write them, and they are ignored. dword("Connect", 0), + dword("Telemetry", 0), text("Settings", '{"enableAgentBrowserAccess": {"value": false, "locked": true}}'), text("UpdatesMode", "off"), dword("AllowNightly", 1), @@ -260,7 +260,6 @@ describe("readManagedPolicy", () => { settings: { enableAgentBrowserAccess: { value: false, locked: true } }, limits: { maxRuntimeMode: "auto-accept-edits", - telemetry: false, updates: { mode: "off", allowNightly: true }, clients: ["desktop", "local-browser", "remote"], providers: { allowedDrivers: ["codex", "claudeAgent", "UnknownDriver"] }, @@ -282,12 +281,12 @@ describe("readManagedPolicy", () => { dword("AllowedDrivers", 0), dword("Updates", 0), dword("AllowNightly", 0), - dword("Telemetry", 0), + dword("WebhookTasks", 0), ], disabledGroups, ), ), - { schemaVersion: 1, organization: "Société 日本 ✓", limits: { telemetry: false } }, + { schemaVersion: 1, organization: "Société 日本 ✓", limits: { webhookTasks: false } }, ); // With nothing else configured, that is no policy rather than one missing Organization. assert.isUndefined( @@ -301,7 +300,7 @@ describe("readManagedPolicy", () => { // Anything the template does not write fails closed rather than being ignored. for (const [registry, reason] of [ - [policyKey([organization, dword("Telemetry", 2)]), "DWORD of 0 or 1"], + [policyKey([organization, dword("WebhookTasks", 2)]), "DWORD of 0 or 1"], [policyKey([organization, text("MaxMode", "auto")]), "not an Ada policy value"], [policyKey([organization], { Other: { x: 1 } }), "Other is not an Ada policy key"], [ diff --git a/packages/shared/src/managedPolicyFile.ts b/packages/shared/src/managedPolicyFile.ts index 6dc2ca0829dec..80ee0c733df68 100644 --- a/packages/shared/src/managedPolicyFile.ts +++ b/packages/shared/src/managedPolicyFile.ts @@ -49,17 +49,34 @@ export function managedPolicyPath(platform: NodeJS.Platform): string { // Unknown keys may be restrictions from a newer release; refusing them keeps a file this // build cannot fully enforce from looking enforced. -const decodeDocumentJsonExit = Schema.decodeUnknownExit( - Schema.fromJsonString(ManagedPolicyDocument), - { onExcessProperty: "error" }, -); const decodeDocumentExit = Schema.decodeUnknownExit(ManagedPolicyDocument, { onExcessProperty: "error", }); +const decodeJsonExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +// Limits for features since removed: dropped before the strict decode, so a deployed policy +// keeps loading. Being permissive is safe because nothing they restricted exists any more. +const RETIRED_LIMITS = new Set(["telemetry"]); +const withoutRetiredLimits = (document: unknown): unknown => { + if (typeof document !== "object" || document === null || !("limits" in document)) { + return document; + } + const { limits } = document; + if (typeof limits !== "object" || limits === null || Array.isArray(limits)) return document; + return { + ...document, + limits: Object.fromEntries(Object.entries(limits).filter(([key]) => !RETIRED_LIMITS.has(key))), + }; +}; + +const decodeDocumentValueExit = (raw: unknown) => { + const parsed = typeof raw === "string" ? decodeJsonExit(raw) : Exit.succeed(raw); + return Exit.isSuccess(parsed) ? decodeDocumentExit(withoutRetiredLimits(parsed.value)) : parsed; +}; /** Decode a policy from JSON text, or from a value a profile already parsed. */ const decodeDocument = (path: string, raw: unknown) => { - const decoded = typeof raw === "string" ? decodeDocumentJsonExit(raw) : decodeDocumentExit(raw); + const decoded = decodeDocumentValueExit(raw); if (Exit.isSuccess(decoded)) return Effect.succeed(decoded.value); const error = Cause.squash(decoded.cause); // The schema message names the offending key, e.g. `Expected no excess property at ["limits"]`. @@ -112,7 +129,6 @@ export const WINDOWS_POLICY_VALUES = { Settings: { type: "json", path: ["settings"] }, MaxRuntimeMode: { type: "string", path: ["limits", "maxRuntimeMode"] }, WebhookTasks: { type: "boolean", path: ["limits", "webhookTasks"] }, - Telemetry: { type: "boolean", path: ["limits", "telemetry"] }, BrowserCookieImport: { type: "boolean", path: ["limits", "browserCookieImport"] }, ProviderInstances: { type: "json", path: ["limits", "providers", "instances"] }, UpdatesMode: { type: "string", path: ["limits", "updates", "mode"] }, @@ -125,7 +141,7 @@ export const WINDOWS_POLICY_VALUES = { // Values older templates wrote for features since removed: accepted and ignored, so a deployed // Group Policy keeps loading. Not in the template any more. -const RETIRED_WINDOWS_POLICY_VALUES = new Set(["connect"]); +const RETIRED_WINDOWS_POLICY_VALUES = new Set(["connect", "telemetry"]); /** Subkeys of checkboxes: every value set to 1 is one entry of the list at `path`. */ export const WINDOWS_POLICY_LISTS = { @@ -353,7 +369,6 @@ const run = Effect.fnUntraced( Effect.timeout(Duration.seconds(10)), ); -const decodeJsonExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); type PolicyRegistryValue = RegistryValue & { /** The list subkey the value sits in, or "" for the policy key itself. */ readonly key: string; diff --git a/packages/shared/src/promptLinks.test.ts b/packages/shared/src/promptLinks.test.ts index 65d18eba07897..ee22d0357e82c 100644 --- a/packages/shared/src/promptLinks.test.ts +++ b/packages/shared/src/promptLinks.test.ts @@ -12,6 +12,8 @@ import { } from "./promptLinks.ts"; import { hostedThreadLink } from "./threadLinks.ts"; +const HOSTED = "https://hosted.example"; + describe("sanitizeLinkedPrompt", () => { it("removes characters a reader cannot see and counts them", () => { const tagged = "rate me" + String.fromCodePoint(0xe0041, 0xe0042); @@ -99,8 +101,9 @@ describe("link round trips", () => { repository: { owner: "pingdotgg", name: "t3code" }, provider: "claudeAgent", model: "opus", + origin: HOSTED, }); - const path = promptLinkAppPath(link); + const path = promptLinkAppPath(link, HOSTED); expect(path).toBe(new URL(link).pathname + new URL(link).search); const parsed = parseNewThreadLink(new URL(link).search); expect(parsed.ok && parsed.value.kind === "new" && parsed.value.prompt.text).toBe( @@ -109,11 +112,14 @@ describe("link round trips", () => { }); it("opens a copied thread link in the app at that thread", () => { - const link = hostedThreadLink({ - environmentId: EnvironmentId.make("env-1"), - threadId: ThreadId.make("mcp:1"), - }); - const path = promptLinkAppPath(link); + const link = hostedThreadLink( + { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("mcp:1"), + }, + HOSTED, + ); + const path = promptLinkAppPath(link, HOSTED); expect(path).toBe("/env-1/mcp%3A1"); const deepLink = promptLinkDeepLink(path!); expect(deepLink).toBe("t3code://threads/env-1/mcp%3A1"); @@ -123,20 +129,21 @@ describe("link round trips", () => { it("maps app scheme and hosted links to in-app paths, and nothing else", () => { expect(promptLinkAppPath("t3code://new?prompt=hi")).toBe("/new?prompt=hi"); expect(promptLinkAppPath("t3code-dev://threads/env-1/thread-1")).toBe("/env-1/thread-1"); - expect(promptLinkAppPath("https://app.t3.codes/env-1/thread-1?prompt=go")).toBe( + expect(promptLinkAppPath("https://hosted.example/env-1/thread-1?prompt=go", HOSTED)).toBe( "/env-1/thread-1?prompt=go", ); - expect(promptLinkAppPath("https://app.t3.codes/threads/env-1/thread-1")).toBe( + expect(promptLinkAppPath("https://hosted.example/threads/env-1/thread-1", HOSTED)).toBe( "/env-1/thread-1", ); - expect(promptLinkAppPath("https://app.t3.codes/threads/env-1/thread-1?prompt=go")).toBe( - "/env-1/thread-1?prompt=go", - ); + expect( + promptLinkAppPath("https://hosted.example/threads/env-1/thread-1?prompt=go", HOSTED), + ).toBe("/env-1/thread-1?prompt=go"); // The web app's own thread route, reloaded, is no link. - expect(promptLinkAppPath("https://app.t3.codes/env-1/thread-1")).toBeNull(); - expect(promptLinkAppPath("https://app.t3.codes/threads/env-1")).toBeNull(); - expect(promptLinkAppPath("https://app.t3.codes/settings/general")).toBeNull(); - expect(promptLinkAppPath("https://evil.test/new?prompt=hi")).toBeNull(); + expect(promptLinkAppPath("https://hosted.example/env-1/thread-1", HOSTED)).toBeNull(); + expect(promptLinkAppPath("https://hosted.example/threads/env-1", HOSTED)).toBeNull(); + expect(promptLinkAppPath("https://hosted.example/settings/general", HOSTED)).toBeNull(); + expect(promptLinkAppPath("https://evil.test/new?prompt=hi", HOSTED)).toBeNull(); + expect(promptLinkAppPath("https://hosted.example/new?prompt=hi")).toBeNull(); expect(promptLinkAppPath("t3code://app/index.html")).toBeNull(); }); }); diff --git a/packages/shared/src/promptLinks.ts b/packages/shared/src/promptLinks.ts index 75a6a43118fe8..c528beab4add2 100644 --- a/packages/shared/src/promptLinks.ts +++ b/packages/shared/src/promptLinks.ts @@ -1,4 +1,3 @@ -import { DEFAULT_HOSTED_APP_URL } from "./hostedApp.ts"; import { APP_NAME } from "./branding.ts"; export const PROMPT_LINK_MAX_PROMPT_LENGTH = 8_000; @@ -169,9 +168,9 @@ export function buildNewThreadLink(input: { readonly repository?: PromptLinkRepository | null; readonly provider?: string | null; readonly model?: string | null; - readonly origin?: string; + readonly origin: string; }): string { - const url = new URL("/new", input.origin ?? DEFAULT_HOSTED_APP_URL); + const url = new URL("/new", input.origin); url.searchParams.set("prompt", input.prompt); if (input.repository) { url.searchParams.set("repo", `${input.repository.owner}/${input.repository.name}`); @@ -184,10 +183,7 @@ export function buildNewThreadLink(input: { const APP_SCHEME = /^t3code(?:-[a-z]+)?:$/; const THREAD_PATH = /^\/([^/]+)\/([^/]+)\/?$/; -export function promptLinkAppPath( - value: string, - hostedAppUrl: string = DEFAULT_HOSTED_APP_URL, -): string | null { +export function promptLinkAppPath(value: string, hostedAppUrl?: string | null): string | null { if (!URL.canParse(value)) return null; const url = new URL(value); if (APP_SCHEME.test(url.protocol)) { @@ -197,7 +193,8 @@ export function promptLinkAppPath( const thread = url.host === "threads" ? THREAD_PATH.exec(url.pathname) : null; return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null; } - if (!URL.canParse(hostedAppUrl) || url.origin !== new URL(hostedAppUrl).origin) return null; + if (!hostedAppUrl || !URL.canParse(hostedAppUrl) || url.origin !== new URL(hostedAppUrl).origin) + return null; if (url.pathname === "/new") return `/new${url.search}`; // The web app's own thread route is a link only with a prompt, so reloading a tab never asks. const thread = url.pathname.startsWith("/threads/") diff --git a/packages/shared/src/providerAuthReturnUrl.ts b/packages/shared/src/providerAuthReturnUrl.ts index 1adc969fa1f5b..667195bab0b47 100644 --- a/packages/shared/src/providerAuthReturnUrl.ts +++ b/packages/shared/src/providerAuthReturnUrl.ts @@ -5,9 +5,7 @@ export function providerAuthReturnUrl(value: string | undefined): string | undef try { const url = new URL(value); const desktop = ["t3code:", "t3code-dev:"].includes(url.protocol) && url.host === "app"; - const web = - ["http:", "https:"].includes(url.protocol) && - (isLoopbackHost(url.hostname) || url.origin === "https://app.t3.codes"); + const web = ["http:", "https:"].includes(url.protocol) && isLoopbackHost(url.hostname); if ( url.username || url.password || diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 30f30a2ad9660..13db9acb50849 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -38,7 +38,7 @@ describe("remote", () => { expect( resolveRemotePairingTarget({ pairingUrl: - "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%3A44342%2F#token=pairing-token", + "https://hosted.example/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%3A44342%2F#token=pairing-token", }), ).toEqual({ credential: "pairing-token", diff --git a/packages/shared/src/telemetryId.test.ts b/packages/shared/src/telemetryId.test.ts deleted file mode 100644 index e17598eb0cc08..0000000000000 --- a/packages/shared/src/telemetryId.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { hashTelemetryId, isTelemetryIdHash } from "./telemetryId.ts"; - -describe("telemetry ids", () => { - it("accepts only hashes, never a raw id", () => { - expect(isTelemetryIdHash(hashTelemetryId("user_connect"))).toBe(true); - expect(isTelemetryIdHash("user_connect")).toBe(false); - expect(isTelemetryIdHash(hashTelemetryId("x").toUpperCase())).toBe(false); - }); -}); diff --git a/packages/shared/src/telemetryId.ts b/packages/shared/src/telemetryId.ts deleted file mode 100644 index 833ee4fe6d913..0000000000000 --- a/packages/shared/src/telemetryId.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { sha256 } from "@noble/hashes/sha2"; -import * as Hex from "effect/encoding/Hex"; - -/** - * Hashes an identifier before it reaches analytics. Clients and the server both - * use it, so one Connect account gives the same owner id on every surface. - */ -export const hashTelemetryId = (value: string): string => - Hex.encode(sha256(new TextEncoder().encode(value))); - -/** True for a `hashTelemetryId` result. The server accepts only this shape from clients. */ -export const isTelemetryIdHash = (value: string): boolean => /^[0-9a-f]{64}$/u.test(value); diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index 00f4f26c8303a..9fdc65c1756b7 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -203,62 +203,19 @@ describe("thread links", () => { }); describe("hosted thread links", () => { - it("escapes every id character a sentence or path could claim, and reads it back", () => { + it("escapes every id character a sentence or path could claim", () => { const thread = ref("env.1", "thread:delegated/mcp%3A1."); - const link = hostedThreadLink(thread); - expect(link).toBe("https://app.t3.codes/threads/env%2E1/thread%3Adelegated%2Fmcp%253A1%2E"); - expect(parseThreadLinkHref(link)).toEqual(thread); - expect(hostedThreadLink(thread, "https://preview.t3.codes/")).toMatch( - /^https:\/\/preview\.t3\.codes\/threads\//, + expect(hostedThreadLink(thread, "https://hosted.example/")).toBe( + "https://hosted.example/threads/env%2E1/thread%3Adelegated%2Fmcp%253A1%2E", ); }); - it("reads only a bare thread path on the hosted app", () => { - expect(parseThreadLinkHref("https://app.t3.codes/threads/env-1/thread-1")).toEqual( - ref("env-1", "thread-1"), - ); - expect(parseThreadLinkHref("https://app.t3.codes/threads/env-1/thread-1?prompt=go")).toBeNull(); - expect(parseThreadLinkHref("https://app.t3.codes/threads/env-1/thread-1/extra")).toBeNull(); - expect(parseThreadLinkHref("https://app.t3.codes/threads/env-1")).toBeNull(); - expect(parseThreadLinkHref("https://app.t3.codes/env-1/thread-1")).toBeNull(); - expect(parseThreadLinkHref("https://evil.test/threads/env-1/thread-1")).toBeNull(); - }); - it("writes a copied link a reader can follow, labeled when it has a title", () => { - expect(formatHostedThreadLink(ref("e", "t1"), "Fix [ci]")).toBe( - "[Fix ci](https://app.t3.codes/threads/e/t1)", + expect(formatHostedThreadLink(ref("e", "t1"), "Fix [ci]", "https://hosted.example")).toBe( + "[Fix ci](https://hosted.example/threads/e/t1)", ); - expect(formatHostedThreadLink(ref("e", "t1"), " ")).toBe("https://app.t3.codes/threads/e/t1"); - }); - - it("titles hosted links in every Markdown form and writes them as v2 links", () => { - const title = titlesIn({ there: { t1: "There" } }); - expect( - relabelThreadLinks( - [ - "See https://app.t3.codes/threads/there/t1.", - "[old](https://app.t3.codes/threads/there/t1)", - "", - "`https://app.t3.codes/threads/there/t1`", - "https://app.t3.codes/threads/there/t1?prompt=go", - ].join("\n"), - local, - title, - ), - ).toBe( - [ - "See [There](t3-thread://v2/there/t1).", - "[There](t3-thread://v2/there/t1)", - "[There](t3-thread://v2/there/t1)", - "`https://app.t3.codes/threads/there/t1`", - "https://app.t3.codes/threads/there/t1?prompt=go", - ].join("\n"), + expect(formatHostedThreadLink(ref("e", "t1"), " ", "https://hosted.example")).toBe( + "https://hosted.example/threads/e/t1", ); - expect( - relabelThreadLinks("https://app.t3.codes/threads/there/t1", local, () => undefined), - ).toBe("Thread on another machine"); - expect(threadLinkRefs("Open https://app.t3.codes/threads/there/t%3A1", local)).toEqual([ - ref("there", "t:1"), - ]); }); }); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index ddd0624ee116c..cd73a3c222a68 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -2,18 +2,13 @@ import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contract import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { DEFAULT_HOSTED_APP_URL } from "./hostedApp.ts"; - export const THREAD_LINK_PROTOCOL = "t3-thread"; const V1_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; const V2_PREFIX = `${THREAD_LINK_PROTOCOL}://v2/`; -const HOSTED_PREFIX = `${DEFAULT_HOSTED_APP_URL}/threads/`; -const HOSTED_SEGMENT = "[A-Za-z0-9_%-]+"; -const HOSTED_LINK = `${HOSTED_PREFIX.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}${HOSTED_SEGMENT}/${HOSTED_SEGMENT}`; // Code comes first in the alternation so a link written inside a code span or fence is skipped. // A labeled link's destination may be angle-bracketed, as CommonMark allows: [label](). const THREAD_LINK_OUTSIDE_CODE = new RegExp( - String.raw`(?(\`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(\`+)[^\n]*?\4)|\[(?
PostHog\n', + "apps/web/vercel.ts": 'const ROUTER_HOST = "app.t3.codes";\n', + }); + assert.deepStrictEqual(findRemovedConnectProblems(repo.root, [], repo.files), [ + "apps/server/src/a.ts:1 names a PostHog project key (phc_fakeFixtureKey0123456789abcdef). Shipped code must not reach it.", + "apps/server/src/b.ts:1 names upstream's analytics endpoint (ingest.t3.codes). Shipped code must not reach it.", + "packages/shared/src/c.ts:2 names upstream's hosted web app (app.t3.codes). Shipped code must not reach it.", + "apps/server/src/d.ts:1 names content fetched from upstream (raw.githubusercontent.com/pingdotgg/). Shipped code must not reach it.", + "apps/mobile/modules/e/E.swift:1 names a Sentry host (sentry.io). Shipped code must not reach it.", + "apps/web/package.json depends on posthog-js; the fork sends no product analytics.", + "apps/mobile/package.json depends on expo-observe; the fork sends no crash reports.", + ]); +}); + it("reads the checked-in manifest", () => { const paths = readManifestPaths(NodePath.resolve(import.meta.dirname, "..")); assert.include(paths, "infra/relay"); assert.include(paths, "apps/server/src/relay"); + assert.include(paths, "apps/server/src/telemetry"); }); diff --git a/scripts/check-removed-connect.ts b/scripts/check-removed-connect.ts index 6111f929fa46c..13ad2d973e720 100644 --- a/scripts/check-removed-connect.ts +++ b/scripts/check-removed-connect.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node // @effect-diagnostics nodeBuiltinImport:off globalConsole:off - a small sync check with no Effect runtime. -// Fails when a path removed with T3 Connect comes back (usually through an upstream sync) or -// when source code depends on Clerk again. +// Fails when an upstream sync brings back T3 Connect, upstream analytics or crash reporting, or a +// default that points the shipped product at a host upstream controls. import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -12,7 +12,11 @@ const SELF_PATHS = new Set([ "scripts/check-removed-connect.ts", "scripts/check-removed-connect.test.ts", ]); -const SOURCE = /\.(?:[cm]?[jt]sx?|astro)$/; +const SOURCE = /\.(?:[cm]?[jt]sx?|astro|swift|kt)$/; +const TEST = /\.test\.[cm]?[jt]sx?$|\/test\/|\/fixtures?\//; +const SHIPPED_ROOTS = ["apps/server/", "apps/web/", "apps/desktop/", "apps/mobile/", "packages/"]; +// Upstream's own hosted deploy config; the fork never deploys it. +const NOT_SHIPPED = new Set(["apps/web/vercel.ts"]); const CLERK_IMPORT = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)["']@clerk\//; const DEPENDENCY_FIELDS = [ "dependencies", @@ -21,6 +25,26 @@ const DEPENDENCY_FIELDS = [ "optionalDependencies", ] as const; +const BANNED_DEPENDENCIES: ReadonlyArray<{ readonly prefix: string; readonly why: string }> = [ + { prefix: "@clerk/", why: "Clerk was removed with T3 Connect" }, + { prefix: "posthog-", why: "the fork sends no product analytics" }, + { prefix: "@posthog/", why: "the fork sends no product analytics" }, + { prefix: "@sentry/", why: "the fork sends no crash reports" }, + { prefix: "expo-observe", why: "the fork sends no crash reports" }, +]; + +/** Hosts and keys that reach upstream from shipped code. Comments count: they become code. */ +export const BANNED_CONTENT: ReadonlyArray<{ readonly pattern: RegExp; readonly why: string }> = [ + { pattern: /\bphc_[A-Za-z0-9]{20,}/, why: "a PostHog project key" }, + { pattern: /T3CODE_POSTHOG_/, why: "upstream's PostHog settings" }, + { pattern: /posthog\.com/, why: "a PostHog host" }, + { pattern: /ingest\.t3\.codes/, why: "upstream's analytics endpoint" }, + { pattern: /sentry\.io/, why: "a Sentry host" }, + { pattern: /app\.t3\.codes/, why: "upstream's hosted web app" }, + { pattern: /raw\.githubusercontent\.com\/pingdotgg\//, why: "content fetched from upstream" }, + { pattern: /github\.com\/pingdotgg\/t3code\/releases/, why: "upstream's release downloads" }, +]; + export function readManifestPaths(root: string): ReadonlyArray { const manifest: unknown = JSON.parse( NodeFS.readFileSync(NodePath.join(root, MANIFEST_PATH), "utf8"), @@ -35,17 +59,28 @@ export function readManifestPaths(root: string): ReadonlyArray { return paths; } -function clerkDependencies(packageJson: string): ReadonlyArray { +function bannedDependencies(packageJson: string): ReadonlyArray { const parsed: unknown = JSON.parse(packageJson); if (typeof parsed !== "object" || parsed === null) return []; return DEPENDENCY_FIELDS.flatMap((field) => { const dependencies: unknown = Reflect.get(parsed, field); - return typeof dependencies === "object" && dependencies !== null - ? Object.keys(dependencies).filter((name) => name.startsWith("@clerk/")) - : []; + if (typeof dependencies !== "object" || dependencies === null) return []; + return Object.keys(dependencies).flatMap((name) => { + const banned = BANNED_DEPENDENCIES.find((entry) => name.startsWith(entry.prefix)); + return banned === undefined ? [] : [`depends on ${name}; ${banned.why}.`]; + }); }); } +function isShippedSource(file: string): boolean { + return ( + SHIPPED_ROOTS.some((root) => file.startsWith(root)) && + SOURCE.test(file) && + !TEST.test(file) && + !NOT_SHIPPED.has(file) + ); +} + /** Problems in `root`, given its manifest paths and the repository files to scan. */ export function findRemovedConnectProblems( root: string, @@ -54,7 +89,7 @@ export function findRemovedConnectProblems( ): ReadonlyArray { const problems = removedPaths .filter((path) => NodeFS.existsSync(NodePath.join(root, path))) - .map((path) => `${path} was removed with T3 Connect and is back. Delete it.`); + .map((path) => `${path} was removed from the fork and is back. Delete it.`); for (const file of files) { if (SELF_PATHS.has(file) || file.startsWith(".repos/") || file.includes("node_modules/")) { continue; @@ -65,12 +100,19 @@ export function findRemovedConnectProblems( if (!NodeFS.existsSync(absolute)) continue; const text = NodeFS.readFileSync(absolute, "utf8"); if (isManifest) { - for (const name of clerkDependencies(text)) { - problems.push(`${file} depends on ${name}; Clerk was removed with T3 Connect.`); - } - } else if (CLERK_IMPORT.test(text)) { + for (const problem of bannedDependencies(text)) problems.push(`${file} ${problem}`); + continue; + } + if (CLERK_IMPORT.test(text)) { problems.push(`${file} imports @clerk/; Clerk was removed with T3 Connect.`); } + if (!isShippedSource(file)) continue; + for (const { pattern, why } of BANNED_CONTENT) { + const match = pattern.exec(text); + if (match === null) continue; + const line = text.slice(0, match.index).split("\n").length; + problems.push(`${file}:${line} names ${why} (${match[0]}). Shipped code must not reach it.`); + } } return problems; } @@ -86,7 +128,9 @@ function main() { .filter(Boolean); const problems = findRemovedConnectProblems(root, readManifestPaths(root), files); if (problems.length === 0) { - console.log("check-removed-connect: no removed T3 Connect paths or Clerk imports found."); + console.log( + "check-removed-connect: no T3 Connect, upstream analytics or upstream hosts found.", + ); return; } for (const problem of problems) console.error(problem); diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index ed097d9bf8396..0794de0e9b631 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -45,7 +45,6 @@ describe("shouldBundleCliDependency", () => { "transcribe-cpp", "@transcribe-cpp/win32-x64-cpu-vulkan", "koffi", - "@clerk/electron-passkeys", "node-addon-api", ]) { assert.strictEqual(shouldBundleCliDependency(id), false, id); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index 372d2e42caacb..8c2f2aebc026a 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -14,7 +14,6 @@ const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "koffi", "@koromix/", "@napi-rs/keyring", - "@clerk/electron-passkeys", "node-gyp-build", "node-addon-api", "bufferutil", diff --git a/scripts/lib/desktop-external-packages.ts b/scripts/lib/desktop-external-packages.ts index 68af7f32512c1..09eab9482c153 100644 --- a/scripts/lib/desktop-external-packages.ts +++ b/scripts/lib/desktop-external-packages.ts @@ -2,7 +2,6 @@ const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [ "@napi-rs/keyring", "@crowecawcaw/xa11y", - "@clerk/electron-passkeys", "electron-webauthn", "ffi-rs", "@yuuang/", diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index 089263037e54e..e7aee19e8b3c3 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -1,6 +1,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { CLI_RELEASE_REPOSITORY } from "@t3tools/shared/cliRelease"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; @@ -855,7 +856,7 @@ const buildOnBlacksmith = Effect.fn("nativeClient.buildOnBlacksmith")( if (environment.GITHUB_ACTIONS === "true" || environment.T3_NATIVE_CLIENT_BUILD === "local") return false; if (!(yield* isCommandAvailable("gh"))) return false; - const repository = environment.T3_NATIVE_CLIENT_REPOSITORY ?? "pingdotgg/t3code"; + const repository = environment.T3_NATIVE_CLIENT_REPOSITORY ?? CLI_RELEASE_REPOSITORY; const { repo } = yield* roots; const sha = yield* command("git", ["rev-parse", "HEAD"], false, repo); const pushed = yield* command( diff --git a/scripts/npm-launcher/t3.mjs b/scripts/npm-launcher/t3.mjs index b92ddd379e1a4..cbd98552efad7 100644 --- a/scripts/npm-launcher/t3.mjs +++ b/scripts/npm-launcher/t3.mjs @@ -76,7 +76,7 @@ async function download(url, file) { async function install(dir, key) { const archive = `t3-${version}-${key}.${process.platform === "win32" ? "zip" : "tar.gz"}`; const base = ( - process.env.T3CODE_RELEASE_BASE_URL || "https://github.com/pingdotgg/t3code/releases/download" + process.env.T3CODE_RELEASE_BASE_URL || "https://github.com/jayleaton/ada/releases/download" ).replace(/\/+$/, ""); const sums = await (await get(`${base}/v${version}/SHA256SUMS`)).text(); const expected = /^([0-9a-f]{64})\s+\*?(.+)$/im; @@ -177,7 +177,7 @@ async function main() { if (!SUPPORTED.includes(key)) { throw new Error( `no Ada CLI build is available for ${key} (supported: ${SUPPORTED.join(", ")}).\n` + - "The desktop app is at https://github.com/pingdotgg/t3code/releases", + "The desktop app is at https://github.com/jayleaton/ada/releases", ); } const dir = path.join(t3Home(), "runtime", "versions", version); diff --git a/scripts/removed-connect-paths.json b/scripts/removed-connect-paths.json index b0d28ec5fc83b..11efe5cb5783c 100644 --- a/scripts/removed-connect-paths.json +++ b/scripts/removed-connect-paths.json @@ -1,5 +1,5 @@ { - "note": "T3 Connect was removed from this fork. On an upstream sync, resolve modify/delete conflicts on these paths by deleting them; scripts/check-removed-connect.ts fails if any returns.", + "note": "T3 Connect, upstream product analytics and mobile crash reporting were removed from this fork. On an upstream sync, resolve modify/delete conflicts on these paths by deleting them; scripts/check-removed-connect.ts fails if any returns, or if shipped code names an upstream analytics host, key or hosted app again.", "paths": [ ".github/scripts/relay-state-output.test.cjs", ".github/workflows/deploy-relay.yml", @@ -22,6 +22,7 @@ "apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx", "apps/mobile/src/features/connection/environmentSections.test.ts", "apps/mobile/src/features/connection/environmentSections.ts", + "apps/mobile/src/features/observability", "apps/mobile/src/features/observability/tracing.test.ts", "apps/mobile/src/features/observability/tracing.ts", "apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx", @@ -42,6 +43,7 @@ "apps/server/src/guest", "apps/server/src/relay", "apps/server/src/scheduledTasks/RelayDeliveryProof.ts", + "apps/server/src/telemetry", "apps/web/src/cloud", "apps/web/src/components/chat/guestShareAvailability.ts", "apps/web/src/components/chat/guestShareDialogState.ts", @@ -62,6 +64,7 @@ "apps/web/src/routes/connect.tsx", "apps/web/src/routes/guest.tsx", "apps/web/src/state/relay.ts", + "docs/internals/product-analytics.md", "docs/internals/t3-connect.md", "docs/operations/android-notifications.md", "docs/operations/connect-setup.md", @@ -79,6 +82,7 @@ "packages/contracts/src/relayClient.ts", "packages/shared/src/connectAuth.test.ts", "packages/shared/src/connectAuth.ts", + "packages/shared/src/hostedApp.ts", "packages/shared/src/mobileSourceMaps.ts", "packages/shared/src/relayAuth.test.ts", "packages/shared/src/relayAuth.ts", @@ -90,6 +94,8 @@ "packages/shared/src/relayTracing.ts", "packages/shared/src/relayUrl.test.ts", "packages/shared/src/relayUrl.ts", + "packages/shared/src/telemetryId.test.ts", + "packages/shared/src/telemetryId.ts", "patches/@alchemy.run__test@2.0.0-beta.85.patch", "patches/@clerk__expo-native-components@0.4.0.patch" ] diff --git a/scripts/upstream-install-commands.test.ts b/scripts/upstream-install-commands.test.ts index 64be051043cbd..4c5f4b5968c9a 100644 --- a/scripts/upstream-install-commands.test.ts +++ b/scripts/upstream-install-commands.test.ts @@ -84,15 +84,8 @@ const BRAND_ALLOWLIST: ReadonlyArray = [ why: "Names the upstream app the fork installs beside, so copy about it can say so.", }, // Functional endpoints the fork still uses. Renaming them would break the feature, not rebrand it. - { file: /./, text: "app.t3.codes", why: "Hosted web client and its thread links." }, - { file: /./, text: "ingest.t3.codes", why: "Telemetry endpoint the server reports to." }, { file: /./, text: "t3.codes/schema/t3.json", why: "Published t3.json schema URL." }, - { - file: /^apps\/server\/src\/provider\/ModelManifest\.ts$/, - text: "raw.githubusercontent.com/pingdotgg/t3code", - why: "Model manifest the server downloads.", - }, - // Legal: the privacy policy and terms cover the upstream telemetry the fork still sends. + // Legal: upstream's privacy policy and terms, which the marketing site still carries. { file: /^apps\/marketing\/src\/pages\/(?:privacy-policy|terms-of-service|security-policy|legal)\.astro$/, why: "Legal text is not ours to rewrite.", From 18f5b54d40f29b037ecc3c54df5136dd33d1ffdb Mon Sep 17 00:00:00 2001 From: JJ Eaton <58495321+oldbettie@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:03:00 +0800 Subject: [PATCH 2/2] test: pin the upstream guard in fork-guards.json Co-Authored-By: Claude Opus 5.5 --- scripts/fork-guards.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/fork-guards.json b/scripts/fork-guards.json index 0e4519ce62200..dc502b61cabda 100644 --- a/scripts/fork-guards.json +++ b/scripts/fork-guards.json @@ -264,5 +264,13 @@ "title": "shows no upstream name in the app", "sha256": "f25c34c8d9f862713f1abc327e501ac71bb9ac80cedac2bd24d58603abaab3cb", "sources": ["apps/mobile/src/lib/mobileBranding.ts"] + }, + { + "fix": "#245", + "protects": "Ada sends nothing to upstream analytics, crash reporting or its hosted app, and upstream syncs cannot bring them back", + "test": "scripts/check-removed-connect.test.ts", + "title": "reports upstream analytics, crash reporting and hosted-app defaults in shipped code only", + "sha256": "be6a27c930fba332a719554b583de6807677caa25314f9e0e9cc5c567fcac6d7", + "sources": ["scripts/check-removed-connect.ts", "scripts/removed-connect-paths.json"] } ]