Skip to content

Commit 7e60bbb

Browse files
feat(analytics): PostHog server-side SDK + analytics service (#3299)
* 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 * fix(analytics): lazy-load posthog-node SDK via dynamic import Addresses Copilot review: posthog-node declares engine >=22.22.0 but this repo allows >=22.0.0. By lazy-importing posthog-node inside init() only when an API key is configured, unconfigured deployments never load the SDK — eliminating engine mismatch warnings and startup risk on older Node 22 patch versions. - analytics.service.js: init() is now async, uses dynamic import() - analytics.init.js: awaits AnalyticsService.init() - express.js: awaits module init functions (initModulesConfiguration) - All test files updated to await init()
1 parent a3d4216 commit 7e60bbb

10 files changed

Lines changed: 462 additions & 201 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'));

lib/services/express.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ const initMiddleware = (app) => {
130130
const initModulesConfiguration = async (app) => {
131131
for (const configPath of config.files.configs) {
132132
const route = await import(path.resolve(configPath));
133-
route.default(app);
133+
await route.default(app);
134134
}
135135
};
136136

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 {Promise<void>}
12+
*/
13+
// eslint-disable-next-line no-unused-vars
14+
export default async (_app) => {
15+
await 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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import config from '../../../config/index.js';
5+
6+
/**
7+
* PostHog client instance (null when not configured)
8+
* @type {import('posthog-node').PostHog|null}
9+
*/
10+
let client = null;
11+
12+
/**
13+
* 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.
17+
*
18+
* The `posthog-node` SDK is lazy-loaded (dynamic import) so that
19+
* applications running on Node versions outside the SDK's engine
20+
* range never pay the import cost when analytics is unconfigured.
21+
* @returns {Promise<void>}
22+
*/
23+
const init = async () => {
24+
const { apiKey, host } = config.posthog ?? {};
25+
if (!apiKey) return;
26+
const { PostHog } = await import('posthog-node');
27+
client = new PostHog(apiKey, { host: host || 'https://us.i.posthog.com' });
28+
};
29+
30+
/**
31+
* Capture an analytics event.
32+
* @param {string} distinctId - User or anonymous identifier
33+
* @param {string} event - Event name
34+
* @param {Object} [properties] - Additional event properties
35+
* @param {Object} [groups] - Group identifiers (e.g. { company: orgId })
36+
* @returns {void}
37+
*/
38+
const track = (distinctId, event, properties, groups) => {
39+
if (!client) return;
40+
client.capture({ distinctId, event, properties, groups });
41+
};
42+
43+
/**
44+
* Identify a user with optional properties.
45+
* @param {string} distinctId - User identifier
46+
* @param {Object} [properties] - User properties to set
47+
* @returns {void}
48+
*/
49+
const identify = (distinctId, properties) => {
50+
if (!client) return;
51+
client.identify({ distinctId, properties });
52+
};
53+
54+
/**
55+
* Identify a group (e.g. organisation).
56+
* @param {string} groupType - Group type (e.g. "company")
57+
* @param {string} groupKey - Group identifier
58+
* @param {Object} [properties] - Group properties to set
59+
* @returns {void}
60+
*/
61+
const groupIdentify = (groupType, groupKey, properties) => {
62+
if (!client) return;
63+
client.groupIdentify({ groupType, groupKey, properties });
64+
};
65+
66+
/**
67+
* Evaluate a feature flag for the given user.
68+
* @param {string} flag - Feature flag key
69+
* @param {string} distinctId - User identifier
70+
* @param {Object} [options] - Additional options forwarded to PostHog
71+
* @returns {Promise<string|boolean|undefined>} Flag value, or undefined when not configured
72+
*/
73+
const getFeatureFlag = async (flag, distinctId, options) => {
74+
if (!client) return undefined;
75+
return client.getFeatureFlag(flag, distinctId, options);
76+
};
77+
78+
/**
79+
* Check whether a feature flag is enabled for the given user.
80+
* @param {string} flag - Feature flag key
81+
* @param {string} distinctId - User identifier
82+
* @param {Object} [options] - Additional options forwarded to PostHog
83+
* @returns {Promise<boolean|undefined>} true/false, or undefined when not configured
84+
*/
85+
const isFeatureEnabled = async (flag, distinctId, options) => {
86+
if (!client) return undefined;
87+
return client.isFeatureEnabled(flag, distinctId, options);
88+
};
89+
90+
/**
91+
* Flush pending events and shut down the PostHog client.
92+
* Safe to call even when the client was never initialised.
93+
* @returns {Promise<void>}
94+
*/
95+
const shutdown = async () => {
96+
if (!client) return;
97+
await client.shutdown();
98+
client = null;
99+
};
100+
101+
export default {
102+
init,
103+
track,
104+
identify,
105+
groupIdentify,
106+
getFeatureFlag,
107+
isFeatureEnabled,
108+
shutdown,
109+
};

0 commit comments

Comments
 (0)