@obsrviq/tracker 0.4.1 → 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/dist/index.js +178 -62
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +36 -36
- package/dist/obsrviq.global.js.map +1 -1
- package/package.json +1 -1
- package/src/network.ts +240 -63
package/package.json
CHANGED
package/src/network.ts
CHANGED
|
@@ -66,40 +66,205 @@ function bodyPreview(
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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'));
|