Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ DEVKIT_NODE_stripe_prices_pro_monthly=price_xxx
DEVKIT_NODE_stripe_prices_pro_annual=price_xxx

# PostHog Analytics
# Get your keys from https://us.posthog.com/settings/project-api-key
# Get your keys from https://eu.posthog.com/settings/project-api-key
DEVKIT_NODE_posthog_enabled=true
DEVKIT_NODE_posthog_apiKey=phc_xxx
DEVKIT_NODE_posthog_host=https://us.i.posthog.com
DEVKIT_NODE_posthog_host=https://eu.i.posthog.com
DEVKIT_NODE_posthog_appTag=myproject
Comment on lines 12 to +17
DEVKIT_NODE_posthog_personalApiKey=phx_xxx
6 changes: 5 additions & 1 deletion config/defaults/development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,12 @@ const config = {
enabled: false,
},
posthog: {
enabled: false, // set to true + apiKey to activate (default off, no breakage on unconfigured projects)
// apiKey: process.env.DEVKIT_NODE_posthog_apiKey ?? '',
// host: process.env.DEVKIT_NODE_posthog_host ?? 'https://us.i.posthog.com',
// host: process.env.DEVKIT_NODE_posthog_host ?? 'https://eu.i.posthog.com',
// appTag: process.env.DEVKIT_NODE_posthog_appTag ?? '', // e.g. 'trawl', 'comes' — auto-injected on every capture
flushAt: 20,
flushInterval: 10000,
errorTracking: false, // opt-in: capture exceptions to PostHog (default: off)
autoCapture: false, // opt-in: auto-capture api_request events (default: off)
},
Expand Down
51 changes: 45 additions & 6 deletions lib/services/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,35 @@ import config from '../../config/index.js';
*/
let client = null;

/**
* Resolved at init time from config.posthog.appTag.
* Stored here so capture() doesn't re-read config on every call.
* @type {string|undefined}
*/
let _appTag;

/**
* Initialise the PostHog client using application config.
* When `posthog.apiKey` is absent the service stays in no-op mode —
* every public method silently returns without side-effects so that
* downstream projects that don't use PostHog are never affected.
* When `posthog.enabled` is false OR `posthog.apiKey` is absent the service
* stays in no-op mode — every public method silently returns without
* side-effects so that downstream projects that don't use PostHog are
* never affected.
*
* The `posthog-node` SDK is lazy-loaded (dynamic import) so that
* applications running on Node versions outside the SDK's engine
* range never pay the import cost when analytics is unconfigured.
* @returns {Promise<void>}
*/
const init = async () => {
const { apiKey, host } = config.posthog ?? {};
if (!apiKey) return;
if (client) return; // already initialised — singleton guard
const { enabled, apiKey, host, flushAt, flushInterval, appTag } = config.posthog ?? {};
if (!enabled || !apiKey) return;
const { PostHog } = await import('posthog-node');
client = new PostHog(apiKey, { host: host || 'https://us.i.posthog.com' });
const options = { host: host || 'https://eu.i.posthog.com' };
if (flushAt != null) options.flushAt = flushAt;
if (flushInterval != null) options.flushInterval = flushInterval;
client = new PostHog(apiKey, options);
Comment on lines +33 to +39
_appTag = appTag;
};

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

/**
* Capture an analytics event with automatic context injection.
* Auto-injects `app` (from config.posthog.appTag) and `env` (NODE_ENV)
* into every event. Custom properties take precedence over defaults.
* No-op when client is not initialised, distinctId or event are missing.
*
* @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)
* @returns {void}
*/
const capture = ({ distinctId, event, properties = {} } = {}) => {
if (!client) return;
if (!distinctId || !event) return;
const defaults = {
env: process.env.NODE_ENV || 'development',
...(_appTag ? { app: _appTag } : {}),
};
try {
client.capture({ distinctId, event, properties: { ...defaults, ...properties } });
} catch (_) { /* analytics must never break caller */ }
};

/**
* Identify a user with optional properties.
* @param {string} distinctId - User identifier
Expand Down Expand Up @@ -182,11 +219,13 @@ const shutdown = async () => {
if (!client) return;
await client.shutdown();
client = null;
_appTag = undefined;
};

export default {
init,
track,
capture,
identify,
groupIdentify,
getFeatureFlag,
Expand Down
245 changes: 245 additions & 0 deletions lib/services/tests/analytics.capture.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/**
* Module dependencies.
*/
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';

/**
* Unit tests for AnalyticsService.capture() and enabled-flag behaviour.
*/
describe('Analytics capture() and enabled-flag:', () => {
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),
}));
});

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

// ─────────────────────────────────────────────────────────────────
// 1. enabled=false disables client creation
// ─────────────────────────────────────────────────────────────────
describe('enabled flag:', () => {
test('returns null client when enabled=false even if apiKey is present', async () => {
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: false, apiKey: 'phc_test_key', host: 'https://eu.i.posthog.com' } },
}));

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

await AnalyticsService.init();
AnalyticsService.capture({ distinctId: 'user-1', event: 'test' });

const { PostHog } = await import('posthog-node');
expect(PostHog).not.toHaveBeenCalled();
expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
});

test('returns null client when apiKey is missing even if enabled=true', async () => {
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: true } },
}));

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

await AnalyticsService.init();
AnalyticsService.capture({ distinctId: 'user-1', event: 'test' });

const { PostHog } = await import('posthog-node');
expect(PostHog).not.toHaveBeenCalled();
});

test('creates client when enabled=true and apiKey is present', async () => {
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: true, apiKey: 'phc_test_key', host: 'https://eu.i.posthog.com' } },
}));

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

await AnalyticsService.init();

const { PostHog } = await import('posthog-node');
expect(PostHog).toHaveBeenCalledWith('phc_test_key', expect.objectContaining({ host: 'https://eu.i.posthog.com' }));
});

test('passes flushAt and flushInterval to PostHog constructor', async () => {
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com', flushAt: 20, flushInterval: 10000 } },
}));

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

await AnalyticsService.init();

const { PostHog } = await import('posthog-node');
expect(PostHog).toHaveBeenCalledWith('phc_key', {
host: 'https://eu.i.posthog.com',
flushAt: 20,
flushInterval: 10000,
});
});

test('singleton: two init() calls on the same module instance result in one PostHog client', async () => {
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } },
}));

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

await AnalyticsService.init();
await AnalyticsService.init(); // singleton guard: no-op, client already set

const { PostHog } = await import('posthog-node');
expect(PostHog).toHaveBeenCalledTimes(1);
});
});

// ─────────────────────────────────────────────────────────────────
// 2. capture() no-ops
// ─────────────────────────────────────────────────────────────────
describe('capture() no-ops:', () => {
test('is a no-op when client is null', async () => {
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: false, apiKey: 'phc_key' } },
}));

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

await AnalyticsService.init();
AnalyticsService.capture({ distinctId: 'user-1', event: 'some_event' });

expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
});

test('is a no-op when distinctId is missing', async () => {
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } },
}));

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

await AnalyticsService.init();
AnalyticsService.capture({ event: 'some_event' });

expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
});

test('is a no-op when event is missing', async () => {
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } },
}));

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

await AnalyticsService.init();
AnalyticsService.capture({ distinctId: 'user-1' });

expect(mockPostHogInstance.capture).not.toHaveBeenCalled();
});
});

// ─────────────────────────────────────────────────────────────────
// 3. capture() auto-injects app + env
// ─────────────────────────────────────────────────────────────────
describe('capture() property injection:', () => {
beforeEach(async () => {
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com', appTag: 'myapp' } },
}));

const mod = await import('../analytics.js');
AnalyticsService = mod.default;
await AnalyticsService.init();
});

test('auto-injects app from appTag and env from NODE_ENV', async () => {
const origEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'test';

AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event' });

expect(mockPostHogInstance.capture).toHaveBeenCalledWith({
distinctId: 'user-1',
event: 'my_event',
properties: { app: 'myapp', env: 'test' },
});

process.env.NODE_ENV = origEnv;
});

test('custom properties win over defaults', async () => {
AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event', properties: { app: 'override', custom: 'val' } });

expect(mockPostHogInstance.capture).toHaveBeenCalledWith({
distinctId: 'user-1',
event: 'my_event',
properties: expect.objectContaining({ app: 'override', custom: 'val' }),
});
});

test('does not inject app when appTag is not configured', async () => {
jest.resetModules();

jest.unstable_mockModule('posthog-node', () => ({
PostHog: jest.fn().mockImplementation(() => mockPostHogInstance),
}));

jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } },
}));

const mod = await import('../analytics.js');
AnalyticsService = mod.default;
await AnalyticsService.init();

AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event' });

const call = mockPostHogInstance.capture.mock.calls[0][0];
expect(call.properties).not.toHaveProperty('app');
expect(call.properties).toHaveProperty('env');
});
});

// ─────────────────────────────────────────────────────────────────
// 4. shutdown idempotency
// ─────────────────────────────────────────────────────────────────
describe('shutdown idempotency:', () => {
test('two shutdown() calls invoke client.shutdown exactly once', async () => {
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } },
}));

const mod = await import('../analytics.js');
AnalyticsService = mod.default;
await AnalyticsService.init();

await AnalyticsService.shutdown();
await AnalyticsService.shutdown();

expect(mockPostHogInstance.shutdown).toHaveBeenCalledTimes(1);
});
});
});
2 changes: 1 addition & 1 deletion lib/services/tests/analytics.forRequest.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('analytics request-aware feature-flag helpers:', () => {
}));

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

const mod = await import('../analytics.js');
Expand Down
Loading
Loading