authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-24 23:24:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 23:24:15-07:00
logbce680926d38915f3590242c0d1a4309e7ca1b9a
tree6b1eb051adbe11289c82c2d57ffcb2aadbb6265f
parentbd9ed2b4bbda3dd6e468df1cb06233e29c4a9f5c
signature Signed by PGP key B5690EEEBB952194

feat(mobile): track device models and OS versions (#8169)


11 files changed, 122 insertions(+), 11 deletions(-)

apps/mobile/package.json+1
...@@ -81,6 +81,7 @@...@@ -81,6 +81,7 @@
81 "expo-constants": "~56.0.18",81 "expo-constants": "~56.0.18",
82 "expo-crypto": "~56.0.4",82 "expo-crypto": "~56.0.4",
83 "expo-dev-client": "~56.0.20",83 "expo-dev-client": "~56.0.20",
84 "expo-device": "~56.0.4",
84 "expo-file-system": "~56.0.8",85 "expo-file-system": "~56.0.8",
85 "expo-font": "~56.0.7",86 "expo-font": "~56.0.7",
86 "expo-glass-effect": "~56.0.4",87 "expo-glass-effect": "~56.0.4",
apps/mobile/src/features/cloud/linkEnvironment.test.ts+5
...@@ -33,6 +33,11 @@ vi.mock("expo-constants", () => ({...@@ -33,6 +33,11 @@ vi.mock("expo-constants", () => ({
33 },33 },
34}));34}));
3535
36vi.mock("expo-device", () => ({
37 osVersion: "18.4.1",
38 modelName: "iPhone 15 Pro",
39}));
40
36vi.mock("react-native", () => ({41vi.mock("react-native", () => ({
37 Platform: {42 Platform: {
38 OS: "ios",43 OS: "ios",
apps/mobile/src/lib/authClientMetadata.ts+6
...@@ -1,11 +1,17 @@...@@ -1,11 +1,17 @@
1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
2import * as Device from "expo-device";
2import { Platform } from "react-native";3import { Platform } from "react-native";
34
4export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata {5export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata {
6 const osMajorVersion = Number.parseInt(Device.osVersion?.split(".")[0] ?? "", 10);
7 const deviceModel = Device.modelName?.trim();
8
5 return {9 return {
6 label: "T3 Code Mobile",10 label: "T3 Code Mobile",
7 deviceType: "mobile",11 deviceType: "mobile",
8 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),12 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
13 ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}),
14 ...(deviceModel ? { deviceModel } : {}),
9 surface: "mobile",15 surface: "mobile",
10 ...(appVersion ? { appVersion } : {}),16 ...(appVersion ? { appVersion } : {}),
11 };17 };
apps/mobile/src/lib/connection.test.ts+31-5
...@@ -1,12 +1,18 @@...@@ -1,12 +1,18 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";2import { EnvironmentId } from "@t3tools/contracts";
33
4import {4import {
5 isRelayManagedConnection,5 isRelayManagedConnection,
6 authClientMetadata,
7 redactPairingCredential,6 redactPairingCredential,
8 toStableSavedRemoteConnection,7 toStableSavedRemoteConnection,
9} from "./connection";8} from "./connection";
9import { authClientMetadata } from "./authClientMetadata";
10
11const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" }));
12const mobileDevice = vi.hoisted(() => ({
13 osVersion: "18.4.1",
14 modelName: "iPhone 15 Pro",
15}));
1016
11vi.mock("./runtime", () => ({17vi.mock("./runtime", () => ({
12 runtime: {18 runtime: {
...@@ -15,21 +21,41 @@ vi.mock("./runtime", () => ({...@@ -15,21 +21,41 @@ vi.mock("./runtime", () => ({
15}));21}));
1622
17vi.mock("react-native", () => ({23vi.mock("react-native", () => ({
18 Platform: {24 Platform: mobilePlatform,
19 OS: "ios",
20 },
21}));25}));
2226
27vi.mock("expo-device", () => mobileDevice);
28
23describe("mobile remote connection records", () => {29describe("mobile remote connection records", () => {
30 afterEach(() => {
31 mobilePlatform.OS = "ios";
32 mobileDevice.osVersion = "18.4.1";
33 mobileDevice.modelName = "iPhone 15 Pro";
34 });
35
24 it("identifies mobile token exchanges for authorized-client presentation", () => {36 it("identifies mobile token exchanges for authorized-client presentation", () => {
25 expect(authClientMetadata()).toEqual({37 expect(authClientMetadata()).toEqual({
26 label: "T3 Code Mobile",38 label: "T3 Code Mobile",
27 deviceType: "mobile",39 deviceType: "mobile",
28 os: "iOS",40 os: "iOS",
41 osMajorVersion: 18,
42 deviceModel: "iPhone 15 Pro",
29 surface: "mobile",43 surface: "mobile",
30 });44 });
31 });45 });
3246
47 it("includes only the Android major version and hardware model", () => {
48 mobilePlatform.OS = "android";
49 mobileDevice.osVersion = "15.2.1";
50 mobileDevice.modelName = "Pixel 9";
51
52 expect(authClientMetadata()).toMatchObject({
53 os: "Android",
54 osMajorVersion: 15,
55 deviceModel: "Pixel 9",
56 });
57 });
58
33 it("includes the mobile app version when the client provides it", () => {59 it("includes the mobile app version when the client provides it", () => {
34 expect(authClientMetadata("1.2.3")).toMatchObject({60 expect(authClientMetadata("1.2.3")).toMatchObject({
35 surface: "mobile",61 surface: "mobile",
apps/mobile/src/lib/connection.ts-2
...@@ -2,8 +2,6 @@ import { EnvironmentId } from "@t3tools/contracts";...@@ -2,8 +2,6 @@ import { EnvironmentId } from "@t3tools/contracts";
2import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";2import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";
3import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";3import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
44
5export { authClientMetadata } from "./authClientMetadata";
6
7export interface SavedRemoteConnection {5export interface SavedRemoteConnection {
8 readonly environmentId: EnvironmentId;6 readonly environmentId: EnvironmentId;
9 readonly environmentLabel: string;7 readonly environmentLabel: string;
apps/server/src/server.test.ts+10-2
...@@ -5209,7 +5209,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5209,7 +5209,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5209 createdAt: "2026-01-01T00:00:00.000Z",5209 createdAt: "2026-01-01T00:00:00.000Z",
5210 }) as const;5210 }) as const;
52115211
5212 const wsUrl = yield* getWsServerUrl("/ws?clientSurface=mobile&clientAppVersion=1.2.3");5212 const wsUrl = yield* getWsServerUrl(
5213 "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro",
5214 );
5213 yield* Effect.scoped(5215 yield* Effect.scoped(
5214 withWsRpcClient(wsUrl, (client) =>5216 withWsRpcClient(wsUrl, (client) =>
5215 Effect.gen(function* () {5217 Effect.gen(function* () {
...@@ -5242,7 +5244,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5242,7 +5244,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5242 "analytics:client.thread.started",5244 "analytics:client.thread.started",
5243 ]);5245 ]);
5244 assert.deepEqual(analyticsProperties, [5246 assert.deepEqual(analyticsProperties, [
5245 { surface: "mobile", appVersion: "1.2.3" },5247 {
5248 surface: "mobile",
5249 appVersion: "1.2.3",
5250 os: "iOS",
5251 osMajorVersion: 18,
5252 deviceModel: "iPhone 15 Pro",
5253 },
5246 { surface: "mobile", appVersion: "1.2.3" },5254 { surface: "mobile", appVersion: "1.2.3" },
5247 ]);5255 ]);
5248 }).pipe(Effect.provide(NodeHttpServer.layerTest)),5256 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
apps/server/src/ws.ts+27-1
...@@ -360,6 +360,7 @@ function toAuthAccessStreamEvent(...@@ -360,6 +360,7 @@ function toAuthAccessStreamEvent(
360360
361const isClientSurface = Schema.is(ClientSurface);361const isClientSurface = Schema.is(ClientSurface);
362const MAX_CLIENT_APP_VERSION_LENGTH = 64;362const MAX_CLIENT_APP_VERSION_LENGTH = 64;
363const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80;
363364
364// Optional client identity announced on the /ws upgrade URL next to wsTicket.365// Optional client identity announced on the /ws upgrade URL next to wsTicket.
365// Lenient by design: absent or malformed values degrade to {} so a connection366// Lenient by design: absent or malformed values degrade to {} so a connection
...@@ -386,6 +387,28 @@ const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({...@@ -386,6 +387,28 @@ const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({
386 ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}),387 ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}),
387});388});
388389
390function readMobileDeviceAnalyticsProps(request: HttpServerRequest.HttpServerRequest) {
391 const url = HttpServerRequest.toURL(request);
392 if (Option.isNone(url) || url.value.searchParams.get("clientSurface") !== "mobile") {
393 return {};
394 }
395
396 const os = url.value.searchParams.get("clientOs");
397 const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? "";
398 const osMajorVersion = Number(rawOsMajorVersion);
399 const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? "";
400
401 return {
402 ...(os === "iOS" || os === "Android" ? { os } : {}),
403 ...(rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0
404 ? { osMajorVersion }
405 : {}),
406 ...(deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH
407 ? { deviceModel }
408 : {}),
409 };
410}
411
389const makeWsRpcLayer = (412const makeWsRpcLayer = (
390 currentSession: EnvironmentAuth.AuthenticatedSession,413 currentSession: EnvironmentAuth.AuthenticatedSession,
391 clientOrigin: OrchestrationClientOrigin,414 clientOrigin: OrchestrationClientOrigin,
...@@ -2434,7 +2457,10 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -2434,7 +2457,10 @@ export const websocketRpcRouteLayer = Layer.unwrap(
2434 );2457 );
2435 const clientOrigin = readClientConnectionOrigin(request);2458 const clientOrigin = readClientConnectionOrigin(request);
2436 yield* sessions.recordClientConnection(session.sessionId, clientOrigin);2459 yield* sessions.recordClientConnection(session.sessionId, clientOrigin);
2437 yield* analytics.record("client.connected", clientOriginAnalyticsProps(clientOrigin));2460 yield* analytics.record("client.connected", {
2461 ...clientOriginAnalyticsProps(clientOrigin),
2462 ...readMobileDeviceAnalyticsProps(request),
2463 });
2438 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {2464 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {
2439 disableTracing: true,2465 disableTracing: true,
2440 }).pipe(2466 }).pipe(
packages/client-runtime/src/authorization/remote.test.ts+10-1
...@@ -467,9 +467,18 @@ describe("remote environment authorization", () => {...@@ -467,9 +467,18 @@ describe("remote environment authorization", () => {
467 wsBaseUrl: "wss://remote.example.com/",467 wsBaseUrl: "wss://remote.example.com/",
468 httpBaseUrl: "https://remote.example.com/",468 httpBaseUrl: "https://remote.example.com/",
469 bearerToken: "bearer-token",469 bearerToken: "bearer-token",
470 clientMetadata: {
471 surface: "mobile",
472 appVersion: "1.2.3",
473 os: "Android",
474 osMajorVersion: 15,
475 deviceModel: "Pixel 9",
476 },
470 }).pipe(provideRemoteHttp(fetch.fetchFn));477 }).pipe(provideRemoteHttp(fetch.fetchFn));
471478
472 expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket");479 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",
481 );
473 }),482 }),
474 );483 );
475});484});
packages/client-runtime/src/authorization/remote.ts+11
...@@ -44,6 +44,17 @@ export const appendClientConnectionParams = (...@@ -44,6 +44,17 @@ export const appendClientConnectionParams = (
44 if (clientMetadata?.appVersion) {44 if (clientMetadata?.appVersion) {
45 url.searchParams.set("clientAppVersion", clientMetadata.appVersion);45 url.searchParams.set("clientAppVersion", clientMetadata.appVersion);
46 }46 }
47 if (clientMetadata?.surface === "mobile") {
48 if (clientMetadata.os) {
49 url.searchParams.set("clientOs", clientMetadata.os);
50 }
51 if (clientMetadata.osMajorVersion !== undefined) {
52 url.searchParams.set("clientOsMajorVersion", String(clientMetadata.osMajorVersion));
53 }
54 if (clientMetadata.deviceModel) {
55 url.searchParams.set("clientDeviceModel", clientMetadata.deviceModel);
56 }
57 }
47};58};
4859
49export const exchangeRemoteDpopAccessToken = Effect.fn(60export const exchangeRemoteDpopAccessToken = Effect.fn(
packages/contracts/src/auth.ts+2
...@@ -169,6 +169,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({...@@ -169,6 +169,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({
169 label: Schema.optionalKey(TrimmedNonEmptyString),169 label: Schema.optionalKey(TrimmedNonEmptyString),
170 deviceType: Schema.optionalKey(AuthClientMetadataDeviceType),170 deviceType: Schema.optionalKey(AuthClientMetadataDeviceType),
171 os: Schema.optionalKey(TrimmedNonEmptyString),171 os: Schema.optionalKey(TrimmedNonEmptyString),
172 osMajorVersion: Schema.optionalKey(Schema.Int),
173 deviceModel: Schema.optionalKey(TrimmedNonEmptyString),
172 surface: Schema.optionalKey(ClientSurface),174 surface: Schema.optionalKey(ClientSurface),
173 appVersion: Schema.optionalKey(TrimmedNonEmptyString),175 appVersion: Schema.optionalKey(TrimmedNonEmptyString),
174});176});
pnpm-lock.yaml+19
...@@ -318,6 +318,9 @@ importers:...@@ -318,6 +318,9 @@ importers:
318 expo-dev-client:318 expo-dev-client:
319 specifier: ~56.0.20319 specifier: ~56.0.20
320 version: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))320 version: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
321 expo-device:
322 specifier: ~56.0.4
323 version: 56.0.4(expo@56.0.12)
321 expo-file-system:324 expo-file-system:
322 specifier: ~56.0.8325 specifier: ~56.0.8
323 version: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))326 version: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
...@@ -6586,6 +6589,11 @@ packages:...@@ -6586,6 +6589,11 @@ packages:
6586 expo: '*'6589 expo: '*'
6587 react-native: '*'6590 react-native: '*'
65886591
6592 expo-device@56.0.4:
6593 resolution: {integrity: sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg==}
6594 peerDependencies:
6595 expo: '*'
6596
6589 expo-eas-client@56.0.1:6597 expo-eas-client@56.0.1:
6590 resolution: {integrity: sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==}6598 resolution: {integrity: sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==}
65916599
...@@ -9857,6 +9865,10 @@ packages:...@@ -9857,6 +9865,10 @@ packages:
9857 engines: {node: '>=14.17'}9865 engines: {node: '>=14.17'}
9858 hasBin: true9866 hasBin: true
98599867
9868 ua-parser-js@0.7.41:
9869 resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
9870 hasBin: true
9871
9860 ufo@1.6.4:9872 ufo@1.6.4:
9861 resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}9873 resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
98629874
...@@ -16867,6 +16879,11 @@ snapshots:...@@ -16867,6 +16879,11 @@ snapshots:
16867 expo-dev-menu-interface: 56.0.1(expo@56.0.12)16879 expo-dev-menu-interface: 56.0.1(expo@56.0.12)
16868 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)16880 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1686916881
16882 expo-device@56.0.4(expo@56.0.12):
16883 dependencies:
16884 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
16885 ua-parser-js: 0.7.41
16886
16870 expo-eas-client@56.0.1: {}16887 expo-eas-client@56.0.1: {}
1687116888
16872 expo-file-system@56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)):16889 expo-file-system@56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)):
...@@ -21104,6 +21121,8 @@ snapshots:...@@ -21104,6 +21121,8 @@ snapshots:
2110421121
21105 typescript@6.0.3: {}21122 typescript@6.0.3: {}
2110621123
21124 ua-parser-js@0.7.41: {}
21125
21107 ufo@1.6.4: {}21126 ufo@1.6.4: {}
2110821127
21109 ultrahtml@1.6.0: {}21128 ultrahtml@1.6.0: {}