Skip to content

Commit 001925f

Browse files
authored
feat(analytics): add run_id to every event and $session_id after login (#661)
1 parent fededc5 commit 001925f

2 files changed

Lines changed: 112 additions & 6 deletions

File tree

src/utils/__tests__/analytics.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ import type { ApiUser } from '@lib/api';
77
jest.mock('posthog-node');
88
jest.mock('uuid');
99

10+
// IS_PRODUCTION_BUILD is read live (property access) in the Analytics
11+
// constructor, so a getter backed by this mutable flag lets a test flip the
12+
// build type without re-importing the module. Defaults falsy → 'dev',
13+
// matching every other test. `var` (not `let`) so the hoisted jest.mock
14+
// factory can read it at import time without hitting the temporal dead zone;
15+
// the `mock` prefix satisfies jest's hoisting rule.
16+
// eslint-disable-next-line no-var
17+
var mockIsProductionBuild = false;
18+
jest.mock('@env', () => ({
19+
...jest.requireActual('@env'),
20+
get IS_PRODUCTION_BUILD() {
21+
return mockIsProductionBuild;
22+
},
23+
}));
24+
1025
const mockUuidv4 = uuidv4 as jest.MockedFunction<typeof uuidv4>;
1126
const MockedPostHog = PostHog as jest.MockedClass<typeof PostHog>;
1227

@@ -16,7 +31,18 @@ describe('Analytics', () => {
1631

1732
beforeEach(() => {
1833
jest.clearAllMocks();
19-
mockUuidv4.mockReturnValue('test-uuid' as any);
34+
mockIsProductionBuild = false;
35+
// Each run mints several distinct uuids; mock them to different values
36+
// so the tests reflect reality (run_id !== $session_id) rather than
37+
// collapsing them. Call order: anonymousId, runId (both in the
38+
// constructor), then sessionId (lazily, on first identify).
39+
let uuidCall = 0;
40+
mockUuidv4.mockImplementation((() => {
41+
uuidCall += 1;
42+
if (uuidCall === 1) return 'test-uuid'; // anonymousId
43+
if (uuidCall === 2) return 'run-uuid'; // runId
44+
return 'session-uuid'; // sessionId (first identify)
45+
}) as any);
2046

2147
mockPostHogInstance = {
2248
capture: jest.fn(),
@@ -45,6 +71,7 @@ describe('Analytics', () => {
4571
team: ANALYTICS_TEAM_TAG,
4672
$app_name: 'wizard',
4773
build: 'dev',
74+
run_id: 'run-uuid',
4875
...properties,
4976
},
5077
);
@@ -64,6 +91,7 @@ describe('Analytics', () => {
6491
team: ANALYTICS_TEAM_TAG,
6592
$app_name: 'wizard',
6693
build: 'dev',
94+
run_id: 'run-uuid',
6795
testTag: 'testValue',
6896
...properties,
6997
},
@@ -84,6 +112,8 @@ describe('Analytics', () => {
84112
team: ANALYTICS_TEAM_TAG,
85113
$app_name: 'wizard',
86114
build: 'dev',
115+
run_id: 'run-uuid',
116+
$session_id: 'session-uuid',
87117
},
88118
);
89119
});
@@ -100,6 +130,7 @@ describe('Analytics', () => {
100130
team: ANALYTICS_TEAM_TAG,
101131
$app_name: 'wizard',
102132
build: 'dev',
133+
run_id: 'run-uuid',
103134
},
104135
);
105136
});
@@ -119,6 +150,7 @@ describe('Analytics', () => {
119150
team: ANALYTICS_TEAM_TAG,
120151
$app_name: 'wizard',
121152
build: 'dev',
153+
run_id: 'run-uuid',
122154
environment: 'test',
123155
version: '1.0.0',
124156
integration: 'nextjs',
@@ -141,6 +173,7 @@ describe('Analytics', () => {
141173
team: ANALYTICS_TEAM_TAG,
142174
$app_name: 'wizard',
143175
build: 'dev',
176+
run_id: 'run-uuid',
144177
integration: 'react',
145178
},
146179
);
@@ -158,11 +191,37 @@ describe('Analytics', () => {
158191
team: ANALYTICS_TEAM_TAG,
159192
$app_name: 'wizard',
160193
build: 'dev',
194+
run_id: 'run-uuid',
161195
},
162196
);
163197
});
164198
});
165199

200+
describe('build tag', () => {
201+
it("tags dev/test runs as 'dev'", () => {
202+
analytics.captureException(new Error('e'));
203+
204+
expect(
205+
(mockPostHogInstance.captureException as jest.Mock).mock.calls.at(
206+
-1,
207+
)?.[2],
208+
).toMatchObject({ build: 'dev' });
209+
});
210+
211+
it("tags production builds as 'prod'", () => {
212+
mockIsProductionBuild = true;
213+
const prodAnalytics = new Analytics();
214+
215+
prodAnalytics.captureException(new Error('e'));
216+
217+
expect(
218+
(mockPostHogInstance.captureException as jest.Mock).mock.calls.at(
219+
-1,
220+
)?.[2],
221+
).toMatchObject({ build: 'prod' });
222+
});
223+
});
224+
166225
describe('identifyUser', () => {
167226
const user = {
168227
distinct_id: 'user-123',
@@ -209,6 +268,24 @@ describe('Analytics', () => {
209268
expect(mockPostHogInstance.alias).not.toHaveBeenCalled();
210269
});
211270

271+
it('opens the session ($session_id) only once the user is identified', () => {
272+
const error = new Error('e');
273+
274+
// Pre-login: run_id is present, $session_id is not.
275+
analytics.captureException(error);
276+
const beforeLogin = (mockPostHogInstance.captureException as jest.Mock)
277+
.mock.calls[0][2];
278+
expect(beforeLogin).toMatchObject({ run_id: 'run-uuid' });
279+
expect(beforeLogin).not.toHaveProperty('$session_id');
280+
281+
// Post-login: both ids ride along.
282+
analytics.identifyUser({ distinct_id: 'user-123' } as unknown as ApiUser);
283+
analytics.captureException(error);
284+
expect(
285+
(mockPostHogInstance.captureException as jest.Mock).mock.calls[1][2],
286+
).toMatchObject({ run_id: 'run-uuid', $session_id: 'session-uuid' });
287+
});
288+
212289
it('omits person properties the user does not have', () => {
213290
analytics.identifyUser({
214291
distinct_id: 'user-123',
@@ -249,6 +326,7 @@ describe('Analytics', () => {
249326
expect(result?.properties).toEqual({
250327
$app_name: 'wizard',
251328
build: 'dev',
329+
run_id: 'run-uuid',
252330
command: 'slack',
253331
$exception_list: [{ type: 'Error' }],
254332
});
@@ -411,6 +489,7 @@ describe('Analytics', () => {
411489
team: ANALYTICS_TEAM_TAG,
412490
$app_name: 'wizard',
413491
build: 'dev',
492+
run_id: 'run-uuid',
414493
integration: 'nextjs',
415494
localMcp: true,
416495
debug: false,
@@ -435,6 +514,8 @@ describe('Analytics', () => {
435514
team: ANALYTICS_TEAM_TAG,
436515
$app_name: 'wizard',
437516
build: 'dev',
517+
run_id: 'run-uuid',
518+
$session_id: 'session-uuid',
438519
integration: 'svelte',
439520
},
440521
);

src/utils/analytics.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export class Analytics {
5353
{};
5454
private distinctId?: string;
5555
private anonymousId: string;
56+
private runId: string;
57+
private sessionId: string | null = null;
5658
private appName = 'wizard';
5759
private activeFlags: Record<string, string> | null = null;
5860
private groups: Record<string, string> = {};
@@ -83,14 +85,24 @@ export class Analytics {
8385
});
8486

8587
this.tags = { $app_name: this.appName };
86-
// Non-production builds tag every event so they segment out of prod
87-
// data. CI runs upgrade the tag to 'ci' (see runWizardCI).
88-
if (!IS_PRODUCTION_BUILD) {
89-
this.tags.build = 'dev';
90-
}
88+
// Tag every run with its build type so prod / dev / ci segment cleanly
89+
// in analytics. tsdown inlines IS_PRODUCTION_BUILD to `true` in published
90+
// builds and `false` for dev/tsx/test runs. CI runs (always non-prod
91+
// builds) upgrade this to 'ci' in runWizardCI.
92+
this.tags.build = IS_PRODUCTION_BUILD ? 'prod' : 'dev';
9193

9294
this.anonymousId = uuidv4();
9395

96+
// One id per process = one id per wizard run, registered in the tag bag
97+
// so it rides on every capture, exception, and autocaptured exception
98+
// (all of which merge `this.tags`). Lets you separate two runs by the
99+
// same logged-in user, who otherwise share one distinct id. Distinct
100+
// from `anonymousId`, the pre-login *person* id that gets aliased onto
101+
// the real user at login. `$session_id` is intentionally not set here —
102+
// it stays null until OAuth completes (see identifyUser).
103+
this.runId = uuidv4();
104+
this.tags.run_id = this.runId;
105+
94106
this.distinctId = undefined;
95107
}
96108

@@ -106,6 +118,15 @@ export class Analytics {
106118
return;
107119
}
108120
this.distinctId = distinctId;
121+
// Open the analytics session on first login. Null until here, so
122+
// pre-OAuth events carry only `run_id`; from now on every event also
123+
// carries `$session_id` and PostHog groups the authenticated run into a
124+
// native Session. Stored in the tag bag so it rides on every subsequent
125+
// capture and exception.
126+
if (!this.sessionId) {
127+
this.sessionId = uuidv4();
128+
this.tags.$session_id = this.sessionId;
129+
}
109130
this.client.identify({
110131
distinctId,
111132
properties: {
@@ -214,6 +235,10 @@ export class Analytics {
214235
distinctId: this.distinctId ?? this.anonymousId,
215236
event: 'setup wizard finished',
216237
properties: {
238+
// Hoisted out of `tags` so the run's terminal event is filterable by
239+
// run, and joins the session when one was opened (post-OAuth runs).
240+
run_id: this.runId,
241+
...(this.sessionId ? { $session_id: this.sessionId } : {}),
217242
status,
218243
tags: this.tags,
219244
},

0 commit comments

Comments
 (0)