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.
- package/CHANGELOG.md +36 -0
- package/README.md +83 -10
- package/dist/context.d.ts +16 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +27 -0
- package/dist/context.js.map +1 -1
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +34 -5
- package/dist/database.js.map +1 -1
- package/dist/exit-hooks.d.ts +23 -0
- package/dist/exit-hooks.d.ts.map +1 -0
- package/dist/exit-hooks.js +88 -0
- package/dist/exit-hooks.js.map +1 -0
- package/dist/express.js +2 -2
- package/dist/express.js.map +1 -1
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +16 -8
- package/dist/fetch.js.map +1 -1
- package/dist/global-errors.d.ts +8 -5
- package/dist/global-errors.d.ts.map +1 -1
- package/dist/global-errors.js +59 -10
- package/dist/global-errors.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -7
- package/dist/index.js.map +1 -1
- package/dist/infra/aggregator.d.ts +5 -0
- package/dist/infra/aggregator.d.ts.map +1 -1
- package/dist/infra/aggregator.js.map +1 -1
- package/dist/infra/aws.d.ts +45 -3
- package/dist/infra/aws.d.ts.map +1 -1
- package/dist/infra/aws.js +419 -29
- package/dist/infra/aws.js.map +1 -1
- package/dist/infra/lambda.d.ts +19 -0
- package/dist/infra/lambda.d.ts.map +1 -1
- package/dist/infra/lambda.js +63 -10
- package/dist/infra/lambda.js.map +1 -1
- package/dist/infra/messaging.d.ts +133 -0
- package/dist/infra/messaging.d.ts.map +1 -0
- package/dist/infra/messaging.js +366 -0
- package/dist/infra/messaging.js.map +1 -0
- package/dist/logs/shipper.d.ts +5 -4
- package/dist/logs/shipper.d.ts.map +1 -1
- package/dist/logs/shipper.js +170 -52
- package/dist/logs/shipper.js.map +1 -1
- package/dist/logs/types.d.ts +18 -2
- package/dist/logs/types.d.ts.map +1 -1
- package/dist/next.js +2 -2
- package/dist/next.js.map +1 -1
- package/dist/node-http.d.ts.map +1 -1
- package/dist/node-http.js +7 -3
- package/dist/node-http.js.map +1 -1
- package/dist/normalize.d.ts +32 -0
- package/dist/normalize.d.ts.map +1 -1
- package/dist/normalize.js +165 -10
- package/dist/normalize.js.map +1 -1
- package/dist/pg.d.ts +8 -3
- package/dist/pg.d.ts.map +1 -1
- package/dist/pg.js +512 -41
- package/dist/pg.js.map +1 -1
- package/dist/scrub.d.ts +5 -0
- package/dist/scrub.d.ts.map +1 -1
- package/dist/scrub.js +17 -1
- package/dist/scrub.js.map +1 -1
- package/dist/tracker.d.ts +109 -7
- package/dist/tracker.d.ts.map +1 -1
- package/dist/tracker.js +502 -95
- package/dist/tracker.js.map +1 -1
- package/dist/transport.d.ts +115 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +323 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +61 -8
- package/dist/types.d.ts.map +1 -1
- 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,
|
|
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
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
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
|
|
204
|
-
const
|
|
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 ?
|
|
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 (
|
|
356
|
+
if (span.sampled) {
|
|
357
|
+
this.emit(payload);
|
|
257
358
|
return;
|
|
258
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
461
|
+
return als.run(span.context(), fn);
|
|
312
462
|
}
|
|
313
463
|
enqueue(span) {
|
|
314
464
|
if (!this.enabled)
|
|
315
465
|
return;
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 (
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
if (!this.infra
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|