@forge-ops/tracker 0.5.0 → 0.9.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 +161 -2
- package/package.json +4 -2
- package/src/breadcrumbBuffer.js +48 -0
- package/src/client.js +29 -0
- package/src/configuration.js +73 -0
- package/src/deliveryQueue.js +16 -3
- package/src/eventBuilder.js +15 -3
- package/src/histogramBucketer.js +26 -0
- package/src/httpTracing.js +104 -0
- package/src/index.js +371 -1
- package/src/integrations/breadcrumbContext.js +70 -0
- package/src/integrations/performance.js +29 -3
- package/src/integrations/tracing.js +80 -0
- package/src/metricBuffer.js +117 -0
- package/src/performanceFlusher.js +43 -5
- package/src/reporter.js +3 -2
- package/src/spanBuffer.js +89 -0
package/src/index.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { BreadcrumbBuffer } from "./breadcrumbBuffer.js";
|
|
2
3
|
import { Client } from "./client.js";
|
|
3
4
|
import { Configuration } from "./configuration.js";
|
|
4
5
|
import { DeliveryQueue } from "./deliveryQueue.js";
|
|
5
6
|
import { EventBuilder } from "./eventBuilder.js";
|
|
7
|
+
import { installHttpTracing, _uninstallHttpTracing } from "./httpTracing.js";
|
|
8
|
+
import { MetricBuffer } from "./metricBuffer.js";
|
|
6
9
|
import { PerformanceFlusher } from "./performanceFlusher.js";
|
|
7
10
|
import { Reporter } from "./reporter.js";
|
|
8
11
|
import { SessionFlusher } from "./sessionFlusher.js";
|
|
12
|
+
import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
|
|
9
13
|
|
|
10
14
|
export { Configuration };
|
|
11
15
|
|
|
@@ -13,6 +17,9 @@ let configuration = null;
|
|
|
13
17
|
let reporter = null;
|
|
14
18
|
let sessionFlusher = null;
|
|
15
19
|
let performanceFlusher = null;
|
|
20
|
+
let spanQueue = null;
|
|
21
|
+
let metricBuffer = null;
|
|
22
|
+
let infrastructureMetricBuffer = null;
|
|
16
23
|
let processHandlersInstalled = false;
|
|
17
24
|
let uncaughtExceptionListener = null;
|
|
18
25
|
let unhandledRejectionListener = null;
|
|
@@ -28,6 +35,48 @@ let unhandledRejectionListener = null;
|
|
|
28
35
|
// wrapping function, not a bare setter.
|
|
29
36
|
const userStorage = new AsyncLocalStorage();
|
|
30
37
|
|
|
38
|
+
// Request-scoped breadcrumb trail storage. A second, independent AsyncLocalStorage instance
|
|
39
|
+
// rather than folding this into userStorage above: distinct concern (an ordered trail, not a
|
|
40
|
+
// single attached value), same "own storage, own wrapping function" shape
|
|
41
|
+
// gems/forge_ops_tracker's own Thread.current[:forge_ops_tracker_breadcrumbs] (separate from
|
|
42
|
+
// Thread.current[:forge_ops_tracker_current_user]) and Python's own separate _breadcrumbs
|
|
43
|
+
// ContextVar (separate from its threading.local .user) both already take.
|
|
44
|
+
//
|
|
45
|
+
// Unlike Ruby's Thread.current and Python's contextvars.ContextVar, AsyncLocalStorage.run()
|
|
46
|
+
// needs no explicit "clear it when the request ends" step (see BreadcrumbContext's own
|
|
47
|
+
// ensure-clear, or _end_breadcrumb_trail's own reset()): once the callback passed to run()
|
|
48
|
+
// finishes (synchronously or, for everything causally descended from it, asynchronously), the
|
|
49
|
+
// store automatically stops being visible to anything outside that call chain, exactly the
|
|
50
|
+
// property AsyncLocalStorage exists to provide. That's why _runWithBreadcrumbTrail below is a
|
|
51
|
+
// single wrapping call, the same shape runWithUser already has, rather than a separate
|
|
52
|
+
// start/end pair the way Ruby's middleware or Python's Django/Flask/Celery hooks need (those
|
|
53
|
+
// frameworks fire two genuinely separate hooks, "request started" and "request ended," with no
|
|
54
|
+
// single function this client could wrap around the whole thing; Express's next()-based
|
|
55
|
+
// middleware chain and a real Fastify onRequest hook wrapping its own `done` callback both do
|
|
56
|
+
// give this client that single continuous call chain to wrap, confirmed directly below, not
|
|
57
|
+
// assumed).
|
|
58
|
+
const breadcrumbStorage = new AsyncLocalStorage();
|
|
59
|
+
|
|
60
|
+
// Request-scoped span-tracing storage: one SpanBuffer per request (see spanBuffer.js), the same
|
|
61
|
+
// per-request AsyncLocalStorage scoping breadcrumbStorage already uses above and for the identical
|
|
62
|
+
// reason (Node interleaves many concurrent requests' async callbacks on one thread, so anything
|
|
63
|
+
// less than AsyncLocalStorage would leak one request's spans into another's).
|
|
64
|
+
const spanStorage = new AsyncLocalStorage();
|
|
65
|
+
|
|
66
|
+
// A second, nested AsyncLocalStorage tracks "the currently open span" (a span_id, not a whole
|
|
67
|
+
// object) separately from the buffer itself: span() below and every leaf-span recorder read this
|
|
68
|
+
// to find their own real parent, and span() enters a fresh nested context around its own callback
|
|
69
|
+
// to make itself the parent for anything recorded further inside. This is deliberately NOT a
|
|
70
|
+
// mutable stack on SpanBuffer the way gems/forge_ops_tracker's own Ruby buffer uses (correct for
|
|
71
|
+
// Ruby's single-threaded-per-request execution, wrong here): two span() calls awaited
|
|
72
|
+
// concurrently in the same request (Promise.all rather than one after another) each get their own,
|
|
73
|
+
// correctly-scoped view of "what's currently open" for free, the exact property nested
|
|
74
|
+
// AsyncLocalStorage.run() calls already provide, rather than one call's pop() corrupting the
|
|
75
|
+
// other's still-pending parent. Defaults to the request's own root span id once _runWithSpanTrace
|
|
76
|
+
// below establishes it; genuinely unset outside of that (span()/_recordSpan both treat "no
|
|
77
|
+
// SpanBuffer at all" as the real signal for "not inside a traced request").
|
|
78
|
+
const spanParentStorage = new AsyncLocalStorage();
|
|
79
|
+
|
|
31
80
|
function getConfiguration() {
|
|
32
81
|
if (configuration === null) {
|
|
33
82
|
configuration = new Configuration();
|
|
@@ -61,6 +110,83 @@ function getPerformanceFlusher() {
|
|
|
61
110
|
return performanceFlusher;
|
|
62
111
|
}
|
|
63
112
|
|
|
113
|
+
/** The two metric buffers, created together on first use: independent of each other (a script may only ever call one), but cheap enough that creating both is simpler than tracking which. */
|
|
114
|
+
function getMetricBuffers() {
|
|
115
|
+
if (metricBuffer === null) {
|
|
116
|
+
const config = getConfiguration();
|
|
117
|
+
const client = new Client(config);
|
|
118
|
+
metricBuffer = new MetricBuffer(config, (entries) => client.deliverMetrics(entries), () => config.metricFlushIntervalMs);
|
|
119
|
+
infrastructureMetricBuffer = new MetricBuffer(
|
|
120
|
+
config,
|
|
121
|
+
(entries) => client.deliverInfrastructureMetrics(entries),
|
|
122
|
+
() => config.infrastructureMetricFlushIntervalMs,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return { metrics: metricBuffer, infrastructure: infrastructureMetricBuffer };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Records a named business metric (a signup, a payment, anything you want to name), buffered and
|
|
130
|
+
* flushed periodically as one batch rather than one network call per capture. `value` defaults to
|
|
131
|
+
* 1 so a bare counter-style call needs no argument; pass one for a real magnitude
|
|
132
|
+
* (`captureMetric("payment", 49)`); it may be negative (a refund). A no-op when the client isn't
|
|
133
|
+
* enabled (no DSN, or this environment isn't in `enabledEnvironments`), and a NaN or infinite value
|
|
134
|
+
* is dropped.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} name
|
|
137
|
+
* @param {number} [value]
|
|
138
|
+
*/
|
|
139
|
+
export function captureMetric(name, value = 1) {
|
|
140
|
+
const config = getConfiguration();
|
|
141
|
+
if (!config.isEnabled()) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
getMetricBuffers().metrics.record({ metric_name: name, value, environment: config.environment, release: config.release });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Records one infrastructure reading (CPU, memory, disk, anything else a script of yours reads)
|
|
149
|
+
* from one of your own hosts. `hostname` defaults to `Configuration#serverName`, so a script running
|
|
150
|
+
* on the box it reports about needs no argument. Same buffered-batch delivery and no-op-when-disabled
|
|
151
|
+
* contract as captureMetric. A short-lived cron script needs nothing more: the buffer also flushes
|
|
152
|
+
* when the event loop drains ("beforeExit"); call `await flushMetrics()` if it exits another way
|
|
153
|
+
* (`process.exit()`).
|
|
154
|
+
*
|
|
155
|
+
* @param {string} name
|
|
156
|
+
* @param {number} value
|
|
157
|
+
* @param {{ hostname?: string }} [options]
|
|
158
|
+
*/
|
|
159
|
+
export function captureInfrastructureMetric(name, value, options = {}) {
|
|
160
|
+
const config = getConfiguration();
|
|
161
|
+
if (!config.isEnabled()) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
getMetricBuffers().infrastructure.record({ metric_name: name, value, hostname: options.hostname ?? config.serverName });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Delivers every buffered metric and infrastructure reading right now, instead of waiting for the next flush interval. */
|
|
168
|
+
export async function flushMetrics() {
|
|
169
|
+
if (metricBuffer === null) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
await Promise.all([metricBuffer.flush(), infrastructureMetricBuffer.flush()]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A DeliveryQueue reused for spans (see that class's own comment for why it takes a
|
|
177
|
+
* deliverMethod/label rather than needing a whole second, near-identical class the way
|
|
178
|
+
* gems/forge_ops_tracker's own SpanQueue is): one whole trace is one queue entry, delivered
|
|
179
|
+
* promptly, never batched together with other traces the way PerformanceFlusher/the error
|
|
180
|
+
* DeliveryQueue's own default usage batch or accumulate over a time window.
|
|
181
|
+
*/
|
|
182
|
+
function getSpanQueue() {
|
|
183
|
+
if (spanQueue === null) {
|
|
184
|
+
const config = getConfiguration();
|
|
185
|
+
spanQueue = new DeliveryQueue(config, new Client(config), { deliverMethod: "deliverSpans", label: "trace" });
|
|
186
|
+
}
|
|
187
|
+
return spanQueue;
|
|
188
|
+
}
|
|
189
|
+
|
|
64
190
|
/**
|
|
65
191
|
* Internal; called by the Express/Fastify session-tracking integrations, never by host app
|
|
66
192
|
* code directly (there's nothing for a caller to decide here beyond what the middleware/hook
|
|
@@ -91,6 +217,125 @@ export function _recordPerformance(transactionName, durationMs) {
|
|
|
91
217
|
getPerformanceFlusher().record(transactionName, durationMs);
|
|
92
218
|
}
|
|
93
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Internal; called by the Express/Fastify breadcrumb-context integrations, never by host app
|
|
222
|
+
* code directly. Gives `callback`'s own async call chain a fresh, empty breadcrumb trail to
|
|
223
|
+
* accumulate into (via addBreadcrumb() below, or whatever automatic source records into it
|
|
224
|
+
* alongside its own timing; see integrations/performance.js), the same per-request scoping
|
|
225
|
+
* gems/forge_ops_tracker's BreadcrumbContext middleware and Python's _start_breadcrumb_trail
|
|
226
|
+
* both provide for their own concurrency model. A no-op wrapper (still calls callback, just
|
|
227
|
+
* with no buffer set up) when trackBreadcrumbs is off or the client isn't enabled at all, the
|
|
228
|
+
* same "skip entirely" gating _recordPerformance/_recordSession already apply to their own
|
|
229
|
+
* automatic sources; addBreadcrumb() below still works regardless of this, by lazily creating
|
|
230
|
+
* its own standalone buffer.
|
|
231
|
+
*
|
|
232
|
+
* @template T
|
|
233
|
+
* @param {() => T} callback
|
|
234
|
+
* @returns {T}
|
|
235
|
+
*/
|
|
236
|
+
export function _runWithBreadcrumbTrail(callback) {
|
|
237
|
+
const config = getConfiguration();
|
|
238
|
+
if (!config.trackBreadcrumbs || !config.isEnabled()) {
|
|
239
|
+
return callback();
|
|
240
|
+
}
|
|
241
|
+
return breadcrumbStorage.run(new BreadcrumbBuffer(config), callback);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Internal; called by the Express/Fastify tracing integration (integrations/tracing.js), never
|
|
246
|
+
* by host app code directly. Gives callback's own async call chain a fresh SpanBuffer, with its
|
|
247
|
+
* own root span id already established as "currently open" in spanParentStorage (see that
|
|
248
|
+
* storage's own comment above), so a leaf span or a manual span() call recorded before the root
|
|
249
|
+
* span itself is ever actually recorded still parents onto it correctly. A no-op wrapper (still
|
|
250
|
+
* calls callback, just with no buffer set up) when trackTracing is off or the client isn't
|
|
251
|
+
* enabled at all, the same "skip entirely" gating every other automatic source in this file
|
|
252
|
+
* already applies to itself.
|
|
253
|
+
*
|
|
254
|
+
* @template T
|
|
255
|
+
* @param {() => T} callback
|
|
256
|
+
* @returns {T}
|
|
257
|
+
*/
|
|
258
|
+
export function _runWithSpanTrace(callback) {
|
|
259
|
+
const config = getConfiguration();
|
|
260
|
+
if (!config.trackTracing || !config.isEnabled()) {
|
|
261
|
+
return callback();
|
|
262
|
+
}
|
|
263
|
+
const buffer = new SpanBuffer(config);
|
|
264
|
+
return spanStorage.run(buffer, () => spanParentStorage.run(buffer.rootSpanId, callback));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Internal; called by the Express/Fastify tracing integration once the request has actually
|
|
269
|
+
* finished and the root span's own real total duration is finally known (not any sooner: that
|
|
270
|
+
* duration is what the slow/fast decision below is actually made against). Records the request's
|
|
271
|
+
* own root "controller" span, then, only now and entirely client-side, decides whether the whole
|
|
272
|
+
* trace crossed configuration.traceCaptureThresholdMs and is worth sending at all: a normal, fast
|
|
273
|
+
* request's buffer is simply discarded here, unsent, the entire reason this feature never costs a
|
|
274
|
+
* fast request a single byte over the wire. Mirrors
|
|
275
|
+
* gems/forge_ops_tracker/lib/forge_ops_tracker/middleware/span_tracing.rb's own ensure block.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} name
|
|
278
|
+
* @param {Date} startedAt
|
|
279
|
+
* @param {number} durationMs
|
|
280
|
+
*/
|
|
281
|
+
export function _finishSpanTrace(name, startedAt, durationMs) {
|
|
282
|
+
const buffer = spanStorage.getStore();
|
|
283
|
+
if (!buffer) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
buffer.record({ spanId: buffer.rootSpanId, name, kind: "controller", startedAt, durationMs, root: true });
|
|
287
|
+
if (buffer.isSlow(getConfiguration().traceCaptureThresholdMs)) {
|
|
288
|
+
getSpanQueue().push({ trace_id: buffer.traceId, spans: buffer.spans });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Internal; called by whatever this client already automatically times end-to-end with no
|
|
294
|
+
* children of its own to nest anything under (currently just the outbound HTTP wrapper; see
|
|
295
|
+
* httpTracing.js), never by host app code directly. A no-op, the same as every other automatic
|
|
296
|
+
* source, when there's no request-level trace currently open at all (trackTracing off, or
|
|
297
|
+
* genuinely outside any request the tracing integration ever wrapped): never fabricates a trace
|
|
298
|
+
* with no request to belong to.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} name
|
|
301
|
+
* @param {string} kind
|
|
302
|
+
* @param {Date} startedAt
|
|
303
|
+
* @param {number} durationMs
|
|
304
|
+
* @param {Record<string, unknown>} [data]
|
|
305
|
+
*/
|
|
306
|
+
export function _recordSpan(name, kind, startedAt, durationMs, data = {}) {
|
|
307
|
+
const config = getConfiguration();
|
|
308
|
+
const buffer = spanStorage.getStore();
|
|
309
|
+
if (!config.trackTracing || !buffer) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
|
|
313
|
+
buffer.record({ spanId: randomSpanId(), parentSpanId, name, kind, startedAt, durationMs, data });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Internal; called by whatever this client already automatically times (currently just the
|
|
318
|
+
* Express controller/route lifecycle; see integrations/performance.js), never by host app code
|
|
319
|
+
* directly, same reasoning as _recordPerformance above. Independent of trackPerformance: an app
|
|
320
|
+
* could want the trail without the timing data, or vice versa, and each call site already has
|
|
321
|
+
* to check its own flag regardless, so there's no real cost to keeping the two independent
|
|
322
|
+
* rather than tying breadcrumbs to whether performance monitoring happens to be on (mirrors
|
|
323
|
+
* gems/forge_ops_tracker/lib/forge_ops_tracker/railtie.rb's own reasoning for the same design
|
|
324
|
+
* choice).
|
|
325
|
+
*
|
|
326
|
+
* @param {string} message
|
|
327
|
+
* @param {string} category
|
|
328
|
+
* @param {string} [level]
|
|
329
|
+
* @param {Record<string, unknown>} [data]
|
|
330
|
+
*/
|
|
331
|
+
export function _recordBreadcrumb(message, category, level = "info", data = {}) {
|
|
332
|
+
const config = getConfiguration();
|
|
333
|
+
if (!config.trackBreadcrumbs || !config.isEnabled()) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
addBreadcrumb(message, { category, level, data });
|
|
337
|
+
}
|
|
338
|
+
|
|
94
339
|
/**
|
|
95
340
|
* Configure the client. Call once at startup.
|
|
96
341
|
*
|
|
@@ -112,6 +357,13 @@ export function init(options = {}) {
|
|
|
112
357
|
installProcessLevelHandlers();
|
|
113
358
|
}
|
|
114
359
|
|
|
360
|
+
// Independent of installProcessHandlers above (that flag is only about the two process-level
|
|
361
|
+
// listeners): installed unconditionally, every init() call, the same way
|
|
362
|
+
// gems/forge_ops_tracker's own Railtie always applies Net::HTTP.prepend(Timing) regardless of
|
|
363
|
+
// config, checking configuration.trackTracing fresh on every actual outbound call instead (see
|
|
364
|
+
// _recordSpan above), never at install time.
|
|
365
|
+
installHttpTracing(_recordSpan);
|
|
366
|
+
|
|
115
367
|
return config;
|
|
116
368
|
}
|
|
117
369
|
|
|
@@ -125,7 +377,42 @@ export function init(options = {}) {
|
|
|
125
377
|
* @param {Record<string, unknown> | null} [user]
|
|
126
378
|
*/
|
|
127
379
|
export function captureException(error, context = {}, user = null) {
|
|
128
|
-
|
|
380
|
+
const breadcrumbs = breadcrumbStorage.getStore()?.all() ?? [];
|
|
381
|
+
getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null, breadcrumbs);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Adds one breadcrumb to the current request's trail (see _runWithBreadcrumbTrail above for how
|
|
386
|
+
* a request gets its own buffer to start with), or, outside a request entirely (a plain script,
|
|
387
|
+
* a worker with no breadcrumb-context wrapping installed at all), this call's own standalone
|
|
388
|
+
* trail for the rest of the process's root async context. Lazily creates a buffer the first
|
|
389
|
+
* time it's needed on whatever AsyncLocalStorage context is currently active, the same "works
|
|
390
|
+
* standalone, no specific setup required" shape gems/forge_ops_tracker's own
|
|
391
|
+
* ForgeOpsTracker.add_breadcrumb and Python's forge_ops_tracker.add_breadcrumb both already have.
|
|
392
|
+
*
|
|
393
|
+
* Uses breadcrumbStorage.enterWith() rather than .run() for that lazy-creation path
|
|
394
|
+
* specifically: unlike _runWithBreadcrumbTrail above, there's no callback here to wrap (this
|
|
395
|
+
* function returns void, called from arbitrary places in host app code), so .run() has nothing
|
|
396
|
+
* to scope the new buffer to. enterWith() sets the store for the remainder of whatever async
|
|
397
|
+
* chain is currently executing, which is exactly the "persists for the rest of this thread"
|
|
398
|
+
* behavior Ruby's Thread.current[:x] ||= ... and Python's ContextVar.set() both give their own
|
|
399
|
+
* manual API for free; the one deliberate difference is test isolation, see
|
|
400
|
+
* _resetForTesting's own comment below for why that needs an explicit disable().
|
|
401
|
+
*
|
|
402
|
+
* Works whether or not trackBreadcrumbs is on: that flag only gates the automatic sources
|
|
403
|
+
* _recordBreadcrumb above records, never this manual call, matching
|
|
404
|
+
* gems/forge_ops_tracker's own ForgeOpsTracker.add_breadcrumb.
|
|
405
|
+
*
|
|
406
|
+
* @param {string} message
|
|
407
|
+
* @param {{ category?: string, level?: string, data?: Record<string, unknown> }} [options]
|
|
408
|
+
*/
|
|
409
|
+
export function addBreadcrumb(message, options = {}) {
|
|
410
|
+
let buffer = breadcrumbStorage.getStore();
|
|
411
|
+
if (buffer === undefined) {
|
|
412
|
+
buffer = new BreadcrumbBuffer(getConfiguration());
|
|
413
|
+
breadcrumbStorage.enterWith(buffer);
|
|
414
|
+
}
|
|
415
|
+
buffer.add(message, options);
|
|
129
416
|
}
|
|
130
417
|
|
|
131
418
|
/**
|
|
@@ -149,6 +436,68 @@ export function runWithUser(user, callback) {
|
|
|
149
436
|
return userStorage.run({ user }, callback);
|
|
150
437
|
}
|
|
151
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Wraps a block of your own service-layer code as a manual span, e.g.
|
|
441
|
+
*
|
|
442
|
+
* await forgeOpsTracker.span("PaymentService.charge", async () => { ... });
|
|
443
|
+
*
|
|
444
|
+
* There's no automatic way to detect "this is a logically distinct service layer" the way a
|
|
445
|
+
* database query or an outbound HTTP call already has a real instrumentation hook to extend, the
|
|
446
|
+
* same gap gems/forge_ops_tracker's own ForgeOpsTracker.span exists to fill. Works with a sync or
|
|
447
|
+
* async callback transparently, the same way runWithUser() above does: callback's own return
|
|
448
|
+
* value (or settled promise) is what this returns, with duration measured either immediately for
|
|
449
|
+
* a sync callback or once its promise settles for an async one, never forcing a synchronous
|
|
450
|
+
* caller through an extra microtask the way an always-`async function span` would.
|
|
451
|
+
*
|
|
452
|
+
* Outside any request-level trace (trackTracing off, or genuinely outside a request the
|
|
453
|
+
* Express/Fastify tracing integration ever wrapped), just runs callback and records nothing:
|
|
454
|
+
* unlike addBreadcrumb, this does NOT lazily create its own standalone buffer, matching
|
|
455
|
+
* gems/forge_ops_tracker's own ForgeOpsTracker.span exactly, since a lone span with no
|
|
456
|
+
* request-level trace around it, and no middleware left running to ever flush it, would just
|
|
457
|
+
* accumulate in memory forever with nothing to ever send it, worse than not recording it at all.
|
|
458
|
+
*
|
|
459
|
+
* @template T
|
|
460
|
+
* @param {string} name
|
|
461
|
+
* @param {() => T} callback
|
|
462
|
+
* @param {{ kind?: string, data?: Record<string, unknown> }} [options]
|
|
463
|
+
* @returns {T}
|
|
464
|
+
*/
|
|
465
|
+
export function span(name, callback, { kind = "service", data = {} } = {}) {
|
|
466
|
+
const buffer = spanStorage.getStore();
|
|
467
|
+
if (!buffer) {
|
|
468
|
+
return callback();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const spanId = randomSpanId();
|
|
472
|
+
const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
|
|
473
|
+
const startedAt = new Date();
|
|
474
|
+
const start = performance.now();
|
|
475
|
+
const finish = () => {
|
|
476
|
+
buffer.record({ spanId, parentSpanId, name, kind, startedAt, durationMs: performance.now() - start, data });
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
const result = spanParentStorage.run(spanId, callback);
|
|
481
|
+
if (result && typeof result.then === "function") {
|
|
482
|
+
return result.then(
|
|
483
|
+
(value) => {
|
|
484
|
+
finish();
|
|
485
|
+
return value;
|
|
486
|
+
},
|
|
487
|
+
(error) => {
|
|
488
|
+
finish();
|
|
489
|
+
throw error;
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
finish();
|
|
494
|
+
return result;
|
|
495
|
+
} catch (error) {
|
|
496
|
+
finish();
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
152
501
|
/**
|
|
153
502
|
* Reports anything that would otherwise crash the process outright (a
|
|
154
503
|
* plain script, an unhandled promise rejection) with no further wiring:
|
|
@@ -199,7 +548,28 @@ export function _resetForTesting() {
|
|
|
199
548
|
reporter = null;
|
|
200
549
|
sessionFlusher = null;
|
|
201
550
|
performanceFlusher = null;
|
|
551
|
+
spanQueue = null;
|
|
552
|
+
metricBuffer?.discard();
|
|
553
|
+
infrastructureMetricBuffer?.discard();
|
|
554
|
+
metricBuffer = null;
|
|
555
|
+
infrastructureMetricBuffer = null;
|
|
202
556
|
processHandlersInstalled = false;
|
|
203
557
|
uncaughtExceptionListener = null;
|
|
204
558
|
unhandledRejectionListener = null;
|
|
559
|
+
// disable(), not just leaving it alone: a test that calls addBreadcrumb() standalone (outside
|
|
560
|
+
// any _runWithBreadcrumbTrail wrapping, exercising the lazy-creation path on purpose) sets the
|
|
561
|
+
// store via enterWith() on whatever async context Node's test runner happens to be executing
|
|
562
|
+
// tests from, which, unlike a scoped run() call, has no natural point where it unwinds on its
|
|
563
|
+
// own. Without this, that leftover store could otherwise bleed into a later, unrelated test
|
|
564
|
+
// sharing the same root context. disable() drops it and leaves the instance perfectly usable
|
|
565
|
+
// again for the next run()/enterWith() call, confirmed directly, not assumed.
|
|
566
|
+
breadcrumbStorage.disable();
|
|
567
|
+
// Re-installed fresh on the very next init() call in whatever test runs next: without this,
|
|
568
|
+
// http.request/https.request would stay wrapped by a closure over a *previous* test's own
|
|
569
|
+
// _recordSpan reference forever (installHttpTracing's own guard only ever installs once per
|
|
570
|
+
// process otherwise), which happens to still work correctly here since _recordSpan itself reads
|
|
571
|
+
// module state fresh on every call rather than closing over anything test-specific, but relying
|
|
572
|
+
// on that instead of actually restoring the original functions would be fragile, not a genuine
|
|
573
|
+
// guarantee.
|
|
574
|
+
_uninstallHttpTracing();
|
|
205
575
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { _runWithBreadcrumbTrail } from "../index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gives every request a fresh, empty breadcrumb trail to accumulate into (via
|
|
5
|
+
* addBreadcrumb(), or whatever automatic source records into it alongside its own timing; see
|
|
6
|
+
* integrations/performance.js), so one request's trail never bleeds into another's, the same
|
|
7
|
+
* concern gems/forge_ops_tracker's own Middleware::BreadcrumbContext and Python's
|
|
8
|
+
* _start_breadcrumb_trail/_end_breadcrumb_trail both solve for their own concurrency model. A
|
|
9
|
+
* sibling to the session-tracking/user-context/performance integrations below, not folded into
|
|
10
|
+
* any of them: distinct concern, same "own file, own registration point" shape every integration
|
|
11
|
+
* in this client already takes.
|
|
12
|
+
*
|
|
13
|
+
* Register FIRST, before forgeOpsTrackerUserContextMiddleware and
|
|
14
|
+
* forgeOpsTrackerPerformanceExpressMiddleware (and, for the same reason, before
|
|
15
|
+
* forgeOpsTrackerSessionTrackingExpressMiddleware too): every one of those can record a
|
|
16
|
+
* breadcrumb of its own or run host app code that calls addBreadcrumb(), so the trail has to
|
|
17
|
+
* already exist by the time any of them runs, exactly why gems/forge_ops_tracker's own
|
|
18
|
+
* initializer ordering comment documents the identical requirement for its Rack middleware.
|
|
19
|
+
*
|
|
20
|
+
* app.use(forgeOpsTrackerBreadcrumbContextExpressMiddleware); // first, before everything else
|
|
21
|
+
* app.use(forgeOpsTrackerSessionTrackingExpressMiddleware);
|
|
22
|
+
* app.use(forgeOpsTrackerPerformanceExpressMiddleware);
|
|
23
|
+
* app.use(forgeOpsTrackerUserContextMiddleware);
|
|
24
|
+
* // ...routes...
|
|
25
|
+
* app.use(forgeOpsTrackerExpressMiddleware); // still last, after all routes
|
|
26
|
+
*
|
|
27
|
+
* Wraps `next` in _runWithBreadcrumbTrail (see that function's own doc comment in index.js for
|
|
28
|
+
* why this needs no explicit "clear" step the way Ruby's ensure or Python's finally do): confirmed
|
|
29
|
+
* directly, with a real Express app and a real async route handler, that a breadcrumb recorded
|
|
30
|
+
* from deep inside an awaited route handler (or from the performance middleware's own
|
|
31
|
+
* res.on("finish") listener, which fires later still) is visible to
|
|
32
|
+
* forgeOpsTrackerExpressMiddleware's own captureException() call at the end of the same request,
|
|
33
|
+
* the same "AsyncLocalStorage survives all the way through, not just synchronously" property
|
|
34
|
+
* forgeOpsTrackerUserContextMiddleware's own comment already confirms for the user-context case.
|
|
35
|
+
*/
|
|
36
|
+
export function forgeOpsTrackerBreadcrumbContextExpressMiddleware(req, res, next) {
|
|
37
|
+
_runWithBreadcrumbTrail(next);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fastify equivalent, added as an onRequest hook (Fastify's earliest request-lifecycle hook,
|
|
42
|
+
* firing before routing and before any other hook this client registers) rather than exported as
|
|
43
|
+
* a plain function to call directly, mirroring registerForgeOpsTracker in integrations/fastify.js:
|
|
44
|
+
*
|
|
45
|
+
* registerForgeOpsTrackerBreadcrumbContext(app); // first, before registerForgeOpsTracker
|
|
46
|
+
*
|
|
47
|
+
* A real design question Ruby's/Python's implementations don't answer for Node, since Fastify has
|
|
48
|
+
* no single function this client could wrap the way Express's next() or Ruby's app.call(env) both
|
|
49
|
+
* let a middleware wrap "the rest of this request" in one call: Fastify's request lifecycle is a
|
|
50
|
+
* sequence of independently-invoked hooks instead. The fix is _runWithBreadcrumbTrail(done): an
|
|
51
|
+
* onRequest hook declared with the callback-style (request, reply, done) signature, wrapping done
|
|
52
|
+
* itself (Fastify's own continuation, which advances to the next hook/handler) in
|
|
53
|
+
* AsyncLocalStorage.run(). Node's async_hooks propagate context by causality, not by lexical
|
|
54
|
+
* nesting, so the context established here is visible to everything Fastify goes on to invoke as
|
|
55
|
+
* a consequence of calling done(): the route handler, and (should it throw) the onError hook this
|
|
56
|
+
* client's own integrations/fastify.js registers. Confirmed directly against a real Fastify app,
|
|
57
|
+
* a real async route handler, and two genuinely concurrent, interleaved requests (one slower than
|
|
58
|
+
* the other, so their event-loop turns actually interleave, not just run back-to-back): each
|
|
59
|
+
* request's own trail stayed correctly isolated from the other's, and each one's onError hook saw
|
|
60
|
+
* only its own request's breadcrumbs, never the other's, exactly the property AsyncLocalStorage
|
|
61
|
+
* exists to provide and exactly what needed confirming for a hook-based framework rather than
|
|
62
|
+
* assuming it carries over unchanged from the Express case above.
|
|
63
|
+
*
|
|
64
|
+
* @param {import("fastify").FastifyInstance} fastify
|
|
65
|
+
*/
|
|
66
|
+
export function registerForgeOpsTrackerBreadcrumbContext(fastify) {
|
|
67
|
+
fastify.addHook("onRequest", (request, reply, done) => {
|
|
68
|
+
_runWithBreadcrumbTrail(done);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { _recordPerformance } from "../index.js";
|
|
1
|
+
import { _recordBreadcrumb, _recordPerformance } from "../index.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Express performance-tracking middleware. Register wherever is convenient relative to routes
|
|
5
5
|
* (unlike sessionTracking.js's own middleware, this doesn't need to run first: it only reads
|
|
6
6
|
* req.route once the request has already been routed, inside the "finish" listener below, not
|
|
7
|
-
* at request-start)
|
|
7
|
+
* at request-start), but after forgeOpsTrackerBreadcrumbContextExpressMiddleware (see
|
|
8
|
+
* integrations/breadcrumbContext.js), so the breadcrumb recorded below has a trail to land in:
|
|
8
9
|
*
|
|
9
10
|
* app.use(forgeOpsTrackerPerformanceExpressMiddleware);
|
|
10
11
|
*
|
|
@@ -17,9 +18,30 @@ import { _recordPerformance } from "../index.js";
|
|
|
17
18
|
* transactionName is "<HTTP method> <route pattern>", e.g. "GET /users/:id", not the raw URL:
|
|
18
19
|
* req.route.path is Express's own matched route pattern, read here (after routing has actually
|
|
19
20
|
* happened, unlike at request-start where it isn't populated yet), which keeps a distinct user
|
|
20
|
-
* id from exploding into its own separate transaction the way the literal URL would
|
|
21
|
+
* id from exploding into its own separate transaction the way the literal URL would: the
|
|
21
22
|
* low-cardinality equivalent of a Rails controller#action. Falls back to req.path (the literal,
|
|
22
23
|
* unmatched path) for a request that never matched a route at all (a 404).
|
|
24
|
+
*
|
|
25
|
+
* The same res.on("finish") listener also records a "controller" breadcrumb, gated on
|
|
26
|
+
* trackBreadcrumbs independently of trackPerformance (each call below checks its own flag,
|
|
27
|
+
* mirroring gems/forge_ops_tracker/lib/forge_ops_tracker/railtie.rb's own
|
|
28
|
+
* process_action.action_controller subscription, which records both a performance sample and a
|
|
29
|
+
* breadcrumb from the one event): an app could want the trail without the timing data, or vice
|
|
30
|
+
* versa. This is currently the only place this client automatically records a breadcrumb at all:
|
|
31
|
+
* unlike Rails' framework-wide ActiveSupport::Notifications, this client has no automatic
|
|
32
|
+
* instrumentation source that isn't also this same opt-in middleware, so an app that wants
|
|
33
|
+
* automatic breadcrumbs (not just manual ones from addBreadcrumb()) needs this middleware
|
|
34
|
+
* installed regardless of whether it also wants the performance data. There's no query-level
|
|
35
|
+
* automatic breadcrumb source for the same reason there's no query-level performance
|
|
36
|
+
* instrumentation in this client at all today (no ORM/DB driver integration exists yet to hang
|
|
37
|
+
* one off of); this doesn't invent one.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately recorded with the request's final status already known (from the same
|
|
40
|
+
* res.on("finish") firing this middleware already needed for timing), the same "recorded once
|
|
41
|
+
* the response actually finished" shape as Ruby's/Python's own automatic controller breadcrumb;
|
|
42
|
+
* see those implementations' own documented gap (a breadcrumb recorded this late can never show
|
|
43
|
+
* up on that exact same request's own error, only a later one on the same connection/process
|
|
44
|
+
* could see it) for why that's an accepted trade-off, not an oversight here either.
|
|
23
45
|
*/
|
|
24
46
|
export function forgeOpsTrackerPerformanceExpressMiddleware(req, res, next) {
|
|
25
47
|
const start = performance.now();
|
|
@@ -28,6 +50,10 @@ export function forgeOpsTrackerPerformanceExpressMiddleware(req, res, next) {
|
|
|
28
50
|
const durationMs = performance.now() - start;
|
|
29
51
|
const transactionName = `${req.method} ${req.route?.path ?? req.path}`;
|
|
30
52
|
_recordPerformance(transactionName, durationMs);
|
|
53
|
+
_recordBreadcrumb(transactionName, "controller", res.statusCode >= 500 ? "error" : "info", {
|
|
54
|
+
status: res.statusCode,
|
|
55
|
+
duration_ms: Math.round(durationMs * 10) / 10,
|
|
56
|
+
});
|
|
31
57
|
});
|
|
32
58
|
|
|
33
59
|
next();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { _finishSpanTrace, _runWithSpanTrace } from "../index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Express distributed-tracing middleware: wraps the rest of the request in _runWithSpanTrace
|
|
5
|
+
* (see that function's own comment in index.js), the same "own file, own registration point"
|
|
6
|
+
* shape breadcrumbContext.js already established for a genuinely distinct concern (this is
|
|
7
|
+
* separate from, and independent of, error capture, session tracking, and aggregate performance
|
|
8
|
+
* timing). Register wherever is convenient relative to routes, the same as
|
|
9
|
+
* forgeOpsTrackerPerformanceExpressMiddleware, since it only reads req.route once the request has
|
|
10
|
+
* already been routed, inside the "finish" listener below, not at request-start:
|
|
11
|
+
*
|
|
12
|
+
* app.use(forgeOpsTrackerTracingExpressMiddleware);
|
|
13
|
+
*
|
|
14
|
+
* transactionName is built exactly like forgeOpsTrackerPerformanceExpressMiddleware's own
|
|
15
|
+
* ("<HTTP method> <route pattern>", never the raw URL): this is the same request a performance
|
|
16
|
+
* sample would already be recorded for, so a trace's own root span name has to match, not invent
|
|
17
|
+
* a second naming scheme for the same thing.
|
|
18
|
+
*
|
|
19
|
+
* The actual send-or-don't decision happens inside _finishSpanTrace, only once the response has
|
|
20
|
+
* actually finished and the root span's real total duration is known: nothing about a normal,
|
|
21
|
+
* fast request costs a single byte over the wire, matching gems/forge_ops_tracker's own
|
|
22
|
+
* Middleware::SpanTracing.
|
|
23
|
+
*/
|
|
24
|
+
export function forgeOpsTrackerTracingExpressMiddleware(req, res, next) {
|
|
25
|
+
_runWithSpanTrace(() => {
|
|
26
|
+
const startedAt = new Date();
|
|
27
|
+
const start = performance.now();
|
|
28
|
+
|
|
29
|
+
res.on("finish", () => {
|
|
30
|
+
const durationMs = performance.now() - start;
|
|
31
|
+
const transactionName = `${req.method} ${req.route?.path ?? req.path}`;
|
|
32
|
+
_finishSpanTrace(transactionName, startedAt, durationMs);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
next();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fastify equivalent, added as two separate hooks rather than one function wrapping "the rest of
|
|
41
|
+
* this request" the way Express's next() allows (see
|
|
42
|
+
* registerForgeOpsTrackerBreadcrumbContext's own comment in breadcrumbContext.js for why Fastify
|
|
43
|
+
* needs this shape at all): onRequest establishes the span trace (wrapping `done`, the same
|
|
44
|
+
* pattern that file already uses) and stashes this request's own start time on `request` itself,
|
|
45
|
+
* the same "flag stashed on the request object" convention integrations/express.js's own
|
|
46
|
+
* req._forgeOpsSessionCrashed already established; onResponse, Fastify's own dedicated
|
|
47
|
+
* "the response has actually been sent" hook, reads that back once the real total duration is
|
|
48
|
+
* known:
|
|
49
|
+
*
|
|
50
|
+
* registerForgeOpsTrackerTracing(app); // alongside registerForgeOpsTrackerBreadcrumbContext
|
|
51
|
+
*
|
|
52
|
+
* transactionName reads request.routeOptions.url, Fastify's own matched route pattern (the
|
|
53
|
+
* property that replaced the older, now-removed request.routerPath), falling back to routerPath
|
|
54
|
+
* for an older Fastify version and finally to the literal request.url for a request that never
|
|
55
|
+
* matched a route at all (a 404), the same "pattern first, literal path as the 404 fallback"
|
|
56
|
+
* shape the Express integration above already takes.
|
|
57
|
+
*
|
|
58
|
+
* @param {import("fastify").FastifyInstance} fastify
|
|
59
|
+
*/
|
|
60
|
+
export function registerForgeOpsTrackerTracing(fastify) {
|
|
61
|
+
fastify.addHook("onRequest", (request, reply, done) => {
|
|
62
|
+
_runWithSpanTrace(() => {
|
|
63
|
+
request._forgeOpsSpanStartedAt = new Date();
|
|
64
|
+
request._forgeOpsSpanStart = performance.now();
|
|
65
|
+
done();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
fastify.addHook("onResponse", async (request) => {
|
|
70
|
+
if (request._forgeOpsSpanStart === undefined) {
|
|
71
|
+
// trackTracing was off (or the client wasn't enabled) when onRequest ran above, so there's
|
|
72
|
+
// nothing to finish; _finishSpanTrace would no-op anyway (no buffer to read back), but
|
|
73
|
+
// there's no point computing a duration for nothing.
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const durationMs = performance.now() - request._forgeOpsSpanStart;
|
|
77
|
+
const transactionName = `${request.method} ${request.routeOptions?.url ?? request.routerPath ?? request.url}`;
|
|
78
|
+
_finishSpanTrace(transactionName, request._forgeOpsSpanStartedAt, durationMs);
|
|
79
|
+
});
|
|
80
|
+
}
|