Skip to content

Commit 583b42a

Browse files
feat(analytics): add PostHog server-side SDK + analytics service
Add a new analytics module wrapping posthog-node with a no-op fallback when PostHog is not configured, so downstream projects are unaffected. Closes #3294
1 parent 1a10722 commit 583b42a

9 files changed

Lines changed: 458 additions & 200 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,9 @@ DEVKIT_NODE_stripe_prices_starter_monthly=price_xxx
88
DEVKIT_NODE_stripe_prices_starter_annual=price_xxx
99
DEVKIT_NODE_stripe_prices_pro_monthly=price_xxx
1010
DEVKIT_NODE_stripe_prices_pro_annual=price_xxx
11+
12+
# PostHog Analytics
13+
# Get your keys from https://us.posthog.com/settings/project-api-key
14+
DEVKIT_NODE_posthog_apiKey=phc_xxx
15+
DEVKIT_NODE_posthog_host=https://us.i.posthog.com
16+
DEVKIT_NODE_posthog_personalApiKey=phx_xxx

jest.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export default {
4141
'!<rootDir>/modules/auth/strategies/clerk/**',
4242
// Exclude passport init glue — just serializeUser/deserializeUser + strategy loader
4343
'!<rootDir>/modules/auth/auth.init.js',
44+
// Exclude analytics init glue — just calls AnalyticsService.init()
45+
'!<rootDir>/modules/analytics/analytics.init.js',
4446
// Exclude dead code — never imported anywhere in the codebase
4547
'!<rootDir>/modules/users/services/users.data.service.js',
4648
// Exclude server bootstrap — startup orchestration, tested indirectly via integration tests

lib/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import config from '../config/index.js';
99
import express from './services/express.js';
1010
import mongooseService from './services/mongoose.js';
1111
import migrations from './services/migrations.js';
12+
import AnalyticsService from '../modules/analytics/services/analytics.service.js';
1213

1314
// Establish an SQL server connection, instantiating all models and schemas
1415
// const startSequelize = () =>
@@ -147,6 +148,7 @@ const shutdown = async (server) => {
147148

148149
try {
149150
const value = await server;
151+
await AnalyticsService.shutdown();
150152
await mongooseService.disconnect();
151153
value.http.close((err) => {
152154
console.info(chalk.yellow('Server closed'));
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import AnalyticsService from './services/analytics.service.js';
5+
6+
/**
7+
* Initialise the analytics module.
8+
* Called automatically by the Express init loop (matched via the
9+
* `modules/{name}/{name}.init.js` glob in config/assets.js).
10+
* @param {object} _app - Express application instance (unused)
11+
* @returns {void}
12+
*/
13+
// eslint-disable-next-line no-unused-vars
14+
export default (_app) => {
15+
AnalyticsService.init();
16+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const config = {
2+
posthog: {
3+
// apiKey: process.env.DEVKIT_NODE_posthog_apiKey ?? '',
4+
// host: process.env.DEVKIT_NODE_posthog_host ?? 'https://us.i.posthog.com',
5+
// personalApiKey: process.env.DEVKIT_NODE_posthog_personalApiKey ?? '',
6+
},
7+
};
8+
9+
export default config;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import { PostHog } from 'posthog-node';
5+
6+
import config from '../../../config/index.js';
7+
8+
/**
9+
* PostHog client instance (null when not configured)
10+
* @type {PostHog|null}
11+
*/
12+
let client = null;
13+
14+
/**
15+
* Initialise the PostHog client using application config.
16+
* When `posthog.apiKey` is absent the service stays in no-op mode —
17+
* every public method silently returns without side-effects so that
18+
* downstream projects that don't use PostHog are never affected.
19+
* @returns {void}
20+
*/
21+
const init = () => {
22+
const { apiKey, host } = config.posthog ?? {};
23+
if (!apiKey) return;
24+
client = new PostHog(apiKey, { host: host || 'https://us.i.posthog.com' });
25+
};
26+
27+
/**
28+
* Capture an analytics event.
29+
* @param {string} distinctId - User or anonymous identifier
30+
* @param {string} event - Event name
31+
* @param {Object} [properties] - Additional event properties
32+
* @param {Object} [groups] - Group identifiers (e.g. { company: orgId })
33+
* @returns {void}
34+
*/
35+
const track = (distinctId, event, properties, groups) => {
36+
if (!client) return;
37+
client.capture({ distinctId, event, properties, groups });
38+
};
39+
40+
/**
41+
* Identify a user with optional properties.
42+
* @param {string} distinctId - User identifier
43+
* @param {Object} [properties] - User properties to set
44+
* @returns {void}
45+
*/
46+
const identify = (distinctId, properties) => {
47+
if (!client) return;
48+
client.identify({ distinctId, properties });
49+
};
50+
51+
/**
52+
* Identify a group (e.g. organisation).
53+
* @param {string} groupType - Group type (e.g. "company")
54+
* @param {string} groupKey - Group identifier
55+
* @param {Object} [properties] - Group properties to set
56+
* @returns {void}
57+
*/
58+
const groupIdentify = (groupType, groupKey, properties) => {
59+
if (!client) return;
60+
client.groupIdentify({ groupType, groupKey, properties });
61+
};
62+
63+
/**
64+
* Evaluate a feature flag for the given user.
65+
* @param {string} flag - Feature flag key
66+
* @param {string} distinctId - User identifier
67+
* @param {Object} [options] - Additional options forwarded to PostHog
68+
* @returns {Promise<string|boolean|undefined>} Flag value, or undefined when not configured
69+
*/
70+
const getFeatureFlag = async (flag, distinctId, options) => {
71+
if (!client) return undefined;
72+
return client.getFeatureFlag(flag, distinctId, options);
73+
};
74+
75+
/**
76+
* Check whether a feature flag is enabled for the given user.
77+
* @param {string} flag - Feature flag key
78+
* @param {string} distinctId - User identifier
79+
* @param {Object} [options] - Additional options forwarded to PostHog
80+
* @returns {Promise<boolean|undefined>} true/false, or undefined when not configured
81+
*/
82+
const isFeatureEnabled = async (flag, distinctId, options) => {
83+
if (!client) return undefined;
84+
return client.isFeatureEnabled(flag, distinctId, options);
85+
};
86+
87+
/**
88+
* Flush pending events and shut down the PostHog client.
89+
* Safe to call even when the client was never initialised.
90+
* @returns {Promise<void>}
91+
*/
92+
const shutdown = async () => {
93+
if (!client) return;
94+
await client.shutdown();
95+
client = null;
96+
};
97+
98+
export default {
99+
init,
100+
track,
101+
identify,
102+
groupIdentify,
103+
getFeatureFlag,
104+
isFeatureEnabled,
105+
shutdown,
106+
};

0 commit comments

Comments
 (0)