-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathanalytics.js
More file actions
273 lines (257 loc) · 9.8 KB
/
analytics.js
File metadata and controls
273 lines (257 loc) · 9.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
/**
* Module dependencies
*/
import config from '../../config/index.js';
/**
* PostHog client instance (null when not configured)
* @type {import('posthog-node').PostHog|null}
*/
let client = null;
/**
* Resolved at init time from config.analytics.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 `analytics.posthog.enabled` is false OR `analytics.posthog.key` 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 () => {
if (client) return; // already initialised — singleton guard
const { enabled, key, host, flushAt, flushInterval, appTag } = config.analytics?.posthog ?? {};
if (!enabled || !key) return;
const { PostHog } = await import('posthog-node');
const options = { host: host || 'https://eu.i.posthog.com' };
if (flushAt != null) options.flushAt = flushAt;
if (flushInterval != null) options.flushInterval = flushInterval;
client = new PostHog(key, options);
_appTag = appTag;
};
/**
* Capture an analytics event.
* @param {string} distinctId - User or anonymous identifier
* @param {string} event - Event name
* @param {Object} [properties] - Additional event properties
* @param {Object} [groups] - Group identifiers (e.g. { company: orgId })
* @returns {void}
*/
const track = (distinctId, event, properties, groups) => {
if (!client) return;
try {
client.capture({ distinctId, event, properties, groups });
} catch (_) { /* analytics must never break caller */ }
};
/**
* Capture an analytics event with automatic context injection.
* Auto-injects `app` (from config.analytics.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.
*
* 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, 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, ...explicit },
});
} catch (_) { /* analytics must never break caller */ }
};
/**
* Identify a user with optional properties.
* @param {string} distinctId - User identifier
* @param {Object} [properties] - User properties to set
* @returns {void}
*/
const identify = (distinctId, properties) => {
if (!client) return;
try {
client.identify({ distinctId, properties });
} catch (_) { /* analytics must never break caller */ }
};
/**
* Identify a group (e.g. organisation).
* @param {string} groupType - Group type (e.g. "company")
* @param {string} groupKey - Group identifier
* @param {Object} [properties] - Group properties to set
* @returns {void}
*/
const groupIdentify = (groupType, groupKey, properties) => {
if (!client) return;
try {
client.groupIdentify({ groupType, groupKey, properties });
} catch (_) { /* analytics must never break caller */ }
};
/**
* Evaluate a feature flag for the given user.
* @param {string} flag - Feature flag key
* @param {string} distinctId - User identifier
* @param {Object} [options] - Additional options forwarded to PostHog
* @returns {Promise<string|boolean|undefined>} Flag value, or undefined when not configured
*/
const getFeatureFlag = async (flag, distinctId, options) => {
if (!client) return undefined;
return client.getFeatureFlag(flag, distinctId, options);
};
/**
* Check whether a feature flag is enabled for the given user.
* @param {string} flag - Feature flag key
* @param {string} distinctId - User identifier
* @param {Object} [options] - Additional options forwarded to PostHog
* @returns {Promise<boolean|undefined>} true/false, or undefined when not configured
*/
const isFeatureEnabled = async (flag, distinctId, options) => {
if (!client) return undefined;
return client.isFeatureEnabled(flag, distinctId, options);
};
/**
* Capture an exception event in PostHog.
* 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.
*
* Uses SDK native `client.captureException()` so PostHog Error Tracking UI
* groups events via auto-generated `$exception_list` + `$exception_fingerprint`.
*
* 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 (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 explicit = ctx.source ? { source: ctx.source } : {};
const additionalProperties = {
source: 'system',
...(ctx.properties ?? {}),
...(ctx.requestId !== undefined ? { requestId: ctx.requestId } : {}),
...explicit,
};
client.captureException(err, distinctId, additionalProperties);
} catch (_) { /* analytics must never break caller */ }
};
/**
* Resolve the PostHog distinctId for a request.
*
* Chain: `req.user?.id` → `req.sessionID` → `'anonymous'`.
*
* Returns `'anonymous'` when `req` is null/undefined so callers can use
* the request-scoped helpers defensively without a pre-check. Callers
* outside a request context (cron, worker, scheduled jobs) should keep
* using {@link getFeatureFlag} / {@link isFeatureEnabled} with an
* explicit identifier.
* @param {import('express').Request|null|undefined} req - Express request
* @returns {string} A stable distinct identifier
*/
const resolveDistinctId = (req) => {
if (!req) return 'anonymous';
// Use nullish checks so legitimate identifiers like `0` or empty strings
// are preserved — any defined id is a valid PostHog distinctId.
if (req.user?.id != null) return String(req.user.id);
if (req.sessionID != null) return String(req.sessionID);
return 'anonymous';
};
/**
* Evaluate a feature flag using a distinctId extracted from the request.
*
* Sugar over {@link getFeatureFlag} that removes the boilerplate
* `req.user?.id ?? req.sessionID ?? 'anonymous'` every route was
* repeating — and guarantees the anonymous fallback is never forgotten.
*
* Use this in route handlers / middlewares. For callers without a
* request (cron, worker, job), keep using {@link getFeatureFlag}.
* @param {string} flag - Feature flag key
* @param {import('express').Request|null|undefined} req - Express request
* @param {Object} [options] - Additional options forwarded to PostHog
* @returns {Promise<string|boolean|undefined>} Flag value, or undefined when not configured
*/
const getFeatureFlagForRequest = async (flag, req, options) =>
getFeatureFlag(flag, resolveDistinctId(req), options);
/**
* Request-aware variant of {@link isFeatureEnabled}.
*
* Same distinctId resolution as {@link getFeatureFlagForRequest}:
* `req.user?.id` → `req.sessionID` → `'anonymous'`.
* @param {string} flag - Feature flag key
* @param {import('express').Request|null|undefined} req - Express request
* @param {Object} [options] - Additional options forwarded to PostHog
* @returns {Promise<boolean|undefined>} true/false, or undefined when not configured
*/
const isFeatureEnabledForRequest = async (flag, req, options) =>
isFeatureEnabled(flag, resolveDistinctId(req), options);
/**
* Return whether the PostHog client is currently initialised.
* Use this as a cheap pre-check before building expensive analytics payloads.
* The underlying `capture()` and `track()` methods are already no-ops when
* the client is absent — `isConfigured()` is for callers that want to skip
* payload construction entirely when analytics is not active.
* @returns {boolean}
*/
const isConfigured = () => client !== null;
/**
* Flush pending events and shut down the PostHog client.
* Safe to call even when the client was never initialised.
* @returns {Promise<void>}
*/
const shutdown = async () => {
if (!client) return;
await client.shutdown();
client = null;
_appTag = undefined;
};
export default {
init,
isConfigured,
track,
capture,
identify,
groupIdentify,
getFeatureFlag,
isFeatureEnabled,
getFeatureFlagForRequest,
isFeatureEnabledForRequest,
captureException,
shutdown,
};