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/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 atributos curtos dentro de um orçamento
101
- * acumulado, e por fim só a marca. Marca `attributes['fg.truncated']`. Nunca lança.
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
- // Atributos curtos, na ordem, enquanto couberem no que sobra depois do resto do evento.
132
+ // Chaves de identidade e vínculo primeiro; depois atributos curtos, na ordem, enquanto couberem.
118
133
  const marker = { 'fg.truncated': true };
119
- let budget = maxBytes - size({ ...out, attributes: marker });
120
- const small = { ...marker };
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: marker,
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
- * `pagehide`: o que está na fila (e o lote em voo sem keepalive) sai em pedaços de até 60 KiB
454
- * por `sendBeacon` (ou `fetch` keepalive), erros primeiro, depois analytics, depois o resto,
455
- * até o orçamento de ~64 KiB do navegador. O que não couber se perde (a página está saindo).
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
- // O lote em voo sem keepalive (o navegador o cancela), os lotes à espera neste flush e as filas.
459
- const taken = [...(inFlight ?? []), ...outbox.splice(0).flatMap((chunk) => chunk.items)];
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 (o resto se perde).
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
- if (!sent)
492
- void post(chunk, true);
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': 'fetch' } : {}),
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
- /* --------------------------------------------------------------- fetch */
574
- const trackedFetch = async (input, init) => {
575
- if (!baseFetch)
576
- throw new Error('fetch indisponível');
577
- const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
578
- let url = null;
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
- url = new URL(raw, (env.location ?? env.window?.location)?.href);
683
+ return new URL(raw, (env.location ?? env.window?.location)?.href);
581
684
  }
582
685
  catch {
583
- url = null;
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 sampled = enabled && (options.tracesSampleRate ?? 0) > 0 && Math.random() < (options.tracesSampleRate ?? 0);
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
- const propagated = sampled && shouldPropagate(url, options.propagateTraceTo, origin);
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
- const durationMs = Date.now() - started;
619
- if (crumbOn('fetch'))
620
- addBreadcrumb({ type: 'fetch', category: 'http', level: status && status >= 500 ? 'error' : failure ? 'error' : 'info', message: `${method} ${url.origin}${normalizePath(url.pathname)} → ${status ?? failure?.name ?? '?'}`, data: { method, url: url.href, status, durationMs } });
621
- if (sampled) {
622
- const loc = env.location ?? env.window?.location;
623
- const endedAt = new Date(started + durationMs).toISOString();
624
- enqueue({ traceId, spanId: rootId, parentSpanId: null, kind: 'browser', name: `page:${loc ? normalizePath(loc.pathname) : '/'}`, method: null, route: loc ? normalizePath(loc.pathname) : null, statusCode: null, startedAt, endedAt, durationMs, attributes: { ...baseAttributes(), sampled: true }, error: null });
625
- enqueue({ traceId, spanId, parentSpanId: rootId, kind: 'http.client', name: `${method} ${url.origin}${normalizePath(url.pathname)}`, method, route: normalizePath(url.pathname), statusCode: status, startedAt, endedAt, durationMs, attributes: { url: url.href, host: url.host, propagated, ...(options.serviceName ? { serviceName: options.serviceName } : {}), ...(options.release ? { release: options.release } : {}) }, error: status && status >= 500 ? { name: 'HttpError', message: `HTTP ${status}` } : failure });
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
- // Pageview depois da troca de URL (pushState/replaceState/popstate); o dedupe ignora a mesma URL.
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
- const onHide = () => flushOnHide();
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;