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