fg-tracker-browser 0.3.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 +18 -0
- package/README.md +53 -16
- package/dist/index.d.ts +5 -3
- package/dist/index.js +415 -59
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +73 -3
- 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,11 +527,38 @@ 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
|
-
const
|
|
539
|
+
const fetchCauses = new WeakMap();
|
|
540
|
+
const linkCause = (key, link) => {
|
|
541
|
+
fetchCauses.set(key, fetchCauses.has(key) ? 'ambiguous' : (link ?? 'none'));
|
|
542
|
+
};
|
|
543
|
+
const causeOf = (error, explicit) => {
|
|
544
|
+
const candidates = [explicit];
|
|
545
|
+
let current = error;
|
|
546
|
+
for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth += 1) {
|
|
547
|
+
candidates.push(current);
|
|
548
|
+
current = current.cause;
|
|
549
|
+
}
|
|
550
|
+
for (const candidate of candidates) {
|
|
551
|
+
if (candidate && typeof candidate === 'object') {
|
|
552
|
+
const hit = fetchCauses.get(candidate);
|
|
553
|
+
if (hit === 'ambiguous')
|
|
554
|
+
return undefined;
|
|
555
|
+
if (hit && hit !== 'none')
|
|
556
|
+
return hit;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return undefined;
|
|
560
|
+
};
|
|
561
|
+
const capture = (error, attributes = {}, level = 'error', explicitCause) => {
|
|
496
562
|
if (!enabled)
|
|
497
563
|
return;
|
|
498
564
|
const err = toError(error);
|
|
@@ -508,9 +574,17 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
508
574
|
return;
|
|
509
575
|
const loc = env.location ?? env.window?.location;
|
|
510
576
|
const startedAt = now();
|
|
577
|
+
let cause;
|
|
578
|
+
try {
|
|
579
|
+
cause = causeOf(error, explicitCause);
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
cause = undefined;
|
|
583
|
+
}
|
|
511
584
|
enqueue({
|
|
512
|
-
traceId: randomHex(16, env),
|
|
585
|
+
traceId: cause?.traceId ?? randomHex(16, env),
|
|
513
586
|
spanId: randomHex(8, env),
|
|
587
|
+
// Continua raiz (o agrupamento da issue não muda); o waterfall a pendura sob o fetch.
|
|
514
588
|
parentSpanId: null,
|
|
515
589
|
kind: 'browser',
|
|
516
590
|
name: `page:${loc ? normalizePath(loc.pathname) : '/'}`,
|
|
@@ -520,80 +594,330 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
520
594
|
startedAt,
|
|
521
595
|
endedAt: startedAt,
|
|
522
596
|
durationMs: 0,
|
|
523
|
-
attributes: {
|
|
597
|
+
attributes: {
|
|
598
|
+
...baseAttributes(),
|
|
599
|
+
...attributes,
|
|
600
|
+
level,
|
|
601
|
+
breadcrumbs: crumbs.slice(-maxBreadcrumbs),
|
|
602
|
+
...(cause ? { remoteParentSpanId: cause.spanId, 'error.cause': cause.transport } : {}),
|
|
603
|
+
},
|
|
524
604
|
error: level === 'error' || level === 'warning' ? err : null,
|
|
525
605
|
});
|
|
526
606
|
addBreadcrumb({ type: 'error', level: level === 'info' ? 'info' : 'error', message: `${err.name}: ${err.message}`.slice(0, 200) });
|
|
527
607
|
void flush();
|
|
528
608
|
};
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
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) => {
|
|
535
682
|
try {
|
|
536
|
-
|
|
683
|
+
return new URL(raw, (env.location ?? env.window?.location)?.href);
|
|
537
684
|
}
|
|
538
685
|
catch {
|
|
539
|
-
|
|
686
|
+
return null;
|
|
540
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);
|
|
541
694
|
if (!url || raw.startsWith(endpoint))
|
|
542
695
|
return baseFetch(input, init);
|
|
543
696
|
const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
544
|
-
const
|
|
545
|
-
const origin = (env.location ?? env.window?.location)?.origin;
|
|
546
|
-
const traceId = randomHex(16, env);
|
|
547
|
-
const rootId = randomHex(8, env);
|
|
548
|
-
const spanId = randomHex(8, env);
|
|
697
|
+
const call = startCall('fetch', method, url);
|
|
549
698
|
let request = { input, init };
|
|
550
|
-
if (
|
|
699
|
+
if (call.propagated) {
|
|
551
700
|
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
552
|
-
headers.set('traceparent', `00-${traceId}-${spanId}-01`);
|
|
701
|
+
headers.set('traceparent', `00-${call.traceId}-${call.spanId}-01`);
|
|
553
702
|
request = { input, init: { ...init, headers } };
|
|
554
703
|
}
|
|
555
|
-
const started = Date.now();
|
|
556
|
-
const startedAt = new Date(started).toISOString();
|
|
557
|
-
let status = null;
|
|
558
|
-
let failure = null;
|
|
559
704
|
try {
|
|
560
705
|
const res = await baseFetch(request.input, request.init);
|
|
561
|
-
status = res.status;
|
|
706
|
+
call.status = res.status;
|
|
707
|
+
if (res && typeof res === 'object')
|
|
708
|
+
linkCause(res, call.propagated ? { traceId: call.traceId, spanId: call.spanId, transport: 'fetch' } : null);
|
|
562
709
|
return res;
|
|
563
710
|
}
|
|
564
711
|
catch (err) {
|
|
565
|
-
failure = toError(err);
|
|
712
|
+
call.failure = toError(err);
|
|
713
|
+
if (err && typeof err === 'object')
|
|
714
|
+
linkCause(err, call.propagated ? { traceId: call.traceId, spanId: call.spanId, transport: 'fetch' } : null);
|
|
566
715
|
throw err;
|
|
567
716
|
}
|
|
568
717
|
finally {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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 });
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
states.delete(this);
|
|
577
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
|
+
};
|
|
578
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
|
+
};
|
|
579
885
|
};
|
|
580
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
|
+
};
|
|
581
892
|
const analyticsRequested = options.analytics === true || options.trackPageviews === true;
|
|
582
893
|
const analytics = enabled && analyticsRequested && !doNotTrackEnabled(env)
|
|
583
894
|
? createAnalytics(env, {
|
|
584
895
|
consent: options.consent ?? 'granted',
|
|
585
896
|
cookieDomain: options.cookieDomain,
|
|
586
897
|
sanitizePath: options.sanitizePath ?? normalizePath,
|
|
587
|
-
onEvent: (event) =>
|
|
588
|
-
analyticsQueue.push(event);
|
|
589
|
-
if (analyticsQueue.length > MAX_QUEUE)
|
|
590
|
-
analyticsQueue.splice(0, analyticsQueue.length - MAX_QUEUE);
|
|
591
|
-
},
|
|
898
|
+
onEvent: (event) => pushAnalytics(event),
|
|
592
899
|
})
|
|
593
900
|
: null;
|
|
594
901
|
if (enabled && analyticsRequested && !analytics)
|
|
595
902
|
log('Do Not Track ligado: analytics desativada');
|
|
596
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
|
+
}
|
|
597
921
|
/* ------------------------------------------------------------ listeners */
|
|
598
922
|
const win = env.window;
|
|
599
923
|
if (win && enabled) {
|
|
@@ -614,15 +938,17 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
614
938
|
doc.addEventListener('click', onClick, true);
|
|
615
939
|
cleanups.push(() => doc.removeEventListener('click', onClick, true));
|
|
616
940
|
}
|
|
617
|
-
if (crumbOn('navigation') || autoPageviews) {
|
|
941
|
+
if (crumbOn('navigation') || autoPageviews || vitals) {
|
|
618
942
|
const hist = env.history ?? win.history;
|
|
619
943
|
const loc = env.location ?? win.location;
|
|
620
944
|
const record = (from, to) => {
|
|
621
945
|
if (crumbOn('navigation'))
|
|
622
946
|
addBreadcrumb({ type: 'navigation', category: 'navigation', message: `${from} → ${to}`, data: { from, to } });
|
|
623
947
|
};
|
|
624
|
-
//
|
|
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).
|
|
625
950
|
const pageview = () => {
|
|
951
|
+
vitals?.onLocationChange();
|
|
626
952
|
if (autoPageviews)
|
|
627
953
|
analytics?.page();
|
|
628
954
|
};
|
|
@@ -671,9 +997,35 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
671
997
|
win.fetch = original;
|
|
672
998
|
});
|
|
673
999
|
}
|
|
674
|
-
|
|
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
|
+
};
|
|
675
1021
|
win.addEventListener('pagehide', onHide);
|
|
676
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));
|
|
677
1029
|
// Pageview da carga inicial.
|
|
678
1030
|
if (autoPageviews)
|
|
679
1031
|
analytics?.page();
|
|
@@ -688,7 +1040,7 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
688
1040
|
if (!enabled)
|
|
689
1041
|
(env.console ?? console).warn('[fg-tracker-browser] apiKey ausente: SDK desabilitado.');
|
|
690
1042
|
return {
|
|
691
|
-
captureException: (error, attributes) => capture(error, attributes, 'error'),
|
|
1043
|
+
captureException: (error, attributes, captureOptions) => capture(error, attributes, 'error', captureOptions?.cause),
|
|
692
1044
|
captureMessage: (message, level = 'info', attributes) => capture(new Error(message), { ...attributes, isMessage: true }, level),
|
|
693
1045
|
addBreadcrumb,
|
|
694
1046
|
setUser: (next) => {
|
|
@@ -709,6 +1061,7 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
709
1061
|
analytics?.setConsent(consent);
|
|
710
1062
|
},
|
|
711
1063
|
close: () => {
|
|
1064
|
+
vitals?.close();
|
|
712
1065
|
for (const cleanup of cleanups.splice(0))
|
|
713
1066
|
cleanup();
|
|
714
1067
|
if (timer)
|
|
@@ -718,6 +1071,9 @@ export function createBrowserTracker(options, env = globalThis) {
|
|
|
718
1071
|
get breadcrumbs() {
|
|
719
1072
|
return crumbs;
|
|
720
1073
|
},
|
|
1074
|
+
get viewId() {
|
|
1075
|
+
return vitals?.viewId ?? null;
|
|
1076
|
+
},
|
|
721
1077
|
};
|
|
722
1078
|
}
|
|
723
1079
|
let current = null;
|