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/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,11 +527,38 @@ 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
- const capture = (error, attributes = {}, level = 'error') => {
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: { ...baseAttributes(), ...attributes, level, breadcrumbs: crumbs.slice(-maxBreadcrumbs) },
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
- /* --------------------------------------------------------------- fetch */
530
- const trackedFetch = async (input, init) => {
531
- if (!baseFetch)
532
- throw new Error('fetch indisponível');
533
- const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
534
- 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) => {
535
682
  try {
536
- url = new URL(raw, (env.location ?? env.window?.location)?.href);
683
+ return new URL(raw, (env.location ?? env.window?.location)?.href);
537
684
  }
538
685
  catch {
539
- url = null;
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 sampled = enabled && (options.tracesSampleRate ?? 0) > 0 && Math.random() < (options.tracesSampleRate ?? 0);
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 (sampled && shouldPropagate(url, options.propagateTraceTo, origin)) {
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
- const durationMs = Date.now() - started;
570
- if (crumbOn('fetch'))
571
- 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 } });
572
- if (sampled) {
573
- const loc = env.location ?? env.window?.location;
574
- const endedAt = new Date(started + durationMs).toISOString();
575
- 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 });
576
- 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: shouldPropagate(url, options.propagateTraceTo, origin), ...(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 });
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
- // 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).
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
- 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
+ };
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;