Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' })`
Comment on lines +268 to +274
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/<version>` (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).
Expand Down
37 changes: 30 additions & 7 deletions lib/services/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
};

Expand Down Expand Up @@ -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',
Expand All @@ -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 */ }
Expand Down
63 changes: 60 additions & 3 deletions lib/services/tests/analytics.capture.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
});
});
Expand All @@ -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' }),
}));
});
});
});
84 changes: 84 additions & 0 deletions lib/services/tests/analytics.captureException.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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();
});
});
1 change: 1 addition & 0 deletions lib/services/tests/analytics.service.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ describe('Analytics service unit tests:', () => {
$exception_type: 'Error',
$exception_stack: err.stack,
requestId: 'req-abc',
source: 'system',
},
});
});
Expand Down
Loading