Skip to content

Commit 8aed5bd

Browse files
authored
[v8] Add runtime analytics to User-Agent string (#1293)
## Summary Add runtime and version detection to the WorkOS Node SDK User-Agent string to gather analytics about JavaScript runtime environments and versions being used by customers. ## Changes ### Core Implementation - **New utility**: `src/common/utils/runtime-info.ts` - Detects runtime type and version across Node.js, Deno, Bun, Cloudflare Workers, and other environments - **User-Agent enhancement**: Modified `createUserAgent()` method in `src/workos.ts` to include runtime information in standard format - **Error handling**: Graceful fallbacks when runtime version detection fails ### User-Agent Format **Before**: `workos-node/8.0.0-beta.1 MyApp: 1.0.0` **After**: `workos-node/8.0.0-beta.1 (node/v20.5.0) MyApp: 1.0.0` The new format follows standard User-Agent conventions with runtime information in parentheses, maintaining backward compatibility with existing analytics parsing. ### Examples - Node.js: `workos-node/8.0.0-beta.1 (node/v20.5.0)` - Node.js with appInfo: `workos-node/8.0.0-beta.1 (node/v20.5.0) MyApp: 1.0.0` - Deno: `workos-node/8.0.0-beta.1 (deno/1.36.4)` - Edge runtime: `workos-node/8.0.0-beta.1 (edge-light)` ## Testing ### Unit Tests - **17 comprehensive test cases** for `getRuntimeInfo()` utility covering all runtime scenarios - **Mock testing** for different environments (Node.js, Deno, Bun, edge runtimes) - **Error handling verification** with graceful degradation - **Real-world testing** with actual Node.js environment ### Integration Tests - **Updated `workos.spec.ts`** with runtime info mocking - **Jest snapshots updated** to reflect new User-Agent format - **Backward compatibility verified** for existing appInfo functionality ## Benefits ### For WorkOS Analytics - **Node.js adoption tracking**: Monitor which Node.js versions are most popular - **Runtime diversity insights**: Understand usage across Node.js, Deno, Bun, and edge environments - **Support optimization**: Prioritize support for popular runtime versions - **Migration patterns**: Track adoption of new JavaScript runtime versions ### For Customer Support - **Environment debugging**: Quickly identify runtime-specific issues from API logs - **Performance optimization**: Focus optimization efforts on popular runtime combinations - **Compatibility planning**: Make informed decisions about runtime support ## Technical Details ### Runtime Detection - **Cached detection**: Uses existing `detectRuntime()` from `env.ts` for performance - **Version extraction**: Safely extracts version numbers from runtime-specific globals - **Fallback mechanisms**: Multiple strategies for version detection (e.g., Bun version from user-agent) - **Error resilience**: Never crashes, gracefully degrades to runtime name only ### Implementation Approach - **No configuration needed**: Runtime info is always included (internal analytics feature) - **Zero breaking changes**: Existing functionality unchanged - **Standards compliant**: Follows established User-Agent format conventions - **Minimal overhead**: One-time detection per process with cached results ## Risk Mitigation - **Version detection failures**: Graceful fallback to runtime name only - **Performance impact**: Minimal (cached detection, ~1ms overhead) - **User-Agent length**: Reasonable increase (~20 characters) - **Privacy concerns**: No personally identifiable information collected
1 parent caee5de commit 8aed5bd

File tree

7 files changed

+378
-67
lines changed

7 files changed

+378
-67
lines changed
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { getRuntimeInfo } from './runtime-info';
2+
import { detectRuntime } from './env';
3+
4+
// Mock the env module
5+
jest.mock('./env');
6+
const mockDetectRuntime = detectRuntime as jest.MockedFunction<
7+
typeof detectRuntime
8+
>;
9+
10+
describe('RuntimeInfo', () => {
11+
let originalProcess: any;
12+
let originalGlobalThis: any;
13+
let originalNavigator: any;
14+
15+
beforeEach(() => {
16+
// Store original globals
17+
originalProcess = global.process;
18+
originalGlobalThis = globalThis;
19+
originalNavigator = global.navigator;
20+
21+
// Reset mocks
22+
mockDetectRuntime.mockReset();
23+
});
24+
25+
afterEach(() => {
26+
// Restore original globals
27+
global.process = originalProcess;
28+
Object.assign(globalThis, originalGlobalThis);
29+
global.navigator = originalNavigator;
30+
});
31+
32+
describe('Node.js runtime', () => {
33+
it('returns node runtime with version', () => {
34+
mockDetectRuntime.mockReturnValue('node');
35+
global.process = { version: 'v20.5.0' } as any;
36+
37+
const result = getRuntimeInfo();
38+
39+
expect(result).toEqual({
40+
name: 'node',
41+
version: 'v20.5.0',
42+
});
43+
});
44+
45+
it('handles missing process.version gracefully', () => {
46+
mockDetectRuntime.mockReturnValue('node');
47+
global.process = {} as any;
48+
49+
const result = getRuntimeInfo();
50+
51+
expect(result).toEqual({
52+
name: 'node',
53+
version: undefined,
54+
});
55+
});
56+
57+
it('handles missing process object gracefully', () => {
58+
mockDetectRuntime.mockReturnValue('node');
59+
delete (global as any).process;
60+
61+
const result = getRuntimeInfo();
62+
63+
expect(result).toEqual({
64+
name: 'node',
65+
version: undefined,
66+
});
67+
});
68+
});
69+
70+
describe('Deno runtime', () => {
71+
it('returns deno runtime with version', () => {
72+
mockDetectRuntime.mockReturnValue('deno');
73+
(globalThis as any).Deno = {
74+
version: { deno: '1.36.4' },
75+
};
76+
77+
const result = getRuntimeInfo();
78+
79+
expect(result).toEqual({
80+
name: 'deno',
81+
version: '1.36.4',
82+
});
83+
});
84+
85+
it('handles missing Deno.version gracefully', () => {
86+
mockDetectRuntime.mockReturnValue('deno');
87+
(globalThis as any).Deno = {};
88+
89+
const result = getRuntimeInfo();
90+
91+
expect(result).toEqual({
92+
name: 'deno',
93+
version: undefined,
94+
});
95+
});
96+
97+
it('handles missing Deno object gracefully', () => {
98+
mockDetectRuntime.mockReturnValue('deno');
99+
100+
const result = getRuntimeInfo();
101+
102+
expect(result).toEqual({
103+
name: 'deno',
104+
version: undefined,
105+
});
106+
});
107+
});
108+
109+
describe('Bun runtime', () => {
110+
it('returns bun runtime with version from Bun.version', () => {
111+
mockDetectRuntime.mockReturnValue('bun');
112+
(globalThis as any).Bun = { version: '1.0.0' };
113+
114+
const result = getRuntimeInfo();
115+
116+
expect(result).toEqual({
117+
name: 'bun',
118+
version: '1.0.0',
119+
});
120+
});
121+
122+
it('falls back to navigator.userAgent for Bun version', () => {
123+
mockDetectRuntime.mockReturnValue('bun');
124+
// Clear any existing Bun global
125+
delete (globalThis as any).Bun;
126+
global.navigator = {
127+
userAgent: 'Bun/1.0.25',
128+
} as any;
129+
130+
const result = getRuntimeInfo();
131+
132+
expect(result).toEqual({
133+
name: 'bun',
134+
version: '1.0.25',
135+
});
136+
});
137+
138+
it('handles missing version sources gracefully', () => {
139+
mockDetectRuntime.mockReturnValue('bun');
140+
// Clear any existing Bun global and navigator
141+
delete (globalThis as any).Bun;
142+
delete (global as any).navigator;
143+
144+
const result = getRuntimeInfo();
145+
146+
expect(result).toEqual({
147+
name: 'bun',
148+
version: undefined,
149+
});
150+
});
151+
152+
it('handles malformed navigator.userAgent gracefully', () => {
153+
mockDetectRuntime.mockReturnValue('bun');
154+
// Clear any existing Bun global
155+
delete (globalThis as any).Bun;
156+
global.navigator = {
157+
userAgent: 'SomeOtherRuntime/1.0.0',
158+
} as any;
159+
160+
const result = getRuntimeInfo();
161+
162+
expect(result).toEqual({
163+
name: 'bun',
164+
version: undefined,
165+
});
166+
});
167+
});
168+
169+
describe('Edge runtimes', () => {
170+
it.each(['cloudflare', 'fastly', 'edge-light', 'other'] as const)(
171+
'returns %s runtime without version',
172+
(runtime) => {
173+
mockDetectRuntime.mockReturnValue(runtime);
174+
175+
const result = getRuntimeInfo();
176+
177+
expect(result).toEqual({
178+
name: runtime,
179+
version: undefined,
180+
});
181+
},
182+
);
183+
});
184+
185+
describe('Error handling', () => {
186+
it('handles exceptions during version detection gracefully', () => {
187+
mockDetectRuntime.mockReturnValue('node');
188+
189+
// Create a process object that throws when accessing version
190+
global.process = {
191+
get version() {
192+
throw new Error('Access denied');
193+
},
194+
} as any;
195+
196+
const result = getRuntimeInfo();
197+
198+
expect(result).toEqual({
199+
name: 'node',
200+
version: undefined,
201+
});
202+
});
203+
204+
it('handles exceptions during Deno version detection gracefully', () => {
205+
mockDetectRuntime.mockReturnValue('deno');
206+
207+
// Create a Deno object that throws when accessing version
208+
(globalThis as any).Deno = {
209+
get version() {
210+
throw new Error('Access denied');
211+
},
212+
};
213+
214+
const result = getRuntimeInfo();
215+
216+
expect(result).toEqual({
217+
name: 'deno',
218+
version: undefined,
219+
});
220+
});
221+
});
222+
223+
describe('Real-world scenarios', () => {
224+
it('works with actual Node.js environment', () => {
225+
// Test with real Node.js environment
226+
mockDetectRuntime.mockReturnValue('node');
227+
228+
const result = getRuntimeInfo();
229+
230+
// In test environment, this should be Node.js with real process.version
231+
expect(result.name).toBe('node');
232+
expect(result.version).toMatch(/^v\d+\.\d+\.\d+/);
233+
});
234+
});
235+
});

src/common/utils/runtime-info.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { detectRuntime } from './env';
2+
3+
// Extend globalThis to include runtime-specific globals
4+
declare global {
5+
var Deno:
6+
| {
7+
version: {
8+
deno: string;
9+
};
10+
}
11+
| undefined;
12+
13+
var Bun:
14+
| {
15+
version: string;
16+
}
17+
| undefined;
18+
}
19+
20+
export interface RuntimeInfo {
21+
name: string;
22+
version?: string;
23+
}
24+
25+
/**
26+
* Get runtime information including name and version.
27+
* Safely extracts version information for different JavaScript runtimes.
28+
* @returns RuntimeInfo object with name and optional version
29+
*/
30+
export function getRuntimeInfo(): RuntimeInfo {
31+
const name = detectRuntime();
32+
let version: string | undefined;
33+
34+
try {
35+
switch (name) {
36+
case 'node':
37+
// process.version includes 'v' prefix (e.g., "v20.5.0")
38+
version = typeof process !== 'undefined' ? process.version : undefined;
39+
break;
40+
41+
case 'deno':
42+
// Deno.version.deno returns just version number (e.g., "1.36.4")
43+
version = globalThis.Deno?.version?.deno;
44+
break;
45+
46+
case 'bun':
47+
version = globalThis.Bun?.version || extractBunVersionFromUserAgent();
48+
break;
49+
50+
// These environments typically don't expose version info
51+
case 'cloudflare':
52+
case 'fastly':
53+
case 'edge-light':
54+
case 'other':
55+
default:
56+
version = undefined;
57+
break;
58+
}
59+
} catch {
60+
version = undefined;
61+
}
62+
63+
return {
64+
name,
65+
version,
66+
};
67+
}
68+
69+
/**
70+
* Extract Bun version from navigator.userAgent as fallback.
71+
* @returns Bun version string or undefined
72+
*/
73+
function extractBunVersionFromUserAgent(): string | undefined {
74+
try {
75+
if (typeof navigator !== 'undefined' && navigator.userAgent) {
76+
const match = navigator.userAgent.match(/Bun\/(\d+\.\d+\.\d+)/);
77+
return match?.[1];
78+
}
79+
} catch {
80+
// Ignore errors
81+
}
82+
return undefined;
83+
}

src/sso/__snapshots__/sso.spec.ts.snap

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,6 @@ exports[`SSO SSO getAuthorizationUrl with state generates an authorize url with
1919
exports[`SSO SSO getProfileAndToken with all information provided sends a request to the WorkOS api for a profile 1`] = `"client_id=proj_123&client_secret=sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU&grant_type=authorization_code&code=authorization_code"`;
2020

2121
exports[`SSO SSO getProfileAndToken with all information provided sends a request to the WorkOS api for a profile 2`] = `
22-
{
23-
"Accept": "application/json, text/plain, */*",
24-
"Authorization": "Bearer sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU",
25-
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
26-
"User-Agent": "workos-node/8.0.0-beta.4/fetch",
27-
}
28-
`;
29-
30-
exports[`SSO SSO getProfileAndToken with all information provided sends a request to the WorkOS api for a profile 3`] = `
3122
{
3223
"connectionId": "conn_123",
3324
"connectionType": "OktaSAML",
@@ -63,15 +54,6 @@ exports[`SSO SSO getProfileAndToken with all information provided sends a reques
6354
exports[`SSO SSO getProfileAndToken without a groups attribute sends a request to the WorkOS api for a profile 1`] = `"client_id=proj_123&client_secret=sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU&grant_type=authorization_code&code=authorization_code"`;
6455

6556
exports[`SSO SSO getProfileAndToken without a groups attribute sends a request to the WorkOS api for a profile 2`] = `
66-
{
67-
"Accept": "application/json, text/plain, */*",
68-
"Authorization": "Bearer sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU",
69-
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
70-
"User-Agent": "workos-node/8.0.0-beta.4/fetch",
71-
}
72-
`;
73-
74-
exports[`SSO SSO getProfileAndToken without a groups attribute sends a request to the WorkOS api for a profile 3`] = `
7557
{
7658
"connectionId": "conn_123",
7759
"connectionType": "OktaSAML",

src/sso/sso.spec.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,19 @@ describe('SSO', () => {
302302
expect(fetch.mock.calls.length).toEqual(1);
303303

304304
expect(fetchBody()).toMatchSnapshot();
305-
expect(fetchHeaders()).toMatchSnapshot();
305+
306+
const headers = fetchHeaders() as Record<string, string>;
307+
expect(headers['Accept']).toBe('application/json, text/plain, */*');
308+
expect(headers['Authorization']).toBe(
309+
'Bearer sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU',
310+
);
311+
expect(headers['Content-Type']).toBe(
312+
'application/x-www-form-urlencoded;charset=utf-8',
313+
);
314+
expect(headers['User-Agent']).toMatch(
315+
/^workos-node\/\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?\/fetch \(node\/v\d+\.\d+\.\d+\)$/,
316+
);
317+
306318
expect(accessToken).toBe('01DMEK0J53CVMC32CK5SE0KZ8Q');
307319
expect(profile).toMatchSnapshot();
308320
});
@@ -342,7 +354,19 @@ describe('SSO', () => {
342354
expect(fetch.mock.calls.length).toEqual(1);
343355

344356
expect(fetchBody()).toMatchSnapshot();
345-
expect(fetchHeaders()).toMatchSnapshot();
357+
358+
const headers = fetchHeaders() as Record<string, string>;
359+
expect(headers['Accept']).toBe('application/json, text/plain, */*');
360+
expect(headers['Authorization']).toBe(
361+
'Bearer sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU',
362+
);
363+
expect(headers['Content-Type']).toBe(
364+
'application/x-www-form-urlencoded;charset=utf-8',
365+
);
366+
expect(headers['User-Agent']).toMatch(
367+
/^workos-node\/\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?\/fetch \(node\/v\d+\.\d+\.\d+\)$/,
368+
);
369+
346370
expect(accessToken).toBe('01DMEK0J53CVMC32CK5SE0KZ8Q');
347371
expect(profile).toMatchSnapshot();
348372
});

0 commit comments

Comments
 (0)