autotel-posthog 1.0.0 → 2.0.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.
- package/README.md +73 -4
- package/dist/index.cjs +260 -14
- package/dist/index.d.cts +66 -23
- package/dist/index.d.ts +66 -23
- package/dist/index.js +260 -15
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -18,10 +18,15 @@ npm install autotel-posthog posthog-js autotel-web
|
|
|
18
18
|
npm install autotel-posthog posthog-node autotel autotel-subscribers
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
Peers are declared optional so the half you skip does not warn about packages it
|
|
22
|
+
will never import — with one exception. `autotel-web` is **required**: the root
|
|
23
|
+
entry's `joinPostHog` imports `autotel-web/baggage` at module scope for the
|
|
24
|
+
session hop, and a peer the entry point always imports is not optional. Server
|
|
25
|
+
events live behind `autotel-posthog/subscriber`, which does not pull it in.
|
|
26
|
+
|
|
27
|
+
`posthog-js` is not bundled: two copies on one page means two session managers
|
|
28
|
+
and two different answers to `get_session_id()`, which would break the join this
|
|
29
|
+
package exists to make.
|
|
25
30
|
|
|
26
31
|
## The short version
|
|
27
32
|
|
|
@@ -111,6 +116,70 @@ posthog.init('<key>', { before_send: [autotelBeforeSend()] });
|
|
|
111
116
|
|
|
112
117
|
A `$exception` or a funnel drop-off in PostHog now names the trace that explains it. Events an earlier hook dropped stay dropped: `before_send` is a chain, and `null` means the page suppressed the event deliberately.
|
|
113
118
|
|
|
119
|
+
#### Events captured after an `await`
|
|
120
|
+
|
|
121
|
+
The browser has no `AsyncLocalStorage`, so OpenTelemetry's active context is gone by the first `await` — which is exactly where the events worth joining fire:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
await span('checkout.click', async () => {
|
|
125
|
+
await fetch('/checkout', { method: 'POST' });
|
|
126
|
+
// The active span is already gone here. joinPostHog still finds it.
|
|
127
|
+
posthog.capture('checkout_failed', { message: 'Card declined' });
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`joinPostHog` falls back to the most recent span it has seen start and not yet end, so no Zone.js and no manual `context.with()`. The active context wins when there is one, and a span that already ended is never used.
|
|
132
|
+
|
|
133
|
+
It refuses to guess when guessing could be wrong. Two overlapping user actions each start their own trace, and with no active context there is nothing to say which one an event belongs to — so nothing is added rather than a trace id pointing at an unrelated request. In development it says so in the console, and names the fix.
|
|
134
|
+
|
|
135
|
+
The fix is one line. Read the ids while the span is still active, before the first `await`, and spread them onto the capture:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { traceProperties } from 'autotel-posthog';
|
|
139
|
+
|
|
140
|
+
await span('checkout.click', async () => {
|
|
141
|
+
const trace = traceProperties();
|
|
142
|
+
await fetch('/checkout', { method: 'POST' });
|
|
143
|
+
posthog.capture('checkout_failed', { ...trace, message: 'Card declined' });
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`traceProperties()` returns `{}` when nothing is being traced, so the spread is always safe, and a property the caller set is never overwritten — explicit always wins. `$trace_url` is still added for you.
|
|
148
|
+
|
|
149
|
+
`autotelBeforeSend()` on its own reads only the active context. Pass `fallbackSpanContext` for the same behaviour without the enricher.
|
|
150
|
+
|
|
151
|
+
## When nothing shows up
|
|
152
|
+
|
|
153
|
+
Every failure here is quiet on purpose — a missing PostHog, a rotated session, replay switched off all just produce no attribute. That is right in production and useless while wiring it up, so each exit says why, once per reason:
|
|
154
|
+
|
|
155
|
+
`debug` is on by default in development and silent in production — `process.env.NODE_ENV` where a bundler substituted one, a localhost page otherwise. A diagnostic nobody switches on is a diagnostic nobody reads. `debug: false` silences it anywhere; `debug: true` forces it on.
|
|
156
|
+
|
|
157
|
+
### Testing it end to end
|
|
158
|
+
|
|
159
|
+
`posthog-js` drops bots — headless Chrome included — **before** `before_send` runs, so a Playwright or Puppeteer test sees no events and no stamping, with no error to explain it. Turn the filter off on the instance under test only:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
await page.evaluate(() =>
|
|
163
|
+
window.posthog.set_config({ opt_out_useragent_filter: true }),
|
|
164
|
+
);
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
PostHog still classifies that traffic as a bot server-side, so filter it out of your own analysis rather than leaving it in production code.
|
|
168
|
+
|
|
169
|
+
## Server hop
|
|
170
|
+
|
|
171
|
+
`joinPostHog` copies PostHog's session id onto subsequent same-origin fetches as W3C `baggage` (`propagateSession`, default on). The backend then stamps that id on the handler span:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
init({ service: 'api', endpoint, baggage: '' });
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`baggage: ''` writes `session.id`. `baggage: true` would write `baggage.session.id`. Distinct id stays off; it can be an email. Pass `propagateSession: false` to skip the header.
|
|
178
|
+
|
|
179
|
+
Needs `autotel` 7.0.1 or later: earlier versions read the empty string as "off" and the attribute never landed.
|
|
180
|
+
|
|
181
|
+
Docs: [PostHog join](https://jagreehal.github.io/autotel/integrations/posthog/). Worked example: [`apps/example-posthog`](../../apps/example-posthog).
|
|
182
|
+
|
|
114
183
|
## Product events from the server
|
|
115
184
|
|
|
116
185
|
```ts
|
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let autotel_web_baggage = require("autotel-web/baggage");
|
|
2
3
|
let _opentelemetry_api = require("@opentelemetry/api");
|
|
4
|
+
//#region src/dev-mode.ts
|
|
5
|
+
/**
|
|
6
|
+
* Whether to say something when the join cannot do its job.
|
|
7
|
+
*
|
|
8
|
+
* Diagnostics behind an opt-in flag are read by people who already know
|
|
9
|
+
* something is wrong, and the failure here is not knowing: every exit on this
|
|
10
|
+
* path produces a missing property and nothing else, which looks exactly like
|
|
11
|
+
* a working integration until someone queries for it weeks later.
|
|
12
|
+
*
|
|
13
|
+
* So it follows the convention React and Redux use — loud while you build,
|
|
14
|
+
* silent once you ship — rather than off until asked.
|
|
15
|
+
*/
|
|
16
|
+
function isDevelopment() {
|
|
17
|
+
try {
|
|
18
|
+
const env = typeof process !== "undefined" ? process.env?.NODE_ENV : void 0;
|
|
19
|
+
if (typeof env === "string" && env.length > 0) return env !== "production";
|
|
20
|
+
} catch {}
|
|
21
|
+
try {
|
|
22
|
+
const host = globalThis.location?.hostname;
|
|
23
|
+
return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host?.endsWith(".local") === true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
3
29
|
//#region src/posthog-like.ts
|
|
4
30
|
/**
|
|
5
31
|
* Whether this object can actually answer questions yet.
|
|
@@ -86,6 +112,24 @@ const STATUS_ERROR = 2;
|
|
|
86
112
|
function failed(span) {
|
|
87
113
|
return span.status?.code === STATUS_ERROR || span.attributes["exception.type"] !== void 0 || span.events?.some((event) => event.name === "exception") === true;
|
|
88
114
|
}
|
|
115
|
+
const EXPLANATION = {
|
|
116
|
+
"no-posthog": "no usable PostHog on the page. Pass the instance to joinPostHog(), or check posthog.init() runs before the first span.",
|
|
117
|
+
"session-rotated": "the PostHog session rotated after this span started, so a link would point at a different recording than the one the span belongs to.",
|
|
118
|
+
"not-recording": "session replay is not recording. Check it is enabled for the project, that this session was not sampled out, and that the recorder has started. Note posthog-js does not record bots or headless browsers."
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* One warning per reason, not per span. A failing join fails on every span,
|
|
122
|
+
* and a console with a thousand copies of the same line is the same as a
|
|
123
|
+
* console with none.
|
|
124
|
+
*/
|
|
125
|
+
function explainer(enabled) {
|
|
126
|
+
const said = /* @__PURE__ */ new Set();
|
|
127
|
+
return (reason) => {
|
|
128
|
+
if (!enabled || said.has(reason)) return;
|
|
129
|
+
said.add(reason);
|
|
130
|
+
console.warn(`[autotel-posthog] No session.replay.url on a failed span: ${EXPLANATION[reason]}`);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
89
133
|
function resolvePostHog(options) {
|
|
90
134
|
const configured = typeof options.posthog === "function" ? options.posthog() : options.posthog;
|
|
91
135
|
if (isUsable(configured)) return configured;
|
|
@@ -93,6 +137,7 @@ function resolvePostHog(options) {
|
|
|
93
137
|
return isUsable(global) ? global : void 0;
|
|
94
138
|
}
|
|
95
139
|
function posthogCompatibility(options = {}) {
|
|
140
|
+
const explain = explainer(options.debug ?? isDevelopment());
|
|
96
141
|
return {
|
|
97
142
|
/**
|
|
98
143
|
* Identity is read here, not in `onEnd`, because it is a fact about when
|
|
@@ -120,13 +165,23 @@ function posthogCompatibility(options = {}) {
|
|
|
120
165
|
onEnd(span) {
|
|
121
166
|
if (!failed(span)) return;
|
|
122
167
|
const posthog = resolvePostHog(options);
|
|
123
|
-
if (!posthog)
|
|
168
|
+
if (!posthog) {
|
|
169
|
+
explain("no-posthog");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
124
172
|
const attributes = span.attributes;
|
|
125
173
|
if (attributes["session.replay.url"] !== void 0) return;
|
|
126
174
|
const current = readSessionId(posthog);
|
|
127
|
-
if (current === void 0 || current !== attributes["session.id"])
|
|
175
|
+
if (current === void 0 || current !== attributes["session.id"]) {
|
|
176
|
+
explain("session-rotated");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
128
179
|
const url = readReplayUrl(posthog);
|
|
129
|
-
if (url
|
|
180
|
+
if (url === void 0) {
|
|
181
|
+
explain("not-recording");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
attributes["session.replay.url"] = url;
|
|
130
185
|
},
|
|
131
186
|
forceFlush() {
|
|
132
187
|
return Promise.resolve();
|
|
@@ -160,21 +215,60 @@ function posthogCompatibility(options = {}) {
|
|
|
160
215
|
* ```
|
|
161
216
|
*/
|
|
162
217
|
/**
|
|
218
|
+
* The ids of the span in progress, shaped to spread straight onto a capture.
|
|
219
|
+
*
|
|
220
|
+
* The hook recovers the span by itself in every case it can be sure of. This
|
|
221
|
+
* is for the case it cannot: two overlapping user actions, each its own trace,
|
|
222
|
+
* with no active context left to say which one an event belongs to. Read it
|
|
223
|
+
* while the span is still active — before the first `await` — and spread it:
|
|
224
|
+
*
|
|
225
|
+
* ```ts
|
|
226
|
+
* await span('checkout.click', async () => {
|
|
227
|
+
* const trace = traceProperties();
|
|
228
|
+
* await fetch('/checkout', { method: 'POST' });
|
|
229
|
+
* posthog.capture('checkout_failed', { ...trace });
|
|
230
|
+
* });
|
|
231
|
+
* ```
|
|
232
|
+
*
|
|
233
|
+
* Returns `{}` when nothing is being traced, so the spread is always safe.
|
|
234
|
+
* Properties the caller sets are never overwritten, so this always wins.
|
|
235
|
+
*/
|
|
236
|
+
function traceProperties() {
|
|
237
|
+
const spanContext = _opentelemetry_api.trace.getSpanContext(_opentelemetry_api.context.active());
|
|
238
|
+
if (!spanContext) return {};
|
|
239
|
+
return {
|
|
240
|
+
$trace_id: spanContext.traceId,
|
|
241
|
+
$span_id: spanContext.spanId
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/** The active context wins; this only answers when there is nothing active. */
|
|
245
|
+
function readFallback(options) {
|
|
246
|
+
try {
|
|
247
|
+
return options.fallbackSpanContext?.();
|
|
248
|
+
} catch {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
163
253
|
* A PostHog `before_send` hook that adds `$trace_id` and `$span_id` from the
|
|
164
254
|
* span in progress.
|
|
165
255
|
*/
|
|
166
256
|
function autotelBeforeSend(options = {}) {
|
|
167
257
|
return (event) => {
|
|
168
258
|
if (event === null) return null;
|
|
169
|
-
const spanContext = _opentelemetry_api.trace.getSpanContext(_opentelemetry_api.context.active());
|
|
170
|
-
if (!spanContext) return event;
|
|
171
259
|
const properties = event.properties;
|
|
172
|
-
|
|
173
|
-
if (
|
|
260
|
+
const spanContext = _opentelemetry_api.trace.getSpanContext(_opentelemetry_api.context.active()) ?? readFallback(options);
|
|
261
|
+
if (spanContext) {
|
|
262
|
+
if (properties["$trace_id"] === void 0) properties["$trace_id"] = spanContext.traceId;
|
|
263
|
+
if (properties["$span_id"] === void 0) properties["$span_id"] = spanContext.spanId;
|
|
264
|
+
}
|
|
265
|
+
const traceId = properties["$trace_id"];
|
|
266
|
+
const spanId = properties["$span_id"];
|
|
267
|
+
if (typeof traceId !== "string" || typeof spanId !== "string") return event;
|
|
174
268
|
if (options.traceUrl && properties["$trace_url"] === void 0) try {
|
|
175
269
|
const url = options.traceUrl({
|
|
176
|
-
traceId
|
|
177
|
-
spanId
|
|
270
|
+
traceId,
|
|
271
|
+
spanId
|
|
178
272
|
});
|
|
179
273
|
if (url !== void 0) properties["$trace_url"] = url;
|
|
180
274
|
} catch {}
|
|
@@ -187,8 +281,21 @@ function autotelBeforeSend(options = {}) {
|
|
|
187
281
|
* Marks our hook so a second call recognises it. Framework code runs more than
|
|
188
282
|
* once — strict mode, HMR, a re-render — and a chain that grows on every render
|
|
189
283
|
* stamps the same properties again and again.
|
|
284
|
+
*
|
|
285
|
+
* The marker is not a flag but the hook's registry of live-span lists. Only one
|
|
286
|
+
* hook is ever installed, while every call returns its own processor tracking
|
|
287
|
+
* its own spans — and the app registers whichever one it was handed last. A
|
|
288
|
+
* later call therefore has to join the installed hook's registry rather than
|
|
289
|
+
* keep its spans to itself, or the join stops working after the first
|
|
290
|
+
* re-render, silently, in exactly the frameworks that re-render.
|
|
190
291
|
*/
|
|
191
292
|
const MARKER = "__autotelBeforeSend";
|
|
293
|
+
/**
|
|
294
|
+
* How many started-but-not-ended spans to keep for the fallback. Deep enough
|
|
295
|
+
* for any real nesting on a page, shallow enough that leaked spans cost
|
|
296
|
+
* nothing.
|
|
297
|
+
*/
|
|
298
|
+
const MAX_LIVE_SPANS = 128;
|
|
192
299
|
function existingHooks(posthog) {
|
|
193
300
|
const current = posthog.config?.before_send;
|
|
194
301
|
if (Array.isArray(current)) return current;
|
|
@@ -210,29 +317,167 @@ function joinPostHog(posthog, options = {}) {
|
|
|
210
317
|
const instance = resolve();
|
|
211
318
|
if (!instance) return false;
|
|
212
319
|
const hooks = existingHooks(instance);
|
|
213
|
-
|
|
320
|
+
const installed = hooks.find((hook) => hook[MARKER]);
|
|
321
|
+
if (installed) {
|
|
322
|
+
const shared = installed[MARKER];
|
|
323
|
+
if (shared && shared !== registry) {
|
|
324
|
+
const wasJoined = registry.includes(live);
|
|
325
|
+
leave();
|
|
326
|
+
registry = shared;
|
|
327
|
+
if (wasJoined) join();
|
|
328
|
+
}
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
214
331
|
if (typeof instance.set_config !== "function") return false;
|
|
215
|
-
const hook = autotelBeforeSend(
|
|
216
|
-
|
|
332
|
+
const hook = autotelBeforeSend({
|
|
333
|
+
...options,
|
|
334
|
+
fallbackSpanContext
|
|
335
|
+
});
|
|
336
|
+
hook[MARKER] = registry;
|
|
217
337
|
instance.set_config({ before_send: [...hooks, hook] });
|
|
218
338
|
return true;
|
|
219
339
|
} catch {
|
|
220
340
|
return false;
|
|
221
341
|
}
|
|
222
342
|
};
|
|
343
|
+
/**
|
|
344
|
+
* Spans that have started and not yet ended, newest last.
|
|
345
|
+
*
|
|
346
|
+
* The browser drops the active context at the first `await`, so by the time
|
|
347
|
+
* the fetch fails and the page captures the event, `context.active()` is
|
|
348
|
+
* root again. These are the spans still in flight, and the newest of them is
|
|
349
|
+
* the one the user is inside — the same answer the active context would have
|
|
350
|
+
* given, recovered from what the processor already sees.
|
|
351
|
+
*
|
|
352
|
+
* An array, not a single slot: spans do not end in the order they start, and
|
|
353
|
+
* an inner span ending must not erase the outer one that is still open.
|
|
354
|
+
* Concurrent *sibling* spans are the one case this cannot tell apart, and it
|
|
355
|
+
* picks the most recent — a wrong span in the same trace, never a wrong
|
|
356
|
+
* trace.
|
|
357
|
+
*/
|
|
358
|
+
const live = [];
|
|
359
|
+
/**
|
|
360
|
+
* The registry the installed hook reads. Starts as this call's own and is
|
|
361
|
+
* replaced by the installed hook's the moment `wire()` finds one, so every
|
|
362
|
+
* call ends up pointing at the same list.
|
|
363
|
+
*/
|
|
364
|
+
let registry = [];
|
|
365
|
+
/**
|
|
366
|
+
* Membership is earned by having a span, not by existing.
|
|
367
|
+
*
|
|
368
|
+
* Every re-render calls `joinPostHog` again and only the processor the app
|
|
369
|
+
* registers is ever handed a span — so a slot per call is a slot per render,
|
|
370
|
+
* growing what each PostHog capture has to walk and holding it for the life
|
|
371
|
+
* of the page. Joining on the first span and leaving on the last keeps the
|
|
372
|
+
* registry the size of what is actually in flight.
|
|
373
|
+
*/
|
|
374
|
+
const join = () => {
|
|
375
|
+
if (!registry.includes(live)) registry.push(live);
|
|
376
|
+
};
|
|
377
|
+
const leave = () => {
|
|
378
|
+
const at = registry.indexOf(live);
|
|
379
|
+
if (at !== -1) registry.splice(at, 1);
|
|
380
|
+
};
|
|
381
|
+
let warnedAmbiguous = false;
|
|
382
|
+
const fallbackSpanContext = () => {
|
|
383
|
+
const open = registry.flat();
|
|
384
|
+
const newest = open.at(-1);
|
|
385
|
+
if (!newest) return void 0;
|
|
386
|
+
if (open.some((candidate) => candidate.traceId !== newest.traceId)) {
|
|
387
|
+
if ((options.debug ?? isDevelopment()) && !warnedAmbiguous) {
|
|
388
|
+
warnedAmbiguous = true;
|
|
389
|
+
console.warn("[autotel-posthog] No $trace_id on a PostHog event: more than one trace was in flight and the browser had already lost the active context, so which one this event belongs to is unknowable. Fix: read traceProperties() before the first await and spread it onto the capture — const t = traceProperties(); ... posthog.capture(name, { ...t }).");
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
return newest;
|
|
394
|
+
};
|
|
395
|
+
/**
|
|
396
|
+
* Same defensiveness as every other read here: a processor that throws on a
|
|
397
|
+
* span it did not expect takes the span down with it.
|
|
398
|
+
*/
|
|
399
|
+
const contextOf = (span) => {
|
|
400
|
+
try {
|
|
401
|
+
return typeof span.spanContext === "function" ? span.spanContext() : void 0;
|
|
402
|
+
} catch {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
const remember = (span) => {
|
|
407
|
+
const spanContext = contextOf(span);
|
|
408
|
+
if (!spanContext) return;
|
|
409
|
+
join();
|
|
410
|
+
live.push(spanContext);
|
|
411
|
+
if (live.length > MAX_LIVE_SPANS) live.shift();
|
|
412
|
+
};
|
|
413
|
+
const forget = (span) => {
|
|
414
|
+
const spanId = contextOf(span)?.spanId;
|
|
415
|
+
if (!spanId) return;
|
|
416
|
+
const at = live.findIndex((candidate) => candidate.spanId === spanId);
|
|
417
|
+
if (at !== -1) live.splice(at, 1);
|
|
418
|
+
if (live.length === 0) leave();
|
|
419
|
+
};
|
|
223
420
|
let wired = wire();
|
|
224
421
|
const enricher = posthogCompatibility({
|
|
225
422
|
...options,
|
|
226
423
|
posthog
|
|
227
424
|
});
|
|
425
|
+
let lastPropagated;
|
|
426
|
+
/**
|
|
427
|
+
* Copy PostHog's session id into baggage, once per session.
|
|
428
|
+
*
|
|
429
|
+
* Called at construction as well as on every span, because the baggage
|
|
430
|
+
* header is decided before the span exists: the wrapped fetch checks for
|
|
431
|
+
* baggage and only then hands off to the instrumented fetch that opens the
|
|
432
|
+
* span. Waiting for `onStart` means the page's first request leaves without
|
|
433
|
+
* `session.id` and only later ones carry it — and with no application span
|
|
434
|
+
* around the call, that is every first request.
|
|
435
|
+
*
|
|
436
|
+
* Still called from `onStart` too: at construction PostHog may be the loader
|
|
437
|
+
* snippet's stub with nothing to read yet, and sessions rotate after 30
|
|
438
|
+
* minutes idle.
|
|
439
|
+
*/
|
|
440
|
+
const propagate = () => {
|
|
441
|
+
if (options.propagateSession === false) return;
|
|
442
|
+
try {
|
|
443
|
+
const instance = resolve();
|
|
444
|
+
if (!instance) return;
|
|
445
|
+
const sessionId = readSessionId(instance);
|
|
446
|
+
if (!sessionId || sessionId === lastPropagated) return;
|
|
447
|
+
lastPropagated = sessionId;
|
|
448
|
+
(0, autotel_web_baggage.setBaggage)({ "session.id": sessionId });
|
|
449
|
+
} catch {}
|
|
450
|
+
};
|
|
451
|
+
propagate();
|
|
228
452
|
return {
|
|
229
453
|
onStart(span, context) {
|
|
454
|
+
remember(span);
|
|
230
455
|
if (!wired) wired = wire();
|
|
231
456
|
enricher.onStart(span, context);
|
|
457
|
+
propagate();
|
|
458
|
+
},
|
|
459
|
+
onEnd: (span) => {
|
|
460
|
+
forget(span);
|
|
461
|
+
enricher.onEnd(span);
|
|
232
462
|
},
|
|
233
|
-
onEnd: (span) => enricher.onEnd(span),
|
|
234
463
|
forceFlush: () => enricher.forceFlush(),
|
|
235
|
-
shutdown: () =>
|
|
464
|
+
shutdown: () => {
|
|
465
|
+
live.length = 0;
|
|
466
|
+
leave();
|
|
467
|
+
return enricher.shutdown();
|
|
468
|
+
},
|
|
469
|
+
/**
|
|
470
|
+
* How many spans the fallback is holding. Bookkeeping, exposed so a test
|
|
471
|
+
* can prove the bound holds; nothing reads it at runtime.
|
|
472
|
+
* @internal
|
|
473
|
+
*/
|
|
474
|
+
liveCount: () => live.length,
|
|
475
|
+
/**
|
|
476
|
+
* How many processors currently have spans in flight. Bookkeeping, exposed
|
|
477
|
+
* so a test can prove repeated calls do not accumulate.
|
|
478
|
+
* @internal
|
|
479
|
+
*/
|
|
480
|
+
registeredCount: () => registry.length
|
|
236
481
|
};
|
|
237
482
|
}
|
|
238
483
|
//#endregion
|
|
@@ -265,3 +510,4 @@ exports.autotelBeforeSend = autotelBeforeSend;
|
|
|
265
510
|
exports.joinPostHog = joinPostHog;
|
|
266
511
|
exports.posthogCompatibility = posthogCompatibility;
|
|
267
512
|
exports.posthogSessionId = posthogSessionId;
|
|
513
|
+
exports.traceProperties = traceProperties;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
2
2
|
import { PostHog } from "posthog-js";
|
|
3
|
+
import { SpanContext } from "@opentelemetry/api";
|
|
3
4
|
//#region src/posthog-like.d.ts
|
|
4
5
|
/** Exactly the members read, with PostHog's own signatures. */
|
|
5
6
|
declare global {
|
|
@@ -44,31 +45,28 @@ interface PostHogCompatibilityOptions {
|
|
|
44
45
|
* turns into a cardinality bill.
|
|
45
46
|
*/
|
|
46
47
|
featureFlags?: string[];
|
|
48
|
+
/**
|
|
49
|
+
* Explain, once per distinct reason, why a span that should have carried a
|
|
50
|
+
* replay link did not.
|
|
51
|
+
*
|
|
52
|
+
* Every exit on this path is deliberately quiet — a missing PostHog, a
|
|
53
|
+
* session that rotated, a project with replay switched off all just produce
|
|
54
|
+
* no attribute. In production that is correct. While wiring it up it is
|
|
55
|
+
* indistinguishable from success, which is how a broken join survives a
|
|
56
|
+
* green test suite.
|
|
57
|
+
*
|
|
58
|
+
* On by default in development and silent in production, the way React and
|
|
59
|
+
* Redux warn: a diagnostic nobody switches on is a diagnostic nobody reads.
|
|
60
|
+
* Development is `process.env.NODE_ENV` where a bundler substituted one, and
|
|
61
|
+
* a localhost page otherwise. Set it explicitly to override either way.
|
|
62
|
+
*
|
|
63
|
+
* @default true in development, false in production
|
|
64
|
+
*/
|
|
65
|
+
debug?: boolean;
|
|
47
66
|
}
|
|
48
67
|
declare function posthogCompatibility(options?: PostHogCompatibilityOptions): SpanProcessor;
|
|
49
68
|
//#endregion
|
|
50
69
|
//#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
70
|
/**
|
|
73
71
|
* Structural copy of PostHog's `CaptureResult`. Only `properties` is touched;
|
|
74
72
|
* everything else is passed through untouched.
|
|
@@ -97,7 +95,42 @@ interface AutotelBeforeSendOptions {
|
|
|
97
95
|
traceId: string;
|
|
98
96
|
spanId: string;
|
|
99
97
|
}) => string | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* Where to look when the active context is empty.
|
|
100
|
+
*
|
|
101
|
+
* Node keeps the active span across `await` through AsyncLocalStorage; the
|
|
102
|
+
* browser has no equivalent, so `context.active()` is back to root by the
|
|
103
|
+
* time the fetch resolves and the interesting event fires. Without this the
|
|
104
|
+
* hook would silently add nothing to exactly the events worth joining.
|
|
105
|
+
*
|
|
106
|
+
* `joinPostHog()` supplies one backed by the spans it has seen start and not
|
|
107
|
+
* yet end. Left unset, the hook uses only the active context.
|
|
108
|
+
*/
|
|
109
|
+
fallbackSpanContext?: () => SpanContext | undefined;
|
|
100
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* The ids of the span in progress, shaped to spread straight onto a capture.
|
|
113
|
+
*
|
|
114
|
+
* The hook recovers the span by itself in every case it can be sure of. This
|
|
115
|
+
* is for the case it cannot: two overlapping user actions, each its own trace,
|
|
116
|
+
* with no active context left to say which one an event belongs to. Read it
|
|
117
|
+
* while the span is still active — before the first `await` — and spread it:
|
|
118
|
+
*
|
|
119
|
+
* ```ts
|
|
120
|
+
* await span('checkout.click', async () => {
|
|
121
|
+
* const trace = traceProperties();
|
|
122
|
+
* await fetch('/checkout', { method: 'POST' });
|
|
123
|
+
* posthog.capture('checkout_failed', { ...trace });
|
|
124
|
+
* });
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* Returns `{}` when nothing is being traced, so the spread is always safe.
|
|
128
|
+
* Properties the caller sets are never overwritten, so this always wins.
|
|
129
|
+
*/
|
|
130
|
+
declare function traceProperties(): {
|
|
131
|
+
$trace_id?: string;
|
|
132
|
+
$span_id?: string;
|
|
133
|
+
};
|
|
101
134
|
/**
|
|
102
135
|
* A PostHog `before_send` hook that adds `$trace_id` and `$span_id` from the
|
|
103
136
|
* span in progress.
|
|
@@ -113,10 +146,20 @@ declare function autotelBeforeSend(options?: AutotelBeforeSendOptions): BeforeSe
|
|
|
113
146
|
* configured — the loader snippet's stub has no `set_config`, and the trace
|
|
114
147
|
* side is worth having even when the PostHog side cannot be wired.
|
|
115
148
|
*/
|
|
116
|
-
interface JoinPostHogOptions extends Omit<PostHogCompatibilityOptions, 'posthog'>, AutotelBeforeSendOptions {
|
|
149
|
+
interface JoinPostHogOptions extends Omit<PostHogCompatibilityOptions, 'posthog'>, AutotelBeforeSendOptions {
|
|
150
|
+
/**
|
|
151
|
+
* Copy PostHog's session id onto subsequent same-origin fetches as W3C
|
|
152
|
+
* `baggage`, so the server span of a traced request carries `session.id`.
|
|
153
|
+
* The backend needs `init({ baggage: '' })` for the attribute to land under
|
|
154
|
+
* that name. Distinct id stays off: it can be an email.
|
|
155
|
+
*
|
|
156
|
+
* @default true
|
|
157
|
+
*/
|
|
158
|
+
propagateSession?: boolean;
|
|
159
|
+
}
|
|
117
160
|
declare function joinPostHog(posthog: PostHogLike | (() => PostHogLike | undefined), options?: JoinPostHogOptions): SpanProcessor;
|
|
118
161
|
//#endregion
|
|
119
162
|
//#region src/session-id.d.ts
|
|
120
163
|
declare function posthogSessionId(posthog?: PostHogLike): string | undefined;
|
|
121
164
|
//#endregion
|
|
122
|
-
export { type AutotelBeforeSendOptions, type BeforeSendLike, type CaptureResultLike, type JoinPostHogOptions, type PostHogCompatibilityOptions, type PostHogLike, autotelBeforeSend, joinPostHog, posthogCompatibility, posthogSessionId };
|
|
165
|
+
export { type AutotelBeforeSendOptions, type BeforeSendLike, type CaptureResultLike, type JoinPostHogOptions, type PostHogCompatibilityOptions, type PostHogLike, autotelBeforeSend, joinPostHog, posthogCompatibility, posthogSessionId, traceProperties };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SpanContext } from "@opentelemetry/api";
|
|
1
2
|
import { SpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
2
3
|
import { PostHog } from "posthog-js";
|
|
3
4
|
//#region src/posthog-like.d.ts
|
|
@@ -44,31 +45,28 @@ interface PostHogCompatibilityOptions {
|
|
|
44
45
|
* turns into a cardinality bill.
|
|
45
46
|
*/
|
|
46
47
|
featureFlags?: string[];
|
|
48
|
+
/**
|
|
49
|
+
* Explain, once per distinct reason, why a span that should have carried a
|
|
50
|
+
* replay link did not.
|
|
51
|
+
*
|
|
52
|
+
* Every exit on this path is deliberately quiet — a missing PostHog, a
|
|
53
|
+
* session that rotated, a project with replay switched off all just produce
|
|
54
|
+
* no attribute. In production that is correct. While wiring it up it is
|
|
55
|
+
* indistinguishable from success, which is how a broken join survives a
|
|
56
|
+
* green test suite.
|
|
57
|
+
*
|
|
58
|
+
* On by default in development and silent in production, the way React and
|
|
59
|
+
* Redux warn: a diagnostic nobody switches on is a diagnostic nobody reads.
|
|
60
|
+
* Development is `process.env.NODE_ENV` where a bundler substituted one, and
|
|
61
|
+
* a localhost page otherwise. Set it explicitly to override either way.
|
|
62
|
+
*
|
|
63
|
+
* @default true in development, false in production
|
|
64
|
+
*/
|
|
65
|
+
debug?: boolean;
|
|
47
66
|
}
|
|
48
67
|
declare function posthogCompatibility(options?: PostHogCompatibilityOptions): SpanProcessor;
|
|
49
68
|
//#endregion
|
|
50
69
|
//#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
70
|
/**
|
|
73
71
|
* Structural copy of PostHog's `CaptureResult`. Only `properties` is touched;
|
|
74
72
|
* everything else is passed through untouched.
|
|
@@ -97,7 +95,42 @@ interface AutotelBeforeSendOptions {
|
|
|
97
95
|
traceId: string;
|
|
98
96
|
spanId: string;
|
|
99
97
|
}) => string | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* Where to look when the active context is empty.
|
|
100
|
+
*
|
|
101
|
+
* Node keeps the active span across `await` through AsyncLocalStorage; the
|
|
102
|
+
* browser has no equivalent, so `context.active()` is back to root by the
|
|
103
|
+
* time the fetch resolves and the interesting event fires. Without this the
|
|
104
|
+
* hook would silently add nothing to exactly the events worth joining.
|
|
105
|
+
*
|
|
106
|
+
* `joinPostHog()` supplies one backed by the spans it has seen start and not
|
|
107
|
+
* yet end. Left unset, the hook uses only the active context.
|
|
108
|
+
*/
|
|
109
|
+
fallbackSpanContext?: () => SpanContext | undefined;
|
|
100
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* The ids of the span in progress, shaped to spread straight onto a capture.
|
|
113
|
+
*
|
|
114
|
+
* The hook recovers the span by itself in every case it can be sure of. This
|
|
115
|
+
* is for the case it cannot: two overlapping user actions, each its own trace,
|
|
116
|
+
* with no active context left to say which one an event belongs to. Read it
|
|
117
|
+
* while the span is still active — before the first `await` — and spread it:
|
|
118
|
+
*
|
|
119
|
+
* ```ts
|
|
120
|
+
* await span('checkout.click', async () => {
|
|
121
|
+
* const trace = traceProperties();
|
|
122
|
+
* await fetch('/checkout', { method: 'POST' });
|
|
123
|
+
* posthog.capture('checkout_failed', { ...trace });
|
|
124
|
+
* });
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* Returns `{}` when nothing is being traced, so the spread is always safe.
|
|
128
|
+
* Properties the caller sets are never overwritten, so this always wins.
|
|
129
|
+
*/
|
|
130
|
+
declare function traceProperties(): {
|
|
131
|
+
$trace_id?: string;
|
|
132
|
+
$span_id?: string;
|
|
133
|
+
};
|
|
101
134
|
/**
|
|
102
135
|
* A PostHog `before_send` hook that adds `$trace_id` and `$span_id` from the
|
|
103
136
|
* span in progress.
|
|
@@ -113,10 +146,20 @@ declare function autotelBeforeSend(options?: AutotelBeforeSendOptions): BeforeSe
|
|
|
113
146
|
* configured — the loader snippet's stub has no `set_config`, and the trace
|
|
114
147
|
* side is worth having even when the PostHog side cannot be wired.
|
|
115
148
|
*/
|
|
116
|
-
interface JoinPostHogOptions extends Omit<PostHogCompatibilityOptions, 'posthog'>, AutotelBeforeSendOptions {
|
|
149
|
+
interface JoinPostHogOptions extends Omit<PostHogCompatibilityOptions, 'posthog'>, AutotelBeforeSendOptions {
|
|
150
|
+
/**
|
|
151
|
+
* Copy PostHog's session id onto subsequent same-origin fetches as W3C
|
|
152
|
+
* `baggage`, so the server span of a traced request carries `session.id`.
|
|
153
|
+
* The backend needs `init({ baggage: '' })` for the attribute to land under
|
|
154
|
+
* that name. Distinct id stays off: it can be an email.
|
|
155
|
+
*
|
|
156
|
+
* @default true
|
|
157
|
+
*/
|
|
158
|
+
propagateSession?: boolean;
|
|
159
|
+
}
|
|
117
160
|
declare function joinPostHog(posthog: PostHogLike | (() => PostHogLike | undefined), options?: JoinPostHogOptions): SpanProcessor;
|
|
118
161
|
//#endregion
|
|
119
162
|
//#region src/session-id.d.ts
|
|
120
163
|
declare function posthogSessionId(posthog?: PostHogLike): string | undefined;
|
|
121
164
|
//#endregion
|
|
122
|
-
export { type AutotelBeforeSendOptions, type BeforeSendLike, type CaptureResultLike, type JoinPostHogOptions, type PostHogCompatibilityOptions, type PostHogLike, autotelBeforeSend, joinPostHog, posthogCompatibility, posthogSessionId };
|
|
165
|
+
export { type AutotelBeforeSendOptions, type BeforeSendLike, type CaptureResultLike, type JoinPostHogOptions, type PostHogCompatibilityOptions, type PostHogLike, autotelBeforeSend, joinPostHog, posthogCompatibility, posthogSessionId, traceProperties };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
|
+
import { setBaggage } from "autotel-web/baggage";
|
|
1
2
|
import { context, trace } from "@opentelemetry/api";
|
|
3
|
+
//#region src/dev-mode.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether to say something when the join cannot do its job.
|
|
6
|
+
*
|
|
7
|
+
* Diagnostics behind an opt-in flag are read by people who already know
|
|
8
|
+
* something is wrong, and the failure here is not knowing: every exit on this
|
|
9
|
+
* path produces a missing property and nothing else, which looks exactly like
|
|
10
|
+
* a working integration until someone queries for it weeks later.
|
|
11
|
+
*
|
|
12
|
+
* So it follows the convention React and Redux use — loud while you build,
|
|
13
|
+
* silent once you ship — rather than off until asked.
|
|
14
|
+
*/
|
|
15
|
+
function isDevelopment() {
|
|
16
|
+
try {
|
|
17
|
+
const env = typeof process !== "undefined" ? process.env?.NODE_ENV : void 0;
|
|
18
|
+
if (typeof env === "string" && env.length > 0) return env !== "production";
|
|
19
|
+
} catch {}
|
|
20
|
+
try {
|
|
21
|
+
const host = globalThis.location?.hostname;
|
|
22
|
+
return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host?.endsWith(".local") === true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
2
28
|
//#region src/posthog-like.ts
|
|
3
29
|
/**
|
|
4
30
|
* Whether this object can actually answer questions yet.
|
|
@@ -85,6 +111,24 @@ const STATUS_ERROR = 2;
|
|
|
85
111
|
function failed(span) {
|
|
86
112
|
return span.status?.code === STATUS_ERROR || span.attributes["exception.type"] !== void 0 || span.events?.some((event) => event.name === "exception") === true;
|
|
87
113
|
}
|
|
114
|
+
const EXPLANATION = {
|
|
115
|
+
"no-posthog": "no usable PostHog on the page. Pass the instance to joinPostHog(), or check posthog.init() runs before the first span.",
|
|
116
|
+
"session-rotated": "the PostHog session rotated after this span started, so a link would point at a different recording than the one the span belongs to.",
|
|
117
|
+
"not-recording": "session replay is not recording. Check it is enabled for the project, that this session was not sampled out, and that the recorder has started. Note posthog-js does not record bots or headless browsers."
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* One warning per reason, not per span. A failing join fails on every span,
|
|
121
|
+
* and a console with a thousand copies of the same line is the same as a
|
|
122
|
+
* console with none.
|
|
123
|
+
*/
|
|
124
|
+
function explainer(enabled) {
|
|
125
|
+
const said = /* @__PURE__ */ new Set();
|
|
126
|
+
return (reason) => {
|
|
127
|
+
if (!enabled || said.has(reason)) return;
|
|
128
|
+
said.add(reason);
|
|
129
|
+
console.warn(`[autotel-posthog] No session.replay.url on a failed span: ${EXPLANATION[reason]}`);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
88
132
|
function resolvePostHog(options) {
|
|
89
133
|
const configured = typeof options.posthog === "function" ? options.posthog() : options.posthog;
|
|
90
134
|
if (isUsable(configured)) return configured;
|
|
@@ -92,6 +136,7 @@ function resolvePostHog(options) {
|
|
|
92
136
|
return isUsable(global) ? global : void 0;
|
|
93
137
|
}
|
|
94
138
|
function posthogCompatibility(options = {}) {
|
|
139
|
+
const explain = explainer(options.debug ?? isDevelopment());
|
|
95
140
|
return {
|
|
96
141
|
/**
|
|
97
142
|
* Identity is read here, not in `onEnd`, because it is a fact about when
|
|
@@ -119,13 +164,23 @@ function posthogCompatibility(options = {}) {
|
|
|
119
164
|
onEnd(span) {
|
|
120
165
|
if (!failed(span)) return;
|
|
121
166
|
const posthog = resolvePostHog(options);
|
|
122
|
-
if (!posthog)
|
|
167
|
+
if (!posthog) {
|
|
168
|
+
explain("no-posthog");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
123
171
|
const attributes = span.attributes;
|
|
124
172
|
if (attributes["session.replay.url"] !== void 0) return;
|
|
125
173
|
const current = readSessionId(posthog);
|
|
126
|
-
if (current === void 0 || current !== attributes["session.id"])
|
|
174
|
+
if (current === void 0 || current !== attributes["session.id"]) {
|
|
175
|
+
explain("session-rotated");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
127
178
|
const url = readReplayUrl(posthog);
|
|
128
|
-
if (url
|
|
179
|
+
if (url === void 0) {
|
|
180
|
+
explain("not-recording");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
attributes["session.replay.url"] = url;
|
|
129
184
|
},
|
|
130
185
|
forceFlush() {
|
|
131
186
|
return Promise.resolve();
|
|
@@ -159,21 +214,60 @@ function posthogCompatibility(options = {}) {
|
|
|
159
214
|
* ```
|
|
160
215
|
*/
|
|
161
216
|
/**
|
|
217
|
+
* The ids of the span in progress, shaped to spread straight onto a capture.
|
|
218
|
+
*
|
|
219
|
+
* The hook recovers the span by itself in every case it can be sure of. This
|
|
220
|
+
* is for the case it cannot: two overlapping user actions, each its own trace,
|
|
221
|
+
* with no active context left to say which one an event belongs to. Read it
|
|
222
|
+
* while the span is still active — before the first `await` — and spread it:
|
|
223
|
+
*
|
|
224
|
+
* ```ts
|
|
225
|
+
* await span('checkout.click', async () => {
|
|
226
|
+
* const trace = traceProperties();
|
|
227
|
+
* await fetch('/checkout', { method: 'POST' });
|
|
228
|
+
* posthog.capture('checkout_failed', { ...trace });
|
|
229
|
+
* });
|
|
230
|
+
* ```
|
|
231
|
+
*
|
|
232
|
+
* Returns `{}` when nothing is being traced, so the spread is always safe.
|
|
233
|
+
* Properties the caller sets are never overwritten, so this always wins.
|
|
234
|
+
*/
|
|
235
|
+
function traceProperties() {
|
|
236
|
+
const spanContext = trace.getSpanContext(context.active());
|
|
237
|
+
if (!spanContext) return {};
|
|
238
|
+
return {
|
|
239
|
+
$trace_id: spanContext.traceId,
|
|
240
|
+
$span_id: spanContext.spanId
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** The active context wins; this only answers when there is nothing active. */
|
|
244
|
+
function readFallback(options) {
|
|
245
|
+
try {
|
|
246
|
+
return options.fallbackSpanContext?.();
|
|
247
|
+
} catch {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
162
252
|
* A PostHog `before_send` hook that adds `$trace_id` and `$span_id` from the
|
|
163
253
|
* span in progress.
|
|
164
254
|
*/
|
|
165
255
|
function autotelBeforeSend(options = {}) {
|
|
166
256
|
return (event) => {
|
|
167
257
|
if (event === null) return null;
|
|
168
|
-
const spanContext = trace.getSpanContext(context.active());
|
|
169
|
-
if (!spanContext) return event;
|
|
170
258
|
const properties = event.properties;
|
|
171
|
-
|
|
172
|
-
if (
|
|
259
|
+
const spanContext = trace.getSpanContext(context.active()) ?? readFallback(options);
|
|
260
|
+
if (spanContext) {
|
|
261
|
+
if (properties["$trace_id"] === void 0) properties["$trace_id"] = spanContext.traceId;
|
|
262
|
+
if (properties["$span_id"] === void 0) properties["$span_id"] = spanContext.spanId;
|
|
263
|
+
}
|
|
264
|
+
const traceId = properties["$trace_id"];
|
|
265
|
+
const spanId = properties["$span_id"];
|
|
266
|
+
if (typeof traceId !== "string" || typeof spanId !== "string") return event;
|
|
173
267
|
if (options.traceUrl && properties["$trace_url"] === void 0) try {
|
|
174
268
|
const url = options.traceUrl({
|
|
175
|
-
traceId
|
|
176
|
-
spanId
|
|
269
|
+
traceId,
|
|
270
|
+
spanId
|
|
177
271
|
});
|
|
178
272
|
if (url !== void 0) properties["$trace_url"] = url;
|
|
179
273
|
} catch {}
|
|
@@ -186,8 +280,21 @@ function autotelBeforeSend(options = {}) {
|
|
|
186
280
|
* Marks our hook so a second call recognises it. Framework code runs more than
|
|
187
281
|
* once — strict mode, HMR, a re-render — and a chain that grows on every render
|
|
188
282
|
* stamps the same properties again and again.
|
|
283
|
+
*
|
|
284
|
+
* The marker is not a flag but the hook's registry of live-span lists. Only one
|
|
285
|
+
* hook is ever installed, while every call returns its own processor tracking
|
|
286
|
+
* its own spans — and the app registers whichever one it was handed last. A
|
|
287
|
+
* later call therefore has to join the installed hook's registry rather than
|
|
288
|
+
* keep its spans to itself, or the join stops working after the first
|
|
289
|
+
* re-render, silently, in exactly the frameworks that re-render.
|
|
189
290
|
*/
|
|
190
291
|
const MARKER = "__autotelBeforeSend";
|
|
292
|
+
/**
|
|
293
|
+
* How many started-but-not-ended spans to keep for the fallback. Deep enough
|
|
294
|
+
* for any real nesting on a page, shallow enough that leaked spans cost
|
|
295
|
+
* nothing.
|
|
296
|
+
*/
|
|
297
|
+
const MAX_LIVE_SPANS = 128;
|
|
191
298
|
function existingHooks(posthog) {
|
|
192
299
|
const current = posthog.config?.before_send;
|
|
193
300
|
if (Array.isArray(current)) return current;
|
|
@@ -209,29 +316,167 @@ function joinPostHog(posthog, options = {}) {
|
|
|
209
316
|
const instance = resolve();
|
|
210
317
|
if (!instance) return false;
|
|
211
318
|
const hooks = existingHooks(instance);
|
|
212
|
-
|
|
319
|
+
const installed = hooks.find((hook) => hook[MARKER]);
|
|
320
|
+
if (installed) {
|
|
321
|
+
const shared = installed[MARKER];
|
|
322
|
+
if (shared && shared !== registry) {
|
|
323
|
+
const wasJoined = registry.includes(live);
|
|
324
|
+
leave();
|
|
325
|
+
registry = shared;
|
|
326
|
+
if (wasJoined) join();
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
213
330
|
if (typeof instance.set_config !== "function") return false;
|
|
214
|
-
const hook = autotelBeforeSend(
|
|
215
|
-
|
|
331
|
+
const hook = autotelBeforeSend({
|
|
332
|
+
...options,
|
|
333
|
+
fallbackSpanContext
|
|
334
|
+
});
|
|
335
|
+
hook[MARKER] = registry;
|
|
216
336
|
instance.set_config({ before_send: [...hooks, hook] });
|
|
217
337
|
return true;
|
|
218
338
|
} catch {
|
|
219
339
|
return false;
|
|
220
340
|
}
|
|
221
341
|
};
|
|
342
|
+
/**
|
|
343
|
+
* Spans that have started and not yet ended, newest last.
|
|
344
|
+
*
|
|
345
|
+
* The browser drops the active context at the first `await`, so by the time
|
|
346
|
+
* the fetch fails and the page captures the event, `context.active()` is
|
|
347
|
+
* root again. These are the spans still in flight, and the newest of them is
|
|
348
|
+
* the one the user is inside — the same answer the active context would have
|
|
349
|
+
* given, recovered from what the processor already sees.
|
|
350
|
+
*
|
|
351
|
+
* An array, not a single slot: spans do not end in the order they start, and
|
|
352
|
+
* an inner span ending must not erase the outer one that is still open.
|
|
353
|
+
* Concurrent *sibling* spans are the one case this cannot tell apart, and it
|
|
354
|
+
* picks the most recent — a wrong span in the same trace, never a wrong
|
|
355
|
+
* trace.
|
|
356
|
+
*/
|
|
357
|
+
const live = [];
|
|
358
|
+
/**
|
|
359
|
+
* The registry the installed hook reads. Starts as this call's own and is
|
|
360
|
+
* replaced by the installed hook's the moment `wire()` finds one, so every
|
|
361
|
+
* call ends up pointing at the same list.
|
|
362
|
+
*/
|
|
363
|
+
let registry = [];
|
|
364
|
+
/**
|
|
365
|
+
* Membership is earned by having a span, not by existing.
|
|
366
|
+
*
|
|
367
|
+
* Every re-render calls `joinPostHog` again and only the processor the app
|
|
368
|
+
* registers is ever handed a span — so a slot per call is a slot per render,
|
|
369
|
+
* growing what each PostHog capture has to walk and holding it for the life
|
|
370
|
+
* of the page. Joining on the first span and leaving on the last keeps the
|
|
371
|
+
* registry the size of what is actually in flight.
|
|
372
|
+
*/
|
|
373
|
+
const join = () => {
|
|
374
|
+
if (!registry.includes(live)) registry.push(live);
|
|
375
|
+
};
|
|
376
|
+
const leave = () => {
|
|
377
|
+
const at = registry.indexOf(live);
|
|
378
|
+
if (at !== -1) registry.splice(at, 1);
|
|
379
|
+
};
|
|
380
|
+
let warnedAmbiguous = false;
|
|
381
|
+
const fallbackSpanContext = () => {
|
|
382
|
+
const open = registry.flat();
|
|
383
|
+
const newest = open.at(-1);
|
|
384
|
+
if (!newest) return void 0;
|
|
385
|
+
if (open.some((candidate) => candidate.traceId !== newest.traceId)) {
|
|
386
|
+
if ((options.debug ?? isDevelopment()) && !warnedAmbiguous) {
|
|
387
|
+
warnedAmbiguous = true;
|
|
388
|
+
console.warn("[autotel-posthog] No $trace_id on a PostHog event: more than one trace was in flight and the browser had already lost the active context, so which one this event belongs to is unknowable. Fix: read traceProperties() before the first await and spread it onto the capture — const t = traceProperties(); ... posthog.capture(name, { ...t }).");
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
return newest;
|
|
393
|
+
};
|
|
394
|
+
/**
|
|
395
|
+
* Same defensiveness as every other read here: a processor that throws on a
|
|
396
|
+
* span it did not expect takes the span down with it.
|
|
397
|
+
*/
|
|
398
|
+
const contextOf = (span) => {
|
|
399
|
+
try {
|
|
400
|
+
return typeof span.spanContext === "function" ? span.spanContext() : void 0;
|
|
401
|
+
} catch {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
const remember = (span) => {
|
|
406
|
+
const spanContext = contextOf(span);
|
|
407
|
+
if (!spanContext) return;
|
|
408
|
+
join();
|
|
409
|
+
live.push(spanContext);
|
|
410
|
+
if (live.length > MAX_LIVE_SPANS) live.shift();
|
|
411
|
+
};
|
|
412
|
+
const forget = (span) => {
|
|
413
|
+
const spanId = contextOf(span)?.spanId;
|
|
414
|
+
if (!spanId) return;
|
|
415
|
+
const at = live.findIndex((candidate) => candidate.spanId === spanId);
|
|
416
|
+
if (at !== -1) live.splice(at, 1);
|
|
417
|
+
if (live.length === 0) leave();
|
|
418
|
+
};
|
|
222
419
|
let wired = wire();
|
|
223
420
|
const enricher = posthogCompatibility({
|
|
224
421
|
...options,
|
|
225
422
|
posthog
|
|
226
423
|
});
|
|
424
|
+
let lastPropagated;
|
|
425
|
+
/**
|
|
426
|
+
* Copy PostHog's session id into baggage, once per session.
|
|
427
|
+
*
|
|
428
|
+
* Called at construction as well as on every span, because the baggage
|
|
429
|
+
* header is decided before the span exists: the wrapped fetch checks for
|
|
430
|
+
* baggage and only then hands off to the instrumented fetch that opens the
|
|
431
|
+
* span. Waiting for `onStart` means the page's first request leaves without
|
|
432
|
+
* `session.id` and only later ones carry it — and with no application span
|
|
433
|
+
* around the call, that is every first request.
|
|
434
|
+
*
|
|
435
|
+
* Still called from `onStart` too: at construction PostHog may be the loader
|
|
436
|
+
* snippet's stub with nothing to read yet, and sessions rotate after 30
|
|
437
|
+
* minutes idle.
|
|
438
|
+
*/
|
|
439
|
+
const propagate = () => {
|
|
440
|
+
if (options.propagateSession === false) return;
|
|
441
|
+
try {
|
|
442
|
+
const instance = resolve();
|
|
443
|
+
if (!instance) return;
|
|
444
|
+
const sessionId = readSessionId(instance);
|
|
445
|
+
if (!sessionId || sessionId === lastPropagated) return;
|
|
446
|
+
lastPropagated = sessionId;
|
|
447
|
+
setBaggage({ "session.id": sessionId });
|
|
448
|
+
} catch {}
|
|
449
|
+
};
|
|
450
|
+
propagate();
|
|
227
451
|
return {
|
|
228
452
|
onStart(span, context) {
|
|
453
|
+
remember(span);
|
|
229
454
|
if (!wired) wired = wire();
|
|
230
455
|
enricher.onStart(span, context);
|
|
456
|
+
propagate();
|
|
457
|
+
},
|
|
458
|
+
onEnd: (span) => {
|
|
459
|
+
forget(span);
|
|
460
|
+
enricher.onEnd(span);
|
|
231
461
|
},
|
|
232
|
-
onEnd: (span) => enricher.onEnd(span),
|
|
233
462
|
forceFlush: () => enricher.forceFlush(),
|
|
234
|
-
shutdown: () =>
|
|
463
|
+
shutdown: () => {
|
|
464
|
+
live.length = 0;
|
|
465
|
+
leave();
|
|
466
|
+
return enricher.shutdown();
|
|
467
|
+
},
|
|
468
|
+
/**
|
|
469
|
+
* How many spans the fallback is holding. Bookkeeping, exposed so a test
|
|
470
|
+
* can prove the bound holds; nothing reads it at runtime.
|
|
471
|
+
* @internal
|
|
472
|
+
*/
|
|
473
|
+
liveCount: () => live.length,
|
|
474
|
+
/**
|
|
475
|
+
* How many processors currently have spans in flight. Bookkeeping, exposed
|
|
476
|
+
* so a test can prove repeated calls do not accumulate.
|
|
477
|
+
* @internal
|
|
478
|
+
*/
|
|
479
|
+
registeredCount: () => registry.length
|
|
235
480
|
};
|
|
236
481
|
}
|
|
237
482
|
//#endregion
|
|
@@ -260,4 +505,4 @@ function posthogSessionId(posthog) {
|
|
|
260
505
|
return instance ? readSessionId(instance) : void 0;
|
|
261
506
|
}
|
|
262
507
|
//#endregion
|
|
263
|
-
export { autotelBeforeSend, joinPostHog, posthogCompatibility, posthogSessionId };
|
|
508
|
+
export { autotelBeforeSend, joinPostHog, posthogCompatibility, posthogSessionId, traceProperties };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autotel-posthog",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Everything PostHog for autotel: browser session/replay join, server-side event subscriber, and trace links in both directions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -30,21 +30,25 @@
|
|
|
30
30
|
"@opentelemetry/sdk-trace-base": ">=2.0.0",
|
|
31
31
|
"posthog-js": ">=1.200.0",
|
|
32
32
|
"posthog-node": ">=4.0.0",
|
|
33
|
-
"autotel": "7.
|
|
34
|
-
"autotel-subscribers": "
|
|
33
|
+
"autotel": "7.1.0",
|
|
34
|
+
"autotel-subscribers": "51.0.0",
|
|
35
|
+
"autotel-web": "1.13.1"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@opentelemetry/context-async-hooks": "^2.10.0",
|
|
38
39
|
"@opentelemetry/sdk-trace-base": "^2.10.0",
|
|
40
|
+
"@opentelemetry/sdk-trace-web": "2.10.0",
|
|
39
41
|
"@types/node": "^26.1.2",
|
|
42
|
+
"jsdom": "29.1.1",
|
|
40
43
|
"posthog-js": "1.417.1",
|
|
41
44
|
"posthog-node": "^4.18.0",
|
|
42
45
|
"rimraf": "^6.1.3",
|
|
43
46
|
"tsdown": "^0.22.14",
|
|
44
47
|
"typescript": "^6.0.3",
|
|
45
48
|
"vitest": "^4.1.10",
|
|
46
|
-
"autotel": "7.
|
|
47
|
-
"autotel-
|
|
49
|
+
"autotel": "7.1.0",
|
|
50
|
+
"autotel-web": "1.13.1",
|
|
51
|
+
"autotel-subscribers": "51.0.0"
|
|
48
52
|
},
|
|
49
53
|
"keywords": [
|
|
50
54
|
"opentelemetry",
|