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,122 @@
1
+ import { SpanProcessor } from "@opentelemetry/sdk-trace-base";
2
+ import { PostHog } from "posthog-js";
3
+ //#region src/posthog-like.d.ts
4
+ /** Exactly the members read, with PostHog's own signatures. */
5
+ declare global {
6
+ /**
7
+ * PostHog installs itself on the page - as the loader snippet's array stub
8
+ * first, then as the real instance. Declaring it here is what lets the rest
9
+ * of this package read `globalThis.posthog` without asserting a shape.
10
+ */
11
+ var posthog: PostHogLike | undefined;
12
+ }
13
+ type PostHogLike = Partial<Pick<PostHog, 'get_session_id' | 'get_distinct_id' | 'get_session_replay_url' | 'getFeatureFlag' | 'sessionRecordingStarted' | 'set_config'>> & {
14
+ /**
15
+ * Pre-`sessionRecordingStarted()` fallback. Not part of the public type —
16
+ * PostHog exposes only `_forceAllowLocalhostNetworkCapture` on this object —
17
+ * so it is declared here and read defensively, never preferred.
18
+ */
19
+ sessionRecording?: {
20
+ started?: boolean;
21
+ };
22
+ config?: Partial<PostHog['config']>;
23
+ };
24
+ //#endregion
25
+ //#region src/compatibility.d.ts
26
+ interface PostHogCompatibilityOptions {
27
+ /**
28
+ * The PostHog instance, or a function returning it.
29
+ *
30
+ * Defaults to `globalThis.posthog`, which is where the snippet and
31
+ * `posthog-js` both leave it. An instance passed here is preferred — two
32
+ * PostHog instances on one page is a real setup and an explicit argument is a
33
+ * decision — but only while it can answer: hand in the loader snippet's array
34
+ * stub and this falls back to the global once posthog-js swaps the real
35
+ * library in over the top of it.
36
+ */
37
+ posthog?: PostHogLike | (() => PostHogLike | undefined);
38
+ /**
39
+ * Flag keys to stamp on every span as `feature_flag.<key>`, so error rate and
40
+ * latency can be split by variant in whichever backend receives the spans.
41
+ *
42
+ * Named explicitly rather than read wholesale: every flag is another
43
+ * attribute on every span, and "all of them" is how an analytics convenience
44
+ * turns into a cardinality bill.
45
+ */
46
+ featureFlags?: string[];
47
+ }
48
+ declare function posthogCompatibility(options?: PostHogCompatibilityOptions): SpanProcessor;
49
+ //#endregion
50
+ //#region src/before-send.d.ts
51
+ /**
52
+ * The other half of the join: PostHog events that know which trace they
53
+ * happened inside.
54
+ *
55
+ * `posthogCompatibility()` teaches the trace about the session. This teaches
56
+ * the session about the trace, so a `$exception` or a funnel drop-off in
57
+ * PostHog carries the trace id that explains it, and the property names match
58
+ * the ones autotel's server-side subscriber already writes — one set of names
59
+ * whichever side captured the event.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * posthog.init('<key>', {
64
+ * before_send: [
65
+ * autotelBeforeSend({
66
+ * traceUrl: ({ traceId }) => `https://traces.example.com/${traceId}`,
67
+ * }),
68
+ * ],
69
+ * });
70
+ * ```
71
+ */
72
+ /**
73
+ * Structural copy of PostHog's `CaptureResult`. Only `properties` is touched;
74
+ * everything else is passed through untouched.
75
+ */
76
+ /** What a PostHog event property can hold: JSON, since that is what is sent. */
77
+ type PostHogPropertyValue = string | number | boolean | null | undefined | Array<PostHogPropertyValue> | {
78
+ [key: string]: PostHogPropertyValue;
79
+ };
80
+ interface CaptureResultLike {
81
+ properties: Record<string, PostHogPropertyValue>;
82
+ [key: string]: PostHogPropertyValue;
83
+ }
84
+ type BeforeSendLike = (event: CaptureResultLike | null) => CaptureResultLike | null;
85
+ interface AutotelBeforeSendOptions {
86
+ /**
87
+ * Build a link to the trace in your own backend.
88
+ *
89
+ * Ids correlate; they do not navigate. Whoever reads a PostHog event wants
90
+ * one click back to the trace, and only the app knows whether that is
91
+ * Traceway, Grafana, Honeycomb or a local devtools port — so the URL shape
92
+ * is yours to supply.
93
+ *
94
+ * Return `undefined` to add nothing for this event.
95
+ */
96
+ traceUrl?: (context: {
97
+ traceId: string;
98
+ spanId: string;
99
+ }) => string | undefined;
100
+ }
101
+ /**
102
+ * A PostHog `before_send` hook that adds `$trace_id` and `$span_id` from the
103
+ * span in progress.
104
+ */
105
+ declare function autotelBeforeSend(options?: AutotelBeforeSendOptions): BeforeSendLike;
106
+ //#endregion
107
+ //#region src/join.d.ts
108
+ /**
109
+ * Wire PostHog to stamp trace context on its events, and return the span
110
+ * enricher for the other direction.
111
+ *
112
+ * Safe to call more than once, and safe to call on an instance that cannot be
113
+ * configured — the loader snippet's stub has no `set_config`, and the trace
114
+ * side is worth having even when the PostHog side cannot be wired.
115
+ */
116
+ interface JoinPostHogOptions extends Omit<PostHogCompatibilityOptions, 'posthog'>, AutotelBeforeSendOptions {}
117
+ declare function joinPostHog(posthog: PostHogLike | (() => PostHogLike | undefined), options?: JoinPostHogOptions): SpanProcessor;
118
+ //#endregion
119
+ //#region src/session-id.d.ts
120
+ declare function posthogSessionId(posthog?: PostHogLike): string | undefined;
121
+ //#endregion
122
+ export { type AutotelBeforeSendOptions, type BeforeSendLike, type CaptureResultLike, type JoinPostHogOptions, type PostHogCompatibilityOptions, type PostHogLike, autotelBeforeSend, joinPostHog, posthogCompatibility, posthogSessionId };
@@ -0,0 +1,122 @@
1
+ import { SpanProcessor } from "@opentelemetry/sdk-trace-base";
2
+ import { PostHog } from "posthog-js";
3
+ //#region src/posthog-like.d.ts
4
+ /** Exactly the members read, with PostHog's own signatures. */
5
+ declare global {
6
+ /**
7
+ * PostHog installs itself on the page - as the loader snippet's array stub
8
+ * first, then as the real instance. Declaring it here is what lets the rest
9
+ * of this package read `globalThis.posthog` without asserting a shape.
10
+ */
11
+ var posthog: PostHogLike | undefined;
12
+ }
13
+ type PostHogLike = Partial<Pick<PostHog, 'get_session_id' | 'get_distinct_id' | 'get_session_replay_url' | 'getFeatureFlag' | 'sessionRecordingStarted' | 'set_config'>> & {
14
+ /**
15
+ * Pre-`sessionRecordingStarted()` fallback. Not part of the public type —
16
+ * PostHog exposes only `_forceAllowLocalhostNetworkCapture` on this object —
17
+ * so it is declared here and read defensively, never preferred.
18
+ */
19
+ sessionRecording?: {
20
+ started?: boolean;
21
+ };
22
+ config?: Partial<PostHog['config']>;
23
+ };
24
+ //#endregion
25
+ //#region src/compatibility.d.ts
26
+ interface PostHogCompatibilityOptions {
27
+ /**
28
+ * The PostHog instance, or a function returning it.
29
+ *
30
+ * Defaults to `globalThis.posthog`, which is where the snippet and
31
+ * `posthog-js` both leave it. An instance passed here is preferred — two
32
+ * PostHog instances on one page is a real setup and an explicit argument is a
33
+ * decision — but only while it can answer: hand in the loader snippet's array
34
+ * stub and this falls back to the global once posthog-js swaps the real
35
+ * library in over the top of it.
36
+ */
37
+ posthog?: PostHogLike | (() => PostHogLike | undefined);
38
+ /**
39
+ * Flag keys to stamp on every span as `feature_flag.<key>`, so error rate and
40
+ * latency can be split by variant in whichever backend receives the spans.
41
+ *
42
+ * Named explicitly rather than read wholesale: every flag is another
43
+ * attribute on every span, and "all of them" is how an analytics convenience
44
+ * turns into a cardinality bill.
45
+ */
46
+ featureFlags?: string[];
47
+ }
48
+ declare function posthogCompatibility(options?: PostHogCompatibilityOptions): SpanProcessor;
49
+ //#endregion
50
+ //#region src/before-send.d.ts
51
+ /**
52
+ * The other half of the join: PostHog events that know which trace they
53
+ * happened inside.
54
+ *
55
+ * `posthogCompatibility()` teaches the trace about the session. This teaches
56
+ * the session about the trace, so a `$exception` or a funnel drop-off in
57
+ * PostHog carries the trace id that explains it, and the property names match
58
+ * the ones autotel's server-side subscriber already writes — one set of names
59
+ * whichever side captured the event.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * posthog.init('<key>', {
64
+ * before_send: [
65
+ * autotelBeforeSend({
66
+ * traceUrl: ({ traceId }) => `https://traces.example.com/${traceId}`,
67
+ * }),
68
+ * ],
69
+ * });
70
+ * ```
71
+ */
72
+ /**
73
+ * Structural copy of PostHog's `CaptureResult`. Only `properties` is touched;
74
+ * everything else is passed through untouched.
75
+ */
76
+ /** What a PostHog event property can hold: JSON, since that is what is sent. */
77
+ type PostHogPropertyValue = string | number | boolean | null | undefined | Array<PostHogPropertyValue> | {
78
+ [key: string]: PostHogPropertyValue;
79
+ };
80
+ interface CaptureResultLike {
81
+ properties: Record<string, PostHogPropertyValue>;
82
+ [key: string]: PostHogPropertyValue;
83
+ }
84
+ type BeforeSendLike = (event: CaptureResultLike | null) => CaptureResultLike | null;
85
+ interface AutotelBeforeSendOptions {
86
+ /**
87
+ * Build a link to the trace in your own backend.
88
+ *
89
+ * Ids correlate; they do not navigate. Whoever reads a PostHog event wants
90
+ * one click back to the trace, and only the app knows whether that is
91
+ * Traceway, Grafana, Honeycomb or a local devtools port — so the URL shape
92
+ * is yours to supply.
93
+ *
94
+ * Return `undefined` to add nothing for this event.
95
+ */
96
+ traceUrl?: (context: {
97
+ traceId: string;
98
+ spanId: string;
99
+ }) => string | undefined;
100
+ }
101
+ /**
102
+ * A PostHog `before_send` hook that adds `$trace_id` and `$span_id` from the
103
+ * span in progress.
104
+ */
105
+ declare function autotelBeforeSend(options?: AutotelBeforeSendOptions): BeforeSendLike;
106
+ //#endregion
107
+ //#region src/join.d.ts
108
+ /**
109
+ * Wire PostHog to stamp trace context on its events, and return the span
110
+ * enricher for the other direction.
111
+ *
112
+ * Safe to call more than once, and safe to call on an instance that cannot be
113
+ * configured — the loader snippet's stub has no `set_config`, and the trace
114
+ * side is worth having even when the PostHog side cannot be wired.
115
+ */
116
+ interface JoinPostHogOptions extends Omit<PostHogCompatibilityOptions, 'posthog'>, AutotelBeforeSendOptions {}
117
+ declare function joinPostHog(posthog: PostHogLike | (() => PostHogLike | undefined), options?: JoinPostHogOptions): SpanProcessor;
118
+ //#endregion
119
+ //#region src/session-id.d.ts
120
+ declare function posthogSessionId(posthog?: PostHogLike): string | undefined;
121
+ //#endregion
122
+ export { type AutotelBeforeSendOptions, type BeforeSendLike, type CaptureResultLike, type JoinPostHogOptions, type PostHogCompatibilityOptions, type PostHogLike, autotelBeforeSend, joinPostHog, posthogCompatibility, posthogSessionId };
package/dist/index.js ADDED
@@ -0,0 +1,263 @@
1
+ import { context, trace } from "@opentelemetry/api";
2
+ //#region src/posthog-like.ts
3
+ /**
4
+ * Whether this object can actually answer questions yet.
5
+ *
6
+ * The loader snippet leaves an array on `window.posthog` that queues calls, and
7
+ * posthog-js later *replaces* it with the real instance. An integration holding
8
+ * the array from before the swap holds something that will never answer, so the
9
+ * only useful test is whether the methods are there.
10
+ */
11
+ function isUsable(posthog) {
12
+ return typeof posthog?.get_session_id === "function";
13
+ }
14
+ /**
15
+ * Call a PostHog getter without letting the page's analytics break the page's
16
+ * tracing. A stub throws `TypeError`, a partly-initialized instance returns an
17
+ * empty string, and neither is worth an attribute.
18
+ */
19
+ function readString(fn, receiver) {
20
+ if (typeof fn !== "function") return void 0;
21
+ try {
22
+ const value = fn.call(receiver);
23
+ return typeof value === "string" && value.length > 0 ? value : void 0;
24
+ } catch {
25
+ return;
26
+ }
27
+ }
28
+ function readSessionId(posthog) {
29
+ return readString(posthog.get_session_id, posthog);
30
+ }
31
+ function readDistinctId(posthog) {
32
+ return readString(posthog.get_distinct_id, posthog);
33
+ }
34
+ /**
35
+ * Whether a replay actually exists to link to.
36
+ *
37
+ * `get_session_replay_url()` composes a URL out of the session id whether or
38
+ * not anything was recorded — replay disabled, sampled out, or simply not
39
+ * started yet all still produce a link, and it lands on an empty player. Only
40
+ * an affirmative answer counts, so an instance too old or too stubbed to say
41
+ * produces no link rather than a confident wrong one.
42
+ */
43
+ function isRecording(posthog) {
44
+ try {
45
+ if (typeof posthog.sessionRecordingStarted === "function") return posthog.sessionRecordingStarted() === true;
46
+ return posthog.sessionRecording?.started === true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+ function readReplayUrl(posthog) {
52
+ if (!isRecording(posthog)) return void 0;
53
+ const fn = posthog.get_session_replay_url;
54
+ if (typeof fn !== "function") return void 0;
55
+ try {
56
+ const value = fn.call(posthog, { withTimestamp: true });
57
+ return typeof value === "string" && value.length > 0 ? value : void 0;
58
+ } catch {
59
+ return;
60
+ }
61
+ }
62
+ /**
63
+ * An evaluated flag value.
64
+ *
65
+ * `false` is an answer — "this person is not in the variant" — and is kept as a
66
+ * boolean so a query can ask for it. Only `undefined`, meaning PostHog has no
67
+ * opinion yet, is dropped: an attribute that says nothing is worse than an
68
+ * absent one.
69
+ */
70
+ function readFeatureFlag(posthog, key) {
71
+ const fn = posthog.getFeatureFlag;
72
+ if (typeof fn !== "function") return void 0;
73
+ try {
74
+ const value = fn.call(posthog, key);
75
+ if (value === void 0) return void 0;
76
+ return typeof value === "boolean" ? value : String(value);
77
+ } catch {
78
+ return;
79
+ }
80
+ }
81
+ //#endregion
82
+ //#region src/compatibility.ts
83
+ /** `SpanStatusCode.ERROR`, without importing the enum for one comparison. */
84
+ const STATUS_ERROR = 2;
85
+ function failed(span) {
86
+ return span.status?.code === STATUS_ERROR || span.attributes["exception.type"] !== void 0 || span.events?.some((event) => event.name === "exception") === true;
87
+ }
88
+ function resolvePostHog(options) {
89
+ const configured = typeof options.posthog === "function" ? options.posthog() : options.posthog;
90
+ if (isUsable(configured)) return configured;
91
+ const global = globalThis.posthog;
92
+ return isUsable(global) ? global : void 0;
93
+ }
94
+ function posthogCompatibility(options = {}) {
95
+ return {
96
+ /**
97
+ * Identity is read here, not in `onEnd`, because it is a fact about when
98
+ * the operation happened. PostHog rotates a session after 30 minutes idle,
99
+ * and `identify()` can land mid-request; a long span asking at the end
100
+ * would be filed under whoever the visitor had become by then rather than
101
+ * who started it.
102
+ */
103
+ onStart(span, _context) {
104
+ const posthog = resolvePostHog(options);
105
+ if (!posthog) return;
106
+ const sessionId = readSessionId(posthog);
107
+ if (sessionId !== void 0) span.setAttribute("session.id", sessionId);
108
+ const attributes = span.attributes;
109
+ const fill = (key, value) => {
110
+ if (value !== void 0 && attributes[key] === void 0) span.setAttribute(key, value);
111
+ };
112
+ fill("user.id", readDistinctId(posthog));
113
+ for (const key of options.featureFlags ?? []) fill(`feature_flag.${key}`, readFeatureFlag(posthog, key));
114
+ },
115
+ /**
116
+ * Only the replay link is left for the end, because only the end knows
117
+ * whether the span failed.
118
+ */
119
+ onEnd(span) {
120
+ if (!failed(span)) return;
121
+ const posthog = resolvePostHog(options);
122
+ if (!posthog) return;
123
+ const attributes = span.attributes;
124
+ if (attributes["session.replay.url"] !== void 0) return;
125
+ const current = readSessionId(posthog);
126
+ if (current === void 0 || current !== attributes["session.id"]) return;
127
+ const url = readReplayUrl(posthog);
128
+ if (url !== void 0) attributes["session.replay.url"] = url;
129
+ },
130
+ forceFlush() {
131
+ return Promise.resolve();
132
+ },
133
+ shutdown() {
134
+ return Promise.resolve();
135
+ }
136
+ };
137
+ }
138
+ //#endregion
139
+ //#region src/before-send.ts
140
+ /**
141
+ * The other half of the join: PostHog events that know which trace they
142
+ * happened inside.
143
+ *
144
+ * `posthogCompatibility()` teaches the trace about the session. This teaches
145
+ * the session about the trace, so a `$exception` or a funnel drop-off in
146
+ * PostHog carries the trace id that explains it, and the property names match
147
+ * the ones autotel's server-side subscriber already writes — one set of names
148
+ * whichever side captured the event.
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * posthog.init('<key>', {
153
+ * before_send: [
154
+ * autotelBeforeSend({
155
+ * traceUrl: ({ traceId }) => `https://traces.example.com/${traceId}`,
156
+ * }),
157
+ * ],
158
+ * });
159
+ * ```
160
+ */
161
+ /**
162
+ * A PostHog `before_send` hook that adds `$trace_id` and `$span_id` from the
163
+ * span in progress.
164
+ */
165
+ function autotelBeforeSend(options = {}) {
166
+ return (event) => {
167
+ if (event === null) return null;
168
+ const spanContext = trace.getSpanContext(context.active());
169
+ if (!spanContext) return event;
170
+ const properties = event.properties;
171
+ if (properties["$trace_id"] === void 0) properties["$trace_id"] = spanContext.traceId;
172
+ if (properties["$span_id"] === void 0) properties["$span_id"] = spanContext.spanId;
173
+ if (options.traceUrl && properties["$trace_url"] === void 0) try {
174
+ const url = options.traceUrl({
175
+ traceId: spanContext.traceId,
176
+ spanId: spanContext.spanId
177
+ });
178
+ if (url !== void 0) properties["$trace_url"] = url;
179
+ } catch {}
180
+ return event;
181
+ };
182
+ }
183
+ //#endregion
184
+ //#region src/join.ts
185
+ /**
186
+ * Marks our hook so a second call recognises it. Framework code runs more than
187
+ * once — strict mode, HMR, a re-render — and a chain that grows on every render
188
+ * stamps the same properties again and again.
189
+ */
190
+ const MARKER = "__autotelBeforeSend";
191
+ function existingHooks(posthog) {
192
+ const current = posthog.config?.before_send;
193
+ if (Array.isArray(current)) return current;
194
+ return current ? [current] : [];
195
+ }
196
+ function joinPostHog(posthog, options = {}) {
197
+ const resolve = () => resolvePostHog({
198
+ ...options,
199
+ posthog
200
+ });
201
+ /**
202
+ * Returns true once the hook is in place. Kept retryable because the loader
203
+ * snippet's stub has no `set_config`: giving up at call time would leave
204
+ * every PostHog event on that page without its trace for the life of the
205
+ * page, and the real library usually arrives a moment later.
206
+ */
207
+ const wire = () => {
208
+ try {
209
+ const instance = resolve();
210
+ if (!instance) return false;
211
+ const hooks = existingHooks(instance);
212
+ if (hooks.some((hook) => hook[MARKER])) return true;
213
+ if (typeof instance.set_config !== "function") return false;
214
+ const hook = autotelBeforeSend(options);
215
+ hook[MARKER] = true;
216
+ instance.set_config({ before_send: [...hooks, hook] });
217
+ return true;
218
+ } catch {
219
+ return false;
220
+ }
221
+ };
222
+ let wired = wire();
223
+ const enricher = posthogCompatibility({
224
+ ...options,
225
+ posthog
226
+ });
227
+ return {
228
+ onStart(span, context) {
229
+ if (!wired) wired = wire();
230
+ enricher.onStart(span, context);
231
+ },
232
+ onEnd: (span) => enricher.onEnd(span),
233
+ forceFlush: () => enricher.forceFlush(),
234
+ shutdown: () => enricher.shutdown()
235
+ };
236
+ }
237
+ //#endregion
238
+ //#region src/session-id.ts
239
+ /**
240
+ * PostHog's session id, shaped for `autotel-web`'s `session.id` provider.
241
+ *
242
+ * The span enricher covers full mode, where there is an OpenTelemetry pipeline
243
+ * to hang a processor on. The minimal browser build has no such pipeline — it
244
+ * writes spans straight to OTLP — so it takes the id from a function instead,
245
+ * and this is that function:
246
+ *
247
+ * ```ts
248
+ * import { init } from 'autotel-web';
249
+ * import { posthogSessionId } from 'autotel-posthog';
250
+ *
251
+ * init({ service: 'web', session: { id: posthogSessionId } });
252
+ * ```
253
+ *
254
+ * Returns `undefined` when PostHog is absent or not yet initialized, which is
255
+ * the signal for autotel-web to fall back to minting its own id rather than
256
+ * emitting spans with no session at all.
257
+ */
258
+ function posthogSessionId(posthog) {
259
+ const instance = posthog ?? globalThis.posthog ?? void 0;
260
+ return instance ? readSessionId(instance) : void 0;
261
+ }
262
+ //#endregion
263
+ export { autotelBeforeSend, joinPostHog, posthogCompatibility, posthogSessionId };