fg-tracker-sdk 0.8.1 → 0.10.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +37 -12
  3. package/dist/cli/release.js +1 -1
  4. package/dist/cli/sourcemaps.js +1 -1
  5. package/dist/context.d.ts +7 -0
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js.map +1 -1
  8. package/dist/exit-hooks.d.ts +23 -0
  9. package/dist/exit-hooks.d.ts.map +1 -0
  10. package/dist/exit-hooks.js +88 -0
  11. package/dist/exit-hooks.js.map +1 -0
  12. package/dist/fetch.d.ts +1 -1
  13. package/dist/fetch.js +1 -1
  14. package/dist/global-errors.d.ts +8 -5
  15. package/dist/global-errors.d.ts.map +1 -1
  16. package/dist/global-errors.js +59 -10
  17. package/dist/global-errors.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +5 -5
  21. package/dist/index.js.map +1 -1
  22. package/dist/infra/aggregator.d.ts +5 -0
  23. package/dist/infra/aggregator.d.ts.map +1 -1
  24. package/dist/infra/aggregator.js.map +1 -1
  25. package/dist/infra/lambda.d.ts +1 -1
  26. package/dist/infra/lambda.d.ts.map +1 -1
  27. package/dist/infra/lambda.js +3 -2
  28. package/dist/infra/lambda.js.map +1 -1
  29. package/dist/logs/nest-logger.d.ts +1 -1
  30. package/dist/logs/nest-logger.js +1 -1
  31. package/dist/logs/shipper.d.ts +5 -4
  32. package/dist/logs/shipper.d.ts.map +1 -1
  33. package/dist/logs/shipper.js +170 -52
  34. package/dist/logs/shipper.js.map +1 -1
  35. package/dist/logs/types.d.ts +19 -3
  36. package/dist/logs/types.d.ts.map +1 -1
  37. package/dist/monitors.d.ts.map +1 -1
  38. package/dist/monitors.js +3 -0
  39. package/dist/monitors.js.map +1 -1
  40. package/dist/scrub.d.ts +5 -0
  41. package/dist/scrub.d.ts.map +1 -1
  42. package/dist/scrub.js +17 -1
  43. package/dist/scrub.js.map +1 -1
  44. package/dist/tracker.d.ts +101 -6
  45. package/dist/tracker.d.ts.map +1 -1
  46. package/dist/tracker.js +468 -92
  47. package/dist/tracker.js.map +1 -1
  48. package/dist/transport.d.ts +115 -0
  49. package/dist/transport.d.ts.map +1 -0
  50. package/dist/transport.js +323 -0
  51. package/dist/transport.js.map +1 -0
  52. package/dist/types.d.ts +50 -5
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +2 -2
package/dist/tracker.js CHANGED
@@ -1,14 +1,27 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { performance } from 'node:perf_hooks';
2
3
  import { EventLoopMonitor } from './event-loop.js';
3
4
  import { als, formatTraceparent, newSpanId, newTraceId, TRACEPARENT_HEADER } from './context.js';
4
5
  import { toSpanError } from './normalize.js';
5
- import { DEFAULT_SCRUB_FIELDS, matchesAny, scrubAttributes } from './scrub.js';
6
+ import { DEFAULT_SCRUB_FIELDS, matchesAny, scrubAttributes, testPattern } from './scrub.js';
6
7
  import { API_KEY_HEADER, DEFAULT_ENDPOINT, readEnv } from './constants.js';
8
+ import { registerExitFlush } from './exit-hooks.js';
7
9
  import { InfraAggregator } from './infra/aggregator.js';
10
+ import { byteLength, DEFAULT_MAX_BODY_BYTES, DEFAULT_REQUEST_TIMEOUT_MS, fitRecord, HttpSender, MIN_BODY_BYTES, safeStringify, Deadlines, truncateText, } from './transport.js';
8
11
  const DEFAULT_FLUSH_INTERVAL_MS = 2000;
9
12
  const DEFAULT_MAX_BATCH = 200;
10
13
  const DEFAULT_MAX_QUEUE = 5000;
14
+ /** Teto de memória da fila de spans (JSON já serializado). */
15
+ const DEFAULT_MAX_QUEUE_BYTES = 32 * 1024 * 1024;
16
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000;
11
17
  const MAX_SPANS_PER_REQUEST = 1000;
18
+ /**
19
+ * `attributes` e `error` acima de 64 KiB (JSON) fazem a API recusar o span
20
+ * (`attributes_too_large`/`error_too_large`, CONTRATO.md §4): o SDK corta antes, com margem.
21
+ */
22
+ const MAX_SPAN_FIELD_BYTES = 60 * 1024;
23
+ /** Raízes locais não amostradas com erro num descendente, guardadas até fecharem (só com `sendUnsampledErrors`). */
24
+ const MAX_KEPT_ROOTS = 1000;
12
25
  /** Limites de agregados de infra (CONTRATO.md §27.4: ≤ 500 por requisição). */
13
26
  const MAX_INFRA_PER_REQUEST = 500;
14
27
  const MAX_INFRA_PENDING = 2000;
@@ -66,6 +79,14 @@ export function resolveOptions(options) {
66
79
  flushIntervalMs: Math.max(100, options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS),
67
80
  maxBatchSize: Math.max(1, options.maxBatchSize ?? DEFAULT_MAX_BATCH),
68
81
  maxQueueSize: Math.max(10, options.maxQueueSize ?? DEFAULT_MAX_QUEUE),
82
+ maxQueueBytes: Math.max(1024 * 1024, options.maxQueueBytes ?? DEFAULT_MAX_QUEUE_BYTES),
83
+ maxBatchBytes: Math.min(DEFAULT_MAX_BODY_BYTES, Math.max(MIN_BODY_BYTES, options.maxBatchBytes ?? DEFAULT_MAX_BODY_BYTES)),
84
+ requestTimeoutMs: Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS),
85
+ shutdownTimeoutMs: Math.max(0, options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS),
86
+ // Com `fetch` próprio o corpo continua string (compatível com quem lê `init.body`), salvo pedido explícito.
87
+ compression: options.compression ?? (options.fetch ? 'none' : 'gzip'),
88
+ exitHooks: options.exitHooks ?? true,
89
+ sendUnsampledErrors: options.sendUnsampledErrors ?? false,
69
90
  enabled: (options.enabled ?? true) && Boolean(options.apiKey),
70
91
  debug: options.debug ?? false,
71
92
  fetch: options.fetch ?? globalThis.fetch,
@@ -84,7 +105,7 @@ export function shouldPropagate(targets, url) {
84
105
  return false;
85
106
  return targets.some((target) => {
86
107
  if (target instanceof RegExp)
87
- return target.test(url.href);
108
+ return testPattern(target, url.href);
88
109
  const pattern = target.trim();
89
110
  if (!pattern)
90
111
  return false;
@@ -97,20 +118,52 @@ export function shouldPropagate(targets, url) {
97
118
  * Núcleo do tracker: constrói spans, mantém a fila e faz o envio em lote.
98
119
  * As integrações (express, pg, next, fetch) ficam em arquivos separados e só
99
120
  * usam `startSpan` / `runInSpan`.
121
+ *
122
+ * Envio (CONTRATO.md §4.3): lotes limitados por bytes (`maxBatchBytes`) e por 1000 spans, uma
123
+ * requisição por vez, timeout de 10 s, backoff exponencial com jitter e `Retry-After` em
124
+ * rede/408/429/5xx, 413 divide o lote, outro 4xx descarta. Spans recusados num 202 parcial não
125
+ * são reenviados (a recusa se repetiria); agregados de infra em `retryableIds` voltam para a fila.
100
126
  */
101
127
  export class TrackerCore {
102
128
  options;
103
129
  queue = [];
130
+ queueBytes = 0;
104
131
  timer = null;
105
132
  flushing = null;
106
133
  warnedFailure = false;
134
+ warnedRejected = false;
107
135
  stopped = false;
136
+ /** Prazo do encerramento em andamento (epoch ms): nenhum envio passa dele. */
137
+ /** Prazos de encerramento ativos; vale o menor (`deadline`). */
138
+ deadlines = new Deadlines();
139
+ get deadline() {
140
+ return this.deadlines.current;
141
+ }
142
+ /** Spans descartados por limite de fila ou por 4xx desde o último aviso. */
143
+ dropped = 0;
144
+ sender;
145
+ /** Teto atual do corpo: começa em `maxBatchBytes` e cai pela metade a cada 413. */
146
+ batchBytes;
147
+ /** Teto de itens por lote: só cai quando um 413 chega com o teto de bytes já no mínimo. */
148
+ batchItems = Number.POSITIVE_INFINITY;
149
+ /** Enfileiramentos desde a última tentativa de envio na saída do processo. */
150
+ enqueuedSinceExit = 0;
151
+ unregisterExit;
152
+ /**
153
+ * Raízes locais (por `spanId`) de traces não amostrados em que um descendente falhou: quando
154
+ * fecham, também são enviadas (`sendUnsampledErrors`). A chave é a raiz, não o trace: duas
155
+ * requisições do mesmo trace distribuído têm raízes locais diferentes.
156
+ */
157
+ keptRoots = new Set();
108
158
  /** Spans ainda abertos, por spanId — permite `currentSpan()` a partir do contexto assíncrono. */
109
159
  active = new Map();
110
160
  /** Agregados de infra (nunca amostrados); `null` quando `infra.enabled === false`. */
111
161
  infra;
112
162
  /** Agregados já fechados aguardando envio (retentativa após falha). */
113
163
  infraPending = [];
164
+ /** Identidade dos agregados deste processo: `<emissor>:<sequência>` (dedupe no servidor). */
165
+ emitterId = randomBytes(8).toString('hex');
166
+ infraSeq = 0;
114
167
  /** A janela de infra venceu: o próximo flush fecha o agregador e envia junto dos spans. */
115
168
  infraDue = false;
116
169
  infraTimer = null;
@@ -124,10 +177,26 @@ export class TrackerCore {
124
177
  eventLoop;
125
178
  constructor(options) {
126
179
  this.options = resolveOptions(options);
180
+ this.batchBytes = this.options.maxBatchBytes;
181
+ this.sender = new HttpSender({
182
+ fetch: (input, init) => this.options.fetch(input, init),
183
+ url: `${this.options.endpoint}/v1/ingest`,
184
+ headers: { [API_KEY_HEADER]: this.options.apiKey },
185
+ timeoutMs: this.options.requestTimeoutMs,
186
+ compression: this.options.compression,
187
+ });
127
188
  this.eventLoop = this.options.enabled && this.options.measureEventLoopLag ? new EventLoopMonitor() : null;
128
189
  this.infra =
129
- this.options.enabled && this.options.infraEnabled ? new InfraAggregator({ onRecord: () => this.ensureInfraTimer() }) : null;
190
+ this.options.enabled && this.options.infraEnabled
191
+ ? new InfraAggregator({
192
+ onRecord: () => {
193
+ this.enqueuedSinceExit += 1;
194
+ this.ensureInfraTimer();
195
+ },
196
+ })
197
+ : null;
130
198
  if (!this.options.enabled) {
199
+ this.unregisterExit = () => undefined;
131
200
  if (!options.apiKey && (options.enabled ?? true)) {
132
201
  console.warn('[fg-tracker] apiKey ausente: tracker desabilitado.');
133
202
  }
@@ -139,10 +208,18 @@ export class TrackerCore {
139
208
  // Não segura o processo vivo só por causa do timer.
140
209
  if (typeof this.timer === 'object' && this.timer && 'unref' in this.timer)
141
210
  this.timer.unref();
211
+ this.unregisterExit = this.options.exitHooks ? registerExitFlush(this) : () => undefined;
142
212
  }
143
213
  get enabled() {
144
214
  return this.options.enabled && !this.stopped;
145
215
  }
216
+ /** Spans aguardando envio. */
217
+ get queueSize() {
218
+ return this.queue.length;
219
+ }
220
+ get exitTimeoutMs() {
221
+ return this.options.shutdownTimeoutMs;
222
+ }
146
223
  currentContext() {
147
224
  return als.getStore();
148
225
  }
@@ -153,11 +230,21 @@ export class TrackerCore {
153
230
  const parentSpanId = parentCtx?.spanId ?? null;
154
231
  const attrs = this.decorate(attributes);
155
232
  // Amostragem: decidida uma vez por trace (raiz) e herdada pelos filhos.
156
- const sampled = parentCtx ? parentCtx.sampled !== false : this.decideSampling({ kind, name, attributes: attrs });
157
- const span = new ActiveSpanImpl(this, traceId, newSpanId(), parentSpanId, kind, name, attrs, sampled);
233
+ // Excluído por `ignoreTransactions` (raiz ou herdado): nunca enviado, nem por `sendUnsampledErrors`.
234
+ // Decidido uma vez e reaproveitado pela amostragem.
235
+ const ignored = parentCtx ? parentCtx.ignored === true : matchesAny(name, this.options.ignoreTransactions);
236
+ const sampled = parentCtx ? parentCtx.sampled !== false : !ignored && this.decideSampling({ kind, name, attributes: attrs }, true);
237
+ const spanId = newSpanId();
238
+ const localRootId = parentCtx ? (parentCtx.localRootId ?? this.localRootOf(parentCtx.spanId)) : spanId;
239
+ const span = new ActiveSpanImpl(this, traceId, spanId, parentSpanId, kind, name, attrs, sampled, ignored, localRootId);
158
240
  this.active.set(span.spanId, span);
159
241
  return span;
160
242
  }
243
+ /** Raiz local de um span ainda aberto (sobe pelos pais abertos); senão o próprio id. */
244
+ localRootOf(spanId) {
245
+ const open = this.active.get(spanId);
246
+ return open ? open.localRootId : spanId;
247
+ }
161
248
  decorate(attributes) {
162
249
  const attrs = { ...attributes };
163
250
  if (this.options.serviceName && attrs.serviceName === undefined)
@@ -172,8 +259,8 @@ export class TrackerCore {
172
259
  return attrs;
173
260
  }
174
261
  /** `ignoreTransactions` → nunca; `tracesSampler` → fração/boolean; senão `tracesSampleRate`. */
175
- decideSampling(context) {
176
- if (matchesAny(context.name, this.options.ignoreTransactions))
262
+ decideSampling(context, exclusionChecked = false) {
263
+ if (!exclusionChecked && matchesAny(context.name, this.options.ignoreTransactions))
177
264
  return false;
178
265
  let rate = this.options.tracesSampleRate;
179
266
  if (this.options.tracesSampler) {
@@ -200,8 +287,10 @@ export class TrackerCore {
200
287
  startRemoteRoot(kind, name, attributes, remote) {
201
288
  const attrs = this.decorate({ ...attributes, remoteParentSpanId: remote.spanId });
202
289
  // A decisão do serviço a montante (flag do traceparent) vale aqui, salvo `ignoreTransactions`.
203
- const sampled = remote.sampled === false ? false : this.decideSampling({ kind, name, attributes: attrs, parentSampled: remote.sampled });
204
- const span = new ActiveSpanImpl(this, remote.traceId, newSpanId(), null, kind, name, attrs, sampled);
290
+ const ignored = matchesAny(name, this.options.ignoreTransactions);
291
+ const sampled = remote.sampled === false || ignored ? false : this.decideSampling({ kind, name, attributes: attrs, parentSampled: remote.sampled }, true);
292
+ const spanId = newSpanId();
293
+ const span = new ActiveSpanImpl(this, remote.traceId, spanId, null, kind, name, attrs, sampled, ignored, spanId);
205
294
  this.active.set(span.spanId, span);
206
295
  return span;
207
296
  }
@@ -253,9 +342,39 @@ export class TrackerCore {
253
342
  /** Chamado por ActiveSpanImpl.end(): amostragem, ignoreErrors, scrubbing e beforeSend. */
254
343
  finishSpan(span, payload) {
255
344
  this.active.delete(span.spanId);
256
- if (!span.sampled)
345
+ if (span.sampled) {
346
+ this.emit(payload);
257
347
  return;
258
- this.emit(payload);
348
+ }
349
+ if (!this.options.sendUnsampledErrors || span.ignored)
350
+ return;
351
+ // Trace não amostrado: só o que falhou (e a raiz local dele, que fecha depois) é enviado.
352
+ // O que já fechou antes do erro foi descartado e não volta.
353
+ if (this.isFailure(payload)) {
354
+ if (span.parentSpanId !== null)
355
+ this.keepRoot(span.localRootId);
356
+ else
357
+ this.keptRoots.delete(span.spanId);
358
+ this.emit(markKeptByError(payload));
359
+ }
360
+ else if (span.parentSpanId === null && this.keptRoots.delete(span.spanId)) {
361
+ this.emit(markKeptByError(payload));
362
+ }
363
+ }
364
+ isFailure(payload) {
365
+ if ((payload.statusCode ?? 0) >= 500)
366
+ return true;
367
+ if (!payload.error)
368
+ return false;
369
+ return !matchesAny(`${payload.error.name}: ${payload.error.message}`, this.options.ignoreErrors);
370
+ }
371
+ keepRoot(localRootId) {
372
+ this.keptRoots.add(localRootId);
373
+ if (this.keptRoots.size > MAX_KEPT_ROOTS) {
374
+ const oldest = this.keptRoots.values().next().value;
375
+ if (oldest !== undefined)
376
+ this.keptRoots.delete(oldest);
377
+ }
259
378
  }
260
379
  /**
261
380
  * Registra um span que já terminou, sem ter existido um `ActiveSpanImpl`
@@ -267,9 +386,14 @@ export class TrackerCore {
267
386
  * nada vira trace órfão.
268
387
  */
269
388
  recordSpan(input) {
270
- if (!this.enabled || input.parent.sampled === false)
389
+ if (!this.enabled)
390
+ return;
391
+ if (input.parent.ignored === true)
392
+ return;
393
+ const unsampled = input.parent.sampled === false;
394
+ if (unsampled && (input.error === undefined || !this.options.sendUnsampledErrors))
271
395
  return;
272
- this.emit({
396
+ const payload = {
273
397
  traceId: input.parent.traceId,
274
398
  spanId: newSpanId(),
275
399
  parentSpanId: input.parent.spanId,
@@ -283,7 +407,14 @@ export class TrackerCore {
283
407
  durationMs: input.durationMs,
284
408
  attributes: this.decorate(input.attributes ?? {}),
285
409
  error: input.error === undefined ? null : toSpanError(input.error),
286
- });
410
+ };
411
+ if (!unsampled) {
412
+ this.emit(payload);
413
+ }
414
+ else if (this.isFailure(payload)) {
415
+ this.keepRoot(input.parent.localRootId ?? this.localRootOf(input.parent.spanId));
416
+ this.emit(markKeptByError(payload));
417
+ }
287
418
  }
288
419
  /** ignoreErrors, scrubbing e beforeSend antes de entrar na fila. */
289
420
  emit(payload) {
@@ -291,8 +422,16 @@ export class TrackerCore {
291
422
  if (out.error && matchesAny(`${out.error.name}: ${out.error.message}`, this.options.ignoreErrors)) {
292
423
  out = { ...out, error: null, attributes: { ...out.attributes, ignoredError: out.error.name } };
293
424
  }
294
- if (this.options.scrubFields.length > 0)
295
- out = { ...out, attributes: scrubAttributes(out.attributes, this.options.scrubFields) };
425
+ if (this.options.scrubFields.length > 0) {
426
+ try {
427
+ out = { ...out, attributes: scrubAttributes(out.attributes, this.options.scrubFields) };
428
+ }
429
+ catch (err) {
430
+ this.dropped += 1;
431
+ this.warnOnce(`span descartado: falha ao limpar atributos (${err?.message ?? err})`);
432
+ return;
433
+ }
434
+ }
296
435
  if (this.options.beforeSend) {
297
436
  try {
298
437
  const result = this.options.beforeSend(out);
@@ -308,22 +447,40 @@ export class TrackerCore {
308
447
  }
309
448
  /** Executa `fn` com `span` como contexto pai das operações internas. */
310
449
  runInSpan(span, fn) {
311
- return als.run({ traceId: span.traceId, spanId: span.spanId, sampled: span.sampled }, fn);
450
+ return als.run({ traceId: span.traceId, spanId: span.spanId, sampled: span.sampled, localRootId: span.localRootId, ...(span.ignored ? { ignored: true } : {}) }, fn);
312
451
  }
313
452
  enqueue(span) {
314
453
  if (!this.enabled)
315
454
  return;
316
- this.queue.push(span);
317
- if (this.queue.length > this.options.maxQueueSize) {
318
- // Descarta os mais antigos para não crescer sem limite.
319
- this.queue.splice(0, this.queue.length - this.options.maxQueueSize);
455
+ let item;
456
+ try {
457
+ item = serializeSpan(span);
458
+ }
459
+ catch (err) {
460
+ // Roda no `span.end()`, dentro da operação da aplicação: nunca lança para ela.
461
+ this.dropped += 1;
462
+ this.warnOnce(`span descartado: não foi possível serializar (${err?.message ?? err})`);
463
+ return;
320
464
  }
465
+ this.queue.push(item);
466
+ this.queueBytes += item.bytes;
467
+ this.enqueuedSinceExit += 1;
468
+ this.trimQueue();
321
469
  if (this.queue.length >= this.options.maxBatchSize)
322
470
  void this.flush(false);
323
471
  }
472
+ /** Acima dos limites (spans ou bytes), descarta os mais antigos para não crescer sem limite. */
473
+ trimQueue() {
474
+ while (this.queue.length > 1 && (this.queue.length > this.options.maxQueueSize || this.queueBytes > this.options.maxQueueBytes)) {
475
+ const removed = this.queue.shift();
476
+ this.queueBytes -= removed.bytes;
477
+ this.dropped += 1;
478
+ }
479
+ }
324
480
  /**
325
481
  * Envia a fila. `includeInfra` (default `true`) também fecha a janela de infra e envia os
326
482
  * agregados no mesmo lote; o timer de spans chama com `false` para respeitar a janela de 60 s/5 s.
483
+ * Não espera o backoff: em backoff, volta na hora e o próximo flush tenta.
327
484
  */
328
485
  async flush(includeInfra = true) {
329
486
  if (!this.options.enabled)
@@ -338,41 +495,192 @@ export class TrackerCore {
338
495
  this.infraDue = false;
339
496
  return;
340
497
  }
341
- this.flushing = this.doFlush().finally(() => {
498
+ this.flushing = this.drain(false).finally(() => {
342
499
  this.flushing = null;
343
500
  });
344
501
  return this.flushing;
345
502
  }
503
+ /**
504
+ * Envia o que der até `deadline`, sem esperar backoff (fim de invocação da Lambda). Aborta a
505
+ * requisição em andamento no prazo: nada fica pendurado no congelamento do ambiente.
506
+ */
507
+ async flushUntil(deadline) {
508
+ if (!this.options.enabled)
509
+ return;
510
+ const release = this.deadlines.add(deadline);
511
+ this.sender.abortAt(this.deadline ?? deadline);
512
+ try {
513
+ await this.flush(true);
514
+ }
515
+ finally {
516
+ release();
517
+ }
518
+ }
346
519
  hasInfraToSend() {
347
520
  return this.infraPending.length > 0 || (this.infraDue && Boolean(this.infra?.hasData()));
348
521
  }
349
- async doFlush() {
522
+ /**
523
+ * Laço de envio. `waitBackoff` (encerramento) espera o backoff enquanto couber no prazo;
524
+ * sem ele, uma falha devolve o lote à fila e encerra o laço.
525
+ */
526
+ async drain(waitBackoff) {
350
527
  for (;;) {
351
528
  if (this.infraDue && this.infra) {
352
529
  this.infraDue = false;
353
530
  if (this.infra.hasData())
354
531
  this.pushInfraPending(this.infra.drain());
355
532
  }
356
- const batch = this.queue.splice(0, MAX_SPANS_PER_REQUEST);
357
- const infra = this.infraPending.splice(0, MAX_INFRA_PER_REQUEST);
358
- if (batch.length === 0 && infra.length === 0)
533
+ if (this.queue.length === 0 && this.infraPending.length === 0)
359
534
  return;
360
- const ok = await this.send(batch, infra);
361
- if (!ok) {
362
- // Devolve para a frente da fila e tenta no próximo flush.
363
- this.queue.unshift(...batch);
364
- if (this.queue.length > this.options.maxQueueSize) {
365
- this.queue.splice(0, this.queue.length - this.options.maxQueueSize);
535
+ const now = Date.now();
536
+ if (this.deadline !== null && now >= this.deadline)
537
+ return;
538
+ const wait = this.sender.waitMs(now);
539
+ if (wait > 0) {
540
+ if (!waitBackoff || this.deadline === null || now + wait >= this.deadline)
541
+ return;
542
+ await this.deadlines.sleep(wait);
543
+ continue;
544
+ }
545
+ const batch = this.takeBatch();
546
+ const result = await this.sender.post(batch.body, this.deadline ?? undefined);
547
+ if (!this.handleResult(batch, result) && !waitBackoff)
548
+ return;
549
+ }
550
+ }
551
+ /**
552
+ * Monta o próximo lote: agregados (até 500) e depois spans (até 1000), todos dentro do teto de
553
+ * bytes e do teto de itens (este só cai depois de 413 com o teto de bytes no mínimo). Sempre leva
554
+ * pelo menos um item.
555
+ */
556
+ takeBatch() {
557
+ // Tamanho exato do corpo de `buildIngestBody`: `{"traces":[` + grupos + `]` + `,"infra":[…]` + `}`.
558
+ let bytes = 13;
559
+ let items = 0;
560
+ const fits = (size) => items === 0 || (bytes + size <= this.batchBytes && items < this.batchItems);
561
+ const infraJsons = [];
562
+ while (infraJsons.length < this.infraPending.length && infraJsons.length < MAX_INFRA_PER_REQUEST) {
563
+ const json = safeStringify(this.infraPending[infraJsons.length]);
564
+ // Primeiro agregado: `,"infra":[` + json + `]`; os seguintes: `,` + json.
565
+ const size = byteLength(json) + (infraJsons.length === 0 ? 11 : 1);
566
+ if (!fits(size))
567
+ break;
568
+ bytes += size;
569
+ items += 1;
570
+ infraJsons.push(json);
571
+ }
572
+ const infra = this.infraPending.splice(0, infraJsons.length);
573
+ const traces = new Set();
574
+ let count = 0;
575
+ while (count < this.queue.length && count < MAX_SPANS_PER_REQUEST) {
576
+ const next = this.queue[count];
577
+ // Span novo num trace já presente: `,<json>`; trace novo: `,{"traceId":"…","spans":[<json>]}`.
578
+ const size = traces.has(next.traceId) ? next.bytes + 1 : next.bytes + byteLength(JSON.stringify(next.traceId)) + 26;
579
+ if (!fits(size))
580
+ break;
581
+ bytes += size;
582
+ items += 1;
583
+ traces.add(next.traceId);
584
+ count += 1;
585
+ }
586
+ const spans = this.queue.splice(0, count);
587
+ for (const span of spans)
588
+ this.queueBytes -= span.bytes;
589
+ const body = buildIngestBody(spans, infraJsons.length > 0 ? `[${infraJsons.join(',')}]` : '');
590
+ return { spans, infra, body, bytes: byteLength(body) };
591
+ }
592
+ /** Aplica o resultado do envio. Devolve `false` quando o lote voltou para a fila (backoff). */
593
+ handleResult(batch, result) {
594
+ switch (result.type) {
595
+ case 'ok':
596
+ if (this.warnedFailure) {
597
+ this.warnedFailure = false;
598
+ if (this.options.debug)
599
+ console.info('[fg-tracker] envio restabelecido.');
366
600
  }
367
- if (infra.length > 0) {
368
- this.infraPending.unshift(...infra);
369
- this.trimInfraPending();
601
+ if (this.options.debug) {
602
+ console.info(`[fg-tracker] enviados ${batch.spans.length} spans${batch.infra.length > 0 ? ` e ${batch.infra.length} agregados de infra` : ''} (${batch.bytes} bytes).`);
370
603
  }
371
- return;
604
+ if (this.readIngestResponse(batch, result.body)) {
605
+ // Agregados com falha transitória na API (banco): voltam para a fila e esperam o
606
+ // backoff, que continua crescendo enquanto a falha durar (o 2xx não zera a sequência).
607
+ this.sender.noteFailure({ type: 'retry', status: result.status, retryAfterMs: null, reason: 'infra retryable' });
608
+ return false;
609
+ }
610
+ this.sender.noteSuccess();
611
+ return true;
612
+ case 'too_large': {
613
+ const items = batch.spans.length + batch.infra.length;
614
+ if (items <= 1) {
615
+ this.dropped += batch.spans.length;
616
+ this.warnOnce(`ingest respondeu 413 para um item de ${batch.bytes} bytes (descartado).`);
617
+ return true;
618
+ }
619
+ // Divide: o mesmo lote volta para a fila e sai em pedaços de metade do tamanho (spans e
620
+ // agregados contam). Abaixo do mínimo de bytes, divide pela metade em número de itens: cada
621
+ // 413 encolhe o lote, então o laço termina, e só um item sozinho acima do limite se perde.
622
+ const half = Math.floor(batch.bytes / 2);
623
+ if (half >= MIN_BODY_BYTES)
624
+ this.batchBytes = Math.min(this.batchBytes, half);
625
+ else
626
+ this.batchItems = Math.max(1, Math.min(this.batchItems, Math.floor(items / 2)));
627
+ this.requeue(batch);
628
+ return true;
629
+ }
630
+ case 'rejected':
631
+ // 4xx = configuração (chave inválida, payload malformado): reenviar daria o mesmo erro.
632
+ this.sender.noteSuccess();
633
+ this.dropped += batch.spans.length;
634
+ this.warnOnce(`ingest respondeu ${result.status} (lote descartado)`);
635
+ return true;
636
+ case 'retry': {
637
+ const delay = this.sender.noteFailure(result);
638
+ this.warnOnce(`falha ao enviar para ${this.options.endpoint}/v1/ingest: ${result.reason} (nova tentativa em ${Math.round(delay / 1000)} s)`);
639
+ this.requeue(batch);
640
+ return false;
372
641
  }
373
642
  }
374
643
  }
644
+ /** Devolve o lote para a frente da fila (os limites de memória continuam valendo). */
645
+ requeue(batch) {
646
+ this.queue.unshift(...batch.spans);
647
+ for (const span of batch.spans)
648
+ this.queueBytes += span.bytes;
649
+ this.trimQueue();
650
+ if (batch.infra.length > 0) {
651
+ this.infraPending.unshift(...batch.infra);
652
+ this.trimInfraPending();
653
+ }
654
+ }
655
+ /**
656
+ * 202 da ingestão: `live`/`liveIntervalMs` (infra ao vivo), spans recusados (não reenvia: a
657
+ * recusa se repetiria) e agregados de infra com falha transitória (`retryableIds`, reenvia só
658
+ * esses). Devolve `true` quando algum agregado voltou para a fila.
659
+ */
660
+ readIngestResponse(batch, body) {
661
+ if (!body || typeof body !== 'object')
662
+ return false;
663
+ const response = body;
664
+ if (typeof response.rejected === 'number' && response.rejected > 0 && !this.warnedRejected) {
665
+ this.warnedRejected = true;
666
+ const first = Array.isArray(response.errors) ? response.errors[0]?.reason : undefined;
667
+ console.warn(`[fg-tracker] a API recusou ${response.rejected} span(s)${typeof first === 'string' ? ` (ex.: ${first})` : ''}; recusas não são reenviadas.`);
668
+ }
669
+ this.readLiveFlag(response);
670
+ const retryable = response.infra?.retryableIds;
671
+ if (!Array.isArray(retryable) || retryable.length === 0 || batch.infra.length === 0)
672
+ return false;
673
+ const ids = new Set(retryable.filter((id) => typeof id === 'string'));
674
+ const again = batch.infra.filter((aggregate) => aggregate.id !== undefined && ids.has(aggregate.id));
675
+ if (again.length === 0)
676
+ return false;
677
+ this.infraPending.unshift(...again);
678
+ this.trimInfraPending();
679
+ return true;
680
+ }
375
681
  pushInfraPending(items) {
682
+ for (const item of items)
683
+ item.id ??= `${this.emitterId}:${++this.infraSeq}`;
376
684
  this.infraPending.push(...items);
377
685
  this.trimInfraPending();
378
686
  }
@@ -408,17 +716,8 @@ export class TrackerCore {
408
716
  this.infraTimer.unref();
409
717
  }
410
718
  /** Lê `live`/`liveIntervalMs` da resposta da ingestão (piggyback, sem chamada extra). */
411
- async readLiveFlag(res) {
412
- if (!this.infra || res.status === 204 || typeof res.json !== 'function')
413
- return;
414
- let body;
415
- try {
416
- body = await res.json();
417
- }
418
- catch {
419
- return;
420
- }
421
- if (!body || typeof body !== 'object')
719
+ readLiveFlag(body) {
720
+ if (!this.infra)
422
721
  return;
423
722
  const { live, liveIntervalMs } = body;
424
723
  if (typeof live !== 'boolean')
@@ -451,55 +750,61 @@ export class TrackerCore {
451
750
  return null;
452
751
  }
453
752
  }
454
- async send(spans, infra = []) {
455
- const byTrace = new Map();
456
- for (const span of spans) {
457
- const { traceId, ...rest } = span;
458
- let list = byTrace.get(traceId);
459
- if (!list) {
460
- list = [];
461
- byTrace.set(traceId, list);
462
- }
463
- list.push(rest);
464
- }
465
- const traces = [...byTrace].map(([traceId, s]) => ({ traceId, spans: s }));
466
- // Sem agregados o body continua idêntico ao das versões anteriores.
467
- const body = JSON.stringify(infra.length > 0 ? { traces, infra } : { traces });
468
- try {
469
- const res = await this.options.fetch(`${this.options.endpoint}/v1/ingest`, {
470
- method: 'POST',
471
- headers: { 'content-type': 'application/json', [API_KEY_HEADER]: this.options.apiKey },
472
- body,
473
- });
474
- if (!res.ok) {
475
- // 4xx = problema de configuração (chave inválida, payload rejeitado): não faz sentido reenviar.
476
- const retry = res.status >= 500 || res.status === 429;
477
- this.warnOnce(`ingest respondeu ${res.status}${retry ? ' (vai tentar de novo)' : ' (lote descartado)'}`);
478
- return !retry;
479
- }
480
- if (this.warnedFailure) {
481
- this.warnedFailure = false;
482
- if (this.options.debug)
483
- console.info('[fg-tracker] envio restabelecido.');
484
- }
485
- await this.readLiveFlag(res);
486
- if (this.options.debug) {
487
- console.info(`[fg-tracker] enviados ${spans.length} spans em ${byTrace.size} traces${infra.length > 0 ? ` e ${infra.length} agregados de infra` : ''}.`);
488
- }
489
- return true;
490
- }
491
- catch (err) {
492
- this.warnOnce(`falha ao enviar para ${this.options.endpoint}/v1/ingest: ${err?.message ?? err}`);
493
- return false;
494
- }
495
- }
496
753
  warnOnce(message) {
497
754
  if (this.warnedFailure)
498
755
  return;
499
756
  this.warnedFailure = true;
500
757
  console.warn(`[fg-tracker] ${message}`);
501
758
  }
502
- async shutdown() {
759
+ hasPendingForExit() {
760
+ if (!this.enabled)
761
+ return false;
762
+ // Um lote já saiu da fila e está em voo: a saída espera ele (até o prazo).
763
+ if (this.flushing)
764
+ return true;
765
+ return this.enqueuedSinceExit > 0 && (this.queue.length > 0 || this.hasInfraToSend() || this.infra?.hasData() === true);
766
+ }
767
+ /** Saída do processo sem `shutdown()` (`beforeExit`, SIGTERM/SIGINT): envia com prazo, sem parar o tracker. */
768
+ async flushForExit(deadline) {
769
+ this.enqueuedSinceExit = 0;
770
+ try {
771
+ await this.sendUntil(deadline);
772
+ }
773
+ catch {
774
+ /* a saída nunca lança */
775
+ }
776
+ }
777
+ /**
778
+ * Envia tudo o que der até `deadline`, esperando backoff/`Retry-After` quando cabe no prazo.
779
+ * A requisição em andamento é abortada no prazo.
780
+ */
781
+ async sendUntil(deadline) {
782
+ if (!this.options.enabled)
783
+ return;
784
+ const release = this.deadlines.add(deadline);
785
+ this.sender.abortAt(this.deadline ?? deadline);
786
+ try {
787
+ // Outro envio (flush, saída do processo) em andamento: espera, sem iniciar um segundo laço.
788
+ while (this.flushing)
789
+ await this.flushing;
790
+ if (this.infra?.hasData())
791
+ this.infraDue = true;
792
+ if (this.queue.length === 0 && !this.hasInfraToSend())
793
+ return;
794
+ this.flushing = this.drain(true).finally(() => {
795
+ this.flushing = null;
796
+ });
797
+ await this.flushing;
798
+ }
799
+ finally {
800
+ release();
801
+ }
802
+ }
803
+ /**
804
+ * Para os timers e envia o que restou em até `timeoutMs` (default `shutdownTimeoutMs`, 10 s).
805
+ * Com a API muda ou fora, volta no prazo; o que não saiu é descartado (e contado em `dropped`).
806
+ */
807
+ async shutdown(options = {}) {
503
808
  if (this.timer) {
504
809
  clearInterval(this.timer);
505
810
  this.timer = null;
@@ -518,10 +823,73 @@ export class TrackerCore {
518
823
  }
519
824
  }
520
825
  this.disposers.clear();
521
- await this.flush(true);
826
+ this.unregisterExit();
827
+ const timeoutMs = Math.max(0, options.timeoutMs ?? this.options.shutdownTimeoutMs);
828
+ await this.sendUntil(Date.now() + timeoutMs);
829
+ if (this.queue.length > 0 && this.options.enabled) {
830
+ this.dropped += this.queue.length;
831
+ this.warnOnce(`encerrado com ${this.queue.length} span(s) sem enviar.`);
832
+ }
522
833
  this.stopped = true;
523
834
  }
524
835
  }
836
+ /** Atributos do span enviado só porque falhou num trace não amostrado (`sendUnsampledErrors`). */
837
+ function markKeptByError(span) {
838
+ return { ...span, attributes: { ...span.attributes, 'sampling.kept': 'error' } };
839
+ }
840
+ /** JSON do span (sem `traceId`), com `attributes`/`error` cortados abaixo do limite da API. */
841
+ function serializeSpan(span) {
842
+ const { traceId, ...rest } = span;
843
+ let json = safeStringify(rest);
844
+ let bytes = byteLength(json);
845
+ // Só um span grande pode ter `attributes` ou `error` acima do limite: o resto não paga a checagem.
846
+ if (bytes > MAX_SPAN_FIELD_BYTES) {
847
+ json = safeStringify({ ...rest, attributes: fitRecord(rest.attributes ?? {}, MAX_SPAN_FIELD_BYTES), error: fitError(rest.error) });
848
+ bytes = byteLength(json);
849
+ }
850
+ return { traceId, json, bytes };
851
+ }
852
+ /**
853
+ * Corta `error` até o JSON serializado caber no limite (o servidor mede `JSON.stringify(error)`:
854
+ * aspas, barras e controles crescem ao escapar). Encolhe primeiro o stack, depois a mensagem, e
855
+ * confere o tamanho final a cada passo.
856
+ */
857
+ function fitError(error) {
858
+ if (!error || byteLength(safeStringify(error)) <= MAX_SPAN_FIELD_BYTES)
859
+ return error;
860
+ const name = truncateText(String(error.name), 256);
861
+ const message = String(error.message);
862
+ const stack = typeof error.stack === 'string' ? error.stack : undefined;
863
+ let messageBytes = 8 * 1024;
864
+ let stackBytes = stack === undefined ? 0 : MAX_SPAN_FIELD_BYTES - 10 * 1024;
865
+ for (;;) {
866
+ const out = { name, message: truncateText(message, messageBytes), truncated: true };
867
+ if (stack !== undefined && stackBytes > 0)
868
+ out.stack = truncateText(stack, stackBytes);
869
+ if (byteLength(safeStringify(out)) <= MAX_SPAN_FIELD_BYTES)
870
+ return out;
871
+ if (stackBytes > 0)
872
+ stackBytes = stackBytes > 256 ? Math.floor(stackBytes / 2) : 0;
873
+ else if (messageBytes > 0)
874
+ messageBytes = messageBytes > 64 ? Math.floor(messageBytes / 2) : 0;
875
+ else
876
+ return { name: truncateText(name, 64), message: '', truncated: true };
877
+ }
878
+ }
879
+ /** `{"traces":[{"traceId":…,"spans":[…]}…]}` a partir do JSON já pronto de cada span. */
880
+ function buildIngestBody(spans, infraJson) {
881
+ const groups = new Map();
882
+ for (const span of spans) {
883
+ const list = groups.get(span.traceId);
884
+ if (list)
885
+ list.push(span.json);
886
+ else
887
+ groups.set(span.traceId, [span.json]);
888
+ }
889
+ const traces = [...groups].map(([traceId, jsons]) => `{"traceId":${JSON.stringify(traceId)},"spans":[${jsons.join(',')}]}`).join(',');
890
+ // Sem agregados o body continua idêntico ao das versões anteriores.
891
+ return infraJson ? `{"traces":[${traces}],"infra":${infraJson}}` : `{"traces":[${traces}]}`;
892
+ }
525
893
  export function userAttributes(user) {
526
894
  if (!user)
527
895
  return { 'user.id': undefined, 'user.email': undefined, 'user.name': undefined };
@@ -539,6 +907,8 @@ export class ActiveSpanImpl {
539
907
  parentSpanId;
540
908
  kind;
541
909
  sampled;
910
+ ignored;
911
+ localRootId;
542
912
  name;
543
913
  method = null;
544
914
  route = null;
@@ -548,13 +918,19 @@ export class ActiveSpanImpl {
548
918
  startedAt;
549
919
  startedPerf;
550
920
  ended = false;
551
- constructor(core, traceId, spanId, parentSpanId, kind, name, attributes, sampled = true) {
921
+ constructor(core, traceId, spanId, parentSpanId, kind, name, attributes, sampled = true,
922
+ /** Trace excluído por `ignoreTransactions`: nunca enviado (nem com `sendUnsampledErrors`). */
923
+ ignored = false,
924
+ /** `spanId` da raiz local deste ramo (o próprio, quando o span é raiz local). */
925
+ localRootId = spanId) {
552
926
  this.core = core;
553
927
  this.traceId = traceId;
554
928
  this.spanId = spanId;
555
929
  this.parentSpanId = parentSpanId;
556
930
  this.kind = kind;
557
931
  this.sampled = sampled;
932
+ this.ignored = ignored;
933
+ this.localRootId = localRootId;
558
934
  this.name = name;
559
935
  this.attributes = attributes;
560
936
  this.startedAt = new Date();