Skip to content

Commit 48766e5

Browse files
feat(analytics): explicit source param + 'system' default for capture/captureException (#3653)
* feat(analytics): add explicit source param to capture() with 'system' default Adds optional `source` parameter to analyticsService.capture() and a `'system'` baseline default so events from cron tasks, webhook handlers, and worker callbacks no longer have null source attribution in PostHog. Precedence: explicit source > properties.source > req.posthogContext.source > 'system'. * feat(analytics): captureException accepts ctx.source + ctx.properties Extends captureException to accept an explicit source override and arbitrary properties merged into the $exception event, with the same 'system' default fallback as capture(). Unblocks the Winston error transport (#3651) and per-call-site source attribution downstream. * test(analytics): fix inner beforeEach + add missing captureException source test Add jest.resetModules() in capture source attribution beforeEach (latent flake risk). Add properties.source > system precedence test for captureException. * style(analytics): add JSDoc module header to captureException test file
1 parent 2bf9c2d commit 48766e5

5 files changed

Lines changed: 202 additions & 10 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,30 @@ git merge devkit-node/master
263263

264264
> Caution: resolve conflicts manually to preserve downstream customizations before pushing.
265265
266+
## :bar_chart: Analytics (PostHog)
267+
268+
The devkit ships an `analyticsService` backed by `posthog-node`. Enable via `config.analytics.posthog.enabled = true` + a valid `key`.
269+
270+
### Source attribution convention
271+
272+
Every `analytics.capture()` and `analytics.captureException()` event gets a `source` property. The lookup order is:
273+
274+
1. Explicit `source` param: `analytics.capture({ ..., source: 'cron' })`
275+
2. `properties.source` legacy/inline form
276+
3. `req.posthogContext.source` (auto-set by `posthogContextMiddleware`)
277+
4. `'system'` default
278+
279+
Canonical sources used downstream:
280+
281+
| Source | Meaning |
282+
|---|---|
283+
| `web` | Request from browser (UA not matched as CLI) |
284+
| `cli` | Request from `@trawlme/cli/<version>` (UA-parsed) |
285+
| `stripe-webhook` | Stripe POST `/api/billing/webhook` |
286+
| `worker-callback` | worker-puppeteer scrap completion callback |
287+
| `cron` | Scheduled background job |
288+
| `system` | Server-side fallback (no req, no caller override) |
289+
266290
## :pencil2: Contribute
267291

268292
Open issues and pull requests on [GitHub](https://github.com/pierreb-devkit/Node).

lib/services/analytics.js

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,27 +61,36 @@ const track = (distinctId, event, properties, groups) => {
6161
* into every event. Custom properties take precedence over defaults.
6262
* No-op when client is not initialised, distinctId or event are missing.
6363
*
64-
* When `req` is supplied and `req.posthogContext` is set (by posthogContextMiddleware),
65-
* its properties (source, cli_version) are merged into event properties so that
66-
* CLI-originated requests are attributed correctly.
64+
* Source attribution (highest wins):
65+
* 1. explicit `source` param
66+
* 2. `properties.source` (legacy/inline)
67+
* 3. `req.posthogContext.source` (set by posthogContextMiddleware)
68+
* 4. `'system'` default
6769
*
6870
* @param {Object} params - Event parameters
6971
* @param {string} params.distinctId - User or anonymous identifier
7072
* @param {string} params.event - Event name
7173
* @param {Object} [params.properties] - Additional event properties (win over defaults)
7274
* @param {import('express').Request} [params.req] - Optional Express request for context injection
75+
* @param {string} [params.source] - Explicit source override (wins over req + properties)
7376
* @returns {void}
7477
*/
75-
const capture = ({ distinctId, event, properties = {}, req } = {}) => {
78+
const capture = ({ distinctId, event, properties = {}, req, source } = {}) => {
7679
if (!client) return;
7780
if (!distinctId || !event) return;
7881
const defaults = {
7982
env: process.env.NODE_ENV || 'development',
8083
...(_appTag ? { app: _appTag } : {}),
84+
source: 'system',
8185
...(req?.posthogContext ?? {}),
8286
};
87+
const explicit = source ? { source } : {};
8388
try {
84-
client.capture({ distinctId, event, properties: { ...defaults, ...properties } });
89+
client.capture({
90+
distinctId,
91+
event,
92+
properties: { ...defaults, ...properties, ...explicit },
93+
});
8594
} catch (_) { /* analytics must never break caller */ }
8695
};
8796

@@ -141,16 +150,27 @@ const isFeatureEnabled = async (flag, distinctId, options) => {
141150
* Only active when `errorTracking` is opted-in via config — the caller
142151
* (`errorTracker.js`) is responsible for checking the flag before calling.
143152
* Safe to call when the PostHog client was never initialised.
144-
* @param {Error} err - Error to capture
153+
*
154+
* Source attribution (highest wins):
155+
* 1. explicit `ctx.source`
156+
* 2. `ctx.properties.source`
157+
* 3. `'system'` default
158+
*
159+
* @param {Error} err - Error to capture (no-op if null/undefined)
145160
* @param {Object} [ctx] - Optional context attached to the event
146-
* @param {string} [ctx.distinctId] - User identifier
161+
* @param {string} [ctx.distinctId] - User identifier (default 'anonymous')
147162
* @param {string} [ctx.requestId] - Request trace ID
163+
* @param {string} [ctx.source] - Explicit source override
164+
* @param {Object} [ctx.properties] - Additional properties merged into the event
148165
* @returns {void}
149166
*/
150167
const captureException = (err, ctx = {}) => {
151168
if (!client) return;
169+
if (!err) return;
152170
try {
153171
const distinctId = ctx.distinctId || 'anonymous';
172+
const defaults = { source: 'system' };
173+
const explicit = ctx.source ? { source: ctx.source } : {};
154174
client.capture({
155175
distinctId,
156176
event: '$exception',
@@ -159,6 +179,9 @@ const captureException = (err, ctx = {}) => {
159179
$exception_type: err?.name,
160180
$exception_stack: err?.stack,
161181
requestId: ctx.requestId,
182+
...defaults,
183+
...(ctx.properties ?? {}),
184+
...explicit,
162185
},
163186
});
164187
} catch (_) { /* analytics must never break caller */ }

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

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ describe('Analytics capture() and enabled-flag:', () => {
184184
expect(mockPostHogInstance.capture).toHaveBeenCalledWith({
185185
distinctId: 'user-1',
186186
event: 'my_event',
187-
properties: { app: 'myapp', env: 'test' },
187+
properties: { app: 'myapp', env: 'test', source: 'system' },
188188
});
189189

190190
process.env.NODE_ENV = origEnv;
@@ -251,11 +251,11 @@ describe('Analytics capture() and enabled-flag:', () => {
251251
});
252252
});
253253

254-
test('absent req does not inject source/cli_version (backward compat)', async () => {
254+
test('absent req injects source="system" default (no cli_version)', async () => {
255255
AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event' });
256256

257257
const call = mockPostHogInstance.capture.mock.calls[0][0];
258-
expect(call.properties).not.toHaveProperty('source');
258+
expect(call.properties).toHaveProperty('source', 'system');
259259
expect(call.properties).not.toHaveProperty('cli_version');
260260
});
261261
});
@@ -279,4 +279,61 @@ describe('Analytics capture() and enabled-flag:', () => {
279279
expect(mockPostHogInstance.shutdown).toHaveBeenCalledTimes(1);
280280
});
281281
});
282+
283+
// ─────────────────────────────────────────────────────────────────
284+
// 5. source attribution
285+
// ─────────────────────────────────────────────────────────────────
286+
describe('source attribution:', () => {
287+
let AnalyticsService;
288+
beforeEach(async () => {
289+
jest.resetModules();
290+
jest.unstable_mockModule('posthog-node', () => ({
291+
PostHog: jest.fn().mockImplementation(() => mockPostHogInstance),
292+
}));
293+
jest.unstable_mockModule('../../../config/index.js', () => ({
294+
default: { analytics: { posthog: { enabled: true, key: 'phc_test', host: 'https://eu.i.posthog.com', appTag: 'trawl' } } },
295+
}));
296+
const mod = await import('../analytics.js');
297+
AnalyticsService = mod.default;
298+
await AnalyticsService.init();
299+
});
300+
301+
test('defaults to source="system" when no req, no explicit source', () => {
302+
AnalyticsService.capture({ distinctId: 'u1', event: 'test' });
303+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
304+
properties: expect.objectContaining({ source: 'system' }),
305+
}));
306+
});
307+
308+
test('explicit source param wins over req.posthogContext', () => {
309+
const req = { posthogContext: { source: 'web' } };
310+
AnalyticsService.capture({ distinctId: 'u1', event: 'test', req, source: 'cron' });
311+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
312+
properties: expect.objectContaining({ source: 'cron' }),
313+
}));
314+
});
315+
316+
test('properties.source wins over req.posthogContext', () => {
317+
const req = { posthogContext: { source: 'web' } };
318+
AnalyticsService.capture({ distinctId: 'u1', event: 'test', req, properties: { source: 'stripe-webhook' } });
319+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
320+
properties: expect.objectContaining({ source: 'stripe-webhook' }),
321+
}));
322+
});
323+
324+
test('req.posthogContext.source wins over default', () => {
325+
const req = { posthogContext: { source: 'cli', cli_version: '1.12.0' } };
326+
AnalyticsService.capture({ distinctId: 'u1', event: 'test', req });
327+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
328+
properties: expect.objectContaining({ source: 'cli', cli_version: '1.12.0' }),
329+
}));
330+
});
331+
332+
test('explicit source param wins over properties.source', () => {
333+
AnalyticsService.capture({ distinctId: 'u1', event: 'test', source: 'cron', properties: { source: 'web' } });
334+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
335+
properties: expect.objectContaining({ source: 'cron' }),
336+
}));
337+
});
338+
});
282339
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';
5+
6+
describe('Analytics captureException():', () => {
7+
let AnalyticsService;
8+
let mockPostHogInstance;
9+
10+
beforeEach(async () => {
11+
jest.resetModules();
12+
mockPostHogInstance = {
13+
capture: jest.fn(),
14+
identify: jest.fn(),
15+
groupIdentify: jest.fn(),
16+
getFeatureFlag: jest.fn().mockResolvedValue(undefined),
17+
isFeatureEnabled: jest.fn().mockResolvedValue(undefined),
18+
shutdown: jest.fn().mockResolvedValue(undefined),
19+
};
20+
jest.unstable_mockModule('posthog-node', () => ({
21+
PostHog: jest.fn().mockImplementation(() => mockPostHogInstance),
22+
}));
23+
jest.unstable_mockModule('../../../config/index.js', () => ({
24+
default: { analytics: { posthog: { enabled: true, key: 'phc_test', host: 'https://eu.i.posthog.com', appTag: 'trawl' } } },
25+
}));
26+
const mod = await import('../analytics.js');
27+
AnalyticsService = mod.default;
28+
await AnalyticsService.init();
29+
});
30+
31+
afterEach(() => { jest.restoreAllMocks(); });
32+
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+
}));
39+
});
40+
41+
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+
}));
46+
});
47+
48+
test('merges ctx.properties (logMessage/logLevel) into event', () => {
49+
AnalyticsService.captureException(new Error('boom'), {
50+
distinctId: 'u1',
51+
properties: { logMessage: 'something failed', logLevel: 'error' },
52+
});
53+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
54+
distinctId: 'u1',
55+
properties: expect.objectContaining({
56+
logMessage: 'something failed',
57+
logLevel: 'error',
58+
source: 'system',
59+
}),
60+
}));
61+
});
62+
63+
test('ctx.properties.source wins over system default', () => {
64+
AnalyticsService.captureException(new Error('boom'), {
65+
properties: { source: 'stripe-webhook' },
66+
});
67+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
68+
properties: expect.objectContaining({ source: 'stripe-webhook' }),
69+
}));
70+
});
71+
72+
test('explicit ctx.source wins over ctx.properties.source', () => {
73+
AnalyticsService.captureException(new Error('boom'), {
74+
source: 'cron',
75+
properties: { source: 'web' },
76+
});
77+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({
78+
properties: expect.objectContaining({ source: 'cron' }),
79+
}));
80+
});
81+
82+
test('no-op when err is null/undefined', () => {
83+
AnalyticsService.captureException(null);
84+
AnalyticsService.captureException(undefined);
85+
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
86+
});
87+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ describe('Analytics service unit tests:', () => {
260260
$exception_type: 'Error',
261261
$exception_stack: err.stack,
262262
requestId: 'req-abc',
263+
source: 'system',
263264
},
264265
});
265266
});

0 commit comments

Comments
 (0)