fg-tracker-browser 0.4.0 → 0.5.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/CHANGELOG.md +13 -0
- package/README.md +48 -16
- package/dist/index.d.ts +5 -3
- package/dist/index.js +374 -67
- 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;
|
|
@@ -274,6 +296,8 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
274
296
|
let lastSignature = '';
|
|
275
297
|
let lastAt = 0;
|
|
276
298
|
let timer = null;
|
|
299
|
+
/** Web Vitals (opt-in); criado depois dos listeners, lido por `baseAttributes` (`view.id`). */
|
|
300
|
+
let vitals = null;
|
|
277
301
|
const cleanups = [];
|
|
278
302
|
const crumbOn = (type) => options.breadcrumbs?.[type] !== false;
|
|
279
303
|
const log = (...args) => {
|
|
@@ -298,6 +322,9 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
298
322
|
viewport: env.window ? `${env.window.innerWidth}x${env.window.innerHeight}` : null,
|
|
299
323
|
client: 'browser',
|
|
300
324
|
};
|
|
325
|
+
const viewId = vitals?.viewId;
|
|
326
|
+
if (viewId)
|
|
327
|
+
attrs['view.id'] = viewId;
|
|
301
328
|
if (options.serviceName)
|
|
302
329
|
attrs.serviceName = options.serviceName;
|
|
303
330
|
if (options.release)
|
|
@@ -450,13 +477,19 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
450
477
|
return flushing;
|
|
451
478
|
};
|
|
452
479
|
/**
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
480
|
+
* Página oculta ou saindo: o que está na fila sai em pedaços de até 60 KiB por `sendBeacon` (ou
|
|
481
|
+
* `fetch` keepalive), erros primeiro, depois analytics, depois o resto, até o orçamento de ~64 KiB
|
|
482
|
+
* do navegador. O que não couber volta para a fila: numa troca de aba a página continua viva e o
|
|
483
|
+
* flush normal o envia; se ela estiver mesmo saindo, a memória vai junto (como antes).
|
|
484
|
+
*
|
|
485
|
+
* `unloading` (`pagehide`): o lote em voo sem keepalive também entra, porque o navegador o
|
|
486
|
+
* cancela no unload (o que dele não couber não volta: o cancelamento o devolve à fila). Numa simples
|
|
487
|
+
* ocultação esse lote continua em voo e fica de fora.
|
|
456
488
|
*/
|
|
457
|
-
const flushOnHide = () => {
|
|
458
|
-
//
|
|
459
|
-
const
|
|
489
|
+
const flushOnHide = (unloading) => {
|
|
490
|
+
// Os lotes à espera neste flush, as filas e, saindo, o lote em voo sem keepalive.
|
|
491
|
+
const fromInFlight = new Set(unloading ? (inFlight ?? []).map((item) => item.event) : []);
|
|
492
|
+
const taken = [...(unloading ? (inFlight ?? []) : []), ...outbox.splice(0).flatMap((chunk) => chunk.items)];
|
|
460
493
|
const pendingSpans = [...taken.flatMap((item) => (item.type === 'span' ? [item.event] : [])), ...queue.splice(0, queue.length)];
|
|
461
494
|
const pendingAnalytics = [...taken.flatMap((item) => (item.type === 'analytics' ? [item.event] : [])), ...analyticsQueue.splice(0, analyticsQueue.length)];
|
|
462
495
|
if (pendingSpans.length === 0 && pendingAnalytics.length === 0)
|
|
@@ -466,16 +499,22 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
466
499
|
...pendingAnalytics.map((event) => ({ type: 'analytics', event })),
|
|
467
500
|
...pendingSpans.filter((event) => !event.error).map((event) => ({ type: 'span', event })),
|
|
468
501
|
];
|
|
469
|
-
// Seleciona, na ordem de prioridade, o que cabe no orçamento que sobra
|
|
502
|
+
// Seleciona, na ordem de prioridade, o que cabe no orçamento que sobra; o resto volta para a fila.
|
|
470
503
|
let budget = KEEPALIVE_BUDGET_BYTES - keepaliveInFlight - 64;
|
|
471
504
|
const selected = [];
|
|
505
|
+
const leftover = [];
|
|
472
506
|
for (const item of ordered) {
|
|
473
507
|
const size = utf8Bytes(JSON.stringify(item.event)) + 48;
|
|
474
|
-
if (size > budget)
|
|
508
|
+
if (size > budget) {
|
|
509
|
+
if (!fromInFlight.has(item.event))
|
|
510
|
+
leftover.push(item);
|
|
475
511
|
continue;
|
|
512
|
+
}
|
|
476
513
|
selected.push(item);
|
|
477
514
|
budget -= size;
|
|
478
515
|
}
|
|
516
|
+
if (leftover.length > 0)
|
|
517
|
+
requeue(leftover);
|
|
479
518
|
const nav = env.navigator ?? env.window?.navigator;
|
|
480
519
|
for (const chunk of buildChunks(selected, KEEPALIVE_BUDGET_BYTES)) {
|
|
481
520
|
let sent = false;
|
|
@@ -488,18 +527,15 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
488
527
|
sent = false;
|
|
489
528
|
}
|
|
490
529
|
}
|
|
491
|
-
|
|
492
|
-
|
|
530
|
+
// Sem beacon, fetch keepalive; com a página viva, uma falha devolve o pedaço à fila.
|
|
531
|
+
if (!sent) {
|
|
532
|
+
void post(chunk, true).then((outcome) => {
|
|
533
|
+
if (!unloading && outcome !== 'ok' && outcome !== 'rejected')
|
|
534
|
+
requeue(chunk.items);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
493
537
|
}
|
|
494
538
|
};
|
|
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
539
|
const fetchCauses = new WeakMap();
|
|
504
540
|
const linkCause = (key, link) => {
|
|
505
541
|
fetchCauses.set(key, fetchCauses.has(key) ? 'ambiguous' : (link ?? 'none'));
|
|
@@ -563,86 +599,325 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
563
599
|
...attributes,
|
|
564
600
|
level,
|
|
565
601
|
breadcrumbs: crumbs.slice(-maxBreadcrumbs),
|
|
566
|
-
...(cause ? { remoteParentSpanId: cause.spanId, 'error.cause':
|
|
602
|
+
...(cause ? { remoteParentSpanId: cause.spanId, 'error.cause': cause.transport } : {}),
|
|
567
603
|
},
|
|
568
604
|
error: level === 'error' || level === 'warning' ? err : null,
|
|
569
605
|
});
|
|
570
606
|
addBreadcrumb({ type: 'error', level: level === 'info' ? 'info' : 'error', message: `${err.name}: ${err.message}`.slice(0, 200) });
|
|
571
607
|
void flush();
|
|
572
608
|
};
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
|
|
609
|
+
/** Decide amostragem e propagação de uma chamada HTTP (fetch ou XHR) para `url`. */
|
|
610
|
+
const startCall = (transport, method, url, canPropagate = true) => {
|
|
611
|
+
const sampled = enabled && (options.tracesSampleRate ?? 0) > 0 && Math.random() < (options.tracesSampleRate ?? 0);
|
|
612
|
+
const loc = env.location ?? env.window?.location;
|
|
613
|
+
const origin = loc?.origin;
|
|
614
|
+
const page = loc ? normalizePath(loc.pathname) : null;
|
|
615
|
+
return {
|
|
616
|
+
transport,
|
|
617
|
+
method,
|
|
618
|
+
url,
|
|
619
|
+
started: Date.now(),
|
|
620
|
+
status: null,
|
|
621
|
+
failure: null,
|
|
622
|
+
sampled,
|
|
623
|
+
propagated: canPropagate && sampled && shouldPropagate(url, options.propagateTraceTo, origin),
|
|
624
|
+
traceId: randomHex(16, env),
|
|
625
|
+
rootId: randomHex(8, env),
|
|
626
|
+
spanId: randomHex(8, env),
|
|
627
|
+
origin: sampled ? { name: `page:${page ?? '/'}`, route: page, attributes: { ...baseAttributes(), sampled: true }, viewId: vitals?.viewId ?? null } : null,
|
|
628
|
+
};
|
|
629
|
+
};
|
|
630
|
+
/**
|
|
631
|
+
* Breadcrumb e, se amostrada, trace (raiz `browser` + `http.client`) de uma chamada terminada.
|
|
632
|
+
* Cancelamento (`AbortError`, `xhr.abort()`) não é erro: `attributes.aborted`, sem `error`
|
|
633
|
+
* (não vira issue). Timeout, falha de rede e 5xx são.
|
|
634
|
+
*/
|
|
635
|
+
const finishCall = (call) => {
|
|
636
|
+
const durationMs = Date.now() - call.started;
|
|
637
|
+
const aborted = call.failure?.name === 'AbortError';
|
|
638
|
+
const { method, url, status, failure } = call;
|
|
639
|
+
const target = `${url.origin}${normalizePath(url.pathname)}`;
|
|
640
|
+
if (crumbOn(call.transport)) {
|
|
641
|
+
addBreadcrumb({
|
|
642
|
+
type: call.transport,
|
|
643
|
+
category: 'http',
|
|
644
|
+
level: aborted ? 'warning' : (status && status >= 500) || failure ? 'error' : 'info',
|
|
645
|
+
message: `${method} ${target} → ${status ?? failure?.name ?? '?'}`,
|
|
646
|
+
// XHR sem query: a URL completa pode levar tokens (o fetch passa pela limpeza do envio).
|
|
647
|
+
data: { method, url: call.transport === 'xhr' ? `${url.origin}${url.pathname}` : url.href, status, durationMs, ...(aborted ? { aborted: true } : {}) },
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
if (!call.sampled || !call.origin)
|
|
651
|
+
return;
|
|
652
|
+
const startedAt = new Date(call.started).toISOString();
|
|
653
|
+
const endedAt = new Date(call.started + durationMs).toISOString();
|
|
654
|
+
const { viewId } = call.origin;
|
|
655
|
+
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 });
|
|
656
|
+
enqueue({
|
|
657
|
+
traceId: call.traceId,
|
|
658
|
+
spanId: call.spanId,
|
|
659
|
+
parentSpanId: call.rootId,
|
|
660
|
+
kind: 'http.client',
|
|
661
|
+
name: `${method} ${target}`,
|
|
662
|
+
method,
|
|
663
|
+
route: normalizePath(url.pathname),
|
|
664
|
+
statusCode: status,
|
|
665
|
+
startedAt,
|
|
666
|
+
endedAt,
|
|
667
|
+
durationMs,
|
|
668
|
+
attributes: {
|
|
669
|
+
url: call.transport === 'xhr' ? `${url.origin}${url.pathname}` : url.href,
|
|
670
|
+
host: url.host,
|
|
671
|
+
propagated: call.propagated,
|
|
672
|
+
...(call.transport === 'xhr' ? { transport: 'xhr' } : {}),
|
|
673
|
+
...(aborted ? { aborted: true } : {}),
|
|
674
|
+
...(viewId ? { 'view.id': viewId } : {}),
|
|
675
|
+
...(options.serviceName ? { serviceName: options.serviceName } : {}),
|
|
676
|
+
...(options.release ? { release: options.release } : {}),
|
|
677
|
+
},
|
|
678
|
+
error: aborted ? null : status && status >= 500 ? { name: 'HttpError', message: `HTTP ${status}` } : failure,
|
|
679
|
+
});
|
|
680
|
+
};
|
|
681
|
+
const resolveUrl = (raw) => {
|
|
579
682
|
try {
|
|
580
|
-
|
|
683
|
+
return new URL(raw, (env.location ?? env.window?.location)?.href);
|
|
581
684
|
}
|
|
582
685
|
catch {
|
|
583
|
-
|
|
686
|
+
return null;
|
|
584
687
|
}
|
|
688
|
+
};
|
|
689
|
+
const trackedFetch = async (input, init) => {
|
|
690
|
+
if (!baseFetch)
|
|
691
|
+
throw new Error('fetch indisponível');
|
|
692
|
+
const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
693
|
+
const url = resolveUrl(raw);
|
|
585
694
|
if (!url || raw.startsWith(endpoint))
|
|
586
695
|
return baseFetch(input, init);
|
|
587
696
|
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);
|
|
697
|
+
const call = startCall('fetch', method, url);
|
|
593
698
|
let request = { input, init };
|
|
594
|
-
|
|
595
|
-
if (propagated) {
|
|
699
|
+
if (call.propagated) {
|
|
596
700
|
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
597
|
-
headers.set('traceparent', `00-${traceId}-${spanId}-01`);
|
|
701
|
+
headers.set('traceparent', `00-${call.traceId}-${call.spanId}-01`);
|
|
598
702
|
request = { input, init: { ...init, headers } };
|
|
599
703
|
}
|
|
600
|
-
const started = Date.now();
|
|
601
|
-
const startedAt = new Date(started).toISOString();
|
|
602
|
-
let status = null;
|
|
603
|
-
let failure = null;
|
|
604
704
|
try {
|
|
605
705
|
const res = await baseFetch(request.input, request.init);
|
|
606
|
-
status = res.status;
|
|
706
|
+
call.status = res.status;
|
|
607
707
|
if (res && typeof res === 'object')
|
|
608
|
-
linkCause(res, propagated ? { traceId, spanId } : null);
|
|
708
|
+
linkCause(res, call.propagated ? { traceId: call.traceId, spanId: call.spanId, transport: 'fetch' } : null);
|
|
609
709
|
return res;
|
|
610
710
|
}
|
|
611
711
|
catch (err) {
|
|
612
|
-
failure = toError(err);
|
|
712
|
+
call.failure = toError(err);
|
|
613
713
|
if (err && typeof err === 'object')
|
|
614
|
-
linkCause(err, propagated ? { traceId, spanId } : null);
|
|
714
|
+
linkCause(err, call.propagated ? { traceId: call.traceId, spanId: call.spanId, transport: 'fetch' } : null);
|
|
615
715
|
throw err;
|
|
616
716
|
}
|
|
617
717
|
finally {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
718
|
+
finishCall(call);
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
/**
|
|
722
|
+
* XHR (`XMLHttpRequest.prototype`): breadcrumb `xhr` e o mesmo trace amostrado do fetch, com
|
|
723
|
+
* `traceparent` (só quando a aplicação não mandou o dela). O vínculo causal é o próprio objeto:
|
|
724
|
+
* `captureException(err, attrs, { cause: xhr })`; um XHR reusado em outra chamada fica ambíguo.
|
|
725
|
+
*
|
|
726
|
+
* Reabrir (`open()`) fecha a chamada anterior antes de trocar o estado: no `readystatechange` de
|
|
727
|
+
* DONE (a aplicação encadeia a próxima chamada no mesmo objeto, e o navegador não dispara mais
|
|
728
|
+
* `load`/`loadend` da anterior) ela fecha com o próprio resultado; ainda pendente, o navegador a
|
|
729
|
+
* cancela, e ela fecha como cancelada (`aborted`). Nunca recebe o resultado da nova. Reaberto no
|
|
730
|
+
* `onload`/`onerror`, o `loadend` da anterior ainda chega: a nova só aceita eventos finais em DONE.
|
|
731
|
+
*
|
|
732
|
+
* A chamada fecha no `readystatechange` para DONE, que vem antes de `load`/`error`/`abort`/
|
|
733
|
+
* `timeout`/`loadend`: um erro capturado no `onload`/`onloadend` da aplicação já encontra o
|
|
734
|
+
* breadcrumb dela (como no fetch, que fecha antes de devolver a resposta). O `loadend` é a rede
|
|
735
|
+
* de segurança. Cancelamento vem do `abort()` (marcado no protótipo) ou do evento `abort`.
|
|
736
|
+
*/
|
|
737
|
+
const instrumentXhr = (Xhr) => {
|
|
738
|
+
const proto = Xhr?.prototype;
|
|
739
|
+
if (!proto || typeof proto.open !== 'function' || typeof proto.send !== 'function')
|
|
740
|
+
return null;
|
|
741
|
+
const originalOpen = proto.open;
|
|
742
|
+
const originalSend = proto.send;
|
|
743
|
+
const originalSetRequestHeader = proto.setRequestHeader;
|
|
744
|
+
const originalAbort = proto.abort;
|
|
745
|
+
const states = new WeakMap();
|
|
746
|
+
proto.open = function open(...args) {
|
|
747
|
+
try {
|
|
748
|
+
states.get(this)?.complete?.(this.readyState === 4 ? 'done' : 'pending');
|
|
749
|
+
}
|
|
750
|
+
catch (err) {
|
|
751
|
+
log('xhr: chamada anterior não registrada', err);
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
const raw = args[1] instanceof URL ? args[1].toString() : String(args[1]);
|
|
755
|
+
states.set(this, { method: String(args[0] ?? 'GET').toUpperCase(), raw, url: resolveUrl(raw), appTraceparent: false, call: null, abortRequested: false, complete: null });
|
|
626
756
|
}
|
|
757
|
+
catch {
|
|
758
|
+
states.delete(this);
|
|
759
|
+
}
|
|
760
|
+
return originalOpen.apply(this, args);
|
|
761
|
+
};
|
|
762
|
+
proto.setRequestHeader = function setRequestHeader(name, value) {
|
|
763
|
+
const state = states.get(this);
|
|
764
|
+
if (state && typeof name === 'string' && name.toLowerCase() === 'traceparent')
|
|
765
|
+
state.appTraceparent = true;
|
|
766
|
+
return originalSetRequestHeader.call(this, name, value);
|
|
767
|
+
};
|
|
768
|
+
if (typeof originalAbort === 'function') {
|
|
769
|
+
proto.abort = function abort() {
|
|
770
|
+
const state = states.get(this);
|
|
771
|
+
if (state?.call)
|
|
772
|
+
state.abortRequested = true;
|
|
773
|
+
return originalAbort.call(this);
|
|
774
|
+
};
|
|
627
775
|
}
|
|
776
|
+
proto.send = function send(body) {
|
|
777
|
+
const state = states.get(this);
|
|
778
|
+
if (!state || !state.url || state.raw.startsWith(endpoint) || state.url.href.startsWith(`${endpoint}/`))
|
|
779
|
+
return originalSend.call(this, body);
|
|
780
|
+
const xhr = this;
|
|
781
|
+
const call = startCall('xhr', state.method, state.url, !state.appTraceparent);
|
|
782
|
+
state.call = call;
|
|
783
|
+
let outcome = null;
|
|
784
|
+
let finished = false;
|
|
785
|
+
const listeners = [];
|
|
786
|
+
const finish = (thrown) => {
|
|
787
|
+
if (finished)
|
|
788
|
+
return;
|
|
789
|
+
finished = true;
|
|
790
|
+
state.complete = null;
|
|
791
|
+
for (const [type, listener] of listeners)
|
|
792
|
+
xhr.removeEventListener(type, listener);
|
|
793
|
+
// Defesa: o estado já é de outra chamada (o `open()` fecha a anterior antes de trocá-lo).
|
|
794
|
+
if (states.get(xhr) !== state || state.call !== call)
|
|
795
|
+
return;
|
|
796
|
+
try {
|
|
797
|
+
const status = typeof xhr.status === 'number' && xhr.status > 0 ? xhr.status : null;
|
|
798
|
+
call.status = status;
|
|
799
|
+
if (outcome === 'abort')
|
|
800
|
+
call.failure = { name: 'AbortError', message: 'XHR cancelado (abort)' };
|
|
801
|
+
else if (outcome === 'timeout')
|
|
802
|
+
call.failure = { name: 'TimeoutError', message: 'XHR excedeu o timeout' };
|
|
803
|
+
else if (thrown !== undefined)
|
|
804
|
+
call.failure = toError(thrown);
|
|
805
|
+
else if (outcome === 'error' || status === null)
|
|
806
|
+
call.failure = { name: 'NetworkError', message: 'XHR falhou (rede)' };
|
|
807
|
+
finishCall(call);
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
log('xhr: chamada não registrada', err);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
// Eventos finais desta chamada só chegam com o objeto em DONE. Reaberto no onload/onerror da
|
|
814
|
+
// anterior, o navegador ainda dispara o loadend dela, com esta já em OPENED: esse é ignorado.
|
|
815
|
+
const ours = () => xhr.readyState === 4;
|
|
816
|
+
for (const type of ['abort', 'timeout', 'error']) {
|
|
817
|
+
const listener = () => {
|
|
818
|
+
if (!ours())
|
|
819
|
+
return;
|
|
820
|
+
outcome ?? (outcome = type);
|
|
821
|
+
finish();
|
|
822
|
+
};
|
|
823
|
+
listeners.push([type, listener]);
|
|
824
|
+
}
|
|
825
|
+
/** Causa de um DONE sem resposta que dá para saber sem esperar o evento: cancelamento pedido ou prazo vencido. */
|
|
826
|
+
const knownFailure = () => {
|
|
827
|
+
const timeoutMs = typeof xhr.timeout === 'number' ? xhr.timeout : 0;
|
|
828
|
+
if (state.abortRequested)
|
|
829
|
+
return 'abort';
|
|
830
|
+
if (timeoutMs > 0 && Date.now() - call.started >= timeoutMs - 5)
|
|
831
|
+
return 'timeout';
|
|
832
|
+
return null;
|
|
833
|
+
};
|
|
834
|
+
// DONE com resposta (ou cancelamento pedido, ou prazo vencido) fecha na hora; falha sem causa
|
|
835
|
+
// conhecida espera o evento seguinte (error/timeout), que vem na mesma tarefa.
|
|
836
|
+
const onState = () => {
|
|
837
|
+
if (xhr.readyState !== 4)
|
|
838
|
+
return;
|
|
839
|
+
if (typeof xhr.status === 'number' && xhr.status > 0)
|
|
840
|
+
finish();
|
|
841
|
+
else {
|
|
842
|
+
const known = knownFailure();
|
|
843
|
+
if (known) {
|
|
844
|
+
outcome ?? (outcome = known);
|
|
845
|
+
finish();
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
// Reaberto: no DONE, o resultado é este (sem resposta e sem causa conhecida, falha de rede);
|
|
850
|
+
// pendente, o navegador cancela a chamada.
|
|
851
|
+
state.complete = (when) => {
|
|
852
|
+
if (when === 'pending')
|
|
853
|
+
outcome ?? (outcome = 'abort');
|
|
854
|
+
else if (!(typeof xhr.status === 'number' && xhr.status > 0))
|
|
855
|
+
outcome ?? (outcome = knownFailure());
|
|
856
|
+
finish();
|
|
857
|
+
};
|
|
858
|
+
listeners.push(['readystatechange', onState], ['loadend', () => ours() && finish()]);
|
|
859
|
+
for (const [type, listener] of listeners)
|
|
860
|
+
xhr.addEventListener(type, listener);
|
|
861
|
+
try {
|
|
862
|
+
if (call.propagated)
|
|
863
|
+
originalSetRequestHeader.call(xhr, 'traceparent', `00-${call.traceId}-${call.spanId}-01`);
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
call.propagated = false;
|
|
867
|
+
}
|
|
868
|
+
// Antes de qualquer callback da aplicação (onload roda antes do loadend).
|
|
869
|
+
linkCause(xhr, call.propagated ? { traceId: call.traceId, spanId: call.spanId, transport: 'xhr' } : null);
|
|
870
|
+
try {
|
|
871
|
+
return originalSend.call(xhr, body);
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
finish(err);
|
|
875
|
+
throw err;
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
return () => {
|
|
879
|
+
proto.open = originalOpen;
|
|
880
|
+
proto.send = originalSend;
|
|
881
|
+
proto.setRequestHeader = originalSetRequestHeader;
|
|
882
|
+
if (typeof originalAbort === 'function')
|
|
883
|
+
proto.abort = originalAbort;
|
|
884
|
+
};
|
|
628
885
|
};
|
|
629
886
|
/* ------------------------------------------------------------ analytics */
|
|
887
|
+
const pushAnalytics = (event) => {
|
|
888
|
+
analyticsQueue.push(event);
|
|
889
|
+
if (analyticsQueue.length > MAX_QUEUE)
|
|
890
|
+
analyticsQueue.splice(0, analyticsQueue.length - MAX_QUEUE);
|
|
891
|
+
};
|
|
630
892
|
const analyticsRequested = options.analytics === true || options.trackPageviews === true;
|
|
631
893
|
const analytics = enabled && analyticsRequested && !doNotTrackEnabled(env)
|
|
632
894
|
? createAnalytics(env, {
|
|
633
895
|
consent: options.consent ?? 'granted',
|
|
634
896
|
cookieDomain: options.cookieDomain,
|
|
635
897
|
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
|
-
},
|
|
898
|
+
onEvent: (event) => pushAnalytics(event),
|
|
641
899
|
})
|
|
642
900
|
: null;
|
|
643
901
|
if (enabled && analyticsRequested && !analytics)
|
|
644
902
|
log('Do Not Track ligado: analytics desativada');
|
|
645
903
|
const autoPageviews = !!analytics && (options.trackPageviews ?? options.analytics) !== false;
|
|
904
|
+
/* ------------------------------------------------------------ web vitals */
|
|
905
|
+
// Opt-in e independente da analytics: sem cookie nem id de visitante (§24.1).
|
|
906
|
+
const vitalsConfig = options.webVitals === true ? {} : options.webVitals && typeof options.webVitals === 'object' ? options.webVitals : null;
|
|
907
|
+
if (enabled && env.window && vitalsConfig) {
|
|
908
|
+
vitals = createWebVitals(env, {
|
|
909
|
+
sampleRate: typeof vitalsConfig.sampleRate === 'number' && Number.isFinite(vitalsConfig.sampleRate) ? vitalsConfig.sampleRate : 1,
|
|
910
|
+
softNavigations: vitalsConfig.softNavigations !== false,
|
|
911
|
+
sanitizePath: options.sanitizePath ?? normalizePath,
|
|
912
|
+
serviceName: options.serviceName,
|
|
913
|
+
environment: options.environment,
|
|
914
|
+
release: options.release,
|
|
915
|
+
onEvent: (event) => pushAnalytics(event),
|
|
916
|
+
log,
|
|
917
|
+
});
|
|
918
|
+
if (!vitals)
|
|
919
|
+
log('web vitals: fora da amostra ou navegador sem PerformanceObserver');
|
|
920
|
+
}
|
|
646
921
|
/* ------------------------------------------------------------ listeners */
|
|
647
922
|
const win = env.window;
|
|
648
923
|
if (win && enabled) {
|
|
@@ -663,15 +938,17 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
663
938
|
doc.addEventListener('click', onClick, true);
|
|
664
939
|
cleanups.push(() => doc.removeEventListener('click', onClick, true));
|
|
665
940
|
}
|
|
666
|
-
if (crumbOn('navigation') || autoPageviews) {
|
|
941
|
+
if (crumbOn('navigation') || autoPageviews || vitals) {
|
|
667
942
|
const hist = env.history ?? win.history;
|
|
668
943
|
const loc = env.location ?? win.location;
|
|
669
944
|
const record = (from, to) => {
|
|
670
945
|
if (crumbOn('navigation'))
|
|
671
946
|
addBreadcrumb({ type: 'navigation', category: 'navigation', message: `${from} → ${to}`, data: { from, to } });
|
|
672
947
|
};
|
|
673
|
-
//
|
|
948
|
+
// Depois da troca de URL (pushState/replaceState/popstate): view nova dos Web Vitals se o
|
|
949
|
+
// caminho mudou, e pageview (o dedupe ignora a mesma URL).
|
|
674
950
|
const pageview = () => {
|
|
951
|
+
vitals?.onLocationChange();
|
|
675
952
|
if (autoPageviews)
|
|
676
953
|
analytics?.page();
|
|
677
954
|
};
|
|
@@ -720,9 +997,35 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
720
997
|
win.fetch = original;
|
|
721
998
|
});
|
|
722
999
|
}
|
|
723
|
-
|
|
1000
|
+
if (crumbOn('xhr') && typeof win.XMLHttpRequest === 'function') {
|
|
1001
|
+
const restore = instrumentXhr(win.XMLHttpRequest);
|
|
1002
|
+
if (restore)
|
|
1003
|
+
cleanups.push(restore);
|
|
1004
|
+
}
|
|
1005
|
+
// Página oculta (troca de aba, app em segundo plano no celular, saída): os Web Vitals fecham o
|
|
1006
|
+
// retrato e a fila sai por beacon. No celular o `pagehide` pode nunca chegar depois disso.
|
|
1007
|
+
if (doc) {
|
|
1008
|
+
const onVisibility = (event) => {
|
|
1009
|
+
if (doc.visibilityState !== 'hidden')
|
|
1010
|
+
return;
|
|
1011
|
+
vitals?.onHidden(event?.timeStamp);
|
|
1012
|
+
flushOnHide(false);
|
|
1013
|
+
};
|
|
1014
|
+
doc.addEventListener('visibilitychange', onVisibility);
|
|
1015
|
+
cleanups.push(() => doc.removeEventListener('visibilitychange', onVisibility));
|
|
1016
|
+
}
|
|
1017
|
+
const onHide = () => {
|
|
1018
|
+
vitals?.onPageHide();
|
|
1019
|
+
flushOnHide(true);
|
|
1020
|
+
};
|
|
724
1021
|
win.addEventListener('pagehide', onHide);
|
|
725
1022
|
cleanups.push(() => win.removeEventListener('pagehide', onHide));
|
|
1023
|
+
const onShow = (event) => {
|
|
1024
|
+
if (event?.persisted)
|
|
1025
|
+
vitals?.onRestore(event.timeStamp);
|
|
1026
|
+
};
|
|
1027
|
+
win.addEventListener('pageshow', onShow);
|
|
1028
|
+
cleanups.push(() => win.removeEventListener('pageshow', onShow));
|
|
726
1029
|
// Pageview da carga inicial.
|
|
727
1030
|
if (autoPageviews)
|
|
728
1031
|
analytics?.page();
|
|
@@ -758,6 +1061,7 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
758
1061
|
analytics?.setConsent(consent);
|
|
759
1062
|
},
|
|
760
1063
|
close: () => {
|
|
1064
|
+
vitals?.close();
|
|
761
1065
|
for (const cleanup of cleanups.splice(0))
|
|
762
1066
|
cleanup();
|
|
763
1067
|
if (timer)
|
|
@@ -767,6 +1071,9 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
767
1071
|
get breadcrumbs() {
|
|
768
1072
|
return crumbs;
|
|
769
1073
|
},
|
|
1074
|
+
get viewId() {
|
|
1075
|
+
return vitals?.viewId ?? null;
|
|
1076
|
+
},
|
|
770
1077
|
};
|
|
771
1078
|
}
|
|
772
1079
|
let current = null;
|