Skip to content

Commit 8db3757

Browse files
feat(analytics): extend analytics service with posthog-node SDK (#3637)
Extends the existing lib/services/analytics.js to wrap posthog-node : - New config block analytics.posthog (enabled, apiKey, host, appTag, flushAt, flushInterval) gated by POSTHOG_ENABLED env var. Default off so existing downstream projects without env stay untouched. - New service exports : identify(distinctId, properties), capture({ distinctId, event, properties }), shutdown(). Auto-injects app=appTag + env=NODE_ENV on every capture (custom properties win). - Auth controller wires identify + capture(user_signed_up) on signup and capture(user_signed_in) on signin (generic events — downstream projects add their own business events on top). - SIGTERM/SIGINT shutdown hook flushes pending events. - 3 new test files covering identify, capture, lifecycle resilience. Project-specific tagging via POSTHOG_APP_TAG=trawl|comes|... env var in each downstream's deployment manifest. PostHog Cloud target (https://eu.i.posthog.com default).
1 parent 3ec12fd commit 8db3757

9 files changed

Lines changed: 450 additions & 14 deletions

.env.example

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ DEVKIT_NODE_stripe_prices_pro_monthly=price_xxx
1010
DEVKIT_NODE_stripe_prices_pro_annual=price_xxx
1111

1212
# PostHog Analytics
13-
# Get your keys from https://us.posthog.com/settings/project-api-key
13+
# Get your keys from https://eu.posthog.com/settings/project-api-key
14+
DEVKIT_NODE_posthog_enabled=true
1415
DEVKIT_NODE_posthog_apiKey=phc_xxx
15-
DEVKIT_NODE_posthog_host=https://us.i.posthog.com
16+
DEVKIT_NODE_posthog_host=https://eu.i.posthog.com
17+
DEVKIT_NODE_posthog_appTag=myproject
1618
DEVKIT_NODE_posthog_personalApiKey=phx_xxx

config/defaults/development.config.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,12 @@ const config = {
8585
enabled: false,
8686
},
8787
posthog: {
88+
enabled: false, // set to true + apiKey to activate (default off, no breakage on unconfigured projects)
8889
// apiKey: process.env.DEVKIT_NODE_posthog_apiKey ?? '',
89-
// host: process.env.DEVKIT_NODE_posthog_host ?? 'https://us.i.posthog.com',
90+
// host: process.env.DEVKIT_NODE_posthog_host ?? 'https://eu.i.posthog.com',
91+
// appTag: process.env.DEVKIT_NODE_posthog_appTag ?? '', // e.g. 'trawl', 'comes' — auto-injected on every capture
92+
flushAt: 20,
93+
flushInterval: 10000,
9094
errorTracking: false, // opt-in: capture exceptions to PostHog (default: off)
9195
autoCapture: false, // opt-in: auto-capture api_request events (default: off)
9296
},

lib/services/analytics.js

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,35 @@ import config from '../../config/index.js';
99
*/
1010
let client = null;
1111

12+
/**
13+
* Resolved at init time from config.posthog.appTag.
14+
* Stored here so capture() doesn't re-read config on every call.
15+
* @type {string|undefined}
16+
*/
17+
let _appTag;
18+
1219
/**
1320
* Initialise the PostHog client using application config.
14-
* When `posthog.apiKey` is absent the service stays in no-op mode —
15-
* every public method silently returns without side-effects so that
16-
* downstream projects that don't use PostHog are never affected.
21+
* When `posthog.enabled` is false OR `posthog.apiKey` is absent the service
22+
* stays in no-op mode — every public method silently returns without
23+
* side-effects so that downstream projects that don't use PostHog are
24+
* never affected.
1725
*
1826
* The `posthog-node` SDK is lazy-loaded (dynamic import) so that
1927
* applications running on Node versions outside the SDK's engine
2028
* range never pay the import cost when analytics is unconfigured.
2129
* @returns {Promise<void>}
2230
*/
2331
const init = async () => {
24-
const { apiKey, host } = config.posthog ?? {};
25-
if (!apiKey) return;
32+
if (client) return; // already initialised — singleton guard
33+
const { enabled, apiKey, host, flushAt, flushInterval, appTag } = config.posthog ?? {};
34+
if (!enabled || !apiKey) return;
2635
const { PostHog } = await import('posthog-node');
27-
client = new PostHog(apiKey, { host: host || 'https://us.i.posthog.com' });
36+
const options = { host: host || 'https://eu.i.posthog.com' };
37+
if (flushAt != null) options.flushAt = flushAt;
38+
if (flushInterval != null) options.flushInterval = flushInterval;
39+
client = new PostHog(apiKey, options);
40+
_appTag = appTag;
2841
};
2942

3043
/**
@@ -42,6 +55,30 @@ const track = (distinctId, event, properties, groups) => {
4255
} catch (_) { /* analytics must never break caller */ }
4356
};
4457

58+
/**
59+
* Capture an analytics event with automatic context injection.
60+
* Auto-injects `app` (from config.posthog.appTag) and `env` (NODE_ENV)
61+
* into every event. Custom properties take precedence over defaults.
62+
* No-op when client is not initialised, distinctId or event are missing.
63+
*
64+
* @param {Object} params - Event parameters
65+
* @param {string} params.distinctId - User or anonymous identifier
66+
* @param {string} params.event - Event name
67+
* @param {Object} [params.properties] - Additional event properties (win over defaults)
68+
* @returns {void}
69+
*/
70+
const capture = ({ distinctId, event, properties = {} } = {}) => {
71+
if (!client) return;
72+
if (!distinctId || !event) return;
73+
const defaults = {
74+
env: process.env.NODE_ENV || 'development',
75+
...(_appTag ? { app: _appTag } : {}),
76+
};
77+
try {
78+
client.capture({ distinctId, event, properties: { ...defaults, ...properties } });
79+
} catch (_) { /* analytics must never break caller */ }
80+
};
81+
4582
/**
4683
* Identify a user with optional properties.
4784
* @param {string} distinctId - User identifier
@@ -182,11 +219,13 @@ const shutdown = async () => {
182219
if (!client) return;
183220
await client.shutdown();
184221
client = null;
222+
_appTag = undefined;
185223
};
186224

187225
export default {
188226
init,
189227
track,
228+
capture,
190229
identify,
191230
groupIdentify,
192231
getFeatureFlag,
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for AnalyticsService.capture() and enabled-flag behaviour.
8+
*/
9+
describe('Analytics capture() and enabled-flag:', () => {
10+
let AnalyticsService;
11+
let mockPostHogInstance;
12+
13+
beforeEach(async () => {
14+
jest.resetModules();
15+
16+
mockPostHogInstance = {
17+
capture: jest.fn(),
18+
identify: jest.fn(),
19+
groupIdentify: jest.fn(),
20+
getFeatureFlag: jest.fn().mockResolvedValue(undefined),
21+
isFeatureEnabled: jest.fn().mockResolvedValue(undefined),
22+
shutdown: jest.fn().mockResolvedValue(undefined),
23+
};
24+
25+
jest.unstable_mockModule('posthog-node', () => ({
26+
PostHog: jest.fn().mockImplementation(() => mockPostHogInstance),
27+
}));
28+
});
29+
30+
afterEach(() => {
31+
jest.restoreAllMocks();
32+
});
33+
34+
// ─────────────────────────────────────────────────────────────────
35+
// 1. enabled=false disables client creation
36+
// ─────────────────────────────────────────────────────────────────
37+
describe('enabled flag:', () => {
38+
test('returns null client when enabled=false even if apiKey is present', async () => {
39+
jest.unstable_mockModule('../../../config/index.js', () => ({
40+
default: { posthog: { enabled: false, apiKey: 'phc_test_key', host: 'https://eu.i.posthog.com' } },
41+
}));
42+
43+
const mod = await import('../analytics.js');
44+
AnalyticsService = mod.default;
45+
46+
await AnalyticsService.init();
47+
AnalyticsService.capture({ distinctId: 'user-1', event: 'test' });
48+
49+
const { PostHog } = await import('posthog-node');
50+
expect(PostHog).not.toHaveBeenCalled();
51+
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
52+
});
53+
54+
test('returns null client when apiKey is missing even if enabled=true', async () => {
55+
jest.unstable_mockModule('../../../config/index.js', () => ({
56+
default: { posthog: { enabled: true } },
57+
}));
58+
59+
const mod = await import('../analytics.js');
60+
AnalyticsService = mod.default;
61+
62+
await AnalyticsService.init();
63+
AnalyticsService.capture({ distinctId: 'user-1', event: 'test' });
64+
65+
const { PostHog } = await import('posthog-node');
66+
expect(PostHog).not.toHaveBeenCalled();
67+
});
68+
69+
test('creates client when enabled=true and apiKey is present', async () => {
70+
jest.unstable_mockModule('../../../config/index.js', () => ({
71+
default: { posthog: { enabled: true, apiKey: 'phc_test_key', host: 'https://eu.i.posthog.com' } },
72+
}));
73+
74+
const mod = await import('../analytics.js');
75+
AnalyticsService = mod.default;
76+
77+
await AnalyticsService.init();
78+
79+
const { PostHog } = await import('posthog-node');
80+
expect(PostHog).toHaveBeenCalledWith('phc_test_key', expect.objectContaining({ host: 'https://eu.i.posthog.com' }));
81+
});
82+
83+
test('passes flushAt and flushInterval to PostHog constructor', async () => {
84+
jest.unstable_mockModule('../../../config/index.js', () => ({
85+
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com', flushAt: 20, flushInterval: 10000 } },
86+
}));
87+
88+
const mod = await import('../analytics.js');
89+
AnalyticsService = mod.default;
90+
91+
await AnalyticsService.init();
92+
93+
const { PostHog } = await import('posthog-node');
94+
expect(PostHog).toHaveBeenCalledWith('phc_key', {
95+
host: 'https://eu.i.posthog.com',
96+
flushAt: 20,
97+
flushInterval: 10000,
98+
});
99+
});
100+
101+
test('singleton: two init() calls on the same module instance result in one PostHog client', async () => {
102+
jest.unstable_mockModule('../../../config/index.js', () => ({
103+
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } },
104+
}));
105+
106+
const mod = await import('../analytics.js');
107+
AnalyticsService = mod.default;
108+
109+
await AnalyticsService.init();
110+
await AnalyticsService.init(); // singleton guard: no-op, client already set
111+
112+
const { PostHog } = await import('posthog-node');
113+
expect(PostHog).toHaveBeenCalledTimes(1);
114+
});
115+
});
116+
117+
// ─────────────────────────────────────────────────────────────────
118+
// 2. capture() no-ops
119+
// ─────────────────────────────────────────────────────────────────
120+
describe('capture() no-ops:', () => {
121+
test('is a no-op when client is null', async () => {
122+
jest.unstable_mockModule('../../../config/index.js', () => ({
123+
default: { posthog: { enabled: false, apiKey: 'phc_key' } },
124+
}));
125+
126+
const mod = await import('../analytics.js');
127+
AnalyticsService = mod.default;
128+
129+
await AnalyticsService.init();
130+
AnalyticsService.capture({ distinctId: 'user-1', event: 'some_event' });
131+
132+
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
133+
});
134+
135+
test('is a no-op when distinctId is missing', async () => {
136+
jest.unstable_mockModule('../../../config/index.js', () => ({
137+
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } },
138+
}));
139+
140+
const mod = await import('../analytics.js');
141+
AnalyticsService = mod.default;
142+
143+
await AnalyticsService.init();
144+
AnalyticsService.capture({ event: 'some_event' });
145+
146+
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
147+
});
148+
149+
test('is a no-op when event is missing', async () => {
150+
jest.unstable_mockModule('../../../config/index.js', () => ({
151+
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } },
152+
}));
153+
154+
const mod = await import('../analytics.js');
155+
AnalyticsService = mod.default;
156+
157+
await AnalyticsService.init();
158+
AnalyticsService.capture({ distinctId: 'user-1' });
159+
160+
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
161+
});
162+
});
163+
164+
// ─────────────────────────────────────────────────────────────────
165+
// 3. capture() auto-injects app + env
166+
// ─────────────────────────────────────────────────────────────────
167+
describe('capture() property injection:', () => {
168+
beforeEach(async () => {
169+
jest.unstable_mockModule('../../../config/index.js', () => ({
170+
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com', appTag: 'myapp' } },
171+
}));
172+
173+
const mod = await import('../analytics.js');
174+
AnalyticsService = mod.default;
175+
await AnalyticsService.init();
176+
});
177+
178+
test('auto-injects app from appTag and env from NODE_ENV', async () => {
179+
const origEnv = process.env.NODE_ENV;
180+
process.env.NODE_ENV = 'test';
181+
182+
AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event' });
183+
184+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith({
185+
distinctId: 'user-1',
186+
event: 'my_event',
187+
properties: { app: 'myapp', env: 'test' },
188+
});
189+
190+
process.env.NODE_ENV = origEnv;
191+
});
192+
193+
test('custom properties win over defaults', async () => {
194+
AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event', properties: { app: 'override', custom: 'val' } });
195+
196+
expect(mockPostHogInstance.capture).toHaveBeenCalledWith({
197+
distinctId: 'user-1',
198+
event: 'my_event',
199+
properties: expect.objectContaining({ app: 'override', custom: 'val' }),
200+
});
201+
});
202+
203+
test('does not inject app when appTag is not configured', async () => {
204+
jest.resetModules();
205+
206+
jest.unstable_mockModule('posthog-node', () => ({
207+
PostHog: jest.fn().mockImplementation(() => mockPostHogInstance),
208+
}));
209+
210+
jest.unstable_mockModule('../../../config/index.js', () => ({
211+
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } },
212+
}));
213+
214+
const mod = await import('../analytics.js');
215+
AnalyticsService = mod.default;
216+
await AnalyticsService.init();
217+
218+
AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event' });
219+
220+
const call = mockPostHogInstance.capture.mock.calls[0][0];
221+
expect(call.properties).not.toHaveProperty('app');
222+
expect(call.properties).toHaveProperty('env');
223+
});
224+
});
225+
226+
// ─────────────────────────────────────────────────────────────────
227+
// 4. shutdown idempotency
228+
// ─────────────────────────────────────────────────────────────────
229+
describe('shutdown idempotency:', () => {
230+
test('two shutdown() calls invoke client.shutdown exactly once', async () => {
231+
jest.unstable_mockModule('../../../config/index.js', () => ({
232+
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } },
233+
}));
234+
235+
const mod = await import('../analytics.js');
236+
AnalyticsService = mod.default;
237+
await AnalyticsService.init();
238+
239+
await AnalyticsService.shutdown();
240+
await AnalyticsService.shutdown();
241+
242+
expect(mockPostHogInstance.shutdown).toHaveBeenCalledTimes(1);
243+
});
244+
});
245+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('analytics request-aware feature-flag helpers:', () => {
3434
}));
3535

3636
jest.unstable_mockModule('../../../config/index.js', () => ({
37-
default: { posthog: { apiKey: 'phk_test', host: 'https://posthog.test' } },
37+
default: { posthog: { enabled: true, apiKey: 'phk_test', host: 'https://posthog.test' } },
3838
}));
3939

4040
const mod = await import('../analytics.js');

0 commit comments

Comments
 (0)