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 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
- Every peer is declared optional so the half you skip does not warn about
22
- packages it will never import. `posthog-js` is not bundled: two copies on one
23
- page means two session managers and two different answers to
24
- `get_session_id()`, which would break the join this package exists to make.
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) return;
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"]) return;
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 !== void 0) attributes["session.replay.url"] = 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
- if (properties["$trace_id"] === void 0) properties["$trace_id"] = spanContext.traceId;
173
- if (properties["$span_id"] === void 0) properties["$span_id"] = spanContext.spanId;
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: spanContext.traceId,
177
- spanId: spanContext.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
- if (hooks.some((hook) => hook[MARKER])) return true;
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(options);
216
- hook[MARKER] = true;
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: () => enricher.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) return;
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"]) return;
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 !== void 0) attributes["session.replay.url"] = 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
- if (properties["$trace_id"] === void 0) properties["$trace_id"] = spanContext.traceId;
172
- if (properties["$span_id"] === void 0) properties["$span_id"] = spanContext.spanId;
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: spanContext.traceId,
176
- spanId: spanContext.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
- if (hooks.some((hook) => hook[MARKER])) return true;
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(options);
215
- hook[MARKER] = true;
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: () => enricher.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": "1.0.0",
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.0.0",
34
- "autotel-subscribers": "50.0.0"
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.0.0",
47
- "autotel-subscribers": "50.0.0"
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",