diff --git a/README.md b/README.md index c69248025..8679e93c8 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,30 @@ git merge devkit-node/master > Caution: resolve conflicts manually to preserve downstream customizations before pushing. +## :bar_chart: Analytics (PostHog) + +The devkit ships an `analyticsService` backed by `posthog-node`. Enable via `config.analytics.posthog.enabled = true` + a valid `key`. + +### Source attribution convention + +Every `analytics.capture()` and `analytics.captureException()` event gets a `source` property. The lookup order is: + +1. Explicit `source` param: `analytics.capture({ ..., source: 'cron' })` +2. `properties.source` legacy/inline form +3. `req.posthogContext.source` (auto-set by `posthogContextMiddleware`) +4. `'system'` default + +Canonical sources used downstream: + +| Source | Meaning | +|---|---| +| `web` | Request from browser (UA not matched as CLI) | +| `cli` | Request from `@trawlme/cli/` (UA-parsed) | +| `stripe-webhook` | Stripe POST `/api/billing/webhook` | +| `worker-callback` | worker-puppeteer scrap completion callback | +| `cron` | Scheduled background job | +| `system` | Server-side fallback (no req, no caller override) | + ## :pencil2: Contribute Open issues and pull requests on [GitHub](https://github.com/pierreb-devkit/Node). diff --git a/lib/services/analytics.js b/lib/services/analytics.js index 07b6bbd8f..0879dbbd0 100644 --- a/lib/services/analytics.js +++ b/lib/services/analytics.js @@ -61,27 +61,36 @@ const track = (distinctId, event, properties, groups) => { * into every event. Custom properties take precedence over defaults. * No-op when client is not initialised, distinctId or event are missing. * - * When `req` is supplied and `req.posthogContext` is set (by posthogContextMiddleware), - * its properties (source, cli_version) are merged into event properties so that - * CLI-originated requests are attributed correctly. + * Source attribution (highest wins): + * 1. explicit `source` param + * 2. `properties.source` (legacy/inline) + * 3. `req.posthogContext.source` (set by posthogContextMiddleware) + * 4. `'system'` default * * @param {Object} params - Event parameters * @param {string} params.distinctId - User or anonymous identifier * @param {string} params.event - Event name * @param {Object} [params.properties] - Additional event properties (win over defaults) * @param {import('express').Request} [params.req] - Optional Express request for context injection + * @param {string} [params.source] - Explicit source override (wins over req + properties) * @returns {void} */ -const capture = ({ distinctId, event, properties = {}, req } = {}) => { +const capture = ({ distinctId, event, properties = {}, req, source } = {}) => { if (!client) return; if (!distinctId || !event) return; const defaults = { env: process.env.NODE_ENV || 'development', ...(_appTag ? { app: _appTag } : {}), + source: 'system', ...(req?.posthogContext ?? {}), }; + const explicit = source ? { source } : {}; try { - client.capture({ distinctId, event, properties: { ...defaults, ...properties } }); + client.capture({ + distinctId, + event, + properties: { ...defaults, ...properties, ...explicit }, + }); } catch (_) { /* analytics must never break caller */ } }; @@ -141,16 +150,27 @@ const isFeatureEnabled = async (flag, distinctId, options) => { * Only active when `errorTracking` is opted-in via config — the caller * (`errorTracker.js`) is responsible for checking the flag before calling. * Safe to call when the PostHog client was never initialised. - * @param {Error} err - Error to capture + * + * Source attribution (highest wins): + * 1. explicit `ctx.source` + * 2. `ctx.properties.source` + * 3. `'system'` default + * + * @param {Error} err - Error to capture (no-op if null/undefined) * @param {Object} [ctx] - Optional context attached to the event - * @param {string} [ctx.distinctId] - User identifier + * @param {string} [ctx.distinctId] - User identifier (default 'anonymous') * @param {string} [ctx.requestId] - Request trace ID + * @param {string} [ctx.source] - Explicit source override + * @param {Object} [ctx.properties] - Additional properties merged into the event * @returns {void} */ const captureException = (err, ctx = {}) => { if (!client) return; + if (!err) return; try { const distinctId = ctx.distinctId || 'anonymous'; + const defaults = { source: 'system' }; + const explicit = ctx.source ? { source: ctx.source } : {}; client.capture({ distinctId, event: '$exception', @@ -159,6 +179,9 @@ const captureException = (err, ctx = {}) => { $exception_type: err?.name, $exception_stack: err?.stack, requestId: ctx.requestId, + ...defaults, + ...(ctx.properties ?? {}), + ...explicit, }, }); } catch (_) { /* analytics must never break caller */ } diff --git a/lib/services/tests/analytics.capture.unit.tests.js b/lib/services/tests/analytics.capture.unit.tests.js index a60b558cf..b01d229d4 100644 --- a/lib/services/tests/analytics.capture.unit.tests.js +++ b/lib/services/tests/analytics.capture.unit.tests.js @@ -184,7 +184,7 @@ describe('Analytics capture() and enabled-flag:', () => { expect(mockPostHogInstance.capture).toHaveBeenCalledWith({ distinctId: 'user-1', event: 'my_event', - properties: { app: 'myapp', env: 'test' }, + properties: { app: 'myapp', env: 'test', source: 'system' }, }); process.env.NODE_ENV = origEnv; @@ -251,11 +251,11 @@ describe('Analytics capture() and enabled-flag:', () => { }); }); - test('absent req does not inject source/cli_version (backward compat)', async () => { + test('absent req injects source="system" default (no cli_version)', async () => { AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event' }); const call = mockPostHogInstance.capture.mock.calls[0][0]; - expect(call.properties).not.toHaveProperty('source'); + expect(call.properties).toHaveProperty('source', 'system'); expect(call.properties).not.toHaveProperty('cli_version'); }); }); @@ -279,4 +279,61 @@ describe('Analytics capture() and enabled-flag:', () => { expect(mockPostHogInstance.shutdown).toHaveBeenCalledTimes(1); }); }); + + // ───────────────────────────────────────────────────────────────── + // 5. source attribution + // ───────────────────────────────────────────────────────────────── + describe('source attribution:', () => { + let AnalyticsService; + beforeEach(async () => { + jest.resetModules(); + jest.unstable_mockModule('posthog-node', () => ({ + PostHog: jest.fn().mockImplementation(() => mockPostHogInstance), + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { analytics: { posthog: { enabled: true, key: 'phc_test', host: 'https://eu.i.posthog.com', appTag: 'trawl' } } }, + })); + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + await AnalyticsService.init(); + }); + + test('defaults to source="system" when no req, no explicit source', () => { + AnalyticsService.capture({ distinctId: 'u1', event: 'test' }); + expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ source: 'system' }), + })); + }); + + test('explicit source param wins over req.posthogContext', () => { + const req = { posthogContext: { source: 'web' } }; + AnalyticsService.capture({ distinctId: 'u1', event: 'test', req, source: 'cron' }); + expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ source: 'cron' }), + })); + }); + + test('properties.source wins over req.posthogContext', () => { + const req = { posthogContext: { source: 'web' } }; + AnalyticsService.capture({ distinctId: 'u1', event: 'test', req, properties: { source: 'stripe-webhook' } }); + expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ source: 'stripe-webhook' }), + })); + }); + + test('req.posthogContext.source wins over default', () => { + const req = { posthogContext: { source: 'cli', cli_version: '1.12.0' } }; + AnalyticsService.capture({ distinctId: 'u1', event: 'test', req }); + expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ source: 'cli', cli_version: '1.12.0' }), + })); + }); + + test('explicit source param wins over properties.source', () => { + AnalyticsService.capture({ distinctId: 'u1', event: 'test', source: 'cron', properties: { source: 'web' } }); + expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ source: 'cron' }), + })); + }); + }); }); diff --git a/lib/services/tests/analytics.captureException.unit.tests.js b/lib/services/tests/analytics.captureException.unit.tests.js new file mode 100644 index 000000000..64c7caa88 --- /dev/null +++ b/lib/services/tests/analytics.captureException.unit.tests.js @@ -0,0 +1,87 @@ +/** + * Module dependencies. + */ +import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; + +describe('Analytics captureException():', () => { + let AnalyticsService; + let mockPostHogInstance; + + beforeEach(async () => { + jest.resetModules(); + mockPostHogInstance = { + capture: jest.fn(), + identify: jest.fn(), + groupIdentify: jest.fn(), + getFeatureFlag: jest.fn().mockResolvedValue(undefined), + isFeatureEnabled: jest.fn().mockResolvedValue(undefined), + shutdown: jest.fn().mockResolvedValue(undefined), + }; + jest.unstable_mockModule('posthog-node', () => ({ + PostHog: jest.fn().mockImplementation(() => mockPostHogInstance), + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { analytics: { posthog: { enabled: true, key: 'phc_test', host: 'https://eu.i.posthog.com', appTag: 'trawl' } } }, + })); + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + await AnalyticsService.init(); + }); + + afterEach(() => { jest.restoreAllMocks(); }); + + test('emits $exception with default source="system" when no ctx', () => { + AnalyticsService.captureException(new Error('boom')); + expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({ + event: '$exception', + properties: expect.objectContaining({ source: 'system', $exception_message: 'boom' }), + })); + }); + + test('honours explicit ctx.source', () => { + AnalyticsService.captureException(new Error('boom'), { source: 'worker-callback' }); + expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ source: 'worker-callback' }), + })); + }); + + test('merges ctx.properties (logMessage/logLevel) into event', () => { + AnalyticsService.captureException(new Error('boom'), { + distinctId: 'u1', + properties: { logMessage: 'something failed', logLevel: 'error' }, + }); + expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({ + distinctId: 'u1', + properties: expect.objectContaining({ + logMessage: 'something failed', + logLevel: 'error', + source: 'system', + }), + })); + }); + + test('ctx.properties.source wins over system default', () => { + AnalyticsService.captureException(new Error('boom'), { + properties: { source: 'stripe-webhook' }, + }); + expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ source: 'stripe-webhook' }), + })); + }); + + test('explicit ctx.source wins over ctx.properties.source', () => { + AnalyticsService.captureException(new Error('boom'), { + source: 'cron', + properties: { source: 'web' }, + }); + expect(mockPostHogInstance.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ source: 'cron' }), + })); + }); + + test('no-op when err is null/undefined', () => { + AnalyticsService.captureException(null); + AnalyticsService.captureException(undefined); + expect(mockPostHogInstance.capture).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/services/tests/analytics.service.unit.tests.js b/lib/services/tests/analytics.service.unit.tests.js index 762b1addc..25a6345d8 100644 --- a/lib/services/tests/analytics.service.unit.tests.js +++ b/lib/services/tests/analytics.service.unit.tests.js @@ -260,6 +260,7 @@ describe('Analytics service unit tests:', () => { $exception_type: 'Error', $exception_stack: err.stack, requestId: 'req-abc', + source: 'system', }, }); });