@forge-ops/tracker 0.9.1 → 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/README.md +105 -3
- package/package.json +1 -1
- package/src/configuration.js +80 -0
- package/src/eventBuilder.js +48 -3
- package/src/httpTracing.js +53 -10
- package/src/index.js +92 -6
- 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/sqlStatement.js +159 -0
- package/src/traceParent.js +88 -0
package/README.md
CHANGED
|
@@ -113,6 +113,28 @@ themselves, long before it would ever reach here.
|
|
|
113
113
|
Every failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and
|
|
114
114
|
dropped rather than thrown, so a broken or unreachable tracker can never take down the host app.
|
|
115
115
|
|
|
116
|
+
## Where an error happened
|
|
117
|
+
|
|
118
|
+
An error reported during a request carries three extra fields:
|
|
119
|
+
|
|
120
|
+
- `transaction_name`: the method and the route pattern, `"GET /orders/:id"`, the same name
|
|
121
|
+
performance monitoring and traces use.
|
|
122
|
+
- `endpoint`: the method and the route as declared, `"GET /orders/:id"`. Never the literal path, so
|
|
123
|
+
an id or a token in the URL never ends up here. On Express, it's the route as declared on the
|
|
124
|
+
router that matched it: for a router mounted with `app.use("/api", router)`, that's the part
|
|
125
|
+
declared on the router, without `/api`, since the mount path Express exposes is the literal
|
|
126
|
+
matched one.
|
|
127
|
+
- `trace_id`: the request's W3C trace id, which ForgeOps uses to link this error to errors that
|
|
128
|
+
other services reported for the same trace (see "Following a request across services" below).
|
|
129
|
+
|
|
130
|
+
Register `forgeOpsTrackerTracingExpressMiddleware` before your routes, or call
|
|
131
|
+
`registerForgeOpsTrackerTracing(app)` on Fastify (see "Distributed tracing" below), for these to be
|
|
132
|
+
set on an error you capture yourself inside a handler; Fastify knows the route before any handler
|
|
133
|
+
runs, Express as soon as the matched handler starts. `forgeOpsTrackerExpressMiddleware` and
|
|
134
|
+
`registerForgeOpsTracker` add them to an unhandled error on their own. They're left out entirely
|
|
135
|
+
outside a request (a script, a worker), and they're never PII-scrubbed, like `environment` and
|
|
136
|
+
`release`: they're structured fields, not free text.
|
|
137
|
+
|
|
116
138
|
## Identifying users
|
|
117
139
|
|
|
118
140
|
```js
|
|
@@ -355,7 +377,7 @@ batch behind it. Requires a ForgeOps plan that includes custom metrics / infrast
|
|
|
355
377
|
|
|
356
378
|
## Distributed tracing
|
|
357
379
|
|
|
358
|
-
For one slow request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
|
|
380
|
+
For one slow or errored request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
|
|
359
381
|
`registerForgeOpsTrackerTracing()` (Fastify, `./integrations/tracing`) capture its full nested
|
|
360
382
|
call tree: the route span, plus every outbound HTTP call and manually-wrapped span nested under
|
|
361
383
|
it, so ForgeOps can render a waterfall for that one request.
|
|
@@ -363,7 +385,7 @@ it, so ForgeOps can render a waterfall for that one request.
|
|
|
363
385
|
```js
|
|
364
386
|
import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
|
|
365
387
|
|
|
366
|
-
app.use(forgeOpsTrackerTracingExpressMiddleware);
|
|
388
|
+
app.use(forgeOpsTrackerTracingExpressMiddleware); // before your routes
|
|
367
389
|
```
|
|
368
390
|
|
|
369
391
|
```js
|
|
@@ -375,7 +397,9 @@ registerForgeOpsTrackerTracing(fastify);
|
|
|
375
397
|
This is the whole point of the feature, so it's worth being explicit about: a request's own trace
|
|
376
398
|
is only ever built, let alone sent, once its own root span's duration crosses a threshold, decided
|
|
377
399
|
entirely client-side before a single byte goes over the wire. A normal, fast request costs
|
|
378
|
-
nothing extra.
|
|
400
|
+
nothing extra. A request that errored (an unhandled error, or any `captureException()` during it)
|
|
401
|
+
is the one exception: its trace is always sent, however fast it was, since the spans leading up to
|
|
402
|
+
an error are exactly what you want next to it.
|
|
379
403
|
|
|
380
404
|
```js
|
|
381
405
|
forgeOpsTracker.init({
|
|
@@ -413,11 +437,89 @@ with `trackTracing` off: it just runs the callback and records nothing, never th
|
|
|
413
437
|
Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn't, a captured
|
|
414
438
|
trace is simply rejected server-side and dropped, exactly like any other delivery failure.
|
|
415
439
|
|
|
440
|
+
### Following a request across services
|
|
441
|
+
|
|
442
|
+
Traces use the [W3C Trace Context](https://www.w3.org/TR/trace-context/) standard, so a request can
|
|
443
|
+
be followed from one service into the next, whichever language or tracing library the other
|
|
444
|
+
service uses:
|
|
445
|
+
|
|
446
|
+
- **Incoming**: a request that arrives with a valid `traceparent` header continues that trace (same
|
|
447
|
+
trace id), and its root span records the caller's span as its parent. A missing or malformed
|
|
448
|
+
header just starts a new trace.
|
|
449
|
+
- **Outgoing**: every outbound call made through Node's `http`/`https` modules during a request
|
|
450
|
+
gets a `traceparent` header whose parent id is that call's own span, so the next service's spans
|
|
451
|
+
nest under it. A `traceparent` you set yourself is never replaced, and this client's own
|
|
452
|
+
deliveries to ForgeOps never get one. The global `fetch()` doesn't go through `http`/`https`, so
|
|
453
|
+
it isn't instrumented and gets no header.
|
|
454
|
+
|
|
455
|
+
A trace id exists for every request even with `trackTracing` off, and the header is still sent,
|
|
456
|
+
since the trace id is also what links an error here to an error in the service you called. Seeing
|
|
457
|
+
the two errors connected in ForgeOps needs both services' projects linked there.
|
|
458
|
+
|
|
459
|
+
```js
|
|
460
|
+
import http from "node:http";
|
|
461
|
+
import express from "express";
|
|
462
|
+
import * as forgeOpsTracker from "@forge-ops/tracker";
|
|
463
|
+
import { forgeOpsTrackerExpressMiddleware } from "@forge-ops/tracker/integrations/express";
|
|
464
|
+
import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
|
|
465
|
+
|
|
466
|
+
forgeOpsTracker.init({
|
|
467
|
+
dsn: "https://<api_key>@getforgeops.net/api/v1/events",
|
|
468
|
+
propagateTraces: true, // default true; false never sends traceparent
|
|
469
|
+
// Default null: every host. A string matches that host and its subdomains on a dot boundary
|
|
470
|
+
// ("internal.example" matches "orders.internal.example", not "notinternal.example"); a RegExp is
|
|
471
|
+
// tested against the host, without the port.
|
|
472
|
+
tracePropagationTargets: ["internal.example", /^10\.0\./],
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const app = express();
|
|
476
|
+
app.use(forgeOpsTrackerTracingExpressMiddleware);
|
|
477
|
+
|
|
478
|
+
app.post("/checkout", (req, res, next) => {
|
|
479
|
+
// Carries traceparent: 00-<this request's trace id>-<this call's span id>-01
|
|
480
|
+
const call = http.request("http://orders.internal.example/orders", { method: "POST" }, (response) => {
|
|
481
|
+
response.resume();
|
|
482
|
+
res.status(response.statusCode === 201 ? 201 : 502).end();
|
|
483
|
+
});
|
|
484
|
+
call.on("error", next);
|
|
485
|
+
call.end(JSON.stringify({ sku: "A1" }));
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
app.use(forgeOpsTrackerExpressMiddleware);
|
|
489
|
+
app.listen(3000);
|
|
490
|
+
```
|
|
491
|
+
|
|
416
492
|
**Known gaps:** no database span capture (this SDK has no existing query/ORM instrumentation hook
|
|
417
493
|
of any kind yet to extend) and no Redis span capture (no existing Redis hook or dependency exists
|
|
418
494
|
here either); a database call or Redis call inside a traced request just won't show up as its own
|
|
419
495
|
span for now.
|
|
420
496
|
|
|
497
|
+
## Database errors
|
|
498
|
+
|
|
499
|
+
When an error carries the SQL behind a failed database call, the event includes the names of the stored procedure, table and view that SQL touched, so the issue tells you where to start looking. This is on by default and sends identifiers only, never values. The statement is read from a `.sql` or `.query` string on the error or anything it wraps (`cause`, and Sequelize's `parent`/`original`), which covers Sequelize, mysql2 and TypeORM. node-postgres, better-sqlite3 and Prisma errors carry none, so attach it where you ran the query with `withSql`.
|
|
500
|
+
|
|
501
|
+
To also send the SQL statement itself, opt in. Every string and number is replaced by `?` before it
|
|
502
|
+
leaves your process (`WHERE email = 'a@b.co' AND id = 42` is sent as `WHERE email = ? AND id = ?`),
|
|
503
|
+
and ForgeOps masks it again on arrival:
|
|
504
|
+
|
|
505
|
+
```js
|
|
506
|
+
import { withSql } from "@forge-ops/tracker";
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
await pool.query(sql, params);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
throw withSql(error, sql);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Opt in to also sending the masked statement (default false).
|
|
515
|
+
forgeOpsTracker.init({ dsn: "...", captureSqlStatement: true });
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Each ForgeOps project also has its own "Capture the SQL behind database errors" setting. Turn it off
|
|
519
|
+
there and the statement is never stored for that project, whatever this flag says; the names are
|
|
520
|
+
still kept. A view and a table are written the same way in SQL, so both show as tables/views; the
|
|
521
|
+
database's own error message usually settles which it was.
|
|
522
|
+
|
|
421
523
|
## Running the tests
|
|
422
524
|
|
|
423
525
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forge-ops/tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "ForgeOps error tracking client: captures unhandled exceptions (Express/Fastify integration, plus explicit capture anywhere else) and delivers them to ForgeOps over HTTP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/configuration.js
CHANGED
|
@@ -42,6 +42,21 @@ export class Configuration {
|
|
|
42
42
|
*/
|
|
43
43
|
captureSourceContext = true;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* When an error comes from a database call (Sequelize, TypeORM, mysql2, and anything that wraps
|
|
47
|
+
* one), send the names of the stored procedure, table and view its SQL touched, so an issue says
|
|
48
|
+
* where to start looking. Names are identifiers, never values, which is why this defaults on.
|
|
49
|
+
* captureSqlStatement is the separate, opt-in step of also sending the statement itself, with
|
|
50
|
+
* every string and number replaced by "?"; off by default because even a masked statement
|
|
51
|
+
* describes your schema, and ForgeOps' own per-project setting is what durably governs whether
|
|
52
|
+
* the server stores it. See sqlStatement.js.
|
|
53
|
+
* @type {boolean}
|
|
54
|
+
*/
|
|
55
|
+
captureSqlObjects = true;
|
|
56
|
+
|
|
57
|
+
/** @type {boolean} */
|
|
58
|
+
captureSqlStatement = false;
|
|
59
|
+
|
|
45
60
|
/** @type {((message: string) => void) | null} */
|
|
46
61
|
logger = null;
|
|
47
62
|
|
|
@@ -101,6 +116,26 @@ export class Configuration {
|
|
|
101
116
|
* gems/forge_ops_tracker's own Configuration#trace_capture_threshold_ms default. */
|
|
102
117
|
traceCaptureThresholdMs = 1000;
|
|
103
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Adds a W3C `traceparent` header (https://www.w3.org/TR/trace-context/) to every outbound
|
|
121
|
+
* http/https request made during a request the Express/Fastify tracing integration is handling
|
|
122
|
+
* (see httpTracing.js), so a service that reports to ForgeOps too (or anything else that
|
|
123
|
+
* understands the standard) continues this request's trace instead of starting its own. On by
|
|
124
|
+
* default, the same as gems/forge_ops_tracker's propagate_traces: the header also carries the
|
|
125
|
+
* trace id that links an error here to an error there, which is useful with or without spans.
|
|
126
|
+
* Never overwrites a traceparent the request already has.
|
|
127
|
+
*/
|
|
128
|
+
propagateTraces = true;
|
|
129
|
+
/**
|
|
130
|
+
* Which hosts get that header. null (the default) means every host. Otherwise an array whose
|
|
131
|
+
* entries are either a host string, matching that host and its subdomains on a dot boundary
|
|
132
|
+
* ("example.com" matches "api.example.com", not "badexample.com"; a leading dot is ignored), or a
|
|
133
|
+
* RegExp tested against the host (no port). Narrow it for a third-party API that rejects headers
|
|
134
|
+
* it doesn't know, or that shouldn't learn this app's trace ids at all.
|
|
135
|
+
* @type {Array<string | RegExp> | null}
|
|
136
|
+
*/
|
|
137
|
+
tracePropagationTargets = null;
|
|
138
|
+
|
|
104
139
|
/** @returns {string | null} */
|
|
105
140
|
apiKey() {
|
|
106
141
|
const parsed = this.#parsedDsn();
|
|
@@ -188,6 +223,51 @@ export class Configuration {
|
|
|
188
223
|
return uri.replace(/\/events$/, "/spans");
|
|
189
224
|
}
|
|
190
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Whether an outbound request to `host` should carry a traceparent header; see
|
|
228
|
+
* propagateTraces/tracePropagationTargets above. Case-insensitive, since hostnames are.
|
|
229
|
+
* @param {string} host
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
propagatesTraceTo(host) {
|
|
233
|
+
if (!this.propagateTraces) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (this.tracePropagationTargets === null || this.tracePropagationTargets === undefined) {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
const normalized = String(host ?? "").toLowerCase();
|
|
240
|
+
return this.tracePropagationTargets.some((target) => {
|
|
241
|
+
if (target instanceof RegExp) {
|
|
242
|
+
target.lastIndex = 0; // a /g or /y pattern would otherwise carry state from the last test
|
|
243
|
+
return target.test(normalized);
|
|
244
|
+
}
|
|
245
|
+
const suffix = String(target).toLowerCase().replace(/^\./, "");
|
|
246
|
+
return suffix !== "" && (normalized === suffix || normalized.endsWith(`.${suffix}`));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Whether a request to `host`:`port` is one of this client's own deliveries to ForgeOps (same
|
|
252
|
+
* host and port as the DSN), so the outbound-HTTP wrapper never adds a traceparent to it. The
|
|
253
|
+
* Client itself uses fetch(), which that wrapper never sees, so this is a second guard, not the
|
|
254
|
+
* only one.
|
|
255
|
+
* @param {string} host
|
|
256
|
+
* @param {number | string | null | undefined} port
|
|
257
|
+
* @param {string} protocol "http:" or "https:"
|
|
258
|
+
* @returns {boolean}
|
|
259
|
+
*/
|
|
260
|
+
isOwnHost(host, port, protocol) {
|
|
261
|
+
const parsed = this.#parsedDsn();
|
|
262
|
+
if (parsed === null || !host) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
const defaultPort = (scheme) => (scheme === "http:" ? "80" : scheme === "https:" ? "443" : "");
|
|
266
|
+
const ownPort = parsed.port || defaultPort(parsed.protocol);
|
|
267
|
+
const targetPort = port === null || port === undefined || port === "" ? defaultPort(protocol) : String(port);
|
|
268
|
+
return String(host).toLowerCase() === parsed.hostname.toLowerCase() && targetPort === ownPort;
|
|
269
|
+
}
|
|
270
|
+
|
|
191
271
|
/** @returns {boolean} */
|
|
192
272
|
isEnabled() {
|
|
193
273
|
return Boolean(this.dsn) && this.apiKey() !== null && this.enabledEnvironments.has(this.environment);
|
package/src/eventBuilder.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { scrub, scrubString } from "./piiScrubber.js";
|
|
4
|
+
import * as sqlStatement from "./sqlStatement.js";
|
|
4
5
|
|
|
5
6
|
const MAX_FRAMES = 500;
|
|
6
7
|
|
|
@@ -48,8 +49,14 @@ export class EventBuilder {
|
|
|
48
49
|
* @param {Record<string, unknown>} [context]
|
|
49
50
|
* @param {Record<string, unknown> | null} [user]
|
|
50
51
|
* @param {Array<Record<string, unknown>>} [breadcrumbs]
|
|
52
|
+
* @param {{ transactionName?: string | null, endpoint?: string | null, traceId?: string | null }} [request]
|
|
53
|
+
* Where the error happened (see RequestState in requestState.js): transactionName is the same
|
|
54
|
+
* "GET /orders/:id" name the request's root span and performance samples use, endpoint the
|
|
55
|
+
* human-readable route ("GET /orders/:id"), traceId the request's 32-character lowercase hex
|
|
56
|
+
* W3C trace id, which is also what links this error to errors other services reported for the
|
|
57
|
+
* same trace. All absent outside a request, and a missing one is left out of the payload.
|
|
51
58
|
*/
|
|
52
|
-
build(error, context = {}, user = null, breadcrumbs = []) {
|
|
59
|
+
build(error, context = {}, user = null, breadcrumbs = [], request = {}) {
|
|
53
60
|
const payload = {
|
|
54
61
|
exception_class: error?.name ?? "Error",
|
|
55
62
|
message: error?.message ?? "",
|
|
@@ -68,12 +75,24 @@ export class EventBuilder {
|
|
|
68
75
|
if (breadcrumbs && breadcrumbs.length > 0) {
|
|
69
76
|
payload.breadcrumbs = [...breadcrumbs];
|
|
70
77
|
}
|
|
78
|
+
if (request.transactionName) {
|
|
79
|
+
payload.transaction_name = request.transactionName;
|
|
80
|
+
}
|
|
81
|
+
if (request.endpoint) {
|
|
82
|
+
payload.endpoint = request.endpoint;
|
|
83
|
+
}
|
|
84
|
+
if (request.traceId) {
|
|
85
|
+
payload.trace_id = request.traceId;
|
|
86
|
+
}
|
|
87
|
+
this.#attachSql(payload, error);
|
|
71
88
|
|
|
72
89
|
return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
|
|
73
90
|
}
|
|
74
91
|
|
|
75
|
-
// exception_class/occurred_at/environment/release/server_name/user
|
|
76
|
-
// left alone
|
|
92
|
+
// exception_class/occurred_at/environment/release/server_name/user/
|
|
93
|
+
// transaction_name/endpoint/trace_id are left alone (endpoint is a
|
|
94
|
+
// declared route pattern, never the literal path, so there's no id or
|
|
95
|
+
// token in it to scrub): structured fields this client or the host app sets
|
|
77
96
|
// deliberately, not free text an exception or its context could
|
|
78
97
|
// accidentally spill sensitive data into. user specifically is a
|
|
79
98
|
// deliberate exemption, not an oversight: the scrubber's own email
|
|
@@ -94,9 +113,35 @@ export class EventBuilder {
|
|
|
94
113
|
if ("breadcrumbs" in payload) {
|
|
95
114
|
scrubbed.breadcrumbs = scrub(payload.breadcrumbs);
|
|
96
115
|
}
|
|
116
|
+
if ("sql_statement" in payload) {
|
|
117
|
+
scrubbed.sql_statement = scrubString(payload.sql_statement);
|
|
118
|
+
}
|
|
97
119
|
return scrubbed;
|
|
98
120
|
}
|
|
99
121
|
|
|
122
|
+
// See sqlStatement.js for what's read off the error and how it's masked. The statement itself
|
|
123
|
+
// only goes out when captureSqlStatement is on; the extracted names go out on their own
|
|
124
|
+
// (captureSqlObjects) so an issue can still name the procedure or view involved.
|
|
125
|
+
#attachSql(payload, error) {
|
|
126
|
+
const config = this.#configuration;
|
|
127
|
+
if (!config.captureSqlObjects && !config.captureSqlStatement) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const masked = sqlStatement.mask(sqlStatement.findIn(error));
|
|
132
|
+
if (masked === null) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const found = sqlStatement.objects(masked);
|
|
137
|
+
if (found && config.captureSqlObjects) {
|
|
138
|
+
payload.sql_objects = found;
|
|
139
|
+
}
|
|
140
|
+
if (config.captureSqlStatement) {
|
|
141
|
+
payload.sql_statement = masked;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
100
145
|
#scrubFrame(frame) {
|
|
101
146
|
const scrubbed = {
|
|
102
147
|
...frame,
|
package/src/httpTracing.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
|
+
import { generateSpanId, TRACEPARENT_HEADER } from "./traceParent.js";
|
|
3
4
|
|
|
4
5
|
let installed = false;
|
|
5
6
|
let originalHttpRequest = null;
|
|
@@ -20,19 +21,28 @@ let originalHttpsRequest = null;
|
|
|
20
21
|
* recordSpan is passed in as a plain parameter rather than imported directly from index.js: this
|
|
21
22
|
* module gets installed FROM index.js's own init(), so importing index.js back from here would
|
|
22
23
|
* create a circular module dependency index.js has never needed before, for no real benefit over
|
|
23
|
-
* just passing the
|
|
24
|
+
* just passing the functions actually used.
|
|
24
25
|
*
|
|
25
|
-
*
|
|
26
|
+
* Also propagates the current request's trace to the service being called, as a W3C `traceparent`
|
|
27
|
+
* header (see traceParent.js, and Configuration#propagateTraces/tracePropagationTargets for turning
|
|
28
|
+
* it off or narrowing it to specific hosts). The header's parent id is this outbound call's own
|
|
29
|
+
* span id, generated before the call is made (the span itself can only be recorded afterward, once
|
|
30
|
+
* its duration is known) and then recorded with that exact id, so the downstream service's root
|
|
31
|
+
* span points at a span that really exists in this trace. traceparentFor decides whether there is
|
|
32
|
+
* a trace to continue at all (only inside a request) and returns null otherwise.
|
|
33
|
+
*
|
|
34
|
+
* @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>, spanId?: string) => void} recordSpan
|
|
35
|
+
* @param {(host: string, port: number | string | null | undefined, protocol: string, spanId: string) => string | null} [traceparentFor]
|
|
26
36
|
*/
|
|
27
|
-
export function installHttpTracing(recordSpan) {
|
|
37
|
+
export function installHttpTracing(recordSpan, traceparentFor = () => null) {
|
|
28
38
|
if (installed) {
|
|
29
39
|
return;
|
|
30
40
|
}
|
|
31
41
|
installed = true;
|
|
32
42
|
originalHttpRequest = http.request;
|
|
33
43
|
originalHttpsRequest = https.request;
|
|
34
|
-
http.request = wrap(originalHttpRequest, recordSpan);
|
|
35
|
-
https.request = wrap(originalHttpsRequest, recordSpan);
|
|
44
|
+
http.request = wrap(originalHttpRequest, recordSpan, traceparentFor, "http:");
|
|
45
|
+
https.request = wrap(originalHttpsRequest, recordSpan, traceparentFor, "https:");
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
/** @internal test-only: restores http/https.request exactly as installHttpTracing found them. */
|
|
@@ -47,12 +57,14 @@ export function _uninstallHttpTracing() {
|
|
|
47
57
|
originalHttpsRequest = null;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
function wrap(originalRequest, recordSpan) {
|
|
60
|
+
function wrap(originalRequest, recordSpan, traceparentFor, defaultProtocol) {
|
|
51
61
|
return function patchedRequest(...args) {
|
|
62
|
+
const spanId = generateSpanId();
|
|
52
63
|
const startedAt = new Date();
|
|
53
64
|
const start = performance.now();
|
|
54
65
|
const req = originalRequest.apply(this, args);
|
|
55
|
-
const { method, host } = describeRequest(args);
|
|
66
|
+
const { method, host, port, protocol } = describeRequest(args, defaultProtocol);
|
|
67
|
+
propagateTrace(req, () => traceparentFor(host, port, protocol, spanId));
|
|
56
68
|
|
|
57
69
|
// "response" and "error" are mutually exclusive on a real ClientRequest, but guarded anyway:
|
|
58
70
|
// recording twice for the same outbound call would double it up in the waterfall.
|
|
@@ -62,7 +74,7 @@ function wrap(originalRequest, recordSpan) {
|
|
|
62
74
|
return;
|
|
63
75
|
}
|
|
64
76
|
finished = true;
|
|
65
|
-
recordSpan(`${method} ${host}`, "http", startedAt, performance.now() - start, status === undefined ? {} : { status });
|
|
77
|
+
recordSpan(`${method} ${host}`, "http", startedAt, performance.now() - start, status === undefined ? {} : { status }, spanId);
|
|
66
78
|
};
|
|
67
79
|
|
|
68
80
|
// No status at all means the request itself failed (DNS, connection refused, timeout) before
|
|
@@ -75,6 +87,31 @@ function wrap(originalRequest, recordSpan) {
|
|
|
75
87
|
};
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Adds the traceparent header to a request that was just created and hasn't sent its headers yet
|
|
92
|
+
* (the caller only writes or ends it after http.request returns). Leaves a traceparent the caller
|
|
93
|
+
* already set alone: whoever set it explicitly knows better than this client which trace the call
|
|
94
|
+
* belongs to. Skipped when headers were already flushed (a raw-array `headers` option sends them at
|
|
95
|
+
* construction). Never throws, since a failure to add a header must never stop the host app's own
|
|
96
|
+
* request from going out.
|
|
97
|
+
*
|
|
98
|
+
* @param {import("node:http").ClientRequest} req
|
|
99
|
+
* @param {() => string | null} traceparent
|
|
100
|
+
*/
|
|
101
|
+
function propagateTrace(req, traceparent) {
|
|
102
|
+
try {
|
|
103
|
+
if (req.headersSent || req.hasHeader(TRACEPARENT_HEADER)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const value = traceparent();
|
|
107
|
+
if (value !== null) {
|
|
108
|
+
req.setHeader(TRACEPARENT_HEADER, value);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// See this function's own comment: the request goes out without the header.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
/**
|
|
79
116
|
* Best-effort parse of whatever calling convention produced this request (a bare options object,
|
|
80
117
|
* a URL string/object plus an optional options object, either form optionally followed by a
|
|
@@ -82,15 +119,21 @@ function wrap(originalRequest, recordSpan) {
|
|
|
82
119
|
* path or query string a customer's own request carries (that could hold a customer's own id or
|
|
83
120
|
* a secret), the same reasoning every other transaction_name/span name in this client already
|
|
84
121
|
* follows.
|
|
122
|
+
*
|
|
123
|
+
* port/protocol are only ever used to recognize this client's own deliveries to ForgeOps (see
|
|
124
|
+
* Configuration#isOwnHost), never in a label.
|
|
85
125
|
* @param {unknown[]} args
|
|
126
|
+
* @param {string} defaultProtocol
|
|
86
127
|
*/
|
|
87
|
-
function describeRequest(args) {
|
|
128
|
+
function describeRequest(args, defaultProtocol) {
|
|
88
129
|
const [first, second] = args;
|
|
89
130
|
const options = {};
|
|
90
131
|
|
|
91
132
|
if (typeof first === "string" || first instanceof URL) {
|
|
92
133
|
const url = typeof first === "string" ? new URL(first) : first;
|
|
93
134
|
options.host = url.hostname;
|
|
135
|
+
options.port = url.port;
|
|
136
|
+
options.protocol = url.protocol;
|
|
94
137
|
if (second && typeof second === "object") {
|
|
95
138
|
Object.assign(options, second);
|
|
96
139
|
}
|
|
@@ -100,5 +143,5 @@ function describeRequest(args) {
|
|
|
100
143
|
|
|
101
144
|
const method = String(options.method ?? "GET").toUpperCase();
|
|
102
145
|
const host = options.host ?? options.hostname ?? "unknown";
|
|
103
|
-
return { method, host };
|
|
146
|
+
return { method, host, port: options.port, protocol: options.protocol ?? defaultProtocol };
|
|
104
147
|
}
|
package/src/index.js
CHANGED
|
@@ -8,10 +8,13 @@ import { installHttpTracing, _uninstallHttpTracing } from "./httpTracing.js";
|
|
|
8
8
|
import { MetricBuffer } from "./metricBuffer.js";
|
|
9
9
|
import { PerformanceFlusher } from "./performanceFlusher.js";
|
|
10
10
|
import { Reporter } from "./reporter.js";
|
|
11
|
+
import { requestStateFor } from "./requestState.js";
|
|
11
12
|
import { SessionFlusher } from "./sessionFlusher.js";
|
|
12
13
|
import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
|
|
14
|
+
import { buildTraceparent } from "./traceParent.js";
|
|
13
15
|
|
|
14
16
|
export { Configuration };
|
|
17
|
+
export { withSql } from "./sqlStatement.js";
|
|
15
18
|
|
|
16
19
|
let configuration = null;
|
|
17
20
|
let reporter = null;
|
|
@@ -77,6 +80,14 @@ const spanStorage = new AsyncLocalStorage();
|
|
|
77
80
|
// SpanBuffer at all" as the real signal for "not inside a traced request").
|
|
78
81
|
const spanParentStorage = new AsyncLocalStorage();
|
|
79
82
|
|
|
83
|
+
// Request-scoped RequestState (trace id, remote parent span id, transaction name, endpoint, errored
|
|
84
|
+
// flag; see requestState.js), published by the Express/Fastify integrations for the duration of a
|
|
85
|
+
// request through _runWithRequestState below. Not gated on trackTracing: the trace id links an
|
|
86
|
+
// error to errors in other services even when no spans are ever recorded. Unset outside a request,
|
|
87
|
+
// where captureException, span tracing, and the outbound-HTTP wrapper all behave exactly as they
|
|
88
|
+
// did before this existed.
|
|
89
|
+
const requestStorage = new AsyncLocalStorage();
|
|
90
|
+
|
|
80
91
|
function getConfiguration() {
|
|
81
92
|
if (configuration === null) {
|
|
82
93
|
configuration = new Configuration();
|
|
@@ -241,6 +252,64 @@ export function _runWithBreadcrumbTrail(callback) {
|
|
|
241
252
|
return breadcrumbStorage.run(new BreadcrumbBuffer(config), callback);
|
|
242
253
|
}
|
|
243
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Internal; called by the Express/Fastify integrations with their own request object and its
|
|
257
|
+
* incoming traceparent header's raw value. Returns the RequestState memoized on that request (see
|
|
258
|
+
* requestState.js), continuing the incoming trace or starting a fresh one, or null when the client
|
|
259
|
+
* isn't enabled at all, the same "skip entirely" gating every other automatic source applies.
|
|
260
|
+
*
|
|
261
|
+
* @param {object} request an Express req or a Fastify request
|
|
262
|
+
* @param {unknown} traceparent
|
|
263
|
+
* @returns {import("./requestState.js").RequestState | null}
|
|
264
|
+
*/
|
|
265
|
+
export function _requestStateFor(request, traceparent) {
|
|
266
|
+
if (!getConfiguration().isEnabled()) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
return requestStateFor(request, traceparent);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Internal; runs callback with `state` published as the current request's for its whole async
|
|
274
|
+
* call chain. Just calls callback when state is null, or already the current one (two integrations
|
|
275
|
+
* wrapping the same request), so it never nests a second identical context.
|
|
276
|
+
*
|
|
277
|
+
* @template T
|
|
278
|
+
* @param {import("./requestState.js").RequestState | null} state
|
|
279
|
+
* @param {() => T} callback
|
|
280
|
+
* @returns {T}
|
|
281
|
+
*/
|
|
282
|
+
export function _runWithRequestState(state, callback) {
|
|
283
|
+
if (!state || requestStorage.getStore() === state) {
|
|
284
|
+
return callback();
|
|
285
|
+
}
|
|
286
|
+
return requestStorage.run(state, callback);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Internal; called by the outbound-HTTP wrapper (httpTracing.js) just before a request to
|
|
291
|
+
* `host`:`port` goes out. Returns the traceparent header value naming `spanId` (that request's own
|
|
292
|
+
* span) as the parent, or null when this request shouldn't carry one: outside a request, with
|
|
293
|
+
* propagateTraces off or `host` not a propagation target, or for a delivery to ForgeOps itself.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} host
|
|
296
|
+
* @param {number | string | null | undefined} port
|
|
297
|
+
* @param {string} protocol
|
|
298
|
+
* @param {string} spanId
|
|
299
|
+
* @returns {string | null}
|
|
300
|
+
*/
|
|
301
|
+
export function _traceparentFor(host, port, protocol, spanId) {
|
|
302
|
+
const state = requestStorage.getStore();
|
|
303
|
+
if (!state) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const config = getConfiguration();
|
|
307
|
+
if (config.isOwnHost(host, port, protocol) || !config.propagatesTraceTo(host)) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
return buildTraceparent(state.traceId, spanId);
|
|
311
|
+
}
|
|
312
|
+
|
|
244
313
|
/**
|
|
245
314
|
* Internal; called by the Express/Fastify tracing integration (integrations/tracing.js), never
|
|
246
315
|
* by host app code directly. Gives callback's own async call chain a fresh SpanBuffer, with its
|
|
@@ -260,7 +329,10 @@ export function _runWithSpanTrace(callback) {
|
|
|
260
329
|
if (!config.trackTracing || !config.isEnabled()) {
|
|
261
330
|
return callback();
|
|
262
331
|
}
|
|
263
|
-
|
|
332
|
+
// The trace id and remote parent come from the request's RequestState (see _runWithRequestState
|
|
333
|
+
// above), so this trace and any error event from the same request agree on the trace.
|
|
334
|
+
const state = requestStorage.getStore();
|
|
335
|
+
const buffer = new SpanBuffer(config, state ? { traceId: state.traceId, parentSpanId: state.parentSpanId } : {});
|
|
264
336
|
return spanStorage.run(buffer, () => spanParentStorage.run(buffer.rootSpanId, callback));
|
|
265
337
|
}
|
|
266
338
|
|
|
@@ -284,7 +356,11 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
|
|
|
284
356
|
return;
|
|
285
357
|
}
|
|
286
358
|
buffer.record({ spanId: buffer.rootSpanId, name, kind: "controller", startedAt, durationMs, root: true });
|
|
287
|
-
|
|
359
|
+
// An errored request's trace is always sent too, however fast it was, since the waterfall of
|
|
360
|
+
// what led up to an error is exactly what an issue page wants to show next to it. "Errored" means
|
|
361
|
+
// captureException ran during the request (the Express/Fastify error integrations call it too).
|
|
362
|
+
const errored = requestStorage.getStore()?.errored === true;
|
|
363
|
+
if (buffer.isSlow(getConfiguration().traceCaptureThresholdMs) || (errored && buffer.rootRecorded)) {
|
|
288
364
|
getSpanQueue().push({ trace_id: buffer.traceId, spans: buffer.spans });
|
|
289
365
|
}
|
|
290
366
|
}
|
|
@@ -302,15 +378,17 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
|
|
|
302
378
|
* @param {Date} startedAt
|
|
303
379
|
* @param {number} durationMs
|
|
304
380
|
* @param {Record<string, unknown>} [data]
|
|
381
|
+
* @param {string} [spanId] only passed by the outbound-HTTP wrapper, which has to pick it before
|
|
382
|
+
* the call goes out so the traceparent header it sends names this exact span
|
|
305
383
|
*/
|
|
306
|
-
export function _recordSpan(name, kind, startedAt, durationMs, data = {}) {
|
|
384
|
+
export function _recordSpan(name, kind, startedAt, durationMs, data = {}, spanId = randomSpanId()) {
|
|
307
385
|
const config = getConfiguration();
|
|
308
386
|
const buffer = spanStorage.getStore();
|
|
309
387
|
if (!config.trackTracing || !buffer) {
|
|
310
388
|
return;
|
|
311
389
|
}
|
|
312
390
|
const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
|
|
313
|
-
buffer.record({ spanId
|
|
391
|
+
buffer.record({ spanId, parentSpanId, name, kind, startedAt, durationMs, data });
|
|
314
392
|
}
|
|
315
393
|
|
|
316
394
|
/**
|
|
@@ -362,7 +440,7 @@ export function init(options = {}) {
|
|
|
362
440
|
// gems/forge_ops_tracker's own Railtie always applies Net::HTTP.prepend(Timing) regardless of
|
|
363
441
|
// config, checking configuration.trackTracing fresh on every actual outbound call instead (see
|
|
364
442
|
// _recordSpan above), never at install time.
|
|
365
|
-
installHttpTracing(_recordSpan);
|
|
443
|
+
installHttpTracing(_recordSpan, _traceparentFor);
|
|
366
444
|
|
|
367
445
|
return config;
|
|
368
446
|
}
|
|
@@ -372,13 +450,20 @@ export function init(options = {}) {
|
|
|
372
450
|
* established for this async call chain, if anything; pass one explicitly to override that for
|
|
373
451
|
* this one report.
|
|
374
452
|
*
|
|
453
|
+
* Inside a request the Express/Fastify integrations are handling, the event also carries the
|
|
454
|
+
* request's transaction_name, endpoint, and trace_id, and the request's trace is sent however fast
|
|
455
|
+
* it was.
|
|
456
|
+
*
|
|
375
457
|
* @param {Error} error
|
|
376
458
|
* @param {Record<string, unknown>} [context]
|
|
377
459
|
* @param {Record<string, unknown> | null} [user]
|
|
378
460
|
*/
|
|
379
461
|
export function captureException(error, context = {}, user = null) {
|
|
380
462
|
const breadcrumbs = breadcrumbStorage.getStore()?.all() ?? [];
|
|
381
|
-
|
|
463
|
+
const state = requestStorage.getStore();
|
|
464
|
+
state?.markErrored();
|
|
465
|
+
const request = state ? { transactionName: state.transactionName, endpoint: state.endpoint, traceId: state.traceId } : {};
|
|
466
|
+
getReporter().report(error, context, user ?? userStorage.getStore()?.user ?? null, breadcrumbs, request);
|
|
382
467
|
}
|
|
383
468
|
|
|
384
469
|
/**
|
|
@@ -564,6 +649,7 @@ export function _resetForTesting() {
|
|
|
564
649
|
// sharing the same root context. disable() drops it and leaves the instance perfectly usable
|
|
565
650
|
// again for the next run()/enterWith() call, confirmed directly, not assumed.
|
|
566
651
|
breadcrumbStorage.disable();
|
|
652
|
+
requestStorage.disable();
|
|
567
653
|
// Re-installed fresh on the very next init() call in whatever test runs next: without this,
|
|
568
654
|
// http.request/https.request would stay wrapped by a closure over a *previous* test's own
|
|
569
655
|
// _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
|
+
}
|
package/src/spanBuffer.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { generateSpanId, generateTraceId } from "./traceParent.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* A short, unique-enough identifier for one span:
|
|
5
|
-
*
|
|
4
|
+
* A short, unique-enough identifier for one span: a W3C span id (16 lowercase hex characters,
|
|
5
|
+
* never all zeros; see traceParent.js), the same id a traceparent header's parent-id carries. The
|
|
6
6
|
* server only ever needs these to be unique within one trace's own array (see
|
|
7
7
|
* Api::V1::SpansController on the Rails side), never a real database id, so this is deliberately
|
|
8
8
|
* cheap rather than a full UUID.
|
|
9
9
|
* @returns {string}
|
|
10
10
|
*/
|
|
11
11
|
export function randomSpanId() {
|
|
12
|
-
return
|
|
12
|
+
return generateSpanId();
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -33,17 +33,31 @@ export class SpanBuffer {
|
|
|
33
33
|
rootSpanId;
|
|
34
34
|
spans = [];
|
|
35
35
|
#rootDurationMs = null;
|
|
36
|
+
#remoteParentSpanId;
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* traceId/parentSpanId come from the request's own state (see _runWithRequestState in index.js)
|
|
40
|
+
* when there is one, never generated separately here, so a trace this sends and an error event
|
|
41
|
+
* from the same request always agree on which trace they belong to. parentSpanId is the calling
|
|
42
|
+
* service's span (from an incoming traceparent header), recorded as the root span's parent: the
|
|
43
|
+
* server treats a parent that isn't in this trace's own batch as remote, and nests this
|
|
44
|
+
* service's root under the caller's outgoing HTTP span.
|
|
45
|
+
*
|
|
46
|
+
* @param {import("./configuration.js").Configuration} configuration
|
|
47
|
+
* @param {{ traceId?: string, parentSpanId?: string | null }} [ids]
|
|
48
|
+
*/
|
|
49
|
+
constructor(configuration, { traceId = generateTraceId(), parentSpanId = null } = {}) {
|
|
38
50
|
this.#configuration = configuration;
|
|
39
|
-
this.traceId =
|
|
51
|
+
this.traceId = traceId;
|
|
40
52
|
this.rootSpanId = randomSpanId();
|
|
53
|
+
this.#remoteParentSpanId = parentSpanId;
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
/**
|
|
44
57
|
* root: true only for the one call that records the request's own root span: forces
|
|
45
|
-
* parent_span_id null
|
|
46
|
-
*
|
|
58
|
+
* parent_span_id to the remote parent (null unless the request arrived with a traceparent
|
|
59
|
+
* header) explicitly, the same reasoning span_buffer.rb's own #record documents for why that
|
|
60
|
+
* can't just be read off of "whatever's currently open" for the root specifically.
|
|
47
61
|
*
|
|
48
62
|
* @param {{
|
|
49
63
|
* spanId: string,
|
|
@@ -59,7 +73,7 @@ export class SpanBuffer {
|
|
|
59
73
|
record({ spanId, parentSpanId = null, name, kind, startedAt, durationMs, data = {}, root = false }) {
|
|
60
74
|
this.spans.push({
|
|
61
75
|
span_id: spanId,
|
|
62
|
-
parent_span_id: root ?
|
|
76
|
+
parent_span_id: root ? this.#remoteParentSpanId : parentSpanId,
|
|
63
77
|
name,
|
|
64
78
|
kind,
|
|
65
79
|
// Date#toISOString() already yields millisecond precision UTC ("...sssZ"), exactly the wire
|
|
@@ -76,6 +90,15 @@ export class SpanBuffer {
|
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Whether the request's own root span has been recorded: an errored request's trace is only sent
|
|
95
|
+
* once it has, since spans with no root to hang from would render as a waterfall with no top.
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
get rootRecorded() {
|
|
99
|
+
return this.#rootDurationMs !== null;
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
/**
|
|
80
103
|
* null (never sent) until the root span has actually been recorded: a request whose own
|
|
81
104
|
* tracing integration never got the chance to call back at all has no duration to compare
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Finds the SQL behind a database error and reduces it to something safe to send: the names of
|
|
2
|
+
// the stored procedures, tables and views it touched, and (only if configuration.
|
|
3
|
+
// captureSqlStatement is on) the statement itself with every string and number replaced by "?".
|
|
4
|
+
// Ported from gems/forge_ops_tracker's SqlStatement, which is itself ported from the server's own
|
|
5
|
+
// SqlStatementMasker/SqlObjectExtractor: same rules everywhere, and the server applies them again
|
|
6
|
+
// on arrival, so a difference here can only ever mean less is masked client-side, never that
|
|
7
|
+
// something unmasked gets stored.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately a single pass over a few patterns, not a SQL parser. No regex lookbehind either:
|
|
10
|
+
// the same source is shared with the browser and React Native SDKs, where an older engine would
|
|
11
|
+
// fail to even parse one, so the "not part of an identifier" check on numbers captures the
|
|
12
|
+
// preceding character and puts it back instead.
|
|
13
|
+
|
|
14
|
+
const MASK = "?";
|
|
15
|
+
const MAX_LENGTH = 4000;
|
|
16
|
+
const MAX_NAMES = 10;
|
|
17
|
+
const MAX_NAME_LENGTH = 200;
|
|
18
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
19
|
+
|
|
20
|
+
// 1: string literal (or one cut off by truncation) 2: dollar-quote tag 3: char before a number
|
|
21
|
+
// 4: the number, not part of an identifier or a $1 placeholder
|
|
22
|
+
const LITERAL = /'(?:[^']|'')*(?:'|$)|(\$[A-Za-z_]*\$)[\s\S]*?(?:\1|$)|(^|[^\w$.])(\d+(?:\.\d+)?)(?!\w)/g;
|
|
23
|
+
|
|
24
|
+
const PART = '(?:[\\w$#@]+|"[^"]+"|\\[[^\\]]+\\]|`[^`]+`)';
|
|
25
|
+
const NAME = `${PART}(?:\\.${PART})*`;
|
|
26
|
+
const OPERATIONS = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "WITH", "CALL", "EXEC", "EXECUTE", "CREATE", "ALTER", "DROP", "TRUNCATE"]);
|
|
27
|
+
const PROCEDURE_CALL = new RegExp(`\\b(?:CALL|EXEC(?:UTE)?|PERFORM)\\s+(?!IMMEDIATE\\b|FUNCTION\\b|PROCEDURE\\b)(${NAME})`, "gi");
|
|
28
|
+
const RELATION = new RegExp(`\\b(FROM|JOIN|INTO|UPDATE|TABLE)\\s+(${NAME})(\\s*\\()?`, "gi");
|
|
29
|
+
const SELECT_FUNCTION = new RegExp(`^\\s*SELECT\\s+(${NAME})\\s*\\(`, "i");
|
|
30
|
+
const BUILTINS = new Set([
|
|
31
|
+
"count", "sum", "min", "max", "avg", "now", "coalesce", "nullif", "lower", "upper", "length", "concat",
|
|
32
|
+
"cast", "date_trunc", "current_timestamp", "current_date", "row_number", "rank", "json_build_object",
|
|
33
|
+
"json_agg", "array_agg",
|
|
34
|
+
]);
|
|
35
|
+
const FROM_INSIDE_FUNCTION = /\b(?:EXTRACT|SUBSTRING|TRIM|OVERLAY)\s*\([^()]*\)/gi;
|
|
36
|
+
const KEYWORDS_NOT_NAMES = new Set(["select", "set", "values", "where", "lateral", "only", "unnest", "generate_series"]);
|
|
37
|
+
const FULL_NAME = new RegExp(`^${NAME}$`);
|
|
38
|
+
const FROM_WORD = /\bFROM\b/i;
|
|
39
|
+
|
|
40
|
+
// Properties that carry the statement on the errors Node database libraries raise: Sequelize and
|
|
41
|
+
// mysql2 use .sql (Sequelize also nests the driver's own error under .parent/.original), TypeORM's
|
|
42
|
+
// QueryFailedError uses .query. Only a string counts, never an object that happens to share the
|
|
43
|
+
// name (a Sequelize query builder, say).
|
|
44
|
+
const STATEMENT_PROPERTIES = ["sql", "query"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The raw statement off the error itself or, for an app that wraps a database error in its own
|
|
48
|
+
* exception, off whatever it was raised from (.cause, or Sequelize's .parent/.original).
|
|
49
|
+
* @param {unknown} error
|
|
50
|
+
* @returns {string | null}
|
|
51
|
+
*/
|
|
52
|
+
export function findIn(error) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const queue = [error];
|
|
55
|
+
let depth = 0;
|
|
56
|
+
while (queue.length > 0 && depth < MAX_CAUSE_DEPTH * 3) {
|
|
57
|
+
const current = queue.shift();
|
|
58
|
+
depth += 1;
|
|
59
|
+
if (!current || typeof current !== "object" || seen.has(current)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
seen.add(current);
|
|
63
|
+
for (const property of STATEMENT_PROPERTIES) {
|
|
64
|
+
const value = current[property];
|
|
65
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
queue.push(current.cause, current.parent, current.original);
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string | null | undefined} statement
|
|
76
|
+
* @returns {string | null}
|
|
77
|
+
*/
|
|
78
|
+
export function mask(statement) {
|
|
79
|
+
if (typeof statement !== "string" || statement.trim() === "") {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const masked = statement.replace(LITERAL, (whole, _tag, before, number) => (number === undefined ? MASK : `${before}${MASK}`));
|
|
83
|
+
return masked.length > MAX_LENGTH ? `${masked.slice(0, MAX_LENGTH)}...` : masked;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Takes an already-masked statement (so a keyword inside a string value can't be mistaken for
|
|
88
|
+
* SQL). Returns null when nothing recognizable was found.
|
|
89
|
+
* @param {string | null} masked
|
|
90
|
+
* @returns {{ operation?: string, procedures: string[], relations: string[] } | null}
|
|
91
|
+
*/
|
|
92
|
+
export function objects(masked) {
|
|
93
|
+
if (typeof masked !== "string" || masked.trim() === "") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sql = masked.replace(FROM_INSIDE_FUNCTION, " ");
|
|
98
|
+
const procedures = [...sql.matchAll(PROCEDURE_CALL)].map((m) => m[1]);
|
|
99
|
+
const relations = [];
|
|
100
|
+
|
|
101
|
+
for (const [, keyword, name, paren] of sql.matchAll(RELATION)) {
|
|
102
|
+
if (KEYWORDS_NOT_NAMES.has(name.toLowerCase())) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const functionCall = Boolean(paren) && ["FROM", "JOIN"].includes(keyword.toUpperCase());
|
|
106
|
+
(functionCall ? procedures : relations).push(name);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const fn = SELECT_FUNCTION.exec(sql)?.[1];
|
|
110
|
+
if (fn && !BUILTINS.has(fn.toLowerCase()) && !FROM_WORD.test(sql)) {
|
|
111
|
+
procedures.push(fn);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const operation = (/^\s*(\w+)/.exec(sql)?.[1] ?? "").toUpperCase();
|
|
115
|
+
const result = {};
|
|
116
|
+
if (OPERATIONS.has(operation)) {
|
|
117
|
+
result.operation = operation;
|
|
118
|
+
}
|
|
119
|
+
result.procedures = clean(procedures);
|
|
120
|
+
result.relations = clean(relations);
|
|
121
|
+
if (result.procedures.length === 0 && result.relations.length === 0 && !("operation" in result)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function clean(names) {
|
|
128
|
+
const cleaned = [];
|
|
129
|
+
for (const raw of names) {
|
|
130
|
+
const name = raw.trim().slice(0, MAX_NAME_LENGTH);
|
|
131
|
+
if (FULL_NAME.test(name) && !cleaned.includes(name)) {
|
|
132
|
+
cleaned.push(name);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return cleaned.slice(0, MAX_NAMES);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Attaches the SQL statement that caused `error` to it and returns the same error, so the code
|
|
140
|
+
* that ran the query can hand it over where the reporter finds it with no extra call. Use it when
|
|
141
|
+
* the database library's own errors don't carry the statement (node-postgres, better-sqlite3 and
|
|
142
|
+
* Prisma don't):
|
|
143
|
+
*
|
|
144
|
+
* try { await pool.query(sql, params); }
|
|
145
|
+
* catch (error) { throw withSql(error, sql); }
|
|
146
|
+
*
|
|
147
|
+
* Only the names of the stored procedure, table and view are sent by default, and the statement
|
|
148
|
+
* itself only with `captureSqlStatement` on, with every string and number replaced by "?" first;
|
|
149
|
+
* the raw statement never leaves the process. Non-enumerable, so it doesn't show up when the error
|
|
150
|
+
* is logged or serialized.
|
|
151
|
+
* @template {object} E
|
|
152
|
+
* @param {E} error
|
|
153
|
+
* @param {string} statement
|
|
154
|
+
* @returns {E}
|
|
155
|
+
*/
|
|
156
|
+
export function withSql(error, statement) {
|
|
157
|
+
Object.defineProperty(error, "sql", { value: statement, enumerable: false, configurable: true, writable: true });
|
|
158
|
+
return error;
|
|
159
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads and writes the W3C Trace Context `traceparent` header
|
|
5
|
+
* (https://www.w3.org/TR/trace-context/), the vendor-neutral format for carrying one trace across
|
|
6
|
+
* service boundaries: `00-<32 hex trace-id>-<16 hex parent-id>-<2 hex flags>`. Used in both
|
|
7
|
+
* directions: the Express/Fastify integrations parse an incoming one so a request continues the
|
|
8
|
+
* caller's trace instead of starting its own, and httpTracing.js builds an outgoing one so the next
|
|
9
|
+
* service along continues this request's. Ported from
|
|
10
|
+
* gems/forge_ops_tracker/lib/forge_ops_tracker/trace_parent.rb.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately strict on the way in, the same posture the spec itself asks receivers to take: a
|
|
13
|
+
* malformed value, uppercase hex, the reserved version "ff", or an all-zero trace/parent id are all
|
|
14
|
+
* treated as "no usable header at all" (parseTraceparent returns null and the request starts a
|
|
15
|
+
* fresh trace), never half-trusted. A version this client doesn't know yet is still accepted as
|
|
16
|
+
* long as its first four fields have version 00's shape, which is exactly what the spec says a
|
|
17
|
+
* version-00 parser should do with a future version; version 00 itself must have exactly four
|
|
18
|
+
* fields.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const TRACEPARENT_HEADER = "traceparent";
|
|
22
|
+
|
|
23
|
+
const PATTERN = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.*)?$/s;
|
|
24
|
+
const INVALID_TRACE_ID = "0".repeat(32);
|
|
25
|
+
const INVALID_PARENT_ID = "0".repeat(16);
|
|
26
|
+
|
|
27
|
+
// Always "01" (sampled) on the way out: this client decides whether to actually send a trace only
|
|
28
|
+
// once the request is over (see _finishSpanTrace in index.js), long after this header has already
|
|
29
|
+
// gone out on an outbound call, so there's no honest earlier answer to give than "this may be
|
|
30
|
+
// recorded." A downstream service is free to make its own decision either way.
|
|
31
|
+
const SAMPLED_FLAGS = "01";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {unknown} value an incoming header's raw value
|
|
35
|
+
* @returns {{ traceId: string, parentSpanId: string } | null} null for anything that isn't a usable header
|
|
36
|
+
*/
|
|
37
|
+
export function parseTraceparent(value) {
|
|
38
|
+
if (typeof value !== "string") {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const match = PATTERN.exec(value.trim());
|
|
42
|
+
if (match === null) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const [, version, traceId, parentSpanId, , rest] = match;
|
|
46
|
+
if (version === "ff" || (version === "00" && rest !== undefined)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (traceId === INVALID_TRACE_ID || parentSpanId === INVALID_PARENT_ID) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return { traceId, parentSpanId };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} traceId
|
|
57
|
+
* @param {string} spanId
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function buildTraceparent(traceId, spanId) {
|
|
61
|
+
return `00-${traceId}-${spanId}-${SAMPLED_FLAGS}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 32 lowercase hex characters, the W3C trace-id format, never all zeros (the spec reserves that as
|
|
66
|
+
* invalid, and a receiver drops the whole header over it): vanishingly unlikely from 16 random
|
|
67
|
+
* bytes, but free to rule out.
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function generateTraceId() {
|
|
71
|
+
return randomId(16);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 16 lowercase hex characters, the W3C parent-id (span id) format, never all zeros.
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function generateSpanId() {
|
|
79
|
+
return randomId(8);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function randomId(bytes) {
|
|
83
|
+
let id = randomBytes(bytes).toString("hex");
|
|
84
|
+
while (/^0+$/.test(id)) {
|
|
85
|
+
id = randomBytes(bytes).toString("hex");
|
|
86
|
+
}
|
|
87
|
+
return id;
|
|
88
|
+
}
|