fg-tracker-browser 0.4.0 → 0.5.1
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/CHANGELOG.md +19 -0
- package/README.md +54 -16
- package/dist/index.d.ts +10 -3
- package/dist/index.js +422 -76
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +67 -6
- package/dist/vitals.d.ts +57 -0
- package/dist/vitals.js +537 -0
- package/dist/vitals.js.map +1 -0
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createAnalytics, doNotTrackEnabled } from './analytics.js';
|
|
2
|
+
import { createWebVitals } from './vitals.js';
|
|
2
3
|
/** Header da chave do projeto enviado pelo SDK (a API aceita também o legado `x-stread-track-key`). */
|
|
3
4
|
export const API_KEY_HEADER = 'x-fg-tracker-key';
|
|
4
5
|
export { doNotTrackEnabled, pageUrl, referrerOrigin, screenBucket, utmParams, SESSION_COOKIE, VISITOR_COOKIE } from './analytics.js';
|
|
6
|
+
export { MAX_REPORTS_PER_VIEW, MAX_VIEWS_PER_DOCUMENT, SOFT_VIEW_GRACE_MS, WEB_VITALS_THRESHOLDS } from './vitals.js';
|
|
5
7
|
/**
|
|
6
8
|
* fg-tracker-browser (CONTRATO.md §24): captura erros não tratados, guarda os
|
|
7
9
|
* últimos breadcrumbs (cliques, navegação, console, fetch) e envia cada erro como
|
|
@@ -95,10 +97,23 @@ export function toJsonSafe(value, seen = new WeakSet()) {
|
|
|
95
97
|
seen.delete(current);
|
|
96
98
|
}
|
|
97
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Atributos que identificam o evento e o ligam ao trace e ao serviço: o corte do `fitEvent` os
|
|
102
|
+
* guarda antes de qualquer outro (sem eles, o erro vira raiz solta no trace da API e some dos
|
|
103
|
+
* filtros de ambiente/serviço).
|
|
104
|
+
*/
|
|
105
|
+
const KEY_ATTRIBUTES = ['remoteParentSpanId', 'error.cause', 'serviceName', 'environment', 'release', 'level', 'client', 'mechanism', 'page', 'user.id', 'view.id', 'sampled', 'propagated', 'transport', 'aborted', 'host'];
|
|
106
|
+
/** Valor curto o bastante para entrar no corte: texto cortado em `maxChars`, outro valor grande vira `[truncado]`. */
|
|
107
|
+
function shortValue(value, maxChars) {
|
|
108
|
+
if (typeof value === 'string')
|
|
109
|
+
return truncateText(value, maxChars);
|
|
110
|
+
return utf8Bytes(JSON.stringify(value) ?? 'null') <= 512 ? value : '[truncado]';
|
|
111
|
+
}
|
|
98
112
|
/**
|
|
99
113
|
* Cabe um evento em `MAX_EVENT_BYTES` (bytes UTF-8 do JSON): primeiro só os 5 últimos
|
|
100
|
-
* breadcrumbs, depois stack e mensagem cortados, depois
|
|
101
|
-
* acumulado, e por fim só
|
|
114
|
+
* breadcrumbs, depois stack e mensagem cortados, depois as chaves de identidade e vínculo
|
|
115
|
+
* (`KEY_ATTRIBUTES`) e os atributos curtos dentro de um orçamento acumulado, e por fim só as
|
|
116
|
+
* chaves de identidade. Marca `attributes['fg.truncated']`. Nunca lança.
|
|
102
117
|
*/
|
|
103
118
|
export function fitEvent(event, maxBytes = MAX_EVENT_BYTES) {
|
|
104
119
|
const safe = toJsonSafe(event);
|
|
@@ -114,12 +129,16 @@ export function fitEvent(event, maxBytes = MAX_EVENT_BYTES) {
|
|
|
114
129
|
let out = { ...safe, attributes, error };
|
|
115
130
|
if (size(out) <= maxBytes)
|
|
116
131
|
return out;
|
|
117
|
-
//
|
|
132
|
+
// Chaves de identidade e vínculo primeiro; depois atributos curtos, na ordem, enquanto couberem.
|
|
118
133
|
const marker = { 'fg.truncated': true };
|
|
119
|
-
|
|
120
|
-
const
|
|
134
|
+
const keys = { ...marker };
|
|
135
|
+
for (const key of KEY_ATTRIBUTES)
|
|
136
|
+
if (key in attributes && attributes[key] !== undefined)
|
|
137
|
+
keys[key] = shortValue(attributes[key], 256);
|
|
138
|
+
let budget = maxBytes - size({ ...out, attributes: keys });
|
|
139
|
+
const small = { ...keys };
|
|
121
140
|
for (const [key, value] of Object.entries(attributes)) {
|
|
122
|
-
if (key === 'breadcrumbs' || key === 'fg.truncated')
|
|
141
|
+
if (key === 'breadcrumbs' || key === 'fg.truncated' || key in keys)
|
|
123
142
|
continue;
|
|
124
143
|
const json = JSON.stringify(value) ?? 'null';
|
|
125
144
|
const candidate = utf8Bytes(json) <= 512 ? value : typeof value === 'string' ? truncateText(value, 128) : '[truncado]';
|
|
@@ -132,12 +151,15 @@ export function fitEvent(event, maxBytes = MAX_EVENT_BYTES) {
|
|
|
132
151
|
out = { ...out, attributes: small };
|
|
133
152
|
if (size(out) <= maxBytes)
|
|
134
153
|
return out;
|
|
135
|
-
// Ainda grande (nome, rota ou erro enormes): só o essencial.
|
|
154
|
+
// Ainda grande (nome, rota ou erro enormes): só o essencial, com as chaves de identidade curtas.
|
|
155
|
+
const essential = { ...marker };
|
|
156
|
+
for (const [key, value] of Object.entries(keys))
|
|
157
|
+
essential[key] = shortValue(value, 128);
|
|
136
158
|
out = {
|
|
137
159
|
...out,
|
|
138
160
|
name: truncateText(String(out.name), 256),
|
|
139
161
|
route: out.route === null ? null : truncateText(String(out.route), 256),
|
|
140
|
-
attributes:
|
|
162
|
+
attributes: essential,
|
|
141
163
|
error: out.error ? { name: truncateText(out.error.name, 100), message: truncateText(out.error.message, 500) } : null,
|
|
142
164
|
};
|
|
143
165
|
return out;
|
|
@@ -208,18 +230,57 @@ export function normalizePath(input) {
|
|
|
208
230
|
.join('/') || '/';
|
|
209
231
|
return out.length > 1 && out.endsWith('/') ? out.slice(0, -1) : out;
|
|
210
232
|
}
|
|
233
|
+
/** Texto de um valor qualquer, sem lançar: primitivos por `String`, objetos pelo JSON seguro (ciclo, BigInt). */
|
|
234
|
+
function describeValue(input) {
|
|
235
|
+
if (typeof input === 'string')
|
|
236
|
+
return input;
|
|
237
|
+
if (input === null || (typeof input !== 'object' && typeof input !== 'function')) {
|
|
238
|
+
try {
|
|
239
|
+
return String(input);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return typeof input;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const json = JSON.stringify(toJsonSafe(input));
|
|
247
|
+
if (json !== undefined)
|
|
248
|
+
return json;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* cai no tipo */
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
return Object.prototype.toString.call(input);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return '[object]';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Nome, mensagem e stack de qualquer valor lançado ou capturado. Nunca lança (BigInt, objeto
|
|
262
|
+
* cíclico, getter ou `toString` que lança, Proxy): roda no `catch` do fetch instrumentado, que
|
|
263
|
+
* relança o motivo original para a aplicação, e no `captureException`.
|
|
264
|
+
*/
|
|
211
265
|
export function toError(input) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
266
|
+
try {
|
|
267
|
+
if (input instanceof Error) {
|
|
268
|
+
const out = { name: describeValue(input.name) || 'Error', message: describeValue(input.message) };
|
|
269
|
+
const stack = input.stack;
|
|
270
|
+
if (typeof stack === 'string' && stack)
|
|
271
|
+
out.stack = stack.split('\n').slice(0, MAX_STACK_LINES).join('\n');
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
if (input && typeof input === 'object' && 'message' in input) {
|
|
275
|
+
const obj = input;
|
|
276
|
+
const stack = obj.stack;
|
|
277
|
+
return { name: describeValue(obj.name ?? 'Error'), message: describeValue(obj.message ?? ''), ...(typeof stack === 'string' ? { stack } : {}) };
|
|
278
|
+
}
|
|
217
279
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return { name: String(obj.name ?? 'Error'), message: String(obj.message ?? ''), ...(typeof obj.stack === 'string' ? { stack: obj.stack } : {}) };
|
|
280
|
+
catch {
|
|
281
|
+
/* getter que lança: vira a descrição do valor */
|
|
221
282
|
}
|
|
222
|
-
return { name: 'Error', message:
|
|
283
|
+
return { name: 'Error', message: describeValue(input) };
|
|
223
284
|
}
|
|
224
285
|
/** `RegExp.test` sem depender de `lastIndex` (flag `g`/`y` alternaria o resultado entre chamadas). */
|
|
225
286
|
function testPattern(pattern, value) {
|
|
@@ -274,6 +335,8 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
274
335
|
let lastSignature = '';
|
|
275
336
|
let lastAt = 0;
|
|
276
337
|
let timer = null;
|
|
338
|
+
/** Web Vitals (opt-in); criado depois dos listeners, lido por `baseAttributes` (`view.id`). */
|
|
339
|
+
let vitals = null;
|
|
277
340
|
const cleanups = [];
|
|
278
341
|
const crumbOn = (type) => options.breadcrumbs?.[type] !== false;
|
|
279
342
|
const log = (...args) => {
|
|
@@ -298,6 +361,9 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
298
361
|
viewport: env.window ? `${env.window.innerWidth}x${env.window.innerHeight}` : null,
|
|
299
362
|
client: 'browser',
|
|
300
363
|
};
|
|
364
|
+
const viewId = vitals?.viewId;
|
|
365
|
+
if (viewId)
|
|
366
|
+
attrs['view.id'] = viewId;
|
|
301
367
|
if (options.serviceName)
|
|
302
368
|
attrs.serviceName = options.serviceName;
|
|
303
369
|
if (options.release)
|
|
@@ -450,13 +516,19 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
450
516
|
return flushing;
|
|
451
517
|
};
|
|
452
518
|
/**
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
519
|
+
* Página oculta ou saindo: o que está na fila sai em pedaços de até 60 KiB por `sendBeacon` (ou
|
|
520
|
+
* `fetch` keepalive), erros primeiro, depois analytics, depois o resto, até o orçamento de ~64 KiB
|
|
521
|
+
* do navegador. O que não couber volta para a fila: numa troca de aba a página continua viva e o
|
|
522
|
+
* flush normal o envia; se ela estiver mesmo saindo, a memória vai junto (como antes).
|
|
523
|
+
*
|
|
524
|
+
* `unloading` (`pagehide`): o lote em voo sem keepalive também entra, porque o navegador o
|
|
525
|
+
* cancela no unload (o que dele não couber não volta: o cancelamento o devolve à fila). Numa simples
|
|
526
|
+
* ocultação esse lote continua em voo e fica de fora.
|
|
456
527
|
*/
|
|
457
|
-
const flushOnHide = () => {
|
|
458
|
-
//
|
|
459
|
-
const
|
|
528
|
+
const flushOnHide = (unloading) => {
|
|
529
|
+
// Os lotes à espera neste flush, as filas e, saindo, o lote em voo sem keepalive.
|
|
530
|
+
const fromInFlight = new Set(unloading ? (inFlight ?? []).map((item) => item.event) : []);
|
|
531
|
+
const taken = [...(unloading ? (inFlight ?? []) : []), ...outbox.splice(0).flatMap((chunk) => chunk.items)];
|
|
460
532
|
const pendingSpans = [...taken.flatMap((item) => (item.type === 'span' ? [item.event] : [])), ...queue.splice(0, queue.length)];
|
|
461
533
|
const pendingAnalytics = [...taken.flatMap((item) => (item.type === 'analytics' ? [item.event] : [])), ...analyticsQueue.splice(0, analyticsQueue.length)];
|
|
462
534
|
if (pendingSpans.length === 0 && pendingAnalytics.length === 0)
|
|
@@ -466,16 +538,22 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
466
538
|
...pendingAnalytics.map((event) => ({ type: 'analytics', event })),
|
|
467
539
|
...pendingSpans.filter((event) => !event.error).map((event) => ({ type: 'span', event })),
|
|
468
540
|
];
|
|
469
|
-
// Seleciona, na ordem de prioridade, o que cabe no orçamento que sobra
|
|
541
|
+
// Seleciona, na ordem de prioridade, o que cabe no orçamento que sobra; o resto volta para a fila.
|
|
470
542
|
let budget = KEEPALIVE_BUDGET_BYTES - keepaliveInFlight - 64;
|
|
471
543
|
const selected = [];
|
|
544
|
+
const leftover = [];
|
|
472
545
|
for (const item of ordered) {
|
|
473
546
|
const size = utf8Bytes(JSON.stringify(item.event)) + 48;
|
|
474
|
-
if (size > budget)
|
|
547
|
+
if (size > budget) {
|
|
548
|
+
if (!fromInFlight.has(item.event))
|
|
549
|
+
leftover.push(item);
|
|
475
550
|
continue;
|
|
551
|
+
}
|
|
476
552
|
selected.push(item);
|
|
477
553
|
budget -= size;
|
|
478
554
|
}
|
|
555
|
+
if (leftover.length > 0)
|
|
556
|
+
requeue(leftover);
|
|
479
557
|
const nav = env.navigator ?? env.window?.navigator;
|
|
480
558
|
for (const chunk of buildChunks(selected, KEEPALIVE_BUDGET_BYTES)) {
|
|
481
559
|
let sent = false;
|
|
@@ -488,18 +566,15 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
488
566
|
sent = false;
|
|
489
567
|
}
|
|
490
568
|
}
|
|
491
|
-
|
|
492
|
-
|
|
569
|
+
// Sem beacon, fetch keepalive; com a página viva, uma falha devolve o pedaço à fila.
|
|
570
|
+
if (!sent) {
|
|
571
|
+
void post(chunk, true).then((outcome) => {
|
|
572
|
+
if (!unloading && outcome !== 'ok' && outcome !== 'rejected')
|
|
573
|
+
requeue(chunk.items);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
493
576
|
}
|
|
494
577
|
};
|
|
495
|
-
/**
|
|
496
|
-
* Fetches propagados (`traceparent` enviado) por objeto: a `Response` que o fetch devolveu e o
|
|
497
|
-
* erro que ele lançou apontam para o span `http.client` dele. É o único vínculo de um erro com um
|
|
498
|
-
* trace: nunca "o último trace ativo" (dois fetches em paralelo dariam o trace errado).
|
|
499
|
-
*/
|
|
500
|
-
// Todo objeto que sai de um fetch instrumentado: o vínculo do fetch propagado, `'none'` para um
|
|
501
|
-
// fetch sem propagação, e `'ambiguous'` quando o mesmo objeto saiu de mais de um fetch (motivo de
|
|
502
|
-
// abort de um AbortController compartilhado, propagado ou não): aí o vínculo não vale.
|
|
503
578
|
const fetchCauses = new WeakMap();
|
|
504
579
|
const linkCause = (key, link) => {
|
|
505
580
|
fetchCauses.set(key, fetchCauses.has(key) ? 'ambiguous' : (link ?? 'none'));
|
|
@@ -563,86 +638,325 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
563
638
|
...attributes,
|
|
564
639
|
level,
|
|
565
640
|
breadcrumbs: crumbs.slice(-maxBreadcrumbs),
|
|
566
|
-
...(cause ? { remoteParentSpanId: cause.spanId, 'error.cause':
|
|
641
|
+
...(cause ? { remoteParentSpanId: cause.spanId, 'error.cause': cause.transport } : {}),
|
|
567
642
|
},
|
|
568
643
|
error: level === 'error' || level === 'warning' ? err : null,
|
|
569
644
|
});
|
|
570
645
|
addBreadcrumb({ type: 'error', level: level === 'info' ? 'info' : 'error', message: `${err.name}: ${err.message}`.slice(0, 200) });
|
|
571
646
|
void flush();
|
|
572
647
|
};
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
|
|
648
|
+
/** Decide amostragem e propagação de uma chamada HTTP (fetch ou XHR) para `url`. */
|
|
649
|
+
const startCall = (transport, method, url, canPropagate = true) => {
|
|
650
|
+
const sampled = enabled && (options.tracesSampleRate ?? 0) > 0 && Math.random() < (options.tracesSampleRate ?? 0);
|
|
651
|
+
const loc = env.location ?? env.window?.location;
|
|
652
|
+
const origin = loc?.origin;
|
|
653
|
+
const page = loc ? normalizePath(loc.pathname) : null;
|
|
654
|
+
return {
|
|
655
|
+
transport,
|
|
656
|
+
method,
|
|
657
|
+
url,
|
|
658
|
+
started: Date.now(),
|
|
659
|
+
status: null,
|
|
660
|
+
failure: null,
|
|
661
|
+
sampled,
|
|
662
|
+
propagated: canPropagate && sampled && shouldPropagate(url, options.propagateTraceTo, origin),
|
|
663
|
+
traceId: randomHex(16, env),
|
|
664
|
+
rootId: randomHex(8, env),
|
|
665
|
+
spanId: randomHex(8, env),
|
|
666
|
+
origin: sampled ? { name: `page:${page ?? '/'}`, route: page, attributes: { ...baseAttributes(), sampled: true }, viewId: vitals?.viewId ?? null } : null,
|
|
667
|
+
};
|
|
668
|
+
};
|
|
669
|
+
/**
|
|
670
|
+
* Breadcrumb e, se amostrada, trace (raiz `browser` + `http.client`) de uma chamada terminada.
|
|
671
|
+
* Cancelamento (`AbortError`, `xhr.abort()`) não é erro: `attributes.aborted`, sem `error`
|
|
672
|
+
* (não vira issue). Timeout, falha de rede e 5xx são.
|
|
673
|
+
*/
|
|
674
|
+
const finishCall = (call) => {
|
|
675
|
+
const durationMs = Date.now() - call.started;
|
|
676
|
+
const aborted = call.failure?.name === 'AbortError';
|
|
677
|
+
const { method, url, status, failure } = call;
|
|
678
|
+
const target = `${url.origin}${normalizePath(url.pathname)}`;
|
|
679
|
+
if (crumbOn(call.transport)) {
|
|
680
|
+
addBreadcrumb({
|
|
681
|
+
type: call.transport,
|
|
682
|
+
category: 'http',
|
|
683
|
+
level: aborted ? 'warning' : (status && status >= 500) || failure ? 'error' : 'info',
|
|
684
|
+
message: `${method} ${target} → ${status ?? failure?.name ?? '?'}`,
|
|
685
|
+
// XHR sem query: a URL completa pode levar tokens (o fetch passa pela limpeza do envio).
|
|
686
|
+
data: { method, url: call.transport === 'xhr' ? `${url.origin}${url.pathname}` : url.href, status, durationMs, ...(aborted ? { aborted: true } : {}) },
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
if (!call.sampled || !call.origin)
|
|
690
|
+
return;
|
|
691
|
+
const startedAt = new Date(call.started).toISOString();
|
|
692
|
+
const endedAt = new Date(call.started + durationMs).toISOString();
|
|
693
|
+
const { viewId } = call.origin;
|
|
694
|
+
enqueue({ traceId: call.traceId, spanId: call.rootId, parentSpanId: null, kind: 'browser', name: call.origin.name, method: null, route: call.origin.route, statusCode: null, startedAt, endedAt, durationMs, attributes: call.origin.attributes, error: null });
|
|
695
|
+
enqueue({
|
|
696
|
+
traceId: call.traceId,
|
|
697
|
+
spanId: call.spanId,
|
|
698
|
+
parentSpanId: call.rootId,
|
|
699
|
+
kind: 'http.client',
|
|
700
|
+
name: `${method} ${target}`,
|
|
701
|
+
method,
|
|
702
|
+
route: normalizePath(url.pathname),
|
|
703
|
+
statusCode: status,
|
|
704
|
+
startedAt,
|
|
705
|
+
endedAt,
|
|
706
|
+
durationMs,
|
|
707
|
+
attributes: {
|
|
708
|
+
url: call.transport === 'xhr' ? `${url.origin}${url.pathname}` : url.href,
|
|
709
|
+
host: url.host,
|
|
710
|
+
propagated: call.propagated,
|
|
711
|
+
...(call.transport === 'xhr' ? { transport: 'xhr' } : {}),
|
|
712
|
+
...(aborted ? { aborted: true } : {}),
|
|
713
|
+
...(viewId ? { 'view.id': viewId } : {}),
|
|
714
|
+
...(options.serviceName ? { serviceName: options.serviceName } : {}),
|
|
715
|
+
...(options.release ? { release: options.release } : {}),
|
|
716
|
+
},
|
|
717
|
+
error: aborted ? null : status && status >= 500 ? { name: 'HttpError', message: `HTTP ${status}` } : failure,
|
|
718
|
+
});
|
|
719
|
+
};
|
|
720
|
+
const resolveUrl = (raw) => {
|
|
579
721
|
try {
|
|
580
|
-
|
|
722
|
+
return new URL(raw, (env.location ?? env.window?.location)?.href);
|
|
581
723
|
}
|
|
582
724
|
catch {
|
|
583
|
-
|
|
725
|
+
return null;
|
|
584
726
|
}
|
|
727
|
+
};
|
|
728
|
+
const trackedFetch = async (input, init) => {
|
|
729
|
+
if (!baseFetch)
|
|
730
|
+
throw new Error('fetch indisponível');
|
|
731
|
+
const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
732
|
+
const url = resolveUrl(raw);
|
|
585
733
|
if (!url || raw.startsWith(endpoint))
|
|
586
734
|
return baseFetch(input, init);
|
|
587
735
|
const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
588
|
-
const
|
|
589
|
-
const origin = (env.location ?? env.window?.location)?.origin;
|
|
590
|
-
const traceId = randomHex(16, env);
|
|
591
|
-
const rootId = randomHex(8, env);
|
|
592
|
-
const spanId = randomHex(8, env);
|
|
736
|
+
const call = startCall('fetch', method, url);
|
|
593
737
|
let request = { input, init };
|
|
594
|
-
|
|
595
|
-
if (propagated) {
|
|
738
|
+
if (call.propagated) {
|
|
596
739
|
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
597
|
-
headers.set('traceparent', `00-${traceId}-${spanId}-01`);
|
|
740
|
+
headers.set('traceparent', `00-${call.traceId}-${call.spanId}-01`);
|
|
598
741
|
request = { input, init: { ...init, headers } };
|
|
599
742
|
}
|
|
600
|
-
const started = Date.now();
|
|
601
|
-
const startedAt = new Date(started).toISOString();
|
|
602
|
-
let status = null;
|
|
603
|
-
let failure = null;
|
|
604
743
|
try {
|
|
605
744
|
const res = await baseFetch(request.input, request.init);
|
|
606
|
-
status = res.status;
|
|
745
|
+
call.status = res.status;
|
|
607
746
|
if (res && typeof res === 'object')
|
|
608
|
-
linkCause(res, propagated ? { traceId, spanId } : null);
|
|
747
|
+
linkCause(res, call.propagated ? { traceId: call.traceId, spanId: call.spanId, transport: 'fetch' } : null);
|
|
609
748
|
return res;
|
|
610
749
|
}
|
|
611
750
|
catch (err) {
|
|
612
|
-
failure = toError(err);
|
|
751
|
+
call.failure = toError(err);
|
|
613
752
|
if (err && typeof err === 'object')
|
|
614
|
-
linkCause(err, propagated ? { traceId, spanId } : null);
|
|
753
|
+
linkCause(err, call.propagated ? { traceId: call.traceId, spanId: call.spanId, transport: 'fetch' } : null);
|
|
615
754
|
throw err;
|
|
616
755
|
}
|
|
617
756
|
finally {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
757
|
+
finishCall(call);
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
/**
|
|
761
|
+
* XHR (`XMLHttpRequest.prototype`): breadcrumb `xhr` e o mesmo trace amostrado do fetch, com
|
|
762
|
+
* `traceparent` (só quando a aplicação não mandou o dela). O vínculo causal é o próprio objeto:
|
|
763
|
+
* `captureException(err, attrs, { cause: xhr })`; um XHR reusado em outra chamada fica ambíguo.
|
|
764
|
+
*
|
|
765
|
+
* Reabrir (`open()`) fecha a chamada anterior antes de trocar o estado: no `readystatechange` de
|
|
766
|
+
* DONE (a aplicação encadeia a próxima chamada no mesmo objeto, e o navegador não dispara mais
|
|
767
|
+
* `load`/`loadend` da anterior) ela fecha com o próprio resultado; ainda pendente, o navegador a
|
|
768
|
+
* cancela, e ela fecha como cancelada (`aborted`). Nunca recebe o resultado da nova. Reaberto no
|
|
769
|
+
* `onload`/`onerror`, o `loadend` da anterior ainda chega: a nova só aceita eventos finais em DONE.
|
|
770
|
+
*
|
|
771
|
+
* A chamada fecha no `readystatechange` para DONE, que vem antes de `load`/`error`/`abort`/
|
|
772
|
+
* `timeout`/`loadend`: um erro capturado no `onload`/`onloadend` da aplicação já encontra o
|
|
773
|
+
* breadcrumb dela (como no fetch, que fecha antes de devolver a resposta). O `loadend` é a rede
|
|
774
|
+
* de segurança. Cancelamento vem do `abort()` (marcado no protótipo) ou do evento `abort`.
|
|
775
|
+
*/
|
|
776
|
+
const instrumentXhr = (Xhr) => {
|
|
777
|
+
const proto = Xhr?.prototype;
|
|
778
|
+
if (!proto || typeof proto.open !== 'function' || typeof proto.send !== 'function')
|
|
779
|
+
return null;
|
|
780
|
+
const originalOpen = proto.open;
|
|
781
|
+
const originalSend = proto.send;
|
|
782
|
+
const originalSetRequestHeader = proto.setRequestHeader;
|
|
783
|
+
const originalAbort = proto.abort;
|
|
784
|
+
const states = new WeakMap();
|
|
785
|
+
proto.open = function open(...args) {
|
|
786
|
+
try {
|
|
787
|
+
states.get(this)?.complete?.(this.readyState === 4 ? 'done' : 'pending');
|
|
788
|
+
}
|
|
789
|
+
catch (err) {
|
|
790
|
+
log('xhr: chamada anterior não registrada', err);
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
const raw = args[1] instanceof URL ? args[1].toString() : String(args[1]);
|
|
794
|
+
states.set(this, { method: String(args[0] ?? 'GET').toUpperCase(), raw, url: resolveUrl(raw), appTraceparent: false, call: null, abortRequested: false, complete: null });
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
states.delete(this);
|
|
626
798
|
}
|
|
799
|
+
return originalOpen.apply(this, args);
|
|
800
|
+
};
|
|
801
|
+
proto.setRequestHeader = function setRequestHeader(name, value) {
|
|
802
|
+
const state = states.get(this);
|
|
803
|
+
if (state && typeof name === 'string' && name.toLowerCase() === 'traceparent')
|
|
804
|
+
state.appTraceparent = true;
|
|
805
|
+
return originalSetRequestHeader.call(this, name, value);
|
|
806
|
+
};
|
|
807
|
+
if (typeof originalAbort === 'function') {
|
|
808
|
+
proto.abort = function abort() {
|
|
809
|
+
const state = states.get(this);
|
|
810
|
+
if (state?.call)
|
|
811
|
+
state.abortRequested = true;
|
|
812
|
+
return originalAbort.call(this);
|
|
813
|
+
};
|
|
627
814
|
}
|
|
815
|
+
proto.send = function send(body) {
|
|
816
|
+
const state = states.get(this);
|
|
817
|
+
if (!state || !state.url || state.raw.startsWith(endpoint) || state.url.href.startsWith(`${endpoint}/`))
|
|
818
|
+
return originalSend.call(this, body);
|
|
819
|
+
const xhr = this;
|
|
820
|
+
const call = startCall('xhr', state.method, state.url, !state.appTraceparent);
|
|
821
|
+
state.call = call;
|
|
822
|
+
let outcome = null;
|
|
823
|
+
let finished = false;
|
|
824
|
+
const listeners = [];
|
|
825
|
+
const finish = (thrown) => {
|
|
826
|
+
if (finished)
|
|
827
|
+
return;
|
|
828
|
+
finished = true;
|
|
829
|
+
state.complete = null;
|
|
830
|
+
for (const [type, listener] of listeners)
|
|
831
|
+
xhr.removeEventListener(type, listener);
|
|
832
|
+
// Defesa: o estado já é de outra chamada (o `open()` fecha a anterior antes de trocá-lo).
|
|
833
|
+
if (states.get(xhr) !== state || state.call !== call)
|
|
834
|
+
return;
|
|
835
|
+
try {
|
|
836
|
+
const status = typeof xhr.status === 'number' && xhr.status > 0 ? xhr.status : null;
|
|
837
|
+
call.status = status;
|
|
838
|
+
if (outcome === 'abort')
|
|
839
|
+
call.failure = { name: 'AbortError', message: 'XHR cancelado (abort)' };
|
|
840
|
+
else if (outcome === 'timeout')
|
|
841
|
+
call.failure = { name: 'TimeoutError', message: 'XHR excedeu o timeout' };
|
|
842
|
+
else if (thrown !== undefined)
|
|
843
|
+
call.failure = toError(thrown);
|
|
844
|
+
else if (outcome === 'error' || status === null)
|
|
845
|
+
call.failure = { name: 'NetworkError', message: 'XHR falhou (rede)' };
|
|
846
|
+
finishCall(call);
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
log('xhr: chamada não registrada', err);
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
// Eventos finais desta chamada só chegam com o objeto em DONE. Reaberto no onload/onerror da
|
|
853
|
+
// anterior, o navegador ainda dispara o loadend dela, com esta já em OPENED: esse é ignorado.
|
|
854
|
+
const ours = () => xhr.readyState === 4;
|
|
855
|
+
for (const type of ['abort', 'timeout', 'error']) {
|
|
856
|
+
const listener = () => {
|
|
857
|
+
if (!ours())
|
|
858
|
+
return;
|
|
859
|
+
outcome ?? (outcome = type);
|
|
860
|
+
finish();
|
|
861
|
+
};
|
|
862
|
+
listeners.push([type, listener]);
|
|
863
|
+
}
|
|
864
|
+
/** Causa de um DONE sem resposta que dá para saber sem esperar o evento: cancelamento pedido ou prazo vencido. */
|
|
865
|
+
const knownFailure = () => {
|
|
866
|
+
const timeoutMs = typeof xhr.timeout === 'number' ? xhr.timeout : 0;
|
|
867
|
+
if (state.abortRequested)
|
|
868
|
+
return 'abort';
|
|
869
|
+
if (timeoutMs > 0 && Date.now() - call.started >= timeoutMs - 5)
|
|
870
|
+
return 'timeout';
|
|
871
|
+
return null;
|
|
872
|
+
};
|
|
873
|
+
// DONE com resposta (ou cancelamento pedido, ou prazo vencido) fecha na hora; falha sem causa
|
|
874
|
+
// conhecida espera o evento seguinte (error/timeout), que vem na mesma tarefa.
|
|
875
|
+
const onState = () => {
|
|
876
|
+
if (xhr.readyState !== 4)
|
|
877
|
+
return;
|
|
878
|
+
if (typeof xhr.status === 'number' && xhr.status > 0)
|
|
879
|
+
finish();
|
|
880
|
+
else {
|
|
881
|
+
const known = knownFailure();
|
|
882
|
+
if (known) {
|
|
883
|
+
outcome ?? (outcome = known);
|
|
884
|
+
finish();
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
// Reaberto: no DONE, o resultado é este (sem resposta e sem causa conhecida, falha de rede);
|
|
889
|
+
// pendente, o navegador cancela a chamada.
|
|
890
|
+
state.complete = (when) => {
|
|
891
|
+
if (when === 'pending')
|
|
892
|
+
outcome ?? (outcome = 'abort');
|
|
893
|
+
else if (!(typeof xhr.status === 'number' && xhr.status > 0))
|
|
894
|
+
outcome ?? (outcome = knownFailure());
|
|
895
|
+
finish();
|
|
896
|
+
};
|
|
897
|
+
listeners.push(['readystatechange', onState], ['loadend', () => ours() && finish()]);
|
|
898
|
+
for (const [type, listener] of listeners)
|
|
899
|
+
xhr.addEventListener(type, listener);
|
|
900
|
+
try {
|
|
901
|
+
if (call.propagated)
|
|
902
|
+
originalSetRequestHeader.call(xhr, 'traceparent', `00-${call.traceId}-${call.spanId}-01`);
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
call.propagated = false;
|
|
906
|
+
}
|
|
907
|
+
// Antes de qualquer callback da aplicação (onload roda antes do loadend).
|
|
908
|
+
linkCause(xhr, call.propagated ? { traceId: call.traceId, spanId: call.spanId, transport: 'xhr' } : null);
|
|
909
|
+
try {
|
|
910
|
+
return originalSend.call(xhr, body);
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
finish(err);
|
|
914
|
+
throw err;
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
return () => {
|
|
918
|
+
proto.open = originalOpen;
|
|
919
|
+
proto.send = originalSend;
|
|
920
|
+
proto.setRequestHeader = originalSetRequestHeader;
|
|
921
|
+
if (typeof originalAbort === 'function')
|
|
922
|
+
proto.abort = originalAbort;
|
|
923
|
+
};
|
|
628
924
|
};
|
|
629
925
|
/* ------------------------------------------------------------ analytics */
|
|
926
|
+
const pushAnalytics = (event) => {
|
|
927
|
+
analyticsQueue.push(event);
|
|
928
|
+
if (analyticsQueue.length > MAX_QUEUE)
|
|
929
|
+
analyticsQueue.splice(0, analyticsQueue.length - MAX_QUEUE);
|
|
930
|
+
};
|
|
630
931
|
const analyticsRequested = options.analytics === true || options.trackPageviews === true;
|
|
631
932
|
const analytics = enabled && analyticsRequested && !doNotTrackEnabled(env)
|
|
632
933
|
? createAnalytics(env, {
|
|
633
934
|
consent: options.consent ?? 'granted',
|
|
634
935
|
cookieDomain: options.cookieDomain,
|
|
635
936
|
sanitizePath: options.sanitizePath ?? normalizePath,
|
|
636
|
-
onEvent: (event) =>
|
|
637
|
-
analyticsQueue.push(event);
|
|
638
|
-
if (analyticsQueue.length > MAX_QUEUE)
|
|
639
|
-
analyticsQueue.splice(0, analyticsQueue.length - MAX_QUEUE);
|
|
640
|
-
},
|
|
937
|
+
onEvent: (event) => pushAnalytics(event),
|
|
641
938
|
})
|
|
642
939
|
: null;
|
|
643
940
|
if (enabled && analyticsRequested && !analytics)
|
|
644
941
|
log('Do Not Track ligado: analytics desativada');
|
|
645
942
|
const autoPageviews = !!analytics && (options.trackPageviews ?? options.analytics) !== false;
|
|
943
|
+
/* ------------------------------------------------------------ web vitals */
|
|
944
|
+
// Opt-in e independente da analytics: sem cookie nem id de visitante (§24.1).
|
|
945
|
+
const vitalsConfig = options.webVitals === true ? {} : options.webVitals && typeof options.webVitals === 'object' ? options.webVitals : null;
|
|
946
|
+
if (enabled && env.window && vitalsConfig) {
|
|
947
|
+
vitals = createWebVitals(env, {
|
|
948
|
+
sampleRate: typeof vitalsConfig.sampleRate === 'number' && Number.isFinite(vitalsConfig.sampleRate) ? vitalsConfig.sampleRate : 1,
|
|
949
|
+
softNavigations: vitalsConfig.softNavigations !== false,
|
|
950
|
+
sanitizePath: options.sanitizePath ?? normalizePath,
|
|
951
|
+
serviceName: options.serviceName,
|
|
952
|
+
environment: options.environment,
|
|
953
|
+
release: options.release,
|
|
954
|
+
onEvent: (event) => pushAnalytics(event),
|
|
955
|
+
log,
|
|
956
|
+
});
|
|
957
|
+
if (!vitals)
|
|
958
|
+
log('web vitals: fora da amostra ou navegador sem PerformanceObserver');
|
|
959
|
+
}
|
|
646
960
|
/* ------------------------------------------------------------ listeners */
|
|
647
961
|
const win = env.window;
|
|
648
962
|
if (win && enabled) {
|
|
@@ -663,15 +977,17 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
663
977
|
doc.addEventListener('click', onClick, true);
|
|
664
978
|
cleanups.push(() => doc.removeEventListener('click', onClick, true));
|
|
665
979
|
}
|
|
666
|
-
if (crumbOn('navigation') || autoPageviews) {
|
|
980
|
+
if (crumbOn('navigation') || autoPageviews || vitals) {
|
|
667
981
|
const hist = env.history ?? win.history;
|
|
668
982
|
const loc = env.location ?? win.location;
|
|
669
983
|
const record = (from, to) => {
|
|
670
984
|
if (crumbOn('navigation'))
|
|
671
985
|
addBreadcrumb({ type: 'navigation', category: 'navigation', message: `${from} → ${to}`, data: { from, to } });
|
|
672
986
|
};
|
|
673
|
-
//
|
|
987
|
+
// Depois da troca de URL (pushState/replaceState/popstate): view nova dos Web Vitals se o
|
|
988
|
+
// caminho mudou, e pageview (o dedupe ignora a mesma URL).
|
|
674
989
|
const pageview = () => {
|
|
990
|
+
vitals?.onLocationChange();
|
|
675
991
|
if (autoPageviews)
|
|
676
992
|
analytics?.page();
|
|
677
993
|
};
|
|
@@ -720,9 +1036,35 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
720
1036
|
win.fetch = original;
|
|
721
1037
|
});
|
|
722
1038
|
}
|
|
723
|
-
|
|
1039
|
+
if (crumbOn('xhr') && typeof win.XMLHttpRequest === 'function') {
|
|
1040
|
+
const restore = instrumentXhr(win.XMLHttpRequest);
|
|
1041
|
+
if (restore)
|
|
1042
|
+
cleanups.push(restore);
|
|
1043
|
+
}
|
|
1044
|
+
// Página oculta (troca de aba, app em segundo plano no celular, saída): os Web Vitals fecham o
|
|
1045
|
+
// retrato e a fila sai por beacon. No celular o `pagehide` pode nunca chegar depois disso.
|
|
1046
|
+
if (doc) {
|
|
1047
|
+
const onVisibility = (event) => {
|
|
1048
|
+
if (doc.visibilityState !== 'hidden')
|
|
1049
|
+
return;
|
|
1050
|
+
vitals?.onHidden(event?.timeStamp);
|
|
1051
|
+
flushOnHide(false);
|
|
1052
|
+
};
|
|
1053
|
+
doc.addEventListener('visibilitychange', onVisibility);
|
|
1054
|
+
cleanups.push(() => doc.removeEventListener('visibilitychange', onVisibility));
|
|
1055
|
+
}
|
|
1056
|
+
const onHide = () => {
|
|
1057
|
+
vitals?.onPageHide();
|
|
1058
|
+
flushOnHide(true);
|
|
1059
|
+
};
|
|
724
1060
|
win.addEventListener('pagehide', onHide);
|
|
725
1061
|
cleanups.push(() => win.removeEventListener('pagehide', onHide));
|
|
1062
|
+
const onShow = (event) => {
|
|
1063
|
+
if (event?.persisted)
|
|
1064
|
+
vitals?.onRestore(event.timeStamp);
|
|
1065
|
+
};
|
|
1066
|
+
win.addEventListener('pageshow', onShow);
|
|
1067
|
+
cleanups.push(() => win.removeEventListener('pageshow', onShow));
|
|
726
1068
|
// Pageview da carga inicial.
|
|
727
1069
|
if (autoPageviews)
|
|
728
1070
|
analytics?.page();
|
|
@@ -758,6 +1100,7 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
758
1100
|
analytics?.setConsent(consent);
|
|
759
1101
|
},
|
|
760
1102
|
close: () => {
|
|
1103
|
+
vitals?.close();
|
|
761
1104
|
for (const cleanup of cleanups.splice(0))
|
|
762
1105
|
cleanup();
|
|
763
1106
|
if (timer)
|
|
@@ -767,6 +1110,9 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
767
1110
|
get breadcrumbs() {
|
|
768
1111
|
return crumbs;
|
|
769
1112
|
},
|
|
1113
|
+
get viewId() {
|
|
1114
|
+
return vitals?.viewId ?? null;
|
|
1115
|
+
},
|
|
770
1116
|
};
|
|
771
1117
|
}
|
|
772
1118
|
let current = null;
|