@obsrviq/tracker 0.4.0 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obsrviq/tracker",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/global.ts CHANGED
@@ -28,27 +28,34 @@ function bool(v: string | null | undefined, fallback: boolean): boolean {
28
28
  return v === 'true' || v === '1';
29
29
  }
30
30
 
31
+ /** Read `data-obsrviq-<name>`, falling back to the pre-rebrand `data-lumera-<name>`
32
+ * so old embeds (which still use the Lumera attribute names + filename) keep
33
+ * initialising. Mirrors the ingest accepting the legacy x-lumera-key header. */
34
+ function attr(el: HTMLScriptElement, name: string): string | null {
35
+ return el.getAttribute(`data-obsrviq-${name}`) ?? el.getAttribute(`data-lumera-${name}`);
36
+ }
37
+
31
38
  if (current) {
32
- const key = current.getAttribute('data-obsrviq-key');
39
+ const key = attr(current, 'key');
33
40
  if (key) {
34
41
  const cfg: ObsrviqInitConfig = {
35
42
  siteKey: key,
36
- endpoint: current.getAttribute('data-obsrviq-endpoint') || undefined,
37
- maskAllInputs: bool(current.getAttribute('data-obsrviq-mask-inputs'), true),
38
- captureNetworkBodies: bool(current.getAttribute('data-obsrviq-capture-bodies'), false),
39
- requireConsent: bool(current.getAttribute('data-obsrviq-require-consent'), false),
40
- askPermission: bool(current.getAttribute('data-obsrviq-ask-permission'), false),
41
- showRecording: bool(current.getAttribute('data-obsrviq-show-recording'), false),
42
- recordCanvas: bool(current.getAttribute('data-obsrviq-record-canvas'), false),
43
- autoStart: bool(current.getAttribute('data-obsrviq-auto-start'), true),
43
+ endpoint: attr(current, 'endpoint') || undefined,
44
+ maskAllInputs: bool(attr(current, 'mask-inputs'), true),
45
+ captureNetworkBodies: bool(attr(current, 'capture-bodies'), false),
46
+ requireConsent: bool(attr(current, 'require-consent'), false),
47
+ askPermission: bool(attr(current, 'ask-permission'), false),
48
+ showRecording: bool(attr(current, 'show-recording'), false),
49
+ recordCanvas: bool(attr(current, 'record-canvas'), false),
50
+ autoStart: bool(attr(current, 'auto-start'), true),
44
51
  };
45
- const rate = current.getAttribute('data-obsrviq-sample-rate');
52
+ const rate = attr(current, 'sample-rate');
46
53
  if (rate) cfg.sampleRate = Number(rate);
47
54
  // Identity is usually set later via Obsrviq.identify() (post-login), but can be
48
55
  // seeded here when known at load time.
49
- const userId = current.getAttribute('data-obsrviq-user-id');
56
+ const userId = attr(current, 'user-id');
50
57
  if (userId) cfg.userId = userId;
51
- const metaAttr = current.getAttribute('data-obsrviq-metadata');
58
+ const metaAttr = attr(current, 'metadata');
52
59
  if (metaAttr) {
53
60
  try {
54
61
  cfg.metadata = JSON.parse(metaAttr) as Record<string, unknown>;
@@ -61,5 +68,7 @@ if (current) {
61
68
  }
62
69
 
63
70
  window.Obsrviq = Obsrviq;
71
+ // Legacy global alias: pre-rebrand embeds call window.Lumera.identify()/track()/etc.
72
+ (window as unknown as { Lumera: typeof Obsrviq }).Lumera = Obsrviq;
64
73
 
65
74
  export { Obsrviq };
package/src/network.ts CHANGED
@@ -66,40 +66,205 @@ function bodyPreview(
66
66
  };
67
67
  }
68
68
 
69
- function enrichTiming(ctx: InstrumentCtx, url: string, base: NetworkTiming): NetworkTiming {
69
+ function perfNow(): number {
70
+ return typeof performance !== 'undefined' ? performance.now() : 0;
71
+ }
72
+
73
+ /** Resource Timing `entry.name` is always absolute; requests may be issued with a
74
+ * relative URL. Absolutize (query preserved) so the two can be matched. */
75
+ function absUrl(raw: string): string {
70
76
  try {
71
- const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
72
- // newest matching entry by URL
73
- const match = entries.filter((e) => e.name === url).pop();
74
- if (!match) return base;
75
- const t: NetworkTiming = { ...base };
76
- if (match.domainLookupEnd && match.domainLookupStart)
77
- t.dns = Math.max(0, match.domainLookupEnd - match.domainLookupStart);
78
- if (match.connectEnd && match.connectStart)
79
- t.tcp = Math.max(0, match.connectEnd - match.connectStart);
80
- if (match.secureConnectionStart)
81
- t.tls = Math.max(0, match.connectEnd - match.secureConnectionStart);
82
- if (match.responseStart && match.requestStart)
83
- t.ttfb = Math.max(0, match.responseStart - match.requestStart);
84
- if (match.responseEnd && match.responseStart)
85
- t.download = Math.max(0, match.responseEnd - match.responseStart);
86
- return t;
77
+ return new URL(raw, typeof location !== 'undefined' ? location.href : undefined).href;
87
78
  } catch {
88
- return base;
79
+ return raw;
80
+ }
81
+ }
82
+
83
+ /** Extract the DNS/TCP/TLS/TTFB/Download breakdown from a matched Resource Timing
84
+ * entry, layered onto the SDK-measured `base` (startT/endT/duration). Phases that
85
+ * are zero — a reused keep-alive connection, or a cross-origin resource without a
86
+ * `Timing-Allow-Origin` header (browser zeroes them for privacy) — are left
87
+ * undefined so the player shows "—" rather than a misleading 0. */
88
+ function phasesFrom(m: PerformanceResourceTiming, base: NetworkTiming): NetworkTiming {
89
+ const t: NetworkTiming = { ...base };
90
+ const r = (n: number) => Math.round(n);
91
+ // Every phase is guarded on its START marker being > 0. A cross-origin resource
92
+ // without `Timing-Allow-Origin` has its start markers (domainLookupStart,
93
+ // connectStart, secureConnectionStart, requestStart, responseStart) zeroed by the
94
+ // browser while responseEnd stays real — so a bare `end > start` check would
95
+ // compute a garbage phase (e.g. download = responseEnd − 0). Requiring start > 0
96
+ // omits those phases instead.
97
+ if (m.domainLookupStart > 0 && m.domainLookupEnd > m.domainLookupStart)
98
+ t.dns = r(m.domainLookupEnd - m.domainLookupStart);
99
+ if (m.connectStart > 0 && m.connectEnd > m.connectStart) t.tcp = r(m.connectEnd - m.connectStart);
100
+ if (m.secureConnectionStart > 0 && m.connectEnd > m.secureConnectionStart)
101
+ t.tls = r(m.connectEnd - m.secureConnectionStart);
102
+ if (m.requestStart > 0 && m.responseStart > m.requestStart)
103
+ t.ttfb = r(m.responseStart - m.requestStart);
104
+ if (m.responseStart > 0 && m.responseEnd > m.responseStart)
105
+ t.download = r(m.responseEnd - m.responseStart);
106
+ return t;
107
+ }
108
+
109
+ /** Pick the Resource Timing entry that best corresponds to a request that started
110
+ * at `perfStart` (performance-timeline ms): same URL, started at ~the same time.
111
+ * Guards against mis-attributing timing when the same URL is hit repeatedly. */
112
+ function matchEntry(
113
+ name: string,
114
+ perfStart: number,
115
+ entries: PerformanceResourceTiming[],
116
+ ): PerformanceResourceTiming | undefined {
117
+ let best: PerformanceResourceTiming | undefined;
118
+ let bestDelta = Infinity;
119
+ for (const e of entries) {
120
+ if (e.name !== name) continue;
121
+ if (e.startTime < perfStart - 4) continue; // started before our request → not ours
122
+ const d = e.startTime - perfStart;
123
+ if (d < bestDelta) {
124
+ bestDelta = d;
125
+ best = e;
126
+ }
89
127
  }
128
+ return best;
129
+ }
130
+
131
+ interface PendingTiming {
132
+ name: string; // absolute URL, to match against PerformanceResourceTiming.name
133
+ perfStart: number;
134
+ base: NetworkTiming;
135
+ resolve: (t: NetworkTiming) => void;
136
+ timer: ReturnType<typeof setTimeout>;
137
+ }
138
+
139
+ /** How long to wait for a Resource Timing entry to be published before giving up
140
+ * and emitting with duration-only timing. The browser adds the entry to the
141
+ * performance buffer *slightly after* fetch/XHR completion, so a synchronous
142
+ * lookup at completion almost always misses it — hence this short async wait. */
143
+ const TIMING_WAIT_MS = 300;
144
+
145
+ interface TimingResolver {
146
+ resolve(rawUrl: string, perfStart: number, base: NetworkTiming): Promise<NetworkTiming>;
147
+ flushPending(): void;
148
+ teardown(): void;
149
+ }
150
+
151
+ /** Resolves the phase breakdown for a fetch/XHR asynchronously. A single shared
152
+ * PerformanceObserver watches for resource entries as they land and settles the
153
+ * matching pending request; unmatched requests settle on a short timeout. */
154
+ function createTimingResolver(): TimingResolver {
155
+ const pending: PendingTiming[] = [];
156
+ let obs: PerformanceObserver | undefined;
157
+
158
+ const stopObs = () => {
159
+ if (obs) {
160
+ try {
161
+ obs.disconnect();
162
+ } catch {
163
+ /* ignore */
164
+ }
165
+ obs = undefined;
166
+ }
167
+ };
168
+
169
+ const settle = (p: PendingTiming, t: NetworkTiming) => {
170
+ const i = pending.indexOf(p);
171
+ if (i === -1) return; // already settled
172
+ pending.splice(i, 1);
173
+ clearTimeout(p.timer);
174
+ p.resolve(t);
175
+ if (pending.length === 0) stopObs();
176
+ };
177
+
178
+ const consume = (entries: PerformanceEntryList) => {
179
+ for (const e of entries as PerformanceResourceTiming[]) {
180
+ let best: PendingTiming | undefined;
181
+ let bestDelta = Infinity;
182
+ for (const p of pending) {
183
+ if (e.name !== p.name || e.startTime < p.perfStart - 4) continue;
184
+ const d = e.startTime - p.perfStart;
185
+ if (d < bestDelta) {
186
+ bestDelta = d;
187
+ best = p;
188
+ }
189
+ }
190
+ if (best) settle(best, phasesFrom(e, best.base));
191
+ }
192
+ };
193
+
194
+ const ensureObserver = () => {
195
+ if (obs || typeof PerformanceObserver === 'undefined') return;
196
+ try {
197
+ obs = new PerformanceObserver((list) => consume(list.getEntries()));
198
+ obs.observe({ type: 'resource', buffered: false });
199
+ } catch {
200
+ obs = undefined;
201
+ }
202
+ };
203
+
204
+ return {
205
+ resolve(rawUrl, perfStart, base) {
206
+ if (typeof performance === 'undefined') return Promise.resolve(base);
207
+ const name = absUrl(rawUrl);
208
+ // Fast path: the entry may already be buffered (cached / same-tick resources).
209
+ try {
210
+ const now = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
211
+ const immediate = matchEntry(name, perfStart, now);
212
+ if (immediate) return Promise.resolve(phasesFrom(immediate, base));
213
+ } catch {
214
+ /* ignore */
215
+ }
216
+ if (typeof PerformanceObserver === 'undefined') return Promise.resolve(base);
217
+ // Slow path: wait for the entry to be published (or time out).
218
+ return new Promise<NetworkTiming>((resolve) => {
219
+ const p: PendingTiming = {
220
+ name,
221
+ perfStart,
222
+ base,
223
+ resolve,
224
+ timer: setTimeout(() => settle(p, base), TIMING_WAIT_MS),
225
+ };
226
+ pending.push(p);
227
+ ensureObserver();
228
+ });
229
+ },
230
+ // Force-settle everything still in flight (with duration-only timing) so their
231
+ // events are emitted into the transport buffer before an unload beacon fires.
232
+ flushPending() {
233
+ for (const p of [...pending]) settle(p, p.base);
234
+ },
235
+ teardown() {
236
+ this.flushPending();
237
+ stopObs();
238
+ },
239
+ };
90
240
  }
91
241
 
92
242
  export function instrumentNetwork(ctx: InstrumentCtx): Teardown {
243
+ const timing = createTimingResolver();
244
+ // On page hide, settle any request whose Resource Timing hasn't arrived yet so
245
+ // its event lands in the transport buffer before the unload flush.
246
+ const onPageHide = () => timing.flushPending();
247
+ const onVisibility = () => {
248
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') timing.flushPending();
249
+ };
250
+ if (typeof window !== 'undefined') window.addEventListener('pagehide', onPageHide);
251
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
252
+
93
253
  const teardowns: Teardown[] = [];
94
- teardowns.push(wrapFetch(ctx));
95
- teardowns.push(wrapXhr(ctx));
254
+ teardowns.push(wrapFetch(ctx, timing));
255
+ teardowns.push(wrapXhr(ctx, timing));
96
256
  teardowns.push(wrapBeacon(ctx));
97
257
  teardowns.push(observeResources(ctx));
258
+ teardowns.push(() => {
259
+ if (typeof window !== 'undefined') window.removeEventListener('pagehide', onPageHide);
260
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
261
+ timing.teardown();
262
+ });
98
263
  return () => teardowns.forEach((t) => t());
99
264
  }
100
265
 
101
266
  // ───────────────────────────── fetch ────────────────────────────────
102
- function wrapFetch(ctx: InstrumentCtx): Teardown {
267
+ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
103
268
  const orig = window.fetch;
104
269
  if (!orig) return () => {};
105
270
  window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
@@ -107,6 +272,7 @@ function wrapFetch(ctx: InstrumentCtx): Teardown {
107
272
  // Never record our own telemetry round-trips to the ingest endpoint.
108
273
  if (rawUrl.includes('/v1/batch')) return orig.call(window, input as RequestInfo, init);
109
274
  const startT = ctx.clock.now();
275
+ const perfStart = perfNow();
110
276
  const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
111
277
  const url = redactUrl(rawUrl);
112
278
  const reqHeaders = pickHeaders(
@@ -116,25 +282,29 @@ function wrapFetch(ctx: InstrumentCtx): Teardown {
116
282
 
117
283
  const emit = (partial: Partial<NetworkEvent>) => {
118
284
  const endT = ctx.clock.now();
119
- const timing = enrichTiming(ctx, rawUrl, { startT, endT, duration: endT - startT });
120
- const ev: NetworkEvent = {
121
- id: uuid(),
122
- sessionId: ctx.sessionId,
123
- type: 'network',
124
- t: startT,
125
- ts: ctx.clock.wall(),
126
- initiator: 'fetch',
127
- method,
128
- url,
129
- status: 0,
130
- ok: false,
131
- resourceType: 'fetch',
132
- requestHeaders: reqHeaders,
133
- timing,
134
- ...partial,
135
- };
136
- ctx.emit(ev);
285
+ const base: NetworkTiming = { startT, endT, duration: endT - startT };
137
286
  ctx.markActivity();
287
+ // Resolve the phase breakdown asynchronously — the Resource Timing entry is
288
+ // published just after completion, so a synchronous read would miss it.
289
+ void tr.resolve(rawUrl, perfStart, base).then((timing) => {
290
+ const ev: NetworkEvent = {
291
+ id: uuid(),
292
+ sessionId: ctx.sessionId,
293
+ type: 'network',
294
+ t: startT,
295
+ ts: ctx.clock.wall(),
296
+ initiator: 'fetch',
297
+ method,
298
+ url,
299
+ status: 0,
300
+ ok: false,
301
+ resourceType: 'fetch',
302
+ requestHeaders: reqHeaders,
303
+ timing,
304
+ ...partial,
305
+ };
306
+ ctx.emit(ev);
307
+ });
138
308
  };
139
309
 
140
310
  try {
@@ -188,11 +358,12 @@ interface XhrMeta {
188
358
  url: string;
189
359
  rawUrl: string;
190
360
  startT: number;
361
+ perfStart: number;
191
362
  reqHeaders: Record<string, string>;
192
363
  body?: string;
193
364
  }
194
365
 
195
- function wrapXhr(ctx: InstrumentCtx): Teardown {
366
+ function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
196
367
  const XHR = XMLHttpRequest.prototype;
197
368
  const origOpen = XHR.open;
198
369
  const origSend = XHR.send;
@@ -206,6 +377,7 @@ function wrapXhr(ctx: InstrumentCtx): Teardown {
206
377
  rawUrl,
207
378
  url: redactUrl(rawUrl),
208
379
  startT: 0,
380
+ perfStart: 0,
209
381
  reqHeaders: {},
210
382
  });
211
383
  return origOpen.apply(this, [method, url, ...rest] as never);
@@ -226,14 +398,15 @@ function wrapXhr(ctx: InstrumentCtx): Teardown {
226
398
  const meta = META.get(this);
227
399
  if (meta) {
228
400
  meta.startT = ctx.clock.now();
401
+ meta.perfStart = perfNow();
229
402
  if (ctx.config.captureNetworkBodies) meta.body = stringifyBody(body);
230
403
  const finish = (error?: string) => {
231
404
  const endT = ctx.clock.now();
232
- const timing = enrichTiming(ctx, meta.rawUrl, {
405
+ const base: NetworkTiming = {
233
406
  startT: meta.startT,
234
407
  endT,
235
408
  duration: endT - meta.startT,
236
- });
409
+ };
237
410
  const status = this.status;
238
411
  const respHeaders = parseRawHeaders(ctx, this.getAllResponseHeaders());
239
412
  // Response body (texty + readable responseType only), parallel to fetch.
@@ -247,28 +420,32 @@ function wrapXhr(ctx: InstrumentCtx): Teardown {
247
420
  /* opaque/cross-origin response — skip the body */
248
421
  }
249
422
  }
250
- const ev: NetworkEvent = {
251
- id: uuid(),
252
- sessionId: ctx.sessionId,
253
- type: 'network',
254
- t: meta.startT,
255
- ts: ctx.clock.wall(),
256
- initiator: 'xhr',
257
- method: meta.method,
258
- url: meta.url,
259
- status,
260
- ok: status >= 200 && status < 400,
261
- resourceType: 'xhr',
262
- requestHeaders: meta.reqHeaders,
263
- responseHeaders: respHeaders,
264
- responseSize: safeLen(this),
265
- requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, undefined) : undefined,
266
- responseBody,
267
- timing,
268
- error: error || classifyHttp(status),
269
- };
270
- ctx.emit(ev);
423
+ const responseSize = safeLen(this);
271
424
  ctx.markActivity();
425
+ // Resolve the phase breakdown asynchronously (see wrapFetch).
426
+ void tr.resolve(meta.rawUrl, meta.perfStart, base).then((timing) => {
427
+ const ev: NetworkEvent = {
428
+ id: uuid(),
429
+ sessionId: ctx.sessionId,
430
+ type: 'network',
431
+ t: meta.startT,
432
+ ts: ctx.clock.wall(),
433
+ initiator: 'xhr',
434
+ method: meta.method,
435
+ url: meta.url,
436
+ status,
437
+ ok: status >= 200 && status < 400,
438
+ resourceType: 'xhr',
439
+ requestHeaders: meta.reqHeaders,
440
+ responseHeaders: respHeaders,
441
+ responseSize,
442
+ requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, undefined) : undefined,
443
+ responseBody,
444
+ timing,
445
+ error: error || classifyHttp(status),
446
+ };
447
+ ctx.emit(ev);
448
+ });
272
449
  };
273
450
  this.addEventListener('loadend', () => finish());
274
451
  this.addEventListener('error', () => finish('network'));