autotel-posthog 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,519 @@
1
+ import { EventSubscriber } from "autotel-subscribers";
2
+ import slowRedact from "slow-redact";
3
+ //#region src/subscriber/error-formatter.ts
4
+ const MAX_CAUSE_DEPTH = 5;
5
+ function formatExceptionForPostHog(exceptionList, platform = "web:javascript", redactor) {
6
+ return { $exception_list: exceptionList.map((ex) => ({
7
+ type: ex.type,
8
+ value: redactor ? redactor(ex.value) : ex.value,
9
+ mechanism: ex.mechanism,
10
+ stacktrace: { frames: (ex.stacktrace?.frames || []).map((frame) => ({
11
+ ...frame,
12
+ abs_path: frame.abs_path && redactor ? redactor(frame.abs_path) : frame.abs_path,
13
+ platform
14
+ })) }
15
+ })) };
16
+ }
17
+ function errorToExceptionList(input, redactor) {
18
+ const error = input instanceof Error ? input : new Error(input === null || input === void 0 ? "Unknown error" : String(input));
19
+ const records = [];
20
+ let current = error;
21
+ let depth = 0;
22
+ while (current && depth < MAX_CAUSE_DEPTH) {
23
+ const value = current.message || "Unknown error";
24
+ const frames = current.stack ? parseStackBasic(current.stack) : void 0;
25
+ records.push({
26
+ type: current.name || "Error",
27
+ value: redactor ? redactor(value) : value,
28
+ mechanism: {
29
+ type: "manual",
30
+ handled: true
31
+ },
32
+ stacktrace: frames ? { frames: redactor ? frames.map((f) => ({
33
+ ...f,
34
+ abs_path: f.abs_path ? redactor(f.abs_path) : f.abs_path
35
+ })) : frames } : void 0
36
+ });
37
+ current = current.cause instanceof Error ? current.cause : void 0;
38
+ depth++;
39
+ }
40
+ return records.toReversed();
41
+ }
42
+ function parseStackBasic(stack) {
43
+ const lines = stack.split("\n");
44
+ const frames = [];
45
+ const re = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/;
46
+ for (const line of lines) {
47
+ const match = line.trim().match(re);
48
+ if (match) {
49
+ const [, fn, absPath, lineStr, colStr] = match;
50
+ if (!absPath) continue;
51
+ frames.push({
52
+ function: fn || void 0,
53
+ abs_path: absPath,
54
+ filename: absPath.split("/").pop() || absPath,
55
+ lineno: Number.parseInt(lineStr ?? "0", 10),
56
+ colno: Number.parseInt(colStr ?? "0", 10),
57
+ in_app: !absPath.includes("node_modules")
58
+ });
59
+ }
60
+ }
61
+ return frames;
62
+ }
63
+ //#endregion
64
+ //#region src/values.ts
65
+ /** The value as an object, or undefined when it is anything else. */
66
+ function asRecord(value) {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
68
+ }
69
+ /** The string the value carries, or undefined when it carries anything else. */
70
+ function asString(value) {
71
+ return typeof value === "string" ? value : void 0;
72
+ }
73
+ /** One field of a value, when the value is an object at all. */
74
+ function readProperty(source, key) {
75
+ return asRecord(source)?.[key];
76
+ }
77
+ /**
78
+ * The Error a caller's callback expects, wrapping whatever was actually
79
+ * thrown - a string, a number, an object with no stack.
80
+ */
81
+ function toError(cause) {
82
+ return cause instanceof Error ? cause : new Error(String(cause));
83
+ }
84
+ //#endregion
85
+ //#region src/subscriber/index.ts
86
+ var PostHogSubscriber = class extends EventSubscriber {
87
+ name = "PostHogSubscriber";
88
+ version = "2.0.0";
89
+ posthog = null;
90
+ config;
91
+ initPromise = null;
92
+ /** True when using browser's window.posthog (different API signature) */
93
+ isBrowserClient = false;
94
+ /**
95
+ * The same client seen through the browser SDK's shape.
96
+ *
97
+ * The two SDKs disagree about `capture`: Node takes one object, the browser
98
+ * takes a name and properties. Rather than cast at each call site, the cast
99
+ * happens once here and the calls are typed against `posthog-js` itself — so
100
+ * a change to the browser signature is a compile error rather than a runtime
101
+ * one in a page.
102
+ */
103
+ get browserClient() {
104
+ return this.isBrowserClient && this.posthog ? this.posthog : void 0;
105
+ }
106
+ pathRedactor = null;
107
+ stringRedactor = null;
108
+ constructor(config) {
109
+ super();
110
+ if (config.serverless) config = {
111
+ flushAt: 1,
112
+ flushInterval: 0,
113
+ requestTimeout: 3e3,
114
+ ...config
115
+ };
116
+ if (!config.apiKey && !config.client && !config.useGlobalClient) throw new Error("PostHogSubscriber requires either apiKey, client, or useGlobalClient to be provided");
117
+ this.enabled = config.enabled ?? true;
118
+ this.config = {
119
+ filterUndefinedValues: true,
120
+ ...config
121
+ };
122
+ if (this.config.redactPaths && this.config.redactPaths.length > 0) this.pathRedactor = slowRedact({
123
+ paths: this.config.redactPaths,
124
+ serialize: false
125
+ });
126
+ if (this.config.stringRedactor) this.stringRedactor = this.config.stringRedactor;
127
+ if (this.enabled) this.initPromise = this.initialize();
128
+ }
129
+ async initialize() {
130
+ try {
131
+ if (this.config.useGlobalClient) {
132
+ const globalPostHog = readProperty(globalThis, "posthog");
133
+ if (globalPostHog) {
134
+ this.posthog = globalPostHog;
135
+ this.isBrowserClient = true;
136
+ this.setupErrorHandling();
137
+ return;
138
+ }
139
+ throw new Error("useGlobalClient enabled but window.posthog not found. Ensure PostHog script is loaded before initializing the subscriber.");
140
+ }
141
+ if (this.config.client) {
142
+ this.posthog = this.config.client;
143
+ this.setupErrorHandling();
144
+ return;
145
+ }
146
+ const { PostHog } = await import("posthog-node");
147
+ this.posthog = new PostHog(this.config.apiKey, {
148
+ host: this.config.host || "https://us.i.posthog.com",
149
+ flushAt: this.config.flushAt,
150
+ flushInterval: this.config.flushInterval,
151
+ requestTimeout: this.config.requestTimeout,
152
+ disableGeoip: this.config.disableGeoip,
153
+ sendFeatureFlagEvent: this.config.sendFeatureFlags
154
+ });
155
+ this.setupErrorHandling();
156
+ } catch (error) {
157
+ console.error("PostHog subscriber failed to initialize. Install posthog-node: pnpm add posthog-node", error);
158
+ this.enabled = false;
159
+ this.config.onError?.(toError(error));
160
+ }
161
+ }
162
+ setupErrorHandling() {
163
+ if (this.config.debug) this.posthog?.debug();
164
+ if (this.config.onError && this.posthog?.on) this.posthog.on("error", this.config.onError);
165
+ }
166
+ async ensureInitialized() {
167
+ if (this.initPromise) {
168
+ await this.initPromise;
169
+ this.initPromise = null;
170
+ }
171
+ }
172
+ /**
173
+ * Feature-flag options as posthog-node's own parameter type.
174
+ *
175
+ * SAFETY: FeatureFlagOptions above mirrors the fields posthog-node accepts -
176
+ * groups, person and group properties, and the two evaluation switches. The
177
+ * SDK declares them inline on each method rather than as a named type, so
178
+ * the correspondence is stated here once instead of at each call.
179
+ */
180
+ flagOptions(options) {
181
+ return options;
182
+ }
183
+ extractDistinctId(attributes) {
184
+ return asString(attributes?.userId) ?? asString(attributes?.user_id) ?? "anonymous";
185
+ }
186
+ redactProperties(properties) {
187
+ let result = properties;
188
+ if (this.pathRedactor) result = this.pathRedactor(properties);
189
+ if (this.stringRedactor) result = this.redactStringValues(result);
190
+ return result;
191
+ }
192
+ redactStringValues(obj) {
193
+ const result = {};
194
+ for (const [key, value] of Object.entries(obj)) result[key] = this.redactValue(value);
195
+ return result;
196
+ }
197
+ redactArray(arr) {
198
+ return arr.map((item) => this.redactValue(item));
199
+ }
200
+ /** One value, redacted: a string masked, a container walked. */
201
+ redactValue(value) {
202
+ const text = asString(value);
203
+ if (text !== void 0) return this.stringRedactor(text);
204
+ if (Array.isArray(value)) return this.redactArray(value);
205
+ const nested = asRecord(value);
206
+ return nested ? this.redactStringValues(nested) : value;
207
+ }
208
+ /**
209
+ * Set the string redactor. Called by autotel init() when attributeRedactor is configured.
210
+ * Can also be called manually.
211
+ */
212
+ setStringRedactor(redactor) {
213
+ this.stringRedactor = redactor;
214
+ }
215
+ /**
216
+ * Send payload to PostHog
217
+ *
218
+ * Maps autotel context to PostHog-specific field names:
219
+ * - autotel.trace_id → $trace_id
220
+ * - autotel.span_id → $span_id
221
+ * - autotel.correlation_id → $correlation_id
222
+ * - autotel.trace_url → $trace_url
223
+ */
224
+ async sendToDestination(payload) {
225
+ await this.ensureInitialized();
226
+ const filteredAttributes = this.config.filterUndefinedValues === false ? payload.attributes : this.filterAttributes(payload.attributes);
227
+ const properties = { ...filteredAttributes };
228
+ if (payload.value !== void 0) properties.value = payload.value;
229
+ if (payload.stepNumber !== void 0) properties.step_number = payload.stepNumber;
230
+ if (payload.stepName !== void 0) properties.step_name = payload.stepName;
231
+ if (payload.autotel) {
232
+ if (payload.autotel.trace_id) properties.$trace_id = payload.autotel.trace_id;
233
+ if (payload.autotel.span_id) properties.$span_id = payload.autotel.span_id;
234
+ if (payload.autotel.correlation_id) properties.$correlation_id = payload.autotel.correlation_id;
235
+ if (payload.autotel.trace_flags) properties.$trace_flags = payload.autotel.trace_flags;
236
+ if (payload.autotel.trace_state) properties.$trace_state = payload.autotel.trace_state;
237
+ if (payload.autotel.trace_url) properties.$trace_url = payload.autotel.trace_url;
238
+ if (payload.autotel.linked_trace_id_count !== void 0) properties.$linked_trace_id_count = payload.autotel.linked_trace_id_count;
239
+ if (payload.autotel.linked_trace_id_hash) properties.$linked_trace_id_hash = payload.autotel.linked_trace_id_hash;
240
+ if (payload.autotel.linked_trace_ids) properties.$linked_trace_ids = payload.autotel.linked_trace_ids;
241
+ }
242
+ const redactedProperties = this.redactProperties(properties);
243
+ if (payload.attributes?.["exception.list"]) try {
244
+ const formatted = formatExceptionForPostHog(JSON.parse(asString(payload.attributes["exception.list"]) ?? "[]"), void 0, this.stringRedactor ?? void 0);
245
+ const exceptionProperties = {
246
+ ...redactedProperties,
247
+ ...formatted
248
+ };
249
+ if (this.isBrowserClient) this.browserClient?.capture("$exception", exceptionProperties);
250
+ else this.posthog?.capture({
251
+ distinctId: this.extractDistinctId(filteredAttributes),
252
+ event: "$exception",
253
+ properties: exceptionProperties
254
+ });
255
+ } catch {}
256
+ const distinctId = this.extractDistinctId(filteredAttributes);
257
+ if (this.isBrowserClient) this.browserClient?.capture(payload.name, redactedProperties);
258
+ else {
259
+ const capturePayload = {
260
+ distinctId,
261
+ event: payload.name,
262
+ properties: redactedProperties
263
+ };
264
+ if (filteredAttributes?.groups) capturePayload.groups = filteredAttributes.groups;
265
+ this.posthog?.capture(capturePayload);
266
+ }
267
+ }
268
+ /**
269
+ * Check if a feature flag is enabled for a user
270
+ *
271
+ * @param flagKey - Feature flag key
272
+ * @param distinctId - User ID or anonymous ID
273
+ * @param options - Feature flag evaluation options
274
+ * @returns true if enabled, false otherwise
275
+ *
276
+ * @example
277
+ * ```typescript
278
+ * const isEnabled = await subscriber.isFeatureEnabled('new-checkout', 'user-123');
279
+ *
280
+ * // With groups
281
+ * const isEnabled = await subscriber.isFeatureEnabled('beta-features', 'user-123', {
282
+ * groups: { company: 'acme-corp' }
283
+ * });
284
+ * ```
285
+ */
286
+ async isFeatureEnabled(flagKey, distinctId, options) {
287
+ if (!this.enabled) return false;
288
+ await this.ensureInitialized();
289
+ try {
290
+ return await this.posthog?.isFeatureEnabled(flagKey, distinctId, this.flagOptions(options)) ?? false;
291
+ } catch (error) {
292
+ this.config.onError?.(toError(error));
293
+ return false;
294
+ }
295
+ }
296
+ /**
297
+ * Get feature flag value for a user
298
+ *
299
+ * @param flagKey - Feature flag key
300
+ * @param distinctId - User ID or anonymous ID
301
+ * @param options - Feature flag evaluation options
302
+ * @returns Flag value (string, boolean, or undefined)
303
+ *
304
+ * @example
305
+ * ```typescript
306
+ * const variant = await subscriber.getFeatureFlag('experiment-variant', 'user-123');
307
+ * // Returns: 'control' | 'test' | 'test-2' | undefined
308
+ *
309
+ * // With person properties
310
+ * const variant = await subscriber.getFeatureFlag('premium-feature', 'user-123', {
311
+ * personProperties: { plan: 'premium' }
312
+ * });
313
+ * ```
314
+ */
315
+ async getFeatureFlag(flagKey, distinctId, options) {
316
+ if (!this.enabled) return void 0;
317
+ await this.ensureInitialized();
318
+ try {
319
+ return await this.posthog?.getFeatureFlag(flagKey, distinctId, this.flagOptions(options));
320
+ } catch (error) {
321
+ this.config.onError?.(toError(error));
322
+ return;
323
+ }
324
+ }
325
+ /**
326
+ * Get all feature flags for a user
327
+ *
328
+ * @param distinctId - User ID or anonymous ID
329
+ * @param options - Feature flag evaluation options
330
+ * @returns Object mapping flag keys to their values
331
+ *
332
+ * @example
333
+ * ```typescript
334
+ * const flags = await subscriber.getAllFlags('user-123');
335
+ * // Returns: { 'new-checkout': true, 'experiment-variant': 'test', ... }
336
+ * ```
337
+ */
338
+ async getAllFlags(distinctId, options) {
339
+ if (!this.enabled) return {};
340
+ await this.ensureInitialized();
341
+ try {
342
+ return await this.posthog?.getAllFlags(distinctId, this.flagOptions(options)) ?? {};
343
+ } catch (error) {
344
+ this.config.onError?.(toError(error));
345
+ return {};
346
+ }
347
+ }
348
+ /**
349
+ * Reload feature flags from PostHog server
350
+ *
351
+ * Call this to refresh feature flag definitions without restarting.
352
+ *
353
+ * @example
354
+ * ```typescript
355
+ * await subscriber.reloadFeatureFlags();
356
+ * ```
357
+ */
358
+ async reloadFeatureFlags() {
359
+ if (!this.enabled) return;
360
+ await this.ensureInitialized();
361
+ try {
362
+ await this.posthog?.reloadFeatureFlags();
363
+ } catch (error) {
364
+ this.config.onError?.(toError(error));
365
+ }
366
+ }
367
+ /**
368
+ * Identify a user and set their properties
369
+ *
370
+ * @param distinctId - User ID
371
+ * @param properties - Person properties ($set, $set_once, or custom properties)
372
+ *
373
+ * @example
374
+ * ```typescript
375
+ * // Set properties (will update existing values)
376
+ * await subscriber.identify('user-123', {
377
+ * $set: {
378
+ * email: 'user@example.com',
379
+ * plan: 'premium'
380
+ * }
381
+ * });
382
+ *
383
+ * // Set properties only once (won't update if already exists)
384
+ * await subscriber.identify('user-123', {
385
+ * $set_once: {
386
+ * signup_date: '2025-01-17'
387
+ * }
388
+ * });
389
+ * ```
390
+ */
391
+ async identify(distinctId, properties) {
392
+ if (!this.enabled) return;
393
+ await this.ensureInitialized();
394
+ try {
395
+ this.posthog?.identify({
396
+ distinctId,
397
+ properties
398
+ });
399
+ } catch (error) {
400
+ this.config.onError?.(toError(error));
401
+ }
402
+ }
403
+ /**
404
+ * Identify a group and set its properties
405
+ *
406
+ * Groups are useful for B2B SaaS to track organizations, teams, or accounts.
407
+ *
408
+ * @param groupType - Type of group (e.g., 'company', 'organization', 'team')
409
+ * @param groupKey - Unique identifier for the group
410
+ * @param properties - Group properties
411
+ *
412
+ * @example
413
+ * ```typescript
414
+ * await subscriber.groupIdentify('company', 'acme-corp', {
415
+ * $set: {
416
+ * name: 'Acme Corporation',
417
+ * industry: 'saas',
418
+ * employees: 500,
419
+ * plan: 'enterprise'
420
+ * }
421
+ * });
422
+ * ```
423
+ */
424
+ async groupIdentify(groupType, groupKey, properties) {
425
+ if (!this.enabled) return;
426
+ await this.ensureInitialized();
427
+ try {
428
+ this.posthog?.groupIdentify({
429
+ groupType,
430
+ groupKey: String(groupKey),
431
+ properties
432
+ });
433
+ } catch (error) {
434
+ this.config.onError?.(toError(error));
435
+ }
436
+ }
437
+ /**
438
+ * Track an event with group context
439
+ *
440
+ * Use this to associate events with groups (e.g., organizations).
441
+ *
442
+ * @param name - Event name
443
+ * @param attributes - Event attributes
444
+ * @param groups - Group context (e.g., { company: 'acme-corp' })
445
+ *
446
+ * @example
447
+ * ```typescript
448
+ * await subscriber.trackEventWithGroups('feature.used', {
449
+ * userId: 'user-123',
450
+ * feature: 'advanced-events'
451
+ * }, {
452
+ * company: 'acme-corp'
453
+ * });
454
+ * ```
455
+ */
456
+ async trackEventWithGroups(name, attributes, groups) {
457
+ if (!this.enabled) return;
458
+ await this.ensureInitialized();
459
+ const eventAttributes = { ...attributes };
460
+ if (groups) eventAttributes.groups = groups;
461
+ await this.trackEvent(name, eventAttributes);
462
+ }
463
+ /**
464
+ * Capture an exception and send to PostHog error tracking.
465
+ *
466
+ * If using browser client (window.posthog), delegates to its captureException.
467
+ * Otherwise, formats and sends via posthog-node capture API.
468
+ */
469
+ async captureException(error, options) {
470
+ if (!this.enabled) return;
471
+ await this.ensureInitialized();
472
+ try {
473
+ if (this.isBrowserClient) {
474
+ const browserProps = options?.additionalProperties ? this.redactProperties(options.additionalProperties) : void 0;
475
+ this.browserClient?.captureException?.(error, browserProps);
476
+ return;
477
+ }
478
+ const properties = {
479
+ ...formatExceptionForPostHog(errorToExceptionList(error, this.stringRedactor ?? void 0), "node:javascript", this.stringRedactor ?? void 0),
480
+ ...options?.additionalProperties
481
+ };
482
+ this.posthog?.capture({
483
+ distinctId: options?.distinctId || "anonymous",
484
+ event: "$exception",
485
+ properties: this.redactProperties(properties)
486
+ });
487
+ } catch (error_) {
488
+ this.config.onError?.(toError(error_));
489
+ }
490
+ }
491
+ /**
492
+ * Flush pending events and clean up resources
493
+ */
494
+ async shutdown() {
495
+ await super.shutdown();
496
+ await this.ensureInitialized();
497
+ if (this.posthog) try {
498
+ await this.posthog.shutdown();
499
+ } catch (error) {
500
+ this.config.onError?.(toError(error));
501
+ }
502
+ }
503
+ /**
504
+ * Handle errors with custom error handler
505
+ */
506
+ handleError(error, payload) {
507
+ this.config.onError?.(error);
508
+ if (this.config.onErrorWithContext) this.config.onErrorWithContext({
509
+ error,
510
+ eventName: payload.name,
511
+ eventType: payload.type,
512
+ attributes: payload.attributes,
513
+ subscriberName: this.name
514
+ });
515
+ super.handleError(error, payload);
516
+ }
517
+ };
518
+ //#endregion
519
+ export { PostHogSubscriber };
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "autotel-posthog",
3
+ "version": "0.1.0",
4
+ "description": "Everything PostHog for autotel: browser session/replay join, server-side event subscriber, and trace links in both directions",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ },
15
+ "./subscriber": {
16
+ "types": "./dist/subscriber.d.ts",
17
+ "import": "./dist/subscriber.js",
18
+ "require": "./dist/subscriber.cjs"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md"
24
+ ],
25
+ "dependencies": {
26
+ "@opentelemetry/api": "^1.9.1",
27
+ "slow-redact": "^0.3.2"
28
+ },
29
+ "peerDependencies": {
30
+ "@opentelemetry/sdk-trace-base": ">=2.0.0",
31
+ "posthog-js": ">=1.200.0",
32
+ "posthog-node": ">=4.0.0",
33
+ "autotel": "6.5.0",
34
+ "autotel-subscribers": "49.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@opentelemetry/context-async-hooks": "^2.10.0",
38
+ "@opentelemetry/sdk-trace-base": "^2.10.0",
39
+ "@types/node": "^26.1.2",
40
+ "posthog-js": "1.417.1",
41
+ "posthog-node": "^4.18.0",
42
+ "rimraf": "^6.1.3",
43
+ "tsdown": "^0.22.14",
44
+ "typescript": "^6.0.3",
45
+ "vitest": "^4.1.10",
46
+ "autotel": "6.5.0",
47
+ "autotel-subscribers": "49.0.0"
48
+ },
49
+ "keywords": [
50
+ "opentelemetry",
51
+ "otel",
52
+ "posthog",
53
+ "session-replay",
54
+ "product-analytics",
55
+ "tracing",
56
+ "observability",
57
+ "autotel"
58
+ ],
59
+ "license": "Apache-2.0",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "https://github.com/jagreehal/autotel",
63
+ "directory": "packages/autotel-posthog"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "@opentelemetry/sdk-trace-base": {
67
+ "optional": true
68
+ },
69
+ "posthog-js": {
70
+ "optional": true
71
+ },
72
+ "autotel": {
73
+ "optional": true
74
+ },
75
+ "autotel-subscribers": {
76
+ "optional": true
77
+ },
78
+ "posthog-node": {
79
+ "optional": true
80
+ }
81
+ },
82
+ "scripts": {
83
+ "build": "tsdown",
84
+ "dev": "tsdown --watch",
85
+ "type-check": "tsc --noEmit",
86
+ "lint": "eslint src",
87
+ "test": "vitest run",
88
+ "clean": "rimraf dist"
89
+ }
90
+ }