Skip to content

Commit 25db1be

Browse files
fix(observability): use posthog-node captureException SDK method for UI grouping (#3659)
* fix(observability): use posthog-node captureException SDK method for UI grouping Replaces legacy client.capture({event: '\$exception', props}) with SDK client.captureException(err, distinctId, additionalProperties) so PostHog auto-populates \$exception_list + \$exception_fingerprint — required by the Error Tracking UI for grouping/display. Fixes #3658. * fix(analytics): make ctx.requestId authoritative + assert single emit CodeRabbit findings on PR #3659: - captureException additionalProperties construction let ctx.properties.requestId override ctx.requestId. Move requestId spread after ctx.properties so the explicit ctx.requestId wins. - logger.posthog.transport.integration.tests.js asserted payload shape but not call count. Add toHaveBeenCalledTimes(1). +1 unit test for ctx.requestId precedence.
1 parent 829b91b commit 25db1be

4 files changed

Lines changed: 96 additions & 65 deletions

File tree

lib/services/analytics.js

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ const isFeatureEnabled = async (flag, distinctId, options) => {
151151
* (`errorTracker.js`) is responsible for checking the flag before calling.
152152
* Safe to call when the PostHog client was never initialised.
153153
*
154+
* Uses SDK native `client.captureException()` so PostHog Error Tracking UI
155+
* groups events via auto-generated `$exception_list` + `$exception_fingerprint`.
156+
*
154157
* Source attribution (highest wins):
155158
* 1. explicit `ctx.source`
156159
* 2. `ctx.properties.source`
@@ -169,21 +172,14 @@ const captureException = (err, ctx = {}) => {
169172
if (!err) return;
170173
try {
171174
const distinctId = ctx.distinctId || 'anonymous';
172-
const defaults = { source: 'system' };
173175
const explicit = ctx.source ? { source: ctx.source } : {};
174-
client.capture({
175-
distinctId,
176-
event: '$exception',
177-
properties: {
178-
$exception_message: err?.message,
179-
$exception_type: err?.name,
180-
$exception_stack: err?.stack,
181-
requestId: ctx.requestId,
182-
...defaults,
183-
...(ctx.properties ?? {}),
184-
...explicit,
185-
},
186-
});
176+
const additionalProperties = {
177+
source: 'system',
178+
...(ctx.properties ?? {}),
179+
...(ctx.requestId !== undefined ? { requestId: ctx.requestId } : {}),
180+
...explicit,
181+
};
182+
client.captureException(err, distinctId, additionalProperties);
187183
} catch (_) { /* analytics must never break caller */ }
188184
};
189185

lib/services/tests/analytics.captureException.unit.tests.js

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ describe('Analytics captureException():', () => {
1111
jest.resetModules();
1212
mockPostHogInstance = {
1313
capture: jest.fn(),
14+
captureException: jest.fn(),
1415
identify: jest.fn(),
1516
groupIdentify: jest.fn(),
1617
getFeatureFlag: jest.fn().mockResolvedValue(undefined),
@@ -30,58 +31,94 @@ describe('Analytics captureException():', () => {
3031

3132
afterEach(() => { jest.restoreAllMocks(); });
3233

33-
test('emits $exception with default source="system" when no ctx', () => {
34-
AnalyticsService.captureException(new Error('boom'));
35-
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
36-
event: '$exception',
37-
properties: expect.objectContaining({ source: 'system', $exception_message: 'boom' }),
38-
}));
34+
test('calls SDK captureException with default source="system" when no ctx', () => {
35+
const err = new Error('boom');
36+
AnalyticsService.captureException(err);
37+
expect(mockPostHogInstance.captureException).toHaveBeenCalledWith(
38+
err,
39+
'anonymous',
40+
expect.objectContaining({ source: 'system' }),
41+
);
3942
});
4043

4144
test('honours explicit ctx.source', () => {
42-
AnalyticsService.captureException(new Error('boom'), { source: 'worker-callback' });
43-
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
44-
properties: expect.objectContaining({ source: 'worker-callback' }),
45-
}));
45+
const err = new Error('boom');
46+
AnalyticsService.captureException(err, { source: 'worker-callback' });
47+
expect(mockPostHogInstance.captureException).toHaveBeenCalledWith(
48+
err,
49+
'anonymous',
50+
expect.objectContaining({ source: 'worker-callback' }),
51+
);
4652
});
4753

48-
test('merges ctx.properties (logMessage/logLevel) into event', () => {
49-
AnalyticsService.captureException(new Error('boom'), {
54+
test('merges ctx.properties (logMessage/logLevel) into additionalProperties', () => {
55+
const err = new Error('boom');
56+
AnalyticsService.captureException(err, {
5057
distinctId: 'u1',
5158
properties: { logMessage: 'something failed', logLevel: 'error' },
5259
});
53-
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
54-
distinctId: 'u1',
55-
properties: expect.objectContaining({
60+
expect(mockPostHogInstance.captureException).toHaveBeenCalledWith(
61+
err,
62+
'u1',
63+
expect.objectContaining({
5664
logMessage: 'something failed',
5765
logLevel: 'error',
5866
source: 'system',
5967
}),
60-
}));
68+
);
6169
});
6270

6371
test('ctx.properties.source wins over system default', () => {
64-
AnalyticsService.captureException(new Error('boom'), {
72+
const err = new Error('boom');
73+
AnalyticsService.captureException(err, {
6574
properties: { source: 'stripe-webhook' },
6675
});
67-
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
68-
properties: expect.objectContaining({ source: 'stripe-webhook' }),
69-
}));
76+
expect(mockPostHogInstance.captureException).toHaveBeenCalledWith(
77+
err,
78+
'anonymous',
79+
expect.objectContaining({ source: 'stripe-webhook' }),
80+
);
7081
});
7182

7283
test('explicit ctx.source wins over ctx.properties.source', () => {
73-
AnalyticsService.captureException(new Error('boom'), {
84+
const err = new Error('boom');
85+
AnalyticsService.captureException(err, {
7486
source: 'cron',
7587
properties: { source: 'web' },
7688
});
77-
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
78-
properties: expect.objectContaining({ source: 'cron' }),
79-
}));
89+
expect(mockPostHogInstance.captureException).toHaveBeenCalledWith(
90+
err,
91+
'anonymous',
92+
expect.objectContaining({ source: 'cron' }),
93+
);
8094
});
8195

8296
test('no-op when err is null/undefined', () => {
8397
AnalyticsService.captureException(null);
8498
AnalyticsService.captureException(undefined);
85-
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
99+
expect(mockPostHogInstance.captureException).not.toHaveBeenCalled();
100+
});
101+
102+
test('passes requestId in additionalProperties', () => {
103+
const err = new Error('with-req');
104+
AnalyticsService.captureException(err, { distinctId: 'u2', requestId: 'req-xyz' });
105+
expect(mockPostHogInstance.captureException).toHaveBeenCalledWith(
106+
err,
107+
'u2',
108+
expect.objectContaining({ requestId: 'req-xyz' }),
109+
);
110+
});
111+
112+
test('ctx.requestId wins over ctx.properties.requestId (authoritative)', () => {
113+
const err = new Error('req-precedence');
114+
AnalyticsService.captureException(err, {
115+
requestId: 'authoritative-req',
116+
properties: { requestId: 'should-be-overridden' },
117+
});
118+
expect(mockPostHogInstance.captureException).toHaveBeenCalledWith(
119+
err,
120+
'anonymous',
121+
expect.objectContaining({ requestId: 'authoritative-req' }),
122+
);
86123
});
87124
});

lib/services/tests/analytics.service.unit.tests.js

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('Analytics service unit tests:', () => {
1515

1616
mockPostHogInstance = {
1717
capture: jest.fn(),
18+
captureException: jest.fn(),
1819
identify: jest.fn(),
1920
groupIdentify: jest.fn(),
2021
getFeatureFlag: jest.fn().mockResolvedValue('variant-a'),
@@ -243,7 +244,7 @@ describe('Analytics service unit tests:', () => {
243244
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
244245
});
245246

246-
test('captureException should send $exception event with correct properties', async () => {
247+
test('captureException should call SDK captureException with correct args', async () => {
247248
const mod = await import('../analytics.js');
248249
AnalyticsService = mod.default;
249250

@@ -252,17 +253,11 @@ describe('Analytics service unit tests:', () => {
252253
err.stack = 'Error: test error\n at test.js:1:1';
253254
AnalyticsService.captureException(err, { distinctId: 'user-1', requestId: 'req-abc' });
254255

255-
expect(mockPostHogInstance.capture).toHaveBeenCalledWith({
256-
distinctId: 'user-1',
257-
event: '$exception',
258-
properties: {
259-
$exception_message: 'test error',
260-
$exception_type: 'Error',
261-
$exception_stack: err.stack,
262-
requestId: 'req-abc',
263-
source: 'system',
264-
},
265-
});
256+
expect(mockPostHogInstance.captureException).toHaveBeenCalledWith(
257+
err,
258+
'user-1',
259+
expect.objectContaining({ requestId: 'req-abc', source: 'system' }),
260+
);
266261
});
267262

268263
test('captureException should use anonymous distinctId when not provided', async () => {
@@ -272,8 +267,10 @@ describe('Analytics service unit tests:', () => {
272267
await AnalyticsService.init();
273268
AnalyticsService.captureException(new Error('anon error'), {});
274269

275-
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(
276-
expect.objectContaining({ distinctId: 'anonymous' }),
270+
expect(mockPostHogInstance.captureException).toHaveBeenCalledWith(
271+
expect.any(Error),
272+
'anonymous',
273+
expect.any(Object),
277274
);
278275
});
279276

@@ -283,15 +280,15 @@ describe('Analytics service unit tests:', () => {
283280

284281
// Do NOT call init — client stays null
285282
AnalyticsService.captureException(new Error('no client'), { distinctId: 'user-1' });
286-
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
283+
expect(mockPostHogInstance.captureException).not.toHaveBeenCalled();
287284
});
288285

289286
test('captureException should silently swallow client errors', async () => {
290287
const mod = await import('../analytics.js');
291288
AnalyticsService = mod.default;
292289

293290
await AnalyticsService.init();
294-
mockPostHogInstance.capture.mockImplementation(() => { throw new Error('client error'); });
291+
mockPostHogInstance.captureException.mockImplementation(() => { throw new Error('client error'); });
295292

296293
// Should not throw
297294
expect(() => AnalyticsService.captureException(new Error('err'), {})).not.toThrow();

lib/services/tests/logger.posthog.transport.integration.tests.js

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ describe('logger.error → PostHog $exception (integration):', () => {
88
jest.resetModules();
99
mockPostHogInstance = {
1010
capture: jest.fn(),
11+
captureException: jest.fn(),
1112
identify: jest.fn(),
1213
groupIdentify: jest.fn(),
1314
getFeatureFlag: jest.fn().mockResolvedValue(undefined),
@@ -33,30 +34,30 @@ describe('logger.error → PostHog $exception (integration):', () => {
3334

3435
afterEach(() => { jest.restoreAllMocks(); });
3536

36-
test('logger.error(message, { error }) emits a single $exception event', () => {
37+
test('logger.error(message, { error }) emits a single $exception event via SDK captureException', () => {
3738
const err = new Error('payment failed');
3839
logger.error('Charge failed for user', { error: err });
39-
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
40-
event: '$exception',
41-
properties: expect.objectContaining({
42-
$exception_message: 'payment failed',
43-
$exception_type: 'Error',
40+
expect(mockPostHogInstance.captureException).toHaveBeenCalledTimes(1);
41+
expect(mockPostHogInstance.captureException).toHaveBeenCalledWith(
42+
err,
43+
'anonymous',
44+
expect.objectContaining({
4445
logMessage: 'Charge failed for user',
4546
logLevel: 'error',
4647
source: 'system',
4748
}),
48-
}));
49+
);
4950
});
5051

51-
test('logger.error(err) directly emits a single $exception event', () => {
52+
test('logger.error(err) directly emits a single $exception event via SDK captureException', () => {
5253
const err = new Error('boom');
5354
logger.error(err);
54-
expect(mockPostHogInstance.capture).toHaveBeenCalledTimes(1);
55+
expect(mockPostHogInstance.captureException).toHaveBeenCalledTimes(1);
5556
});
5657

5758
test('error already marked posthogCaptured does NOT re-emit', () => {
5859
const err = Object.assign(new Error('boom'), { posthogCaptured: true });
5960
logger.error('skipped', { error: err });
60-
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
61+
expect(mockPostHogInstance.captureException).not.toHaveBeenCalled();
6162
});
6263
});

0 commit comments

Comments
 (0)