@forge-ops/tracker 0.10.0 → 0.12.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/README.md +129 -3
- package/package.json +1 -1
- package/src/changes.js +147 -0
- package/src/client.js +17 -0
- package/src/configuration.js +105 -0
- package/src/eventBuilder.js +20 -3
- package/src/httpTracing.js +53 -10
- package/src/index.js +169 -8
- package/src/integrations/express.js +16 -5
- package/src/integrations/fastify.js +18 -5
- package/src/integrations/tracing.js +73 -16
- package/src/reporter.js +3 -2
- package/src/requestState.js +117 -0
- package/src/spanBuffer.js +32 -9
- package/src/traceParent.js +88 -0
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { BreadcrumbBuffer } from "./breadcrumbBuffer.js";
|
|
3
|
+
import { buildChange, buildSnapshot } from "./changes.js";
|
|
3
4
|
import { Client } from "./client.js";
|
|
4
5
|
import { Configuration } from "./configuration.js";
|
|
5
6
|
import { DeliveryQueue } from "./deliveryQueue.js";
|
|
@@ -8,8 +9,10 @@ import { installHttpTracing, _uninstallHttpTracing } from "./httpTracing.js";
|
|
|
8
9
|
import { MetricBuffer } from "./metricBuffer.js";
|
|
9
10
|
import { PerformanceFlusher } from "./performanceFlusher.js";
|
|
10
11
|
import { Reporter } from "./reporter.js";
|
|
12
|
+
import { requestStateFor } from "./requestState.js";
|
|
11
13
|
import { SessionFlusher } from "./sessionFlusher.js";
|
|
12
14
|
import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
|
|
15
|
+
import { buildTraceparent } from "./traceParent.js";
|
|
13
16
|
|
|
14
17
|
export { Configuration };
|
|
15
18
|
export { withSql } from "./sqlStatement.js";
|
|
@@ -19,6 +22,8 @@ let reporter = null;
|
|
|
19
22
|
let sessionFlusher = null;
|
|
20
23
|
let performanceFlusher = null;
|
|
21
24
|
let spanQueue = null;
|
|
25
|
+
let changeQueue = null;
|
|
26
|
+
let changeSnapshotStarted = false;
|
|
22
27
|
let metricBuffer = null;
|
|
23
28
|
let infrastructureMetricBuffer = null;
|
|
24
29
|
let processHandlersInstalled = false;
|
|
@@ -78,6 +83,14 @@ const spanStorage = new AsyncLocalStorage();
|
|
|
78
83
|
// SpanBuffer at all" as the real signal for "not inside a traced request").
|
|
79
84
|
const spanParentStorage = new AsyncLocalStorage();
|
|
80
85
|
|
|
86
|
+
// Request-scoped RequestState (trace id, remote parent span id, transaction name, endpoint, errored
|
|
87
|
+
// flag; see requestState.js), published by the Express/Fastify integrations for the duration of a
|
|
88
|
+
// request through _runWithRequestState below. Not gated on trackTracing: the trace id links an
|
|
89
|
+
// error to errors in other services even when no spans are ever recorded. Unset outside a request,
|
|
90
|
+
// where captureException, span tracing, and the outbound-HTTP wrapper all behave exactly as they
|
|
91
|
+
// did before this existed.
|
|
92
|
+
const requestStorage = new AsyncLocalStorage();
|
|
93
|
+
|
|
81
94
|
function getConfiguration() {
|
|
82
95
|
if (configuration === null) {
|
|
83
96
|
configuration = new Configuration();
|
|
@@ -173,6 +186,70 @@ export async function flushMetrics() {
|
|
|
173
186
|
await Promise.all([metricBuffer.flush(), infrastructureMetricBuffer.flush()]);
|
|
174
187
|
}
|
|
175
188
|
|
|
189
|
+
function getChangeQueue() {
|
|
190
|
+
if (changeQueue === null) {
|
|
191
|
+
const config = getConfiguration();
|
|
192
|
+
changeQueue = new DeliveryQueue(config, new Client(config), { deliverMethod: "deliverChange", label: "change" });
|
|
193
|
+
}
|
|
194
|
+
return changeQueue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Records one thing that changed in a running system, so ForgeOps can show it next to the errors
|
|
199
|
+
* and slowdowns that followed:
|
|
200
|
+
*
|
|
201
|
+
* recordChange({ kind: "feature_flag", title: "Enabled new_checkout for 10%", details: { rollout: 10 } });
|
|
202
|
+
*
|
|
203
|
+
* `kind` is one of feature_flag, config, migration, dependency, infrastructure, or other (anything
|
|
204
|
+
* else is sent as "other"). `environment` defaults to the configured one; `occurredAt` (a Date or an
|
|
205
|
+
* ISO 8601 string) defaults to now; `id` is an optional idempotency key. Queued and delivered on the
|
|
206
|
+
* same async loop as error events, so it never blocks the caller. A no-op when the client isn't
|
|
207
|
+
* enabled. Never throws: returns false when nothing was queued.
|
|
208
|
+
*
|
|
209
|
+
* @param {{ kind: string, title: string, details?: Record<string, unknown>, environment?: string,
|
|
210
|
+
* service?: string, actor?: string, url?: string, id?: string, occurredAt?: Date | string }} change
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
export function recordChange(change) {
|
|
214
|
+
try {
|
|
215
|
+
const config = getConfiguration();
|
|
216
|
+
if (!config.isEnabled()) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
const payload = buildChange(config, change ?? {});
|
|
220
|
+
if (payload === null) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return getChangeQueue().push(payload);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
getConfiguration().log(`[forge-ops-tracker] recordChange failed: ${e.name}: ${e.message}`);
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Sends the one startup snapshot this process sends (see changes.js), on a later event-loop turn so
|
|
232
|
+
* init() returns immediately and startup never waits on it. A second call is a no-op. Does nothing,
|
|
233
|
+
* and doesn't use up the once, when the client isn't enabled or detectChanges is off. The timer is
|
|
234
|
+
* unref'd, so a short script that's otherwise done doesn't stay alive just to send it. Never throws.
|
|
235
|
+
*/
|
|
236
|
+
function startChangeSnapshot(config) {
|
|
237
|
+
if (changeSnapshotStarted || !config.isEnabled() || !config.detectChanges) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
changeSnapshotStarted = true;
|
|
241
|
+
|
|
242
|
+
const timer = setTimeout(async () => {
|
|
243
|
+
try {
|
|
244
|
+
await new Client(config).deliverChangeSnapshot(await buildSnapshot(config));
|
|
245
|
+
} catch (e) {
|
|
246
|
+
config.log(`[forge-ops-tracker] change snapshot failed: ${e.name}: ${e.message}`);
|
|
247
|
+
}
|
|
248
|
+
}, 0);
|
|
249
|
+
timer.unref?.();
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
176
253
|
/**
|
|
177
254
|
* A DeliveryQueue reused for spans (see that class's own comment for why it takes a
|
|
178
255
|
* deliverMethod/label rather than needing a whole second, near-identical class the way
|
|
@@ -242,6 +319,64 @@ export function _runWithBreadcrumbTrail(callback) {
|
|
|
242
319
|
return breadcrumbStorage.run(new BreadcrumbBuffer(config), callback);
|
|
243
320
|
}
|
|
244
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Internal; called by the Express/Fastify integrations with their own request object and its
|
|
324
|
+
* incoming traceparent header's raw value. Returns the RequestState memoized on that request (see
|
|
325
|
+
* requestState.js), continuing the incoming trace or starting a fresh one, or null when the client
|
|
326
|
+
* isn't enabled at all, the same "skip entirely" gating every other automatic source applies.
|
|
327
|
+
*
|
|
328
|
+
* @param {object} request an Express req or a Fastify request
|
|
329
|
+
* @param {unknown} traceparent
|
|
330
|
+
* @returns {import("./requestState.js").RequestState | null}
|
|
331
|
+
*/
|
|
332
|
+
export function _requestStateFor(request, traceparent) {
|
|
333
|
+
if (!getConfiguration().isEnabled()) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
return requestStateFor(request, traceparent);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Internal; runs callback with `state` published as the current request's for its whole async
|
|
341
|
+
* call chain. Just calls callback when state is null, or already the current one (two integrations
|
|
342
|
+
* wrapping the same request), so it never nests a second identical context.
|
|
343
|
+
*
|
|
344
|
+
* @template T
|
|
345
|
+
* @param {import("./requestState.js").RequestState | null} state
|
|
346
|
+
* @param {() => T} callback
|
|
347
|
+
* @returns {T}
|
|
348
|
+
*/
|
|
349
|
+
export function _runWithRequestState(state, callback) {
|
|
350
|
+
if (!state || requestStorage.getStore() === state) {
|
|
351
|
+
return callback();
|
|
352
|
+
}
|
|
353
|
+
return requestStorage.run(state, callback);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Internal; called by the outbound-HTTP wrapper (httpTracing.js) just before a request to
|
|
358
|
+
* `host`:`port` goes out. Returns the traceparent header value naming `spanId` (that request's own
|
|
359
|
+
* span) as the parent, or null when this request shouldn't carry one: outside a request, with
|
|
360
|
+
* propagateTraces off or `host` not a propagation target, or for a delivery to ForgeOps itself.
|
|
361
|
+
*
|
|
362
|
+
* @param {string} host
|
|
363
|
+
* @param {number | string | null | undefined} port
|
|
364
|
+
* @param {string} protocol
|
|
365
|
+
* @param {string} spanId
|
|
366
|
+
* @returns {string | null}
|
|
367
|
+
*/
|
|
368
|
+
export function _traceparentFor(host, port, protocol, spanId) {
|
|
369
|
+
const state = requestStorage.getStore();
|
|
370
|
+
if (!state) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
const config = getConfiguration();
|
|
374
|
+
if (config.isOwnHost(host, port, protocol) || !config.propagatesTraceTo(host)) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return buildTraceparent(state.traceId, spanId);
|
|
378
|
+
}
|
|
379
|
+
|
|
245
380
|
/**
|
|
246
381
|
* Internal; called by the Express/Fastify tracing integration (integrations/tracing.js), never
|
|
247
382
|
* by host app code directly. Gives callback's own async call chain a fresh SpanBuffer, with its
|
|
@@ -261,7 +396,10 @@ export function _runWithSpanTrace(callback) {
|
|
|
261
396
|
if (!config.trackTracing || !config.isEnabled()) {
|
|
262
397
|
return callback();
|
|
263
398
|
}
|
|
264
|
-
|
|
399
|
+
// The trace id and remote parent come from the request's RequestState (see _runWithRequestState
|
|
400
|
+
// above), so this trace and any error event from the same request agree on the trace.
|
|
401
|
+
const state = requestStorage.getStore();
|
|
402
|
+
const buffer = new SpanBuffer(config, state ? { traceId: state.traceId, parentSpanId: state.parentSpanId } : {});
|
|
265
403
|
return spanStorage.run(buffer, () => spanParentStorage.run(buffer.rootSpanId, callback));
|
|
266
404
|
}
|
|
267
405
|
|
|
@@ -285,7 +423,11 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
|
|
|
285
423
|
return;
|
|
286
424
|
}
|
|
287
425
|
buffer.record({ spanId: buffer.rootSpanId, name, kind: "controller", startedAt, durationMs, root: true });
|
|
288
|
-
|
|
426
|
+
// An errored request's trace is always sent too, however fast it was, since the waterfall of
|
|
427
|
+
// what led up to an error is exactly what an issue page wants to show next to it. "Errored" means
|
|
428
|
+
// captureException ran during the request (the Express/Fastify error integrations call it too).
|
|
429
|
+
const errored = requestStorage.getStore()?.errored === true;
|
|
430
|
+
if (buffer.isSlow(getConfiguration().traceCaptureThresholdMs) || (errored && buffer.rootRecorded)) {
|
|
289
431
|
getSpanQueue().push({ trace_id: buffer.traceId, spans: buffer.spans });
|
|
290
432
|
}
|
|
291
433
|
}
|
|
@@ -303,15 +445,17 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
|
|
|
303
445
|
* @param {Date} startedAt
|
|
304
446
|
* @param {number} durationMs
|
|
305
447
|
* @param {Record<string, unknown>} [data]
|
|
448
|
+
* @param {string} [spanId] only passed by the outbound-HTTP wrapper, which has to pick it before
|
|
449
|
+
* the call goes out so the traceparent header it sends names this exact span
|
|
306
450
|
*/
|
|
307
|
-
export function _recordSpan(name, kind, startedAt, durationMs, data = {}) {
|
|
451
|
+
export function _recordSpan(name, kind, startedAt, durationMs, data = {}, spanId = randomSpanId()) {
|
|
308
452
|
const config = getConfiguration();
|
|
309
453
|
const buffer = spanStorage.getStore();
|
|
310
454
|
if (!config.trackTracing || !buffer) {
|
|
311
455
|
return;
|
|
312
456
|
}
|
|
313
457
|
const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
|
|
314
|
-
buffer.record({ spanId
|
|
458
|
+
buffer.record({ spanId, parentSpanId, name, kind, startedAt, durationMs, data });
|
|
315
459
|
}
|
|
316
460
|
|
|
317
461
|
/**
|
|
@@ -363,7 +507,9 @@ export function init(options = {}) {
|
|
|
363
507
|
// gems/forge_ops_tracker's own Railtie always applies Net::HTTP.prepend(Timing) regardless of
|
|
364
508
|
// config, checking configuration.trackTracing fresh on every actual outbound call instead (see
|
|
365
509
|
// _recordSpan above), never at install time.
|
|
366
|
-
installHttpTracing(_recordSpan);
|
|
510
|
+
installHttpTracing(_recordSpan, _traceparentFor);
|
|
511
|
+
|
|
512
|
+
startChangeSnapshot(config);
|
|
367
513
|
|
|
368
514
|
return config;
|
|
369
515
|
}
|
|
@@ -373,13 +519,20 @@ export function init(options = {}) {
|
|
|
373
519
|
* established for this async call chain, if anything; pass one explicitly to override that for
|
|
374
520
|
* this one report.
|
|
375
521
|
*
|
|
522
|
+
* Inside a request the Express/Fastify integrations are handling, the event also carries the
|
|
523
|
+
* request's transaction_name, endpoint, and trace_id, and the request's trace is sent however fast
|
|
524
|
+
* it was.
|
|
525
|
+
*
|
|
376
526
|
* @param {Error} error
|
|
377
527
|
* @param {Record<string, unknown>} [context]
|
|
378
528
|
* @param {Record<string, unknown> | null} [user]
|
|
379
529
|
*/
|
|
380
530
|
export function captureException(error, context = {}, user = null) {
|
|
381
531
|
const breadcrumbs = breadcrumbStorage.getStore()?.all() ?? [];
|
|
382
|
-
|
|
532
|
+
const state = requestStorage.getStore();
|
|
533
|
+
state?.markErrored();
|
|
534
|
+
const request = state ? { transactionName: state.transactionName, endpoint: state.endpoint, traceId: state.traceId } : {};
|
|
535
|
+
getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null, breadcrumbs, request);
|
|
383
536
|
}
|
|
384
537
|
|
|
385
538
|
/**
|
|
@@ -537,8 +690,13 @@ function installProcessLevelHandlers() {
|
|
|
537
690
|
process.on("unhandledRejection", unhandledRejectionListener);
|
|
538
691
|
}
|
|
539
692
|
|
|
540
|
-
/**
|
|
541
|
-
|
|
693
|
+
/**
|
|
694
|
+
* @internal not part of the public API: resets module state between test cases. The startup change
|
|
695
|
+
* snapshot is left marked as already sent, so the many tests that init() an enabled client and
|
|
696
|
+
* count fetch calls don't see an extra one; pass { changeSnapshot: true } to let the next init()
|
|
697
|
+
* send it (see test/changes.test.js).
|
|
698
|
+
*/
|
|
699
|
+
export function _resetForTesting({ changeSnapshot = false } = {}) {
|
|
542
700
|
if (uncaughtExceptionListener) {
|
|
543
701
|
process.off("uncaughtExceptionMonitor", uncaughtExceptionListener);
|
|
544
702
|
}
|
|
@@ -550,6 +708,8 @@ export function _resetForTesting() {
|
|
|
550
708
|
sessionFlusher = null;
|
|
551
709
|
performanceFlusher = null;
|
|
552
710
|
spanQueue = null;
|
|
711
|
+
changeQueue = null;
|
|
712
|
+
changeSnapshotStarted = !changeSnapshot;
|
|
553
713
|
metricBuffer?.discard();
|
|
554
714
|
infrastructureMetricBuffer?.discard();
|
|
555
715
|
metricBuffer = null;
|
|
@@ -565,6 +725,7 @@ export function _resetForTesting() {
|
|
|
565
725
|
// sharing the same root context. disable() drops it and leaves the instance perfectly usable
|
|
566
726
|
// again for the next run()/enterWith() call, confirmed directly, not assumed.
|
|
567
727
|
breadcrumbStorage.disable();
|
|
728
|
+
requestStorage.disable();
|
|
568
729
|
// Re-installed fresh on the very next init() call in whatever test runs next: without this,
|
|
569
730
|
// http.request/https.request would stay wrapped by a closure over a *previous* test's own
|
|
570
731
|
// _recordSpan reference forever (installHttpTracing's own guard only ever installs once per
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { captureException, runWithUser } from "../index.js";
|
|
1
|
+
import { _requestStateFor, _runWithRequestState, captureException, runWithUser } from "../index.js";
|
|
2
|
+
import { describeExpressRoute } from "./tracing.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Express error-handling middleware. Register last, after all routes:
|
|
@@ -12,6 +13,12 @@ import { captureException, runWithUser } from "../index.js";
|
|
|
12
13
|
* async route handlers to error-handling middleware automatically,
|
|
13
14
|
* verified directly against a real async handler, not assumed. Only an
|
|
14
15
|
* exception your own code catches and handles is invisible to this.
|
|
16
|
+
*
|
|
17
|
+
* The event carries the request's trace id, transaction name, and
|
|
18
|
+
* endpoint: the RequestState forgeOpsTrackerTracingExpressMiddleware
|
|
19
|
+
* already stashed on req when that's installed, or one created here from
|
|
20
|
+
* the request's own traceparent header when it isn't, so an unhandled
|
|
21
|
+
* error is linked across services either way.
|
|
15
22
|
*/
|
|
16
23
|
export function forgeOpsTrackerExpressMiddleware(err, req, res, next) {
|
|
17
24
|
// See integrations/sessionTracking.js's own comment: marks the request crashed before
|
|
@@ -20,10 +27,14 @@ export function forgeOpsTrackerExpressMiddleware(err, req, res, next) {
|
|
|
20
27
|
// the time it checks.
|
|
21
28
|
req._forgeOpsSessionCrashed = true;
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
const state = _requestStateFor(req, req.headers?.traceparent);
|
|
31
|
+
state?.describeWith(() => describeExpressRoute(req));
|
|
32
|
+
_runWithRequestState(state, () =>
|
|
33
|
+
captureException(err, {
|
|
34
|
+
path: req.path,
|
|
35
|
+
method: req.method,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
27
38
|
next(err);
|
|
28
39
|
}
|
|
29
40
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { captureException } from "../index.js";
|
|
1
|
+
import { _requestStateFor, _runWithRequestState, captureException } from "../index.js";
|
|
2
|
+
import { fastifyRoutePattern } from "./tracing.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Called directly with your Fastify instance: not registered via
|
|
@@ -15,13 +16,25 @@ import { captureException } from "../index.js";
|
|
|
15
16
|
* continues exactly as if this hook weren't registered. Only an
|
|
16
17
|
* exception your own code catches and handles is invisible to this.
|
|
17
18
|
*
|
|
19
|
+
* The event carries the request's trace id, transaction name, and
|
|
20
|
+
* endpoint: the RequestState registerForgeOpsTrackerTracing already
|
|
21
|
+
* created for this request when that's registered, or one created here
|
|
22
|
+
* from the request's own traceparent header when it isn't.
|
|
23
|
+
*
|
|
18
24
|
* @param {import("fastify").FastifyInstance} fastify
|
|
19
25
|
*/
|
|
20
26
|
export function registerForgeOpsTracker(fastify) {
|
|
21
27
|
fastify.addHook("onError", async (request, reply, error) => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
const state = _requestStateFor(request, request.headers?.traceparent);
|
|
29
|
+
const route = fastifyRoutePattern(request);
|
|
30
|
+
if (state && route && state.transactionName === null) {
|
|
31
|
+
state.setRoute(`${request.method} ${route}`, `${request.method} ${route}`);
|
|
32
|
+
}
|
|
33
|
+
_runWithRequestState(state, () =>
|
|
34
|
+
captureException(error, {
|
|
35
|
+
path: request.url,
|
|
36
|
+
method: request.method,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
26
39
|
});
|
|
27
40
|
}
|
|
@@ -1,4 +1,23 @@
|
|
|
1
|
-
import { _finishSpanTrace, _runWithSpanTrace } from "../index.js";
|
|
1
|
+
import { _finishSpanTrace, _requestStateFor, _runWithRequestState, _runWithSpanTrace } from "../index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How an Express request is named once a route has matched: transactionName the same
|
|
5
|
+
* "<HTTP method> <route pattern>" the root span and performance samples use, endpoint the same
|
|
6
|
+
* route pattern. req.route.path is the pattern as declared on the router that matched it; for a
|
|
7
|
+
* router mounted with app.use("/api", router), that's the part declared on the router, without
|
|
8
|
+
* "/api": req.baseUrl is the literal matched mount path, which for a parameterized mount
|
|
9
|
+
* ("/users/:userId") would put a real id into a field that must never carry one. null before a
|
|
10
|
+
* route has matched (the event then leaves both fields out).
|
|
11
|
+
*
|
|
12
|
+
* @param {import("express").Request} req
|
|
13
|
+
*/
|
|
14
|
+
export function describeExpressRoute(req) {
|
|
15
|
+
if (!req.route) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const name = `${req.method} ${req.route.path}`;
|
|
19
|
+
return { transactionName: name, endpoint: name };
|
|
20
|
+
}
|
|
2
21
|
|
|
3
22
|
/**
|
|
4
23
|
* Express distributed-tracing middleware: wraps the rest of the request in _runWithSpanTrace
|
|
@@ -20,20 +39,33 @@ import { _finishSpanTrace, _runWithSpanTrace } from "../index.js";
|
|
|
20
39
|
* actually finished and the root span's real total duration is known: nothing about a normal,
|
|
21
40
|
* fast request costs a single byte over the wire, matching gems/forge_ops_tracker's own
|
|
22
41
|
* Middleware::SpanTracing.
|
|
42
|
+
*
|
|
43
|
+
* Also publishes this request's RequestState (see requestState.js) whether or not trackTracing is
|
|
44
|
+
* on, so every request gets a trace id, continuing an incoming W3C traceparent header when there
|
|
45
|
+
* is one (its root span's parent is then the caller's span), and an error captured anywhere in
|
|
46
|
+
* the request carries it plus the matched route. Register it before your routes for that: an
|
|
47
|
+
* error thrown by a route registered ahead of it never sees this request's trace. Express only
|
|
48
|
+
* sets req.route once the matched handler starts, so the route is read lazily, whenever an error
|
|
49
|
+
* needs it (see RequestState#describeWith).
|
|
23
50
|
*/
|
|
24
51
|
export function forgeOpsTrackerTracingExpressMiddleware(req, res, next) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
const state = _requestStateFor(req, req.headers?.traceparent);
|
|
53
|
+
state?.describeWith(() => describeExpressRoute(req));
|
|
54
|
+
|
|
55
|
+
_runWithRequestState(state, () =>
|
|
56
|
+
_runWithSpanTrace(() => {
|
|
57
|
+
const startedAt = new Date();
|
|
58
|
+
const start = performance.now();
|
|
28
59
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
60
|
+
res.on("finish", () => {
|
|
61
|
+
const durationMs = performance.now() - start;
|
|
62
|
+
const transactionName = `${req.method} ${req.route?.path ?? req.path}`;
|
|
63
|
+
_finishSpanTrace(transactionName, startedAt, durationMs);
|
|
64
|
+
});
|
|
34
65
|
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
next();
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
37
69
|
}
|
|
38
70
|
|
|
39
71
|
/**
|
|
@@ -55,15 +87,29 @@ export function forgeOpsTrackerTracingExpressMiddleware(req, res, next) {
|
|
|
55
87
|
* matched a route at all (a 404), the same "pattern first, literal path as the 404 fallback"
|
|
56
88
|
* shape the Express integration above already takes.
|
|
57
89
|
*
|
|
90
|
+
* Also publishes this request's RequestState (see requestState.js) whether or not trackTracing is
|
|
91
|
+
* on, the same as the Express middleware above: a trace id for every request, continuing an
|
|
92
|
+
* incoming W3C traceparent header. Fastify has already matched the route by onRequest, so the
|
|
93
|
+
* transaction name and endpoint are set right here, before any handler runs, and only from the
|
|
94
|
+
* matched pattern: a request that matched no route (a 404) leaves both out.
|
|
95
|
+
*
|
|
58
96
|
* @param {import("fastify").FastifyInstance} fastify
|
|
59
97
|
*/
|
|
60
98
|
export function registerForgeOpsTrackerTracing(fastify) {
|
|
61
99
|
fastify.addHook("onRequest", (request, reply, done) => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
100
|
+
const state = _requestStateFor(request, request.headers?.traceparent);
|
|
101
|
+
const route = fastifyRoutePattern(request);
|
|
102
|
+
if (state && route) {
|
|
103
|
+
state.setRoute(`${request.method} ${route}`, `${request.method} ${route}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_runWithRequestState(state, () =>
|
|
107
|
+
_runWithSpanTrace(() => {
|
|
108
|
+
request._forgeOpsSpanStartedAt = new Date();
|
|
109
|
+
request._forgeOpsSpanStart = performance.now();
|
|
110
|
+
done();
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
67
113
|
});
|
|
68
114
|
|
|
69
115
|
fastify.addHook("onResponse", async (request) => {
|
|
@@ -78,3 +124,14 @@ export function registerForgeOpsTrackerTracing(fastify) {
|
|
|
78
124
|
_finishSpanTrace(transactionName, request._forgeOpsSpanStartedAt, durationMs);
|
|
79
125
|
});
|
|
80
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fastify's matched route pattern ("/orders/:id", prefixes included), or undefined for a request
|
|
130
|
+
* that matched no route: request.routeOptions.url, falling back to the older request.routerPath.
|
|
131
|
+
*
|
|
132
|
+
* @param {import("fastify").FastifyRequest} request
|
|
133
|
+
* @returns {string | undefined}
|
|
134
|
+
*/
|
|
135
|
+
export function fastifyRoutePattern(request) {
|
|
136
|
+
return request.routeOptions?.url ?? request.routerPath ?? undefined;
|
|
137
|
+
}
|
package/src/reporter.js
CHANGED
|
@@ -22,14 +22,15 @@ export class Reporter {
|
|
|
22
22
|
* @param {Record<string, unknown>} [context]
|
|
23
23
|
* @param {Record<string, unknown> | null} [user]
|
|
24
24
|
* @param {Array<Record<string, unknown>>} [breadcrumbs]
|
|
25
|
+
* @param {{ transactionName?: string | null, endpoint?: string | null, traceId?: string | null }} [request] see EventBuilder#build
|
|
25
26
|
*/
|
|
26
|
-
report(error, context = {}, user = null, breadcrumbs = []) {
|
|
27
|
+
report(error, context = {}, user = null, breadcrumbs = [], request = {}) {
|
|
27
28
|
try {
|
|
28
29
|
if (!this.#configuration.isEnabled()) {
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
const payload = this.#eventBuilder.build(error, context, user, breadcrumbs);
|
|
33
|
+
const payload = this.#eventBuilder.build(error, context, user, breadcrumbs, request);
|
|
33
34
|
this.#deliveryQueue.push(payload);
|
|
34
35
|
} catch (e) {
|
|
35
36
|
this.#configuration.log(`[forge-ops-tracker] report failed: ${e.name}: ${e.message}`);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { generateTraceId, parseTraceparent } from "./traceParent.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Everything this client knows about the current request that isn't already owned by one of the
|
|
5
|
+
* older, single-purpose AsyncLocalStorage instances (the user, the breadcrumb trail, the span
|
|
6
|
+
* buffer): which trace it belongs to, which remote span called it (if any), what it's called, and
|
|
7
|
+
* whether it errored. One instance per request, published by the Express/Fastify integrations
|
|
8
|
+
* (see _runWithRequestState in index.js) so captureException, the tracing integration, and the
|
|
9
|
+
* outbound-HTTP wrapper can all reach it. Ported from
|
|
10
|
+
* gems/forge_ops_tracker/lib/forge_ops_tracker/request_state.rb.
|
|
11
|
+
*
|
|
12
|
+
* The traceId exists for every request, not only when trackTracing is on: it's also what an error
|
|
13
|
+
* event carries so ForgeOps can link it to errors reported by other services taking part in the
|
|
14
|
+
* same trace, which works on any plan and with tracing off entirely.
|
|
15
|
+
*
|
|
16
|
+
* Also stashed on the framework's own request object (see requestStateFor), the Node equivalent of
|
|
17
|
+
* the Ruby gem memoizing it in the Rack env: an error-handling middleware that runs outside the
|
|
18
|
+
* tracing middleware's async context still finds the same instance there.
|
|
19
|
+
*/
|
|
20
|
+
export class RequestState {
|
|
21
|
+
traceId;
|
|
22
|
+
parentSpanId;
|
|
23
|
+
errored = false;
|
|
24
|
+
#describe;
|
|
25
|
+
#transactionName = null;
|
|
26
|
+
#endpoint = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ traceId?: string, parentSpanId?: string | null }} [ids]
|
|
30
|
+
*/
|
|
31
|
+
constructor({ traceId = generateTraceId(), parentSpanId = null } = {}) {
|
|
32
|
+
this.traceId = traceId;
|
|
33
|
+
this.parentSpanId = parentSpanId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Continues the caller's trace when the request arrived with a usable W3C traceparent header,
|
|
38
|
+
* remembering the caller's span as this request's remote parent; starts a fresh trace otherwise.
|
|
39
|
+
* @param {unknown} traceparent
|
|
40
|
+
*/
|
|
41
|
+
static fromTraceparent(traceparent) {
|
|
42
|
+
const incoming = parseTraceparent(traceparent);
|
|
43
|
+
return incoming === null ? new RequestState() : new RequestState(incoming);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How to name this request, read lazily, each time an error needs it: Express only sets req.route
|
|
48
|
+
* once the matched route's own handler starts running, with no app-level hook in between, so the
|
|
49
|
+
* integration hands over a function that reads it then instead of a name it can't know yet at
|
|
50
|
+
* request start. Returns null (and the event leaves both fields out) until a route has matched.
|
|
51
|
+
* @param {() => { transactionName: string, endpoint: string } | null} describe
|
|
52
|
+
*/
|
|
53
|
+
describeWith(describe) {
|
|
54
|
+
this.#describe = describe;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sets both names outright, for a framework that already knows the matched route when the
|
|
59
|
+
* request starts (Fastify's onRequest).
|
|
60
|
+
* @param {string} transactionName
|
|
61
|
+
* @param {string} endpoint
|
|
62
|
+
*/
|
|
63
|
+
setRoute(transactionName, endpoint) {
|
|
64
|
+
this.#transactionName = transactionName;
|
|
65
|
+
this.#endpoint = endpoint;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @returns {string | null} */
|
|
69
|
+
get transactionName() {
|
|
70
|
+
return this.#transactionName ?? this.#described()?.transactionName ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @returns {string | null} */
|
|
74
|
+
get endpoint() {
|
|
75
|
+
return this.#endpoint ?? this.#described()?.endpoint ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
markErrored() {
|
|
79
|
+
this.errored = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#described() {
|
|
83
|
+
try {
|
|
84
|
+
return this.#describe?.() ?? null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const REQUEST_STATE_KEY = Symbol.for("forge-ops-tracker.requestState");
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The RequestState memoized on `request` (an Express req or a Fastify request), created from its
|
|
95
|
+
* traceparent header on first use: whichever integration reaches a request first creates it, and
|
|
96
|
+
* every other one gets the identical instance back, so they all agree on one trace id. A symbol
|
|
97
|
+
* key, not a plain property: invisible to the app's own enumeration and serialization of req.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} request
|
|
100
|
+
* @param {unknown} traceparent the incoming traceparent header's raw value
|
|
101
|
+
* @returns {RequestState}
|
|
102
|
+
*/
|
|
103
|
+
export function requestStateFor(request, traceparent) {
|
|
104
|
+
if (request[REQUEST_STATE_KEY] === undefined) {
|
|
105
|
+
request[REQUEST_STATE_KEY] = RequestState.fromTraceparent(traceparent);
|
|
106
|
+
}
|
|
107
|
+
return request[REQUEST_STATE_KEY];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The RequestState already memoized on `request`, if any integration created one.
|
|
112
|
+
* @param {object} request
|
|
113
|
+
* @returns {RequestState | undefined}
|
|
114
|
+
*/
|
|
115
|
+
export function existingRequestState(request) {
|
|
116
|
+
return request?.[REQUEST_STATE_KEY];
|
|
117
|
+
}
|