authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-28 01:43:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 01:43:20-07:00
log88be5631ff6a48b98ead48fa5af6a2be0a4d63e4
treed1148d617aa9125f2d721281de09f65dcbfbeeff
parent49f6241ddeee2a5d20ec181b5b94ec0a9ac72f7b
signature Signed by PGP key B5690EEEBB952194

feat(analytics): report connected client platforms (#8481)


29 files changed, 1005 insertions(+), 60 deletions(-)

.github/workflows/ci.yml+4-4
......@@ -6,6 +6,9 @@ on:
66 branches:
77 - main
88
9permissions:
10 contents: read
11
912concurrency:
1013 group: ci-${{ github.event.pull_request.number || github.sha }}
1114 cancel-in-progress: ${{ github.event_name == 'pull_request' }}
......@@ -52,10 +55,7 @@ jobs:
5255 run: vp run build:desktop
5356
5457 - name: Verify preload bundle output
55 run: |
56 test -f apps/desktop/dist-electron/preload.cjs
57 grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs
58 grep -n "__clerk_internal_electron_passkeys" apps/desktop/dist-electron/preload.cjs
58 run: node apps/desktop/scripts/verify-preload-bundle.mjs
5959
6060 # Everything except `t3` (apps/server). `--parallel` drops the package
6161 # dependency ordering that `vp run` applies by default: these `test` tasks
apps/desktop/package.json+1
......@@ -30,6 +30,7 @@
3030 "devDependencies": {
3131 "@effect/vitest": "catalog:",
3232 "@types/node": "catalog:",
33 "acorn": "8.16.0",
3334 "cross-env": "^10.1.0",
3435 "electron-builder": "26.15.6",
3536 "tailwindcss": "^4.0.0",
apps/desktop/scripts/verify-preload-bundle.mjs created+147
......@@ -0,0 +1,147 @@
1import * as NodeEvents from "node:events";
2import * as NodeFS from "node:fs";
3import * as NodeFSP from "node:fs/promises";
4import * as NodeTimers from "node:timers";
5import * as NodeURL from "node:url";
6import * as NodeVM from "node:vm";
7import { parse } from "acorn";
8
9const expectedDesktopBridgeApis = [
10 "getClientPlatform",
11 "getLocalEnvironmentBootstraps",
12 "pickFolder",
13];
14const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys";
15const preloadExecutionTimeoutMs = 1_000;
16const desktopPackage = JSON.parse(
17 NodeFS.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
18);
19const electronVersion = desktopPackage.dependencies.electron;
20
21const isSyntaxNode = (value) =>
22 typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
23
24const inspectBundle = (source) => {
25 const runtimeImports = [];
26 const visit = (node) => {
27 if (node.type === "ImportExpression") {
28 throw new Error("Desktop preload bundle contains a dynamic import() call");
29 }
30
31 if (node.type === "CallExpression" && node.callee.type === "Identifier") {
32 if (node.callee.name === "require") {
33 const [argument] = node.arguments;
34 if (node.arguments.length !== 1 || argument?.type !== "Literal") {
35 throw new Error("Desktop preload bundle contains a dynamic require() call");
36 }
37 if (typeof argument.value !== "string") {
38 throw new Error("Desktop preload bundle contains a dynamic require() call");
39 }
40 runtimeImports.push(argument.value);
41 }
42 }
43
44 for (const child of Object.values(node)) {
45 if (Array.isArray(child)) {
46 for (const item of child) {
47 if (isSyntaxNode(item)) visit(item);
48 }
49 } else if (isSyntaxNode(child)) {
50 visit(child);
51 }
52 }
53 };
54
55 visit(parse(source, { ecmaVersion: "latest", sourceType: "script" }));
56 return runtimeImports;
57};
58
59const createSandboxModules = (exposedGlobals) => {
60 const ipcRenderer = {
61 invoke: () => Promise.resolve(undefined),
62 on: () => undefined,
63 removeListener: () => undefined,
64 sendSync: () => undefined,
65 };
66 const electron = {
67 contextBridge: {
68 exposeInMainWorld: (name, api) => exposedGlobals.set(name, api),
69 },
70 ipcRenderer,
71 };
72
73 return new Map([
74 ["electron", electron],
75 ["electron/common", electron],
76 ["electron/renderer", electron],
77 ["events", NodeEvents.default],
78 ["node:events", NodeEvents.default],
79 ["timers", NodeTimers.default],
80 ["node:timers", NodeTimers.default],
81 ["url", NodeURL.default],
82 ["node:url", NodeURL.default],
83 ]);
84};
85
86const executeBundle = (source, sandboxModules) => {
87 const sandboxProcess = {
88 contextIsolated: true,
89 // oxlint-disable-next-line t3code/no-global-process-runtime -- This standalone CI verifier supplies the preload's host platform without loading Effect.
90 platform: process.platform,
91 versions: { electron: electronVersion },
92 };
93 const requireSandboxModule = (moduleName) => {
94 if (!sandboxModules.has(moduleName)) {
95 throw new Error(
96 `Unsupported sandbox module requested during preload execution: ${moduleName}`,
97 );
98 }
99 return sandboxModules.get(moduleName);
100 };
101
102 NodeVM.runInNewContext(
103 source,
104 {
105 process: sandboxProcess,
106 require: requireSandboxModule,
107 },
108 {
109 filename: "desktop-preload.cjs",
110 timeout: preloadExecutionTimeoutMs,
111 },
112 );
113};
114
115export const verifyPreloadBundle = (source) => {
116 const runtimeImports = inspectBundle(source);
117 const exposedGlobals = new Map();
118 const sandboxModules = createSandboxModules(exposedGlobals);
119 const unsupportedImports = [...new Set(runtimeImports)]
120 .filter((moduleName) => !sandboxModules.has(moduleName))
121 .toSorted();
122
123 if (unsupportedImports.length > 0) {
124 throw new Error(
125 `Desktop preload bundle contains unsupported sandbox imports: ${unsupportedImports.join(", ")}`,
126 );
127 }
128
129 executeBundle(source, sandboxModules);
130
131 const desktopBridge = exposedGlobals.get("desktopBridge");
132 const missingApis = expectedDesktopBridgeApis.filter(
133 (api) => typeof desktopBridge?.[api] !== "function",
134 );
135 if (!exposedGlobals.has("desktopBridge")) missingApis.unshift("desktopBridge exposure");
136 if (!exposedGlobals.has(clerkPasskeysGlobal)) missingApis.push(`${clerkPasskeysGlobal} exposure`);
137
138 if (missingApis.length > 0) {
139 throw new Error(`Desktop preload bundle is missing executable APIs: ${missingApis.join(", ")}`);
140 }
141};
142
143if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) {
144 const preloadUrl = new URL("../dist-electron/preload.cjs", import.meta.url);
145 const source = await NodeFSP.readFile(preloadUrl, "utf8");
146 verifyPreloadBundle(source);
147}
apps/desktop/scripts/verify-preload-bundle.test.mjs created+104
......@@ -0,0 +1,104 @@
1import { assert, describe, it } from "vite-plus/test";
2
3import { verifyPreloadBundle } from "./verify-preload-bundle.mjs";
4
5const validPreload = `
6 const electron = require("electron");
7 const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
8 electron.contextBridge.exposeInMainWorld("__clerk_internal_electron_passkeys", {});
9 electron.contextBridge.exposeInMainWorld("desktopBridge", {
10 getClientPlatform: () => process.platform,
11 getLocalEnvironmentBootstraps: () => [],
12 pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
13 });
14`;
15
16describe("desktop preload bundle verifier", () => {
17 it("rejects required API names that only appear in strings", () => {
18 assert.throws(
19 () =>
20 verifyPreloadBundle(`
21 "desktopBridge getClientPlatform getLocalEnvironmentBootstraps pickFolder";
22 "__clerk_internal_electron_passkeys";
23 require("electron");
24 `),
25 /missing executable APIs/,
26 );
27 });
28
29 it("rejects a required API whose exposed value is not callable", () => {
30 assert.throws(
31 () =>
32 verifyPreloadBundle(
33 validPreload.replace(
34 "getClientPlatform: () => process.platform,",
35 "getClientPlatform: undefined,",
36 ),
37 ),
38 /missing executable APIs: getClientPlatform/,
39 );
40 });
41
42 it("accepts a required API exposed through a function alias", () => {
43 assert.doesNotThrow(() =>
44 verifyPreloadBundle(`
45 const readClientPlatform = () => process.platform;
46 ${validPreload.replace(
47 "getClientPlatform: () => process.platform,",
48 "getClientPlatform: readClientPlatform,",
49 )}
50 `),
51 );
52 });
53
54 it("rejects dynamic imports with comments before the opening parenthesis", () => {
55 assert.throws(
56 () =>
57 verifyPreloadBundle(`${validPreload}\nimport /* @vite-ignore */("unsupported-module");`),
58 /dynamic import\(\)/,
59 );
60 });
61
62 it("ignores import-like text in strings", () => {
63 assert.doesNotThrow(() =>
64 verifyPreloadBundle(`${validPreload}\nconst message = 'import /* comment */("module")';`),
65 );
66 });
67
68 it("rejects unsupported require calls with comments before the opening parenthesis", () => {
69 assert.throws(
70 () => verifyPreloadBundle(`${validPreload}\nrequire /* @__PURE__ */ ("node:fs");`),
71 /unsupported sandbox imports: node:fs/,
72 );
73 });
74
75 it("rejects unsupported optional require calls", () => {
76 assert.throws(
77 () => verifyPreloadBundle(`${validPreload}\nrequire?.("node:fs");`),
78 /unsupported sandbox imports: node:fs/,
79 );
80 });
81
82 it("accepts Electron sandbox module aliases", () => {
83 assert.doesNotThrow(() =>
84 verifyPreloadBundle(`
85 ${validPreload}
86 require("electron/common");
87 require("electron/renderer");
88 require("node:events");
89 require("node:timers");
90 require("node:url");
91 `),
92 );
93 });
94
95 it("ignores require-like text in strings and comments", () => {
96 assert.doesNotThrow(() =>
97 verifyPreloadBundle(`
98 ${validPreload}
99 const message = 'require("node:fs")';
100 // require("node:path")
101 `),
102 );
103 });
104});
apps/desktop/src/preload.ts+4
......@@ -11,6 +11,9 @@ import * as IpcChannels from "./ipc/channels.ts";
1111
1212exposeClerkBridge({ passkeys: true });
1313
14// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process.
15const clientPlatform = process.platform;
16
1417function unwrapEnsureSshEnvironmentResult(result: unknown) {
1518 if (
1619 typeof result === "object" &&
......@@ -35,6 +38,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
3538 }
3639 return result as ReturnType<DesktopBridge["getAppBranding"]>;
3740 },
41 getClientPlatform: () => clientPlatform,
3842 getSystemLocale: () => {
3943 const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
4044 return typeof result === "string" ? result : null;
apps/mobile/src/features/cloud/linkEnvironment.test.ts+8
......@@ -34,6 +34,14 @@ vi.mock("expo-constants", () => ({
3434}));
3535
3636vi.mock("expo-device", () => ({
37 deviceType: 1,
38 DeviceType: {
39 UNKNOWN: 0,
40 PHONE: 1,
41 TABLET: 2,
42 DESKTOP: 3,
43 TV: 4,
44 },
3745 osVersion: "18.4.1",
3846 modelName: "iPhone 15 Pro",
3947}));
apps/mobile/src/lib/authClientMetadata.ts+6-1
......@@ -8,7 +8,12 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM
88
99 return {
1010 label: "T3 Code Mobile",
11 deviceType: "mobile",
11 deviceType:
12 Device.deviceType === Device.DeviceType.TABLET
13 ? "tablet"
14 : Device.deviceType === Device.DeviceType.PHONE
15 ? "mobile"
16 : "unknown",
1217 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
1318 ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}),
1419 ...(deviceModel ? { deviceModel } : {}),
apps/mobile/src/lib/connection.test.ts+20
......@@ -10,6 +10,14 @@ import { authClientMetadata } from "./authClientMetadata";
1010
1111const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" }));
1212const mobileDevice = vi.hoisted(() => ({
13 deviceType: 1,
14 DeviceType: {
15 UNKNOWN: 0,
16 PHONE: 1,
17 TABLET: 2,
18 DESKTOP: 3,
19 TV: 4,
20 },
1321 osVersion: "18.4.1",
1422 modelName: "iPhone 15 Pro",
1523}));
......@@ -29,6 +37,7 @@ vi.mock("expo-device", () => mobileDevice);
2937describe("mobile remote connection records", () => {
3038 afterEach(() => {
3139 mobilePlatform.OS = "ios";
40 mobileDevice.deviceType = mobileDevice.DeviceType.PHONE;
3241 mobileDevice.osVersion = "18.4.1";
3342 mobileDevice.modelName = "iPhone 15 Pro";
3443 });
......@@ -56,6 +65,17 @@ describe("mobile remote connection records", () => {
5665 });
5766 });
5867
68 it("identifies native tablets separately from phones", () => {
69 mobileDevice.deviceType = mobileDevice.DeviceType.TABLET;
70 mobileDevice.modelName = "iPad Pro 13-inch";
71
72 expect(authClientMetadata()).toMatchObject({
73 deviceType: "tablet",
74 os: "iOS",
75 deviceModel: "iPad Pro 13-inch",
76 });
77 });
78
5979 it("includes the mobile app version when the client provides it", () => {
6080 expect(authClientMetadata("1.2.3")).toMatchObject({
6181 surface: "mobile",
apps/server/src/server.test.ts+131-2
......@@ -5261,7 +5261,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
52615261 }) as const;
52625262
52635263 const wsUrl = yield* getWsServerUrl(
5264 "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro",
5264 "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientDeviceType=phone&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro&connectionMethod=relay",
52655265 );
52665266 yield* Effect.scoped(
52675267 withWsRpcClient(wsUrl, (client) =>
......@@ -5298,15 +5298,144 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
52985298 {
52995299 surface: "mobile",
53005300 appVersion: "1.2.3",
5301 clientAppVersion: "1.2.3",
5302 clientOs: "iOS",
53015303 os: "iOS",
5304 clientDeviceType: "phone",
53025305 osMajorVersion: 18,
5306 clientOsMajorVersion: 18,
53035307 deviceModel: "iPhone 15 Pro",
5308 clientDeviceModel: "iPhone 15 Pro",
5309 connectionMethod: "relay",
5310 },
5311 {
5312 surface: "mobile",
5313 appVersion: "1.2.3",
5314 clientAppVersion: "1.2.3",
5315 clientOs: "iOS",
5316 os: "iOS",
5317 clientDeviceType: "phone",
5318 osMajorVersion: 18,
5319 clientOsMajorVersion: 18,
5320 deviceModel: "iPhone 15 Pro",
5321 clientDeviceModel: "iPhone 15 Pro",
5322 connectionMethod: "relay",
53045323 },
5305 { surface: "mobile", appVersion: "1.2.3" },
53065324 ]);
53075325 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
53085326 );
53095327
5328 it.effect("keeps telemetry separate for simultaneous clients", () =>
5329 Effect.gen(function* () {
5330 const analyticsEvents: Array<{
5331 event: string;
5332 properties: Readonly<Record<string, unknown>> | undefined;
5333 }> = [];
5334
5335 yield* buildAppUnderTest({
5336 layers: {
5337 analyticsService: {
5338 record: (event, properties) =>
5339 Effect.sync(() => analyticsEvents.push({ event, properties })),
5340 },
5341 orchestrationEngine: {
5342 dispatch: () => Effect.succeed({ sequence: 1 }),
5343 },
5344 },
5345 });
5346
5347 const webUrl = yield* getWsServerUrl(
5348 "/ws?clientSurface=web&clientAppVersion=2.0.0&clientDeviceType=desktop&clientOs=Windows&clientWebDeployment=hosted&clientBrowser=Chrome&connectionMethod=direct",
5349 );
5350 const mobileUrl = yield* getWsServerUrl(
5351 "/ws?clientSurface=mobile&clientAppVersion=3.0.0&clientDeviceType=tablet&clientOs=Android&clientOsMajorVersion=15&clientDeviceModel=Pixel+Tablet&connectionMethod=relay",
5352 );
5353 const turnCommand = (client: string) => ({
5354 type: "thread.turn.start" as const,
5355 commandId: CommandId.make(`cmd-${client}-turn`),
5356 threadId: ThreadId.make(`thread-${client}`),
5357 message: {
5358 messageId: MessageId.make(`message-${client}`),
5359 role: "user" as const,
5360 text: "hello",
5361 attachments: [],
5362 },
5363 modelSelection: defaultModelSelection,
5364 runtimeMode: "full-access" as const,
5365 interactionMode: "default" as const,
5366 createdAt: "2026-01-01T00:00:00.000Z",
5367 });
5368
5369 yield* Effect.scoped(
5370 withWsRpcClient(webUrl, (webClient) =>
5371 withWsRpcClient(mobileUrl, (mobileClient) =>
5372 Effect.gen(function* () {
5373 yield* mobileClient[ORCHESTRATION_WS_METHODS.dispatchCommand](turnCommand("mobile"));
5374 yield* webClient[ORCHESTRATION_WS_METHODS.dispatchCommand](turnCommand("web"));
5375 }),
5376 ),
5377 ),
5378 );
5379
5380 assert.deepEqual(
5381 analyticsEvents
5382 .filter(({ event }) => event === "client.turn.requested")
5383 .map(({ properties }) => properties),
5384 [
5385 {
5386 surface: "mobile",
5387 appVersion: "3.0.0",
5388 clientAppVersion: "3.0.0",
5389 clientOs: "Android",
5390 os: "Android",
5391 clientDeviceType: "tablet",
5392 osMajorVersion: 15,
5393 clientOsMajorVersion: 15,
5394 deviceModel: "Pixel Tablet",
5395 clientDeviceModel: "Pixel Tablet",
5396 connectionMethod: "relay",
5397 },
5398 {
5399 surface: "web",
5400 appVersion: "2.0.0",
5401 clientAppVersion: "2.0.0",
5402 clientOs: "Windows",
5403 clientDeviceType: "desktop",
5404 webDeployment: "hosted",
5405 clientBrowser: "Chrome",
5406 connectionMethod: "direct",
5407 },
5408 ],
5409 );
5410 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5411 );
5412
5413 it.effect("ignores invalid client telemetry without rejecting the connection", () =>
5414 Effect.gen(function* () {
5415 const connectedProperties: Array<Readonly<Record<string, unknown>> | undefined> = [];
5416
5417 yield* buildAppUnderTest({
5418 layers: {
5419 analyticsService: {
5420 record: (event, properties) =>
5421 event === "client.connected"
5422 ? Effect.sync(() => connectedProperties.push(properties))
5423 : Effect.void,
5424 },
5425 },
5426 });
5427
5428 const invalidUrl = yield* getWsServerUrl(
5429 "/ws?clientSurface=watch&clientDeviceType=television&clientOs=Plan9&clientWebDeployment=cdn&clientBrowser=&clientOsMajorVersion=-1&connectionMethod=teleport",
5430 );
5431 yield* Effect.scoped(
5432 withWsRpcClient(invalidUrl, (client) => client[WS_METHODS.serverGetSettings]({})),
5433 );
5434
5435 assert.deepEqual(connectedProperties, [{}]);
5436 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5437 );
5438
53105439 it.effect("routes websocket rpc projects.writeFile errors", () =>
53115440 Effect.gen(function* () {
53125441 const fs = yield* FileSystem.FileSystem;
apps/server/src/telemetry/AnalyticsService.test.ts+29
......@@ -7,6 +7,7 @@ import * as Layer from "effect/Layer";
77import * as HttpServer from "effect/unstable/http/HttpServer";
88import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
99import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
10import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1011
1112import * as ServerConfig from "../config.ts";
1213import { getTelemetryIdentifier } from "./Identify.ts";
......@@ -20,6 +21,11 @@ interface RecordedBatchRequest {
2021 readonly properties?: {
2122 readonly index?: number;
2223 readonly clientType?: string;
24 readonly serverOs?: string;
25 readonly serverArch?: string;
26 readonly serverAppVersion?: string;
27 readonly serverMode?: string;
28 readonly t3CodeVersion?: string;
2329 };
2430 }>;
2531 } | null;
......@@ -31,6 +37,11 @@ interface RecordedBatchBody {
3137 readonly properties?: {
3238 readonly index?: number;
3339 readonly clientType?: string;
40 readonly serverOs?: string;
41 readonly serverArch?: string;
42 readonly serverAppVersion?: string;
43 readonly serverMode?: string;
44 readonly t3CodeVersion?: string;
3445 };
3546 }>;
3647}
......@@ -71,6 +82,12 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
7182 );
7283 const runtimeLayer = telemetryLayer.pipe(
7384 Layer.provide(configLayer),
85 Layer.provide(
86 Layer.mergeAll(
87 Layer.succeed(HostProcessPlatform, "linux"),
88 Layer.succeed(HostProcessArchitecture, "arm64"),
89 ),
90 ),
7491 Layer.provideMerge(NodeHttpServer.layerTest),
7592 );
7693
......@@ -117,6 +134,18 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
117134 ),
118135 true,
119136 );
137 assert.equal(
138 batchRequests.every((request) =>
139 request.body.batch.every(
140 (event) =>
141 event.properties?.serverOs === "Linux" &&
142 event.properties.serverArch === "arm64" &&
143 event.properties.serverAppVersion === event.properties.t3CodeVersion &&
144 event.properties.serverMode === "web",
145 ),
146 ),
147 true,
148 );
120149 }),
121150 );
122151});
apps/server/src/telemetry/AnalyticsService.ts+21
......@@ -7,6 +7,7 @@
77 * @module AnalyticsService
88 */
99import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
10import type { ClientOs } from "@t3tools/contracts";
1011import * as Config from "effect/Config";
1112import * as Context from "effect/Context";
1213import * as DateTime from "effect/DateTime";
......@@ -66,6 +67,21 @@ export class AnalyticsService extends Context.Service<
6667 );
6768}
6869
70export function serverOsFromNodePlatform(platform: string): ClientOs {
71 switch (platform) {
72 case "darwin":
73 return "macOS";
74 case "win32":
75 return "Windows";
76 case "linux":
77 return "Linux";
78 case "android":
79 return "Android";
80 default:
81 return "other";
82 }
83}
84
6985export const make = Effect.gen(function* () {
7086 const telemetryConfig = yield* TelemetryEnvConfig;
7187 const httpClient = yield* HttpClient.HttpClient;
......@@ -121,6 +137,11 @@ export const make = Effect.gen(function* () {
121137 arch: hostArchitecture,
122138 t3CodeVersion: packageJson.version,
123139 clientType,
140 serverOs: serverOsFromNodePlatform(hostPlatform),
141 serverArch: hostArchitecture,
142 serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName),
143 serverAppVersion: packageJson.version,
144 serverMode: serverConfig.mode,
124145 },
125146 timestamp: event.capturedAt,
126147 })),
apps/server/src/ws.ts+53-22
......@@ -15,7 +15,11 @@ import {
1515 type AuthAccessStreamEvent,
1616 type AuthEnvironmentScope,
1717 AuthSessionId,
18 ClientConnectionMethod,
19 ClientDeviceType,
20 ClientOs,
1821 ClientSurface,
22 ClientWebDeployment,
1923 CommandId,
2024 type DiscoveredLocalServerList,
2125 EventId,
......@@ -370,7 +374,12 @@ function toAuthAccessStreamEvent(
370374}
371375
372376const isClientSurface = Schema.is(ClientSurface);
377const isClientConnectionMethod = Schema.is(ClientConnectionMethod);
378const isClientDeviceType = Schema.is(ClientDeviceType);
379const isClientOs = Schema.is(ClientOs);
380const isClientWebDeployment = Schema.is(ClientWebDeployment);
373381const MAX_CLIENT_APP_VERSION_LENGTH = 64;
382const MAX_CLIENT_BROWSER_LENGTH = 64;
374383const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80;
375384
376385// Optional client identity announced on the /ws upgrade URL next to wsTicket.
......@@ -393,36 +402,56 @@ function readClientConnectionOrigin(
393402 };
394403}
395404
396const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({
397 ...(origin.surface !== undefined ? { surface: origin.surface } : {}),
398 ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}),
399});
400
401function readMobileDeviceAnalyticsProps(request: HttpServerRequest.HttpServerRequest) {
405// Client telemetry stays in this socket's RPC layer. It must not become a
406// server-global "current client" because several client types can connect at once.
407function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) {
402408 const url = HttpServerRequest.toURL(request);
403 if (Option.isNone(url) || url.value.searchParams.get("clientSurface") !== "mobile") {
409 if (Option.isNone(url)) {
404410 return {};
405411 }
406412
413 const surface = url.value.searchParams.get("clientSurface");
414 const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? "";
415 const deviceType = url.value.searchParams.get("clientDeviceType");
407416 const os = url.value.searchParams.get("clientOs");
417 const webDeployment = url.value.searchParams.get("clientWebDeployment");
418 const browser = url.value.searchParams.get("clientBrowser")?.trim() ?? "";
419 const connectionMethod = url.value.searchParams.get("connectionMethod");
408420 const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? "";
409421 const osMajorVersion = Number(rawOsMajorVersion);
410422 const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? "";
423 const isMobile = surface === "mobile";
424 const hasOsMajorVersion =
425 isMobile && rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0;
426 const hasDeviceModel =
427 isMobile && deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH;
411428
412429 return {
413 ...(os === "iOS" || os === "Android" ? { os } : {}),
414 ...(rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0
415 ? { osMajorVersion }
430 ...(isClientSurface(surface) ? { surface } : {}),
431 ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH
432 ? { appVersion, clientAppVersion: appVersion }
433 : {}),
434 ...(isClientOs(os)
435 ? {
436 clientOs: os,
437 ...(isMobile && (os === "iOS" || os === "Android") ? { os } : {}),
438 }
416439 : {}),
417 ...(deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH
418 ? { deviceModel }
440 ...(isClientDeviceType(deviceType) ? { clientDeviceType: deviceType } : {}),
441 ...(surface === "web" && isClientWebDeployment(webDeployment) ? { webDeployment } : {}),
442 ...(surface === "web" && browser !== "" && browser.length <= MAX_CLIENT_BROWSER_LENGTH
443 ? { clientBrowser: browser }
419444 : {}),
445 ...(hasOsMajorVersion ? { osMajorVersion, clientOsMajorVersion: osMajorVersion } : {}),
446 ...(hasDeviceModel ? { deviceModel, clientDeviceModel: deviceModel } : {}),
447 ...(isClientConnectionMethod(connectionMethod) ? { connectionMethod } : {}),
420448 };
421449}
422450
423451const makeWsRpcLayer = (
424452 currentSession: EnvironmentAuth.AuthenticatedSession,
425453 clientOrigin: OrchestrationClientOrigin,
454 clientAnalyticsProps: Readonly<Record<string, unknown>>,
426455 previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],
427456) =>
428457 WsRpcGroup.toLayer(
......@@ -444,18 +473,17 @@ const makeWsRpcLayer = (
444473 command,
445474 hasClientOrigin ? { origin: clientOrigin } : undefined,
446475 );
447 const originProps = clientOriginAnalyticsProps(clientOrigin);
448476 const recordClientCommandAnalytics = (command: OrchestrationCommand) => {
449477 switch (command.type) {
450478 case "thread.create":
451 return analytics.record("client.thread.started", originProps);
479 return analytics.record("client.thread.started", clientAnalyticsProps);
452480 case "thread.turn.start":
453481 return command.bootstrap?.createThread
454482 ? Effect.andThen(
455 analytics.record("client.thread.started", originProps),
456 analytics.record("client.turn.requested", originProps),
483 analytics.record("client.thread.started", clientAnalyticsProps),
484 analytics.record("client.turn.requested", clientAnalyticsProps),
457485 )
458 : analytics.record("client.turn.requested", originProps);
486 : analytics.record("client.turn.requested", clientAnalyticsProps);
459487 default:
460488 return Effect.void;
461489 }
......@@ -2479,16 +2507,19 @@ export const websocketRpcRouteLayer = Layer.unwrap(
24792507 ),
24802508 );
24812509 const clientOrigin = readClientConnectionOrigin(request);
2510 const clientAnalyticsProps = readClientAnalyticsProps(request);
24822511 yield* sessions.recordClientConnection(session.sessionId, clientOrigin);
2483 yield* analytics.record("client.connected", {
2484 ...clientOriginAnalyticsProps(clientOrigin),
2485 ...readMobileDeviceAnalyticsProps(request),
2486 });
2512 yield* analytics.record("client.connected", clientAnalyticsProps);
24872513 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {
24882514 disableTracing: true,
24892515 }).pipe(
24902516 Effect.provide(
2491 makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe(
2517 makeWsRpcLayer(
2518 session,
2519 clientOrigin,
2520 clientAnalyticsProps,
2521 previewAutomationBroker,
2522 ).pipe(
24922523 Layer.provideMerge(RpcSerialization.layerJson),
24932524 Layer.provide(ProviderMaintenanceRunner.layer),
24942525 Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),
apps/web/src/connection/clientMetadata.test.ts created+90
......@@ -0,0 +1,90 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 browserClientOs,
5 browserDeviceType,
6 browserFamily,
7 clientPresentationMetadata,
8} from "./clientMetadata";
9
10const desktopChrome = {
11 userAgent:
12 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36",
13 platform: "Win32",
14 maxTouchPoints: 0,
15};
16
17describe("client telemetry metadata", () => {
18 it("distinguishes hosted web from server-served web", () => {
19 expect(
20 clientPresentationMetadata({
21 appVersion: "1.2.3",
22 hosted: true,
23 identity: desktopChrome,
24 desktopBridge: undefined,
25 }),
26 ).toMatchObject({
27 surface: "web",
28 webDeployment: "hosted",
29 deviceType: "desktop",
30 os: "Windows",
31 browser: "Chrome",
32 appVersion: "1.2.3",
33 });
34
35 expect(
36 clientPresentationMetadata({
37 appVersion: "0.0.0",
38 hosted: false,
39 identity: desktopChrome,
40 desktopBridge: undefined,
41 }),
42 ).toMatchObject({ surface: "web", webDeployment: "server" });
43 });
44
45 it("identifies phone and tablet browsers", () => {
46 const iphone = {
47 userAgent:
48 "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1",
49 platform: "iPhone",
50 maxTouchPoints: 5,
51 };
52 const androidTablet = {
53 userAgent:
54 "Mozilla/5.0 (Linux; Android 15; Pixel Tablet) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36",
55 platform: "Linux armv8l",
56 maxTouchPoints: 5,
57 };
58 const ipadosDesktopUa = {
59 userAgent:
60 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15",
61 platform: "MacIntel",
62 maxTouchPoints: 5,
63 };
64
65 expect(browserDeviceType(iphone)).toBe("mobile");
66 expect(browserClientOs(iphone)).toBe("iOS");
67 expect(browserFamily(iphone.userAgent)).toBe("Safari");
68 expect(browserDeviceType(androidTablet)).toBe("tablet");
69 expect(browserClientOs(androidTablet)).toBe("Android");
70 expect(browserDeviceType(ipadosDesktopUa)).toBe("tablet");
71 expect(browserClientOs(ipadosDesktopUa)).toBe("iOS");
72 });
73
74 it("uses Electron's client platform for desktop", () => {
75 expect(
76 clientPresentationMetadata({
77 appVersion: "1.2.3",
78 hosted: false,
79 identity: desktopChrome,
80 desktopBridge: { getClientPlatform: () => "darwin" },
81 }),
82 ).toEqual({
83 label: "T3 Code Desktop",
84 deviceType: "desktop",
85 os: "macOS",
86 surface: "desktop",
87 appVersion: "1.2.3",
88 });
89 });
90});
apps/web/src/connection/clientMetadata.ts created+97
......@@ -0,0 +1,97 @@
1import type {
2 AuthClientMetadataDeviceType,
3 AuthClientPresentationMetadata,
4 ClientOs,
5 DesktopBridge,
6} from "@t3tools/contracts";
7
8interface BrowserIdentity {
9 readonly userAgent: string;
10 readonly platform: string;
11 readonly maxTouchPoints: number;
12}
13
14function clientOsFromElectronPlatform(platform: string | undefined): ClientOs {
15 switch (platform) {
16 case "darwin":
17 return "macOS";
18 case "win32":
19 return "Windows";
20 case "linux":
21 return "Linux";
22 default:
23 return platform ? "other" : "unknown";
24 }
25}
26
27function isIpadosDesktopUserAgent(identity: BrowserIdentity): boolean {
28 return (
29 /macintosh/i.test(identity.userAgent) &&
30 /mac/i.test(identity.platform) &&
31 identity.maxTouchPoints > 1
32 );
33}
34
35export function browserClientOs(identity: BrowserIdentity): ClientOs {
36 const userAgent = identity.userAgent;
37 if (userAgent.trim() === "") return "unknown";
38 if (/iphone|ipad|ipod/i.test(userAgent) || isIpadosDesktopUserAgent(identity)) return "iOS";
39 if (/android/i.test(userAgent)) return "Android";
40 if (/cros/i.test(userAgent)) return "ChromeOS";
41 if (/windows/i.test(userAgent)) return "Windows";
42 if (/macintosh|mac os x/i.test(userAgent)) return "macOS";
43 if (/linux|x11/i.test(userAgent)) return "Linux";
44 return "other";
45}
46
47export function browserFamily(userAgent: string): string {
48 if (userAgent.trim() === "") return "unknown";
49 if (/edg(?:e|a|ios)?\//i.test(userAgent)) return "Edge";
50 if (/opr\/|opios\//i.test(userAgent)) return "Opera";
51 if (/samsungbrowser\//i.test(userAgent)) return "Samsung Internet";
52 if (/firefox\/|fxios\//i.test(userAgent)) return "Firefox";
53 if (/chrome\/|crios\//i.test(userAgent)) return "Chrome";
54 if (/safari\//i.test(userAgent)) return "Safari";
55 return "other";
56}
57
58export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadataDeviceType {
59 const userAgent = identity.userAgent;
60 if (userAgent.trim() === "") return "unknown";
61 if (
62 /ipad|tablet|kindle|silk/i.test(userAgent) ||
63 (/android/i.test(userAgent) && !/mobile/i.test(userAgent)) ||
64 isIpadosDesktopUserAgent(identity)
65 ) {
66 return "tablet";
67 }
68 if (/iphone|ipod|android.+mobile|mobile/i.test(userAgent)) return "mobile";
69 return "desktop";
70}
71
72export function clientPresentationMetadata(input: {
73 readonly appVersion: string;
74 readonly hosted: boolean;
75 readonly identity: BrowserIdentity;
76 readonly desktopBridge: Pick<DesktopBridge, "getClientPlatform"> | undefined;
77}): AuthClientPresentationMetadata {
78 if (input.desktopBridge !== undefined) {
79 return {
80 label: "T3 Code Desktop",
81 deviceType: "desktop",
82 os: clientOsFromElectronPlatform(input.desktopBridge.getClientPlatform?.()),
83 surface: "desktop",
84 ...(input.appVersion === "0.0.0" ? {} : { appVersion: input.appVersion }),
85 };
86 }
87
88 return {
89 label: "T3 Code Web",
90 deviceType: browserDeviceType(input.identity),
91 os: browserClientOs(input.identity),
92 surface: "web",
93 webDeployment: input.hosted ? "hosted" : "server",
94 browser: browserFamily(input.identity.userAgent),
95 ...(input.appVersion === "0.0.0" ? {} : { appVersion: input.appVersion }),
96 };
97}
apps/web/src/connection/platform.ts+11-9
......@@ -59,6 +59,7 @@ import {
5959 type DesktopSecondaryBootstrapsRead,
6060} from "./desktopLocal";
6161import { connectionStorageLayer } from "./storage";
62import { clientPresentationMetadata } from "./clientMetadata";
6263
6364let nextObservedRpcRequestId = 0;
6465
......@@ -115,15 +116,16 @@ const wakeupsLayer = Wakeups.layer({
115116});
116117
117118function clientMetadata() {
118 const desktop = window.desktopBridge !== undefined;
119 const platform = navigator.platform.trim();
120 return {
121 label: desktop ? "T3 Code Desktop" : "T3 Code Web",
122 deviceType: "desktop" as const,
123 ...(platform === "" ? {} : { os: platform }),
124 surface: desktop ? ("desktop" as const) : ("web" as const),
125 ...(APP_VERSION === "0.0.0" ? {} : { appVersion: APP_VERSION }),
126 };
119 return clientPresentationMetadata({
120 appVersion: APP_VERSION,
121 hosted: isHostedStaticApp(),
122 identity: {
123 userAgent: navigator.userAgent,
124 platform: navigator.platform,
125 maxTouchPoints: navigator.maxTouchPoints,
126 },
127 desktopBridge: window.desktopBridge,
128 });
127129}
128130
129131function sshPreparationError(cause: unknown) {
docs/README.md+1
......@@ -33,6 +33,7 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A
3333- [Remote environments](./internals/remote.md)
3434- [Server updates](./internals/server-updates.md)
3535- [Resource telemetry](./internals/resource-telemetry.md)
36- [Product analytics](./internals/product-analytics.md)
3637- [Environment auth](./internals/environment-auth.md)
3738- [T3 Connect](./internals/t3-connect.md)
3839- [CI gates](./internals/ci.md)
docs/internals/ci.md+2-1
......@@ -8,7 +8,8 @@ and pushes to `main`:
88- **Check**: `vp check` (format and lint; this repo sets `typeCheck: false` in its lint options),
99 then `vpr typecheck` for the workspace type check. The same job
1010 builds the desktop pipeline (`vp run build:desktop`) and verifies the preload bundle exists and
11 still exports its expected symbols.
11 uses only imports that Electron's sandbox can load. The verifier parses imports, then executes the
12 trusted artifact with controlled bridge stubs to confirm that its required APIs are callable.
1213- **Test**: `vp run test` across the workspace.
1314- **Mobile Native Static Analysis**: `vp run lint:mobile` on macOS, wrapping
1415 `scripts/mobile-native-static-check.ts`. A cheap Linux **Mobile Native Changes** job gates it:
docs/internals/overview.md+1
......@@ -134,6 +134,7 @@ already dispatch.
134134- [Workspace layout](./workspace-layout.md), [Glossary](./glossary.md)
135135- [Remote environments](./remote.md), [Server updates](./server-updates.md)
136136- [Resource telemetry](./resource-telemetry.md)
137- [Product analytics](./product-analytics.md)
137138- [Scripts](./scripts.md), [CI gates](./ci.md)
138139
139140[rpc]: ../../packages/contracts/src/rpc.ts
docs/internals/product-analytics.md created+112
......@@ -0,0 +1,112 @@
1# Product analytics
2
3T3 Code sends anonymous product events from the server to PostHog. The server
4uses the first available hashed Codex account ID, hashed Claude user ID, or
5installation-scoped anonymous ID as the distinct ID. It also keeps the
6telemetry opt-out, event buffer, and batch delivery. Clients do not load the
7PostHog browser SDK.
8
9## Client events
10
11These events use the metadata from the WebSocket connection that caused them.
12The metadata is not a person property or server-global current-client value.
13Two clients connected to one server can report different values at the same
14time.
15
16| Event | Description |
17| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
18| `client.connected` | The server accepted an authenticated WebSocket connection. Reconnects count again. Use this event for connection diagnostics, not active-use counts. |
19| `client.thread.started` | The server accepted a command that created a thread. |
20| `client.turn.requested` | The server accepted a turn request. This is the standard active-use event. |
21
22`provider.turn.sent` stays a provider execution event. It does not receive
23client metadata because a provider turn can continue after the requesting
24client disconnects.
25
26## Recommended properties
27
28Client properties appear on the three client events when the connected client
29reports them. Older clients can omit every client property.
30
31| Property | Values and meaning |
32| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33| `surface` | Product client: `web`, `desktop`, or `mobile`. |
34| `webDeployment` | Web delivery: `hosted` for the hosted app or `server` for web files served by a T3 server. Web only. This does not describe connection distance. |
35| `clientOs` | `macOS`, `Windows`, `Linux`, `iOS`, `Android`, `ChromeOS`, `other`, or `unknown`. |
36| `clientDeviceType` | `desktop`, `phone`, `tablet`, or `unknown`. This is separate from `surface`. |
37| `clientBrowser` | Normalized browser family. Web only. Browser detection is best effort. |
38| `clientAppVersion` | Version of the connected client. |
39| `clientOsMajorVersion` | Client OS major version when the native client reports it. Initially mobile only. |
40| `clientDeviceModel` | Hardware model when the native client reports it. Initially mobile only. This is not a user-assigned device name. |
41| `connectionMethod` | `direct`, `ssh`, `relay`, or `unknown`. `direct` means that the client connected to the server endpoint without an SSH or relay connection. It does not mean both processes run on one machine. |
42
43Server properties appear on all events, including server boot and background
44events.
45
46| Property | Values and meaning |
47| ------------------ | -------------------------------------------------------------- |
48| `serverOs` | Server process OS, normalized to the same names as `clientOs`. |
49| `serverArch` | Server process architecture. |
50| `serverWslDistro` | WSL distribution from `WSL_DISTRO_NAME`, when present. |
51| `serverAppVersion` | T3 server version. |
52| `serverMode` | Server runtime mode: `desktop` or `web`. |
53
54## Legacy properties
55
56Existing property meanings do not change:
57
58- `clientType` describes how the server runs. It is `desktop-app` for a desktop
59 server and `cli-web-client` for a CLI web server. It does not describe the
60 connected client. Use `surface` and `webDeployment` for new reports.
61- `platform`, `arch`, `wsl`, and `t3CodeVersion` describe the server. Use the
62 new `server*` names for new reports.
63- `appVersion` describes the connected client. Use `clientAppVersion` for new
64 reports.
65- Mobile connection events keep `os`, `osMajorVersion`, and `deviceModel`.
66 Use the new `client*` names for new reports.
67
68## PostHog dashboard
69
70Create one saved dashboard named `Client and platform usage`. Set
71`client.turn.requested` as the event for active-use reports. A user can appear
72in several client groups during one period, so do not add breakdown values to
73calculate a total.
74
75Save these insights:
76
77| Insight | Configuration |
78| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
79| Active users, daily | Trends, `client.turn.requested`, unique users, daily interval. |
80| Active users, weekly | Trends, `client.turn.requested`, unique users, weekly interval. |
81| Active users, monthly | Trends, `client.turn.requested`, unique users, monthly interval. |
82| Client usage | Trends, `client.turn.requested`, unique users. Save four filtered series: `surface = desktop`, `surface = mobile`, `surface = web` and `webDeployment = hosted`, and `surface = web` and `webDeployment = server`. Name them Desktop, Native mobile, Hosted web, and Server-served web. |
83| Client OS, active users | Trends, `client.turn.requested`, unique users, breakdown by `clientOs`. |
84| Client OS, turns | Trends, `client.turn.requested`, total events, breakdown by `clientOs`. |
85| Client versus server OS | Table, `client.turn.requested`, breakdown by `clientOs` and `serverOs`. |
86| Connection method, active users | Trends, `client.turn.requested`, unique users, breakdown by `connectionMethod`. |
87| Connection method, turns | Trends, `client.turn.requested`, total events, breakdown by `connectionMethod`. |
88| Mobile devices | Table, `client.turn.requested`, filter `surface = mobile`, breakdown by `clientOs`, `clientOsMajorVersion`, and `clientDeviceType`. |
89| Client version adoption | Trends, `client.turn.requested`, unique users, breakdown by `clientAppVersion`. |
90| Server version adoption | Trends, `client.turn.requested`, unique users, breakdown by `serverAppVersion`. |
91| Missing metadata | Table or SQL insight that shows the percentage of `client.turn.requested` events where each of `surface`, `clientOs`, `clientDeviceType`, `clientAppVersion`, and `connectionMethod` is absent. Track `webDeployment` and `clientBrowser` only within `surface = web`. |
92
93In PostHog Data management, use the event and property descriptions from this
94document. Mark the recommended properties as verified. Keep `clientType`
95visible with its legacy description so old reports remain understandable.
96
97## Collection and release boundary
98
99Client values are best effort. Invalid values are ignored and never reject a
100connection. Browser clients use user-agent data for broad OS, browser, phone,
101and tablet groups. They do not infer CPU architecture or an exact OS version
102from `navigator.platform`.
103
104This change does not collect URLs, tokens, prompts, IP addresses, or
105user-assigned device names. It measures authenticated product use. It does not
106measure a person who visits the hosted app without connecting to a server.
107
108The new fields start with the first client and server release that contains
109this metadata path. Historical events cannot reliably identify the client OS,
110hosted web use, device type, or connection method when the old client did not
111send those fields. Reports must treat missing values as pre-release or older
112client data instead of backfilling them from server fields.
packages/client-runtime/src/authorization/layer.test.ts+3
......@@ -174,6 +174,7 @@ describe("RemoteEnvironmentAuthorization", () => {
174174 httpBaseUrl: ENDPOINT.httpBaseUrl,
175175 wsBaseUrl: ENDPOINT.wsBaseUrl,
176176 bearerToken: "bearer-token",
177 connectionMethod: "direct",
177178 });
178179 return [yield* authorize(), yield* authorize()] as const;
179180 }).pipe(Effect.provide(harness.layer));
......@@ -211,6 +212,7 @@ describe("RemoteEnvironmentAuthorization", () => {
211212 httpBaseUrl: ENDPOINT.httpBaseUrl,
212213 wsBaseUrl: ENDPOINT.wsBaseUrl,
213214 bearerToken: "bearer-token",
215 connectionMethod: "direct",
214216 });
215217
216218 yield* authorize();
......@@ -255,6 +257,7 @@ describe("RemoteEnvironmentAuthorization", () => {
255257 }).pipe(Effect.provide(harness.layer));
256258
257259 expect(authorized.socketUrl).toContain("wsTicket=cached-ticket");
260 expect(authorized.socketUrl).toContain("connectionMethod=relay");
258261 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0);
259262 expect(harness.fetch.calls).toHaveLength(1);
260263 expect(String(harness.fetch.calls[0]?.[0])).toBe(
packages/client-runtime/src/authorization/remote.test.ts+45-1
......@@ -7,6 +7,7 @@ import * as TestClock from "effect/testing/TestClock";
77
88import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
99import {
10 appendClientConnectionParams,
1011 bootstrapRemoteBearerSession,
1112 exchangeRemoteDpopAccessToken,
1213 fetchRemoteDpopSessionState,
......@@ -209,6 +210,47 @@ describe("remote environment authorization", () => {
209210 }),
210211 );
211212
213 it.effect("keeps OS sentinels in telemetry but out of display metadata", () =>
214 Effect.gen(function* () {
215 const tokenResponse = () =>
216 Response.json(
217 {
218 access_token: "bearer-token",
219 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
220 token_type: "Bearer",
221 expires_in: 3600,
222 scope: "orchestration:read",
223 },
224 { status: 200 },
225 );
226 const fetch = recordedFetch(tokenResponse(), tokenResponse());
227
228 for (const os of ["unknown", "other"] as const) {
229 yield* bootstrapRemoteBearerSession({
230 httpBaseUrl: "https://remote.example.com/",
231 credential: "pairing-token",
232 clientMetadata: {
233 label: "T3 Code Web",
234 deviceType: "desktop",
235 os,
236 },
237 }).pipe(provideRemoteHttp(fetch.fetchFn));
238 }
239
240 for (const [, init] of fetch.calls) {
241 expect(String(init.body)).not.toContain("client_os=");
242 }
243
244 const websocketUrl = new URL("wss://remote.example.com/ws");
245 appendClientConnectionParams(websocketUrl, {
246 surface: "web",
247 deviceType: "desktop",
248 os: "unknown",
249 });
250 expect(websocketUrl.searchParams.get("clientOs")).toBe("unknown");
251 }),
252 );
253
212254 it.effect("allows a client to explicitly narrow a pairing grant", () =>
213255 Effect.gen(function* () {
214256 const fetch = recordedFetch(
......@@ -470,14 +512,16 @@ describe("remote environment authorization", () => {
470512 clientMetadata: {
471513 surface: "mobile",
472514 appVersion: "1.2.3",
515 deviceType: "mobile",
473516 os: "Android",
474517 osMajorVersion: 15,
475518 deviceModel: "Pixel 9",
476519 },
520 connectionMethod: "relay",
477521 }).pipe(provideRemoteHttp(fetch.fetchFn));
478522
479523 expect(url).toBe(
480 "wss://remote.example.com/ws?wsTicket=ws-ticket&clientSurface=mobile&clientAppVersion=1.2.3&clientOs=Android&clientOsMajorVersion=15&clientDeviceModel=Pixel+9",
524 "wss://remote.example.com/ws?wsTicket=ws-ticket&clientSurface=mobile&clientAppVersion=1.2.3&clientDeviceType=phone&clientOs=Android&clientOsMajorVersion=15&clientDeviceModel=Pixel+9&connectionMethod=relay",
481525 );
482526 }),
483527 );
packages/client-runtime/src/authorization/remote.ts+39-10
......@@ -3,6 +3,7 @@ import {
33 type AuthClientPresentationMetadata,
44 AuthEnvironmentBootstrapTokenType,
55 AuthTokenExchangeGrantType,
6 type ClientConnectionMethod,
67 type AuthEnvironmentScope,
78} from "@t3tools/contracts";
89import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
......@@ -26,17 +27,23 @@ const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;
2627
2728const clientMetadataTokenExchangeFields = (
2829 clientMetadata: AuthClientPresentationMetadata | undefined,
29) => ({
30 ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}),
31 ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}),
32 ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}),
33});
30) => {
31 const displayOs = clientMetadata?.os;
32 return {
33 ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}),
34 ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}),
35 ...(displayOs && displayOs !== "unknown" && displayOs !== "other"
36 ? { client_os: displayOs }
37 : {}),
38 };
39};
3440
3541// The server reads these off the /ws upgrade URL next to wsTicket. Optional on
3642// both ends: old servers ignore unknown params, old clients never send them.
3743export const appendClientConnectionParams = (
3844 url: URL,
3945 clientMetadata: AuthClientPresentationMetadata | undefined,
46 connectionMethod?: ClientConnectionMethod,
4047): void => {
4148 if (clientMetadata?.surface) {
4249 url.searchParams.set("clientSurface", clientMetadata.surface);
......@@ -44,10 +51,27 @@ export const appendClientConnectionParams = (
4451 if (clientMetadata?.appVersion) {
4552 url.searchParams.set("clientAppVersion", clientMetadata.appVersion);
4653 }
47 if (clientMetadata?.surface === "mobile") {
48 if (clientMetadata.os) {
49 url.searchParams.set("clientOs", clientMetadata.os);
54 if (clientMetadata?.deviceType) {
55 const deviceType =
56 clientMetadata.deviceType === "mobile"
57 ? "phone"
58 : clientMetadata.deviceType === "desktop" || clientMetadata.deviceType === "tablet"
59 ? clientMetadata.deviceType
60 : "unknown";
61 url.searchParams.set("clientDeviceType", deviceType);
62 }
63 if (clientMetadata?.os) {
64 url.searchParams.set("clientOs", clientMetadata.os);
65 }
66 if (clientMetadata?.surface === "web") {
67 if (clientMetadata.webDeployment) {
68 url.searchParams.set("clientWebDeployment", clientMetadata.webDeployment);
5069 }
70 if (clientMetadata.browser) {
71 url.searchParams.set("clientBrowser", clientMetadata.browser);
72 }
73 }
74 if (clientMetadata?.surface === "mobile") {
5175 if (clientMetadata.osMajorVersion !== undefined) {
5276 url.searchParams.set("clientOsMajorVersion", String(clientMetadata.osMajorVersion));
5377 }
......@@ -55,6 +79,9 @@ export const appendClientConnectionParams = (
5579 url.searchParams.set("clientDeviceModel", clientMetadata.deviceModel);
5680 }
5781 }
82 if (connectionMethod) {
83 url.searchParams.set("connectionMethod", connectionMethod);
84 }
5885};
5986
6087export const exchangeRemoteDpopAccessToken = Effect.fn(
......@@ -200,6 +227,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
200227 readonly httpBaseUrl: string;
201228 readonly bearerToken: string;
202229 readonly clientMetadata?: AuthClientPresentationMetadata;
230 readonly connectionMethod?: ClientConnectionMethod;
203231 readonly timeoutMs?: number;
204232}) {
205233 const issued = yield* issueRemoteWebSocketTicket({
......@@ -213,7 +241,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
213241 url.pathname = "/ws";
214242 }
215243 url.searchParams.set("wsTicket", issued.ticket);
216 appendClientConnectionParams(url, input.clientMetadata);
244 appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod);
217245 return url.toString();
218246});
219247
......@@ -225,6 +253,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(
225253 readonly accessToken: string;
226254 readonly dpopProof: string;
227255 readonly clientMetadata?: AuthClientPresentationMetadata;
256 readonly connectionMethod?: ClientConnectionMethod;
228257 readonly timeoutMs?: number;
229258}) {
230259 const issued = yield* issueRemoteDpopWebSocketTicket({
......@@ -238,6 +267,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(
238267 url.pathname = "/ws";
239268 }
240269 url.searchParams.set("wsTicket", issued.ticket);
241 appendClientConnectionParams(url, input.clientMetadata);
270 appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod);
242271 return url.toString();
243272});
packages/client-runtime/src/authorization/service.ts+9-1
......@@ -1,4 +1,8 @@
1import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
1import {
2 type ClientConnectionMethod,
3 EnvironmentId,
4 type ExecutionEnvironmentDescriptor,
5} from "@t3tools/contracts";
26import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";
37import {
48 exchangeRemoteDpopAccessToken,
......@@ -46,6 +50,7 @@ export class RemoteEnvironmentAuthorization extends Context.Service<
4650 readonly httpBaseUrl: string;
4751 readonly wsBaseUrl: string;
4852 readonly bearerToken: string;
53 readonly connectionMethod: ClientConnectionMethod;
4954 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
5055 readonly authorizeDpop: (input: {
5156 readonly expectedEnvironmentId: EnvironmentId;
......@@ -99,6 +104,7 @@ export const make = Effect.gen(function* () {
99104 readonly httpBaseUrl: string;
100105 readonly wsBaseUrl: string;
101106 readonly bearerToken: string;
107 readonly connectionMethod: ClientConnectionMethod;
102108 }) {
103109 const now = yield* Clock.currentTimeMillis;
104110 const cachedDescriptor = (yield* Ref.get(bearerDescriptors)).get(input.expectedEnvironmentId);
......@@ -132,6 +138,7 @@ export const make = Effect.gen(function* () {
132138 httpBaseUrl: input.httpBaseUrl,
133139 bearerToken: input.bearerToken,
134140 clientMetadata: presentation.metadata,
141 connectionMethod: input.connectionMethod,
135142 }).pipe(
136143 Effect.mapError(mapRemoteEnvironmentError),
137144 Effect.provideService(HttpClient.HttpClient, httpClient),
......@@ -172,6 +179,7 @@ export const make = Effect.gen(function* () {
172179 accessToken: token.accessToken,
173180 dpopProof: ticketProof,
174181 clientMetadata: presentation.metadata,
182 connectionMethod: "relay",
175183 ...(timeoutMs === undefined ? {} : { timeoutMs }),
176184 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
177185 },
packages/client-runtime/src/connection/resolver.test.ts+29-7
......@@ -223,7 +223,8 @@ describe("ConnectionResolver", () => {
223223 environmentId: ENVIRONMENT_ID,
224224 label: "Primary",
225225 httpBaseUrl: "http://127.0.0.1:3777",
226 socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web",
226 socketUrl:
227 "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct",
227228 httpAuthorization: null,
228229 target,
229230 });
......@@ -232,11 +233,14 @@ describe("ConnectionResolver", () => {
232233
233234 it.effect("authorizes a desktop primary environment with its platform bearer token", () =>
234235 Effect.gen(function* () {
235 const bearerInputs = yield* Ref.make<ReadonlyArray<string>>([]);
236 const bearerInputs = yield* Ref.make<ReadonlyArray<{ token: string; method: string }>>([]);
236237 const brokerLayer = yield* makeDependencies({
237238 primaryBearerToken: "desktop-bearer",
238239 authorizeBearer: (input) =>
239 Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe(
240 Ref.update(bearerInputs, (values) => [
241 ...values,
242 { token: input.bearerToken, method: input.connectionMethod },
243 ]).pipe(
240244 Effect.as({
241245 environmentId: input.expectedEnvironmentId,
242246 label: "Primary",
......@@ -262,13 +266,13 @@ describe("ConnectionResolver", () => {
262266 httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" },
263267 target,
264268 });
265 expect(yield* Ref.get(bearerInputs)).toEqual(["desktop-bearer"]);
269 expect(yield* Ref.get(bearerInputs)).toEqual([{ token: "desktop-bearer", method: "direct" }]);
266270 }),
267271 );
268272
269273 it.effect("uses the registered bearer profile without re-reading the profile store", () =>
270274 Effect.gen(function* () {
271 const bearerInputs = yield* Ref.make<ReadonlyArray<string>>([]);
275 const bearerInputs = yield* Ref.make<ReadonlyArray<{ token: string; method: string }>>([]);
272276 const target = new BearerConnectionTarget({
273277 environmentId: ENVIRONMENT_ID,
274278 label: "Saved",
......@@ -284,7 +288,10 @@ describe("ConnectionResolver", () => {
284288 const brokerLayer = yield* makeDependencies({
285289 credentials: [["saved-1", new BearerConnectionCredential({ token: "secret-bearer" })]],
286290 authorizeBearer: (input) =>
287 Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe(
291 Ref.update(bearerInputs, (values) => [
292 ...values,
293 { token: input.bearerToken, method: input.connectionMethod },
294 ]).pipe(
288295 Effect.as({
289296 environmentId: input.expectedEnvironmentId,
290297 label: "Saved",
......@@ -302,7 +309,7 @@ describe("ConnectionResolver", () => {
302309 expect(
303310 (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl,
304311 ).toContain("wsTicket=ticket");
305 expect(yield* Ref.get(bearerInputs)).toEqual(["secret-bearer"]);
312 expect(yield* Ref.get(bearerInputs)).toEqual([{ token: "secret-bearer", method: "direct" }]);
306313 }),
307314 );
308315
......@@ -411,6 +418,7 @@ describe("ConnectionResolver", () => {
411418 it.effect("delegates SSH launch to the platform gateway before remote authorization", () =>
412419 Effect.gen(function* () {
413420 const preparedTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]);
421 const connectionMethods = yield* Ref.make<ReadonlyArray<string>>([]);
414422 const target = new SshConnectionTarget({
415423 environmentId: ENVIRONMENT_ID,
416424 label: "SSH",
......@@ -435,6 +443,19 @@ describe("ConnectionResolver", () => {
435443 bearerToken: "ssh-bearer",
436444 }),
437445 ),
446 authorizeBearer: (input) =>
447 Ref.update(connectionMethods, (methods) => [...methods, input.connectionMethod]).pipe(
448 Effect.as({
449 environmentId: input.expectedEnvironmentId,
450 label: "SSH",
451 httpBaseUrl: input.httpBaseUrl,
452 socketUrl: "wss://environment.example.test/ws?wsTicket=bearer",
453 httpAuthorization: {
454 _tag: "Bearer" as const,
455 token: input.bearerToken,
456 },
457 }),
458 ),
438459 });
439460 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
440461
......@@ -442,6 +463,7 @@ describe("ConnectionResolver", () => {
442463 (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl,
443464 ).toContain("wsTicket=bearer");
444465 expect(yield* Ref.get(preparedTargets)).toEqual([SSH_TARGET]);
466 expect(yield* Ref.get(connectionMethods)).toEqual(["ssh"]);
445467 }),
446468 );
447469
packages/client-runtime/src/connection/resolver.ts+4-1
......@@ -56,7 +56,7 @@ function primarySocketUrl(
5656 if (url.pathname === "" || url.pathname === "/") {
5757 url.pathname = "/ws";
5858 }
59 appendClientConnectionParams(url, clientMetadata);
59 appendClientConnectionParams(url, clientMetadata, "direct");
6060 return url.toString();
6161}
6262
......@@ -85,6 +85,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary
8585 httpBaseUrl: target.httpBaseUrl,
8686 wsBaseUrl: target.wsBaseUrl,
8787 bearerToken: bearerToken.value,
88 connectionMethod: "direct",
8889 });
8990 return {
9091 ...authorized,
......@@ -133,6 +134,7 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
133134 httpBaseUrl: profile.httpBaseUrl,
134135 wsBaseUrl: profile.wsBaseUrl,
135136 bearerToken: credential.token,
137 connectionMethod: "direct",
136138 });
137139 return {
138140 environmentId: authorized.environmentId,
......@@ -236,6 +238,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct
236238 httpBaseUrl: prepared.bootstrap.httpBaseUrl,
237239 wsBaseUrl: prepared.bootstrap.wsBaseUrl,
238240 bearerToken: prepared.bearerToken,
241 connectionMethod: "ssh",
239242 });
240243 return {
241244 environmentId: authorized.environmentId,
packages/contracts/src/auth.ts+8-1
......@@ -1,7 +1,12 @@
11import * as Schema from "effect/Schema";
22import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";
33
4import { AuthSessionId, ClientSurface, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import {
5 AuthSessionId,
6 ClientSurface,
7 ClientWebDeployment,
8 TrimmedNonEmptyString,
9} from "./baseSchemas.ts";
510
611/**
712 * Declares the server's overall authentication posture.
......@@ -172,6 +177,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({
172177 osMajorVersion: Schema.optionalKey(Schema.Int),
173178 deviceModel: Schema.optionalKey(TrimmedNonEmptyString),
174179 surface: Schema.optionalKey(ClientSurface),
180 webDeployment: Schema.optionalKey(ClientWebDeployment),
181 browser: Schema.optionalKey(TrimmedNonEmptyString),
175182 appVersion: Schema.optionalKey(TrimmedNonEmptyString),
176183});
177184export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type;
packages/contracts/src/baseSchemas.ts+21
......@@ -80,6 +80,27 @@ export type RpcClientId = typeof RpcClientId.Type;
8080export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]);
8181export type ClientSurface = typeof ClientSurface.Type;
8282
83export const ClientOs = Schema.Literals([
84 "macOS",
85 "Windows",
86 "Linux",
87 "iOS",
88 "Android",
89 "ChromeOS",
90 "other",
91 "unknown",
92]);
93export type ClientOs = typeof ClientOs.Type;
94
95export const ClientDeviceType = Schema.Literals(["desktop", "phone", "tablet", "unknown"]);
96export type ClientDeviceType = typeof ClientDeviceType.Type;
97
98export const ClientWebDeployment = Schema.Literals(["hosted", "server"]);
99export type ClientWebDeployment = typeof ClientWebDeployment.Type;
100
101export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]);
102export type ClientConnectionMethod = typeof ClientConnectionMethod.Type;
103
83104export const ProviderItemId = makeEntityId("ProviderItemId");
84105export type ProviderItemId = typeof ProviderItemId.Type;
85106export const RuntimeSessionId = makeEntityId("RuntimeSessionId");
packages/contracts/src/ipc.ts+2
......@@ -1069,6 +1069,8 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({
10691069
10701070export interface DesktopBridge {
10711071 getAppBranding: () => DesktopAppBranding | null;
1072 /** The desktop client's OS platform, read from Electron's preload process. */
1073 getClientPlatform?: () => string;
10721074 /**
10731075 * The OS locale as a BCP-47 tag, which the renderer cannot read for itself:
10741076 * the packaged app ships only the `en-US` Chromium locale pak, so
pnpm-lock.yaml+3
......@@ -170,6 +170,9 @@ importers:
170170 '@types/node':
171171 specifier: 24.12.4
172172 version: 24.12.4
173 acorn:
174 specifier: 8.16.0
175 version: 8.16.0
173176 cross-env:
174177 specifier: ^10.1.0
175178 version: 10.1.0