fg-tracker-browser 0.4.0 → 0.5.1

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