@forge-ops/tracker 0.13.0 → 0.15.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 +128 -24
- package/package.json +9 -2
- package/src/configuration.js +2 -2
- package/src/databaseTracing.js +222 -0
- package/src/index.js +92 -14
- package/src/integrations/performance.js +5 -8
- package/src/optionalModules.js +65 -0
- package/src/redisTracing.js +279 -0
- package/src/sqlStatement.js +43 -7
package/README.md
CHANGED
|
@@ -174,8 +174,9 @@ to override what was auto-detected.
|
|
|
174
174
|
A trail of what happened right before an error. With `forgeOpsTrackerBreadcrumbContextExpressMiddleware`/
|
|
175
175
|
`registerForgeOpsTrackerBreadcrumbContext` installed (see the Express/Fastify snippets above), every
|
|
176
176
|
request gets its own trail, and the Express performance middleware records a `"controller"`
|
|
177
|
-
breadcrumb into it automatically, no further setup needed.
|
|
178
|
-
|
|
177
|
+
breadcrumb into it automatically, no further setup needed. Every `pg` query adds a `"query"`
|
|
178
|
+
breadcrumb and every Redis command a `"redis"` one too (see "Database spans" and "Redis spans"
|
|
179
|
+
below), inside a request or not. Shows up alongside the error on an issue's own detail page.
|
|
179
180
|
|
|
180
181
|
```js
|
|
181
182
|
forgeOpsTracker.init({
|
|
@@ -212,9 +213,9 @@ the end of that same request. Unlike the affected user above, a breadcrumb's `me
|
|
|
212
213
|
free text (a bind parameter showing up in a message, a URL with a token in it) the scrubber exists
|
|
213
214
|
to catch, not a deliberately-structured field the way `user` is.
|
|
214
215
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
The `"query"` and `"redis"` breadcrumbs need no middleware: a query or command run outside any
|
|
217
|
+
request (a script, a worker) lands in the same standalone trail `addBreadcrumb()` would start
|
|
218
|
+
there. Fastify gets no automatic `"controller"` breadcrumb today, since
|
|
218
219
|
there's no Fastify performance/timing integration in this client for one to ride alongside (see
|
|
219
220
|
"Performance monitoring" below); `registerForgeOpsTrackerBreadcrumbContext` still gives Fastify
|
|
220
221
|
requests their own trail, so `addBreadcrumb()` calls from inside a Fastify route handler work
|
|
@@ -429,8 +430,8 @@ server-side and dropped, exactly like any other delivery failure.
|
|
|
429
430
|
|
|
430
431
|
For one slow or errored request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
|
|
431
432
|
`registerForgeOpsTrackerTracing()` (Fastify, `./integrations/tracing`) capture its full nested
|
|
432
|
-
call tree: the route span, plus every outbound HTTP call
|
|
433
|
-
it, so ForgeOps can render a waterfall for that one request.
|
|
433
|
+
call tree: the route span, plus every outbound HTTP call, database query, Redis command and
|
|
434
|
+
manually-wrapped span nested under it, so ForgeOps can render a waterfall for that one request.
|
|
434
435
|
|
|
435
436
|
```js
|
|
436
437
|
import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
|
|
@@ -539,39 +540,142 @@ app.use(forgeOpsTrackerExpressMiddleware);
|
|
|
539
540
|
app.listen(3000);
|
|
540
541
|
```
|
|
541
542
|
|
|
542
|
-
### Database spans
|
|
543
|
+
### Database spans
|
|
543
544
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
545
|
+
Queries run through [`pg`](https://node-postgres.com) (node-postgres) are captured automatically,
|
|
546
|
+
one `"database"` span per query, with no extra setup: `init()` finds `pg` in your app and wraps
|
|
547
|
+
`Client#query`, which `Pool#query` goes through too. Callbacks, promises, and config objects
|
|
548
|
+
(`{ text, values, name }`) are all covered.
|
|
547
549
|
|
|
548
550
|
```js
|
|
549
551
|
import pg from "pg";
|
|
550
|
-
import * as forgeOpsTracker from "@forge-ops/tracker";
|
|
551
552
|
|
|
552
553
|
const pool = new pg.Pool();
|
|
553
554
|
|
|
554
555
|
app.get("/orders", async (req, res) => {
|
|
555
|
-
|
|
556
|
-
const { rows } = await
|
|
556
|
+
// Recorded as a "SELECT orders" span, nested under this request's route span.
|
|
557
|
+
const { rows } = await pool.query(
|
|
558
|
+
"SELECT id, total FROM orders WHERE customer_id = $1 AND status = 'open'",
|
|
559
|
+
[req.user.id],
|
|
560
|
+
);
|
|
561
|
+
res.json(rows);
|
|
562
|
+
});
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The span is named after the statement's verb and table (`"SELECT orders"`, `"INSERT INTO orders"`,
|
|
566
|
+
`"UPDATE orders"`, `"DELETE sessions"`, or just the first keyword, like `"BEGIN"`), never the SQL
|
|
567
|
+
itself. It carries the statement in its data as `db.statement`, masked before it's stored: every
|
|
568
|
+
string and number becomes `?`, so the query above is sent as
|
|
569
|
+
`SELECT id, total FROM orders WHERE customer_id = $1 AND status = ?`, and ForgeOps masks it again
|
|
570
|
+
on arrival. It's cut to 4000 characters. Bind values (the `[req.user.id]` array) are never read or
|
|
571
|
+
sent. `db.system` is `"postgresql"`. A failed query is recorded too, and its error reaches your
|
|
572
|
+
code exactly as before.
|
|
573
|
+
|
|
574
|
+
Each query also adds a `"query"` breadcrumb (see "Breadcrumbs" above), named the same way as the
|
|
575
|
+
span (`"SELECT orders"`), with only `{ duration_ms }` as its data: never the statement, masked or
|
|
576
|
+
not. It's recorded whether or not the request is being traced, and outside a request too, unless
|
|
577
|
+
`trackBreadcrumbs` is off.
|
|
578
|
+
|
|
579
|
+
For any other database library, wrap a query by hand with `kind: "database"` and pass the SQL as
|
|
580
|
+
`statement`; it's masked the same way:
|
|
581
|
+
|
|
582
|
+
```js
|
|
583
|
+
import mysql from "mysql2/promise";
|
|
584
|
+
import * as forgeOpsTracker from "@forge-ops/tracker";
|
|
585
|
+
|
|
586
|
+
const connection = await mysql.createConnection(process.env.DATABASE_URL);
|
|
587
|
+
|
|
588
|
+
app.get("/orders", async (req, res) => {
|
|
589
|
+
const sql = "SELECT id, total FROM orders WHERE customer_id = ? AND status = 'open'";
|
|
590
|
+
const [rows] = await forgeOpsTracker.span("Load open orders", () => connection.execute(sql, [req.user.id]), {
|
|
557
591
|
kind: "database",
|
|
558
592
|
statement: sql,
|
|
559
|
-
dbSystem: "
|
|
593
|
+
dbSystem: "mysql",
|
|
560
594
|
});
|
|
561
595
|
res.json(rows);
|
|
562
596
|
});
|
|
563
597
|
```
|
|
564
598
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
599
|
+
`dbSystem` is optional (`"postgresql"`, `"mysql"`, `"sqlite"`, `"mssql"`, `"oracle"`, or any other
|
|
600
|
+
lowercase name) and both options are ignored on spans of any other kind. They go out in the span's
|
|
601
|
+
data as `db.statement` and `db.system`. With `dbSystem` set to `"mysql"` or `"mariadb"`, text in
|
|
602
|
+
"double quotes" is masked too, since it's a string there rather than a name. A hand-made span
|
|
603
|
+
around a `pg` query still works: the automatic query span just nests under it.
|
|
604
|
+
|
|
605
|
+
Masking follows the same rules ForgeOps applies on arrival: `''` and backslash escapes inside a
|
|
606
|
+
string, a string's type prefix (`E'...'`, `X'DEADBEEF'`, `N'...'`, `B'...'`, `U&'...'`), `$tag$`
|
|
607
|
+
dollar quotes, and numbers of every shape (`42`, `1.5`, `.5`, `3e10`, `1.5E-3`, `0x1F`, `0b101`),
|
|
608
|
+
never the digits inside a name like `orders2` or a `$1` placeholder.
|
|
609
|
+
|
|
610
|
+
### Redis spans
|
|
611
|
+
|
|
612
|
+
Commands sent through [`ioredis`](https://github.com/redis/ioredis) or
|
|
613
|
+
[`redis`](https://github.com/redis/node-redis) (node-redis) are captured automatically too, one
|
|
614
|
+
`"redis"` span per command, pipelined ones included:
|
|
615
|
+
|
|
616
|
+
```js
|
|
617
|
+
import Redis from "ioredis";
|
|
618
|
+
|
|
619
|
+
const redis = new Redis(process.env.REDIS_URL);
|
|
620
|
+
|
|
621
|
+
app.get("/cart", async (req, res) => {
|
|
622
|
+
// Recorded as a "Redis GET" span.
|
|
623
|
+
const cart = await redis.get(`cart:${req.user.id}`);
|
|
624
|
+
res.json(JSON.parse(cart ?? "[]"));
|
|
625
|
+
});
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
A span is named after the command alone (`"Redis GET"`, `"Redis SET"`, `"Redis LPUSH"`) and
|
|
629
|
+
carries no data: never the key or any other argument, since a key can easily hold a user's id or
|
|
630
|
+
another sensitive value. A failed command is recorded too.
|
|
631
|
+
|
|
632
|
+
Each command also adds a `"redis"` breadcrumb (see "Breadcrumbs" above) with the same name as its
|
|
633
|
+
message, level `"error"` when the command failed (`"info"` otherwise), and only `{ duration_ms }`
|
|
634
|
+
as its data. It's recorded whether or not the request is being traced, and outside a request too,
|
|
635
|
+
unless `trackBreadcrumbs` is off.
|
|
636
|
+
|
|
637
|
+
`ioredis` is covered from version 4 on. `redis` is covered from 4.0 on: from 5.12 (with Node 18.19
|
|
638
|
+
or later) through the diagnostics channel it publishes every command on, so nothing in it is
|
|
639
|
+
patched; before that (all of 4.x, and 5.0 to 5.11) by wrapping its client's command queue, which
|
|
640
|
+
every command goes through. A version that publishes on the channel is never wrapped, so no
|
|
641
|
+
command is counted twice. On those older versions every command in a pipeline or a `MULTI` gets
|
|
642
|
+
its own span (`MULTI` and `EXEC` included), like `ioredis` pipelines.
|
|
643
|
+
|
|
644
|
+
### When `init()` can't find `pg` or `ioredis`
|
|
645
|
+
|
|
646
|
+
`init()` looks for `pg`, `ioredis` and `redis` from the directory your app was started in, and
|
|
647
|
+
from where this package is installed. Neither is a dependency of this package: one your app doesn't have is
|
|
648
|
+
simply skipped. If your app is bundled, or started from a directory its `node_modules` isn't
|
|
649
|
+
under, hand the module over yourself, once, at startup:
|
|
650
|
+
|
|
651
|
+
```js
|
|
652
|
+
import pg from "pg";
|
|
653
|
+
import Redis from "ioredis";
|
|
654
|
+
import * as forgeOpsTracker from "@forge-ops/tracker";
|
|
655
|
+
|
|
656
|
+
forgeOpsTracker.init({ dsn: "..." });
|
|
657
|
+
forgeOpsTracker.instrumentPg(pg);
|
|
658
|
+
forgeOpsTracker.instrumentIoredis(Redis);
|
|
659
|
+
```
|
|
571
660
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
661
|
+
Both are safe to call more than once, and return `false` when given something that isn't `pg` or
|
|
662
|
+
an `ioredis` class. `redis` from 5.12 on never needs this.
|
|
663
|
+
|
|
664
|
+
**Known gaps:**
|
|
665
|
+
|
|
666
|
+
- `redis` (node-redis) before 5.12 can't be handed over by hand: in a bundled app, or one whose
|
|
667
|
+
`node_modules` `init()` can't find, its commands aren't captured. Upgrade to 5.12 or later, or
|
|
668
|
+
wrap the calls you care about with `span(name, fn, { kind: "redis" })`.
|
|
669
|
+
- `redis` from 5.12 on doesn't publish the commands inside a `MULTI` transaction on its
|
|
670
|
+
diagnostics channel, so they don't show up (a pipeline's commands do).
|
|
671
|
+
- Pub/sub commands sent by `redis` before 5.12 (`SUBSCRIBE`, `UNSUBSCRIBE` and the like) aren't
|
|
672
|
+
captured. The commands it sends on its own when it connects (`SELECT`, `CLIENT SETINFO`, `AUTH`,
|
|
673
|
+
by name only) are, like any other command, and so are the `PING`s its `pingInterval` option
|
|
674
|
+
sends.
|
|
675
|
+
- `pg-native` (`pg.native`), and cursors and streams passed to `query()` (`pg-cursor`,
|
|
676
|
+
`pg-query-stream`), aren't captured.
|
|
677
|
+
- Other database libraries (`mysql2`, `better-sqlite3`, Prisma, and so on) aren't captured
|
|
678
|
+
automatically; wrap their queries with `span()` as shown above.
|
|
575
679
|
|
|
576
680
|
## Database errors
|
|
577
681
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forge-ops/tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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",
|
|
@@ -46,6 +46,13 @@
|
|
|
46
46
|
"@eslint/js": "^10.0.0",
|
|
47
47
|
"eslint": "^10.0.0",
|
|
48
48
|
"express": "^5.0.0",
|
|
49
|
-
"fastify": "^5.0.0"
|
|
49
|
+
"fastify": "^5.0.0",
|
|
50
|
+
"ioredis": "^6.0.0",
|
|
51
|
+
"pg": "^8.23.0",
|
|
52
|
+
"redis": "^6.2.1",
|
|
53
|
+
"redis4": "npm:redis@^4.7.1",
|
|
54
|
+
"redis4-0": "npm:redis@4.0.6",
|
|
55
|
+
"redis5-0": "npm:redis@5.0.1",
|
|
56
|
+
"redis5-11": "npm:redis@5.11.0"
|
|
50
57
|
}
|
|
51
58
|
}
|
package/src/configuration.js
CHANGED
|
@@ -89,8 +89,8 @@ export class Configuration {
|
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
91
|
* Whether the Express/Fastify integrations give every request its own breadcrumb trail, and
|
|
92
|
-
* whatever automatic sources this client already times (
|
|
93
|
-
*
|
|
92
|
+
* whatever automatic sources this client already times (the Express controller/route lifecycle,
|
|
93
|
+
* see integrations/performance.js; every pg query; every Redis command) also record a breadcrumb
|
|
94
94
|
* alongside the timing they already do. On by default, the same posture trackSessions/
|
|
95
95
|
* trackPerformance above already have. addBreadcrumb() itself is never gated by this: only the
|
|
96
96
|
* automatic sources are, matching gems/forge_ops_tracker's own track_breadcrumbs.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { AsyncResource } from "node:async_hooks";
|
|
2
|
+
import { requireOptional } from "./optionalModules.js";
|
|
3
|
+
import { mask } from "./sqlStatement.js";
|
|
4
|
+
|
|
5
|
+
const WRAPPED = Symbol("forgeOpsTrackerWrapped");
|
|
6
|
+
|
|
7
|
+
let installed = false;
|
|
8
|
+
// Every patch made, so _uninstallDatabaseTracing can put each method back exactly as found.
|
|
9
|
+
const patches = [];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Records one "database" span and one "query" breadcrumb per query run through node-postgres (pg),
|
|
13
|
+
* for any app that has it installed; installed from index.js's init() the same way
|
|
14
|
+
* installHttpTracing is, exactly once for the life of the process (see the installed guard), and
|
|
15
|
+
* checking whether a trace is being captured, and whether breadcrumbs are on, fresh on every query
|
|
16
|
+
* rather than at install time. An app without pg gets nothing patched and nothing loaded (see
|
|
17
|
+
* requireOptional).
|
|
18
|
+
*
|
|
19
|
+
* Wraps pg.Client.prototype.query, which pg.Pool's own query() also goes through, so a pooled
|
|
20
|
+
* query is covered with no separate hook. Every calling style is handled: a callback (as the last
|
|
21
|
+
* argument, or on a config object's own `callback`), a returned promise, and a string or config
|
|
22
|
+
* object ({ text, values, name, ... }). A submittable (pg-cursor, pg-query-stream, or a raw
|
|
23
|
+
* pg.Query) is passed straight through unrecorded: it has no single "done" moment this could
|
|
24
|
+
* observe without adding an "error" listener, which would change what an unhandled stream error
|
|
25
|
+
* does in the host app.
|
|
26
|
+
*
|
|
27
|
+
* The span is named "<VERB> <table>" (see queryName) and carries the statement masked by
|
|
28
|
+
* sqlStatement.js's mask() as data["db.statement"] (every string and number replaced by "?", the
|
|
29
|
+
* same masking span({ statement }) and gems/forge_ops_tracker's database spans apply) and
|
|
30
|
+
* "postgresql" as data["db.system"]. Bind values are never read.
|
|
31
|
+
*
|
|
32
|
+
* The breadcrumb is the one gems/forge_ops_tracker and the Python SDK's SQLAlchemy integration add
|
|
33
|
+
* per query: the span's own name as its message (never the statement), category "query", and only
|
|
34
|
+
* { duration_ms } as data. A span is only recorded inside a trace; a breadcrumb whenever automatic
|
|
35
|
+
* breadcrumbs are on, into whatever trail is current (a request's own, or the standalone one
|
|
36
|
+
* addBreadcrumb would start outside a request).
|
|
37
|
+
*
|
|
38
|
+
* @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>) => void} recordSpan
|
|
39
|
+
* @param {() => boolean} isTracing whether the current async context has a trace to record into
|
|
40
|
+
* @param {(message: string, category: string, level?: string, data?: Record<string, unknown>) => void} [recordBreadcrumb]
|
|
41
|
+
* @param {() => boolean} [ensureBreadcrumbTrail] whether a breadcrumb recorded from the current
|
|
42
|
+
* async context would be kept (see index.js's _ensureBreadcrumbTrail)
|
|
43
|
+
*/
|
|
44
|
+
export function installDatabaseTracing(recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail) {
|
|
45
|
+
if (installed) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
installed = true;
|
|
49
|
+
for (const pg of requireOptional("pg")) {
|
|
50
|
+
instrumentPg(pg, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Patches one pg module (what `import pg from "pg"` returns). Safe to call more than once for the
|
|
56
|
+
* same module: a method this already wrapped is left alone.
|
|
57
|
+
*
|
|
58
|
+
* Also binds a callback passed to pg.Pool#connect to the async context it was called from, while a
|
|
59
|
+
* trace is being captured or breadcrumbs are being kept. When every pooled client is busy, pg-pool
|
|
60
|
+
* queues the request and later hands it a client from inside whichever other request released
|
|
61
|
+
* one, so without this, a query run on that client would be recorded into the wrong request's
|
|
62
|
+
* trace and breadcrumb trail.
|
|
63
|
+
*
|
|
64
|
+
* @param {any} pg
|
|
65
|
+
* @param {Parameters<typeof installDatabaseTracing>[0]} recordSpan
|
|
66
|
+
* @param {() => boolean} isTracing
|
|
67
|
+
* @param {Parameters<typeof installDatabaseTracing>[2]} [recordBreadcrumb]
|
|
68
|
+
* @param {() => boolean} [ensureBreadcrumbTrail]
|
|
69
|
+
* @returns {boolean} whether there was a pg Client to patch
|
|
70
|
+
*/
|
|
71
|
+
export function instrumentPg(pg, recordSpan, isTracing, recordBreadcrumb = () => {}, ensureBreadcrumbTrail = () => false) {
|
|
72
|
+
const clientPrototype = pg?.Client?.prototype;
|
|
73
|
+
if (typeof clientPrototype?.query !== "function") {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
patch(clientPrototype, "query", (original) => wrapQuery(original, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail));
|
|
77
|
+
|
|
78
|
+
const poolPrototype = pg.Pool?.prototype;
|
|
79
|
+
if (typeof poolPrototype?.connect === "function") {
|
|
80
|
+
patch(poolPrototype, "connect", (original) => wrapConnect(original, isTracing, ensureBreadcrumbTrail));
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @internal test-only: restores every method installDatabaseTracing/instrumentPg replaced. */
|
|
86
|
+
export function _uninstallDatabaseTracing() {
|
|
87
|
+
while (patches.length > 0) {
|
|
88
|
+
const { target, key, original, own } = patches.pop();
|
|
89
|
+
if (own) {
|
|
90
|
+
target[key] = original;
|
|
91
|
+
} else {
|
|
92
|
+
delete target[key];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
installed = false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function patch(target, key, wrapper) {
|
|
99
|
+
const original = target[key];
|
|
100
|
+
if (original[WRAPPED]) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const wrapped = wrapper(original);
|
|
104
|
+
wrapped[WRAPPED] = true;
|
|
105
|
+
patches.push({ target, key, original, own: Object.prototype.hasOwnProperty.call(target, key) });
|
|
106
|
+
target[key] = wrapped;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function wrapQuery(originalQuery, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail) {
|
|
110
|
+
return function patchedQuery(config, values, callback) {
|
|
111
|
+
if (config == null || typeof config.submit === "function") {
|
|
112
|
+
return originalQuery.apply(this, arguments);
|
|
113
|
+
}
|
|
114
|
+
const span = isTracing();
|
|
115
|
+
const breadcrumb = ensureBreadcrumbTrail();
|
|
116
|
+
if (!span && !breadcrumb) {
|
|
117
|
+
return originalQuery.apply(this, arguments);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const statement = typeof config === "string" ? config : config.text;
|
|
121
|
+
const masked = typeof statement === "string" ? mask(statement, "postgresql") : null;
|
|
122
|
+
const startedAt = new Date();
|
|
123
|
+
const start = performance.now();
|
|
124
|
+
|
|
125
|
+
// Bound to the caller's async context now: pg calls back (and settles its promise) from its
|
|
126
|
+
// socket's own event handlers, where the trace and the breadcrumb trail this query belongs to
|
|
127
|
+
// aren't the current ones.
|
|
128
|
+
let finished = false;
|
|
129
|
+
const finish = AsyncResource.bind(() => {
|
|
130
|
+
if (finished) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
finished = true;
|
|
134
|
+
const name = queryName(masked);
|
|
135
|
+
const durationMs = performance.now() - start;
|
|
136
|
+
if (breadcrumb) {
|
|
137
|
+
recordBreadcrumb(name, "query", "info", { duration_ms: Math.round(durationMs * 10) / 10 });
|
|
138
|
+
}
|
|
139
|
+
if (span) {
|
|
140
|
+
const data = { "db.system": "postgresql" };
|
|
141
|
+
if (masked !== null) {
|
|
142
|
+
data["db.statement"] = masked;
|
|
143
|
+
}
|
|
144
|
+
recordSpan(name, "database", startedAt, durationMs, data);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const args = [...arguments];
|
|
149
|
+
const callbackIndex = typeof callback === "function" ? 2 : typeof values === "function" ? 1 : -1;
|
|
150
|
+
if (callbackIndex !== -1) {
|
|
151
|
+
args[callbackIndex] = withFinish(args[callbackIndex], finish);
|
|
152
|
+
return originalQuery.apply(this, args);
|
|
153
|
+
}
|
|
154
|
+
if (typeof config === "object" && typeof config.callback === "function") {
|
|
155
|
+
// A new object inheriting from the caller's, with only `callback` replaced: pg reads the
|
|
156
|
+
// rest (text, values, name, rowMode...) through it unchanged, getters included.
|
|
157
|
+
args[0] = Object.create(config, {
|
|
158
|
+
callback: { value: withFinish(config.callback, finish), writable: true, enumerable: true, configurable: true },
|
|
159
|
+
});
|
|
160
|
+
return originalQuery.apply(this, args);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const result = originalQuery.apply(this, args);
|
|
164
|
+
if (!result || typeof result.then !== "function") {
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
// The promise returned in pg's place settles exactly like pg's own, so a rejection the app
|
|
168
|
+
// never handles is still reported as unhandled, the same as without this wrapper.
|
|
169
|
+
return result.then(
|
|
170
|
+
(value) => {
|
|
171
|
+
finish();
|
|
172
|
+
return value;
|
|
173
|
+
},
|
|
174
|
+
(error) => {
|
|
175
|
+
finish();
|
|
176
|
+
throw error;
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function withFinish(callback, finish) {
|
|
183
|
+
return function queryCallback(...args) {
|
|
184
|
+
finish();
|
|
185
|
+
return callback.apply(this, args);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function wrapConnect(originalConnect, isTracing, ensureBreadcrumbTrail) {
|
|
190
|
+
return function patchedConnect(callback, ...rest) {
|
|
191
|
+
if (typeof callback === "function" && (isTracing() || ensureBreadcrumbTrail())) {
|
|
192
|
+
return originalConnect.call(this, AsyncResource.bind(callback), ...rest);
|
|
193
|
+
}
|
|
194
|
+
return originalConnect.apply(this, arguments);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const SELECT_DELETE = /^\s*(SELECT|DELETE)\b[\s\S]*?\bFROM\s+["'`]?(\w+)/i;
|
|
199
|
+
const INSERT_UPDATE = /^\s*(INSERT INTO|UPDATE)\s+["'`]?(\w+)/i;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A low-cardinality span name for a query, never the SQL text itself: "<VERB> <table>" for the
|
|
203
|
+
* shapes that carry a table in a predictable place ("SELECT orders", "INSERT INTO orders",
|
|
204
|
+
* "UPDATE orders", "DELETE sessions"), just the first keyword otherwise ("BEGIN", "SELECT" for
|
|
205
|
+
* "SELECT 1"), and "SQL" for a blank or missing statement. A direct port of the Python SDK's
|
|
206
|
+
* _query_transaction_name (and the Elixir SDK's QueryNaming), so the same query gets the same name
|
|
207
|
+
* in every SDK. Given the masked statement, so a quoted value can never become part of a name.
|
|
208
|
+
*
|
|
209
|
+
* @param {string | null | undefined} sql
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
export function queryName(sql) {
|
|
213
|
+
if (typeof sql !== "string") {
|
|
214
|
+
return "SQL";
|
|
215
|
+
}
|
|
216
|
+
const match = SELECT_DELETE.exec(sql) ?? INSERT_UPDATE.exec(sql);
|
|
217
|
+
if (match) {
|
|
218
|
+
return `${match[1].toUpperCase()} ${match[2]}`;
|
|
219
|
+
}
|
|
220
|
+
const first = sql.trim().split(/\s+/, 1)[0];
|
|
221
|
+
return first ? first.toUpperCase() : "SQL";
|
|
222
|
+
}
|
package/src/index.js
CHANGED
|
@@ -3,11 +3,13 @@ import { BreadcrumbBuffer } from "./breadcrumbBuffer.js";
|
|
|
3
3
|
import { buildChange, buildSnapshot } from "./changes.js";
|
|
4
4
|
import { Client } from "./client.js";
|
|
5
5
|
import { Configuration } from "./configuration.js";
|
|
6
|
+
import { installDatabaseTracing, instrumentPg as instrumentPgModule, _uninstallDatabaseTracing } from "./databaseTracing.js";
|
|
6
7
|
import { DeliveryQueue } from "./deliveryQueue.js";
|
|
7
8
|
import { EventBuilder } from "./eventBuilder.js";
|
|
8
9
|
import { installHttpTracing, _uninstallHttpTracing } from "./httpTracing.js";
|
|
9
10
|
import { MetricBuffer } from "./metricBuffer.js";
|
|
10
11
|
import { PerformanceFlusher } from "./performanceFlusher.js";
|
|
12
|
+
import { installRedisTracing, instrumentIoredis as instrumentIoredisClass, _uninstallRedisTracing } from "./redisTracing.js";
|
|
11
13
|
import { Reporter } from "./reporter.js";
|
|
12
14
|
import { requestStateFor } from "./requestState.js";
|
|
13
15
|
import { SessionFlusher } from "./sessionFlusher.js";
|
|
@@ -435,11 +437,11 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
|
|
|
435
437
|
|
|
436
438
|
/**
|
|
437
439
|
* Internal; called by whatever this client already automatically times end-to-end with no
|
|
438
|
-
* children of its own to nest anything under (
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* with no request to belong to.
|
|
440
|
+
* children of its own to nest anything under (the outbound HTTP wrapper in httpTracing.js, and the
|
|
441
|
+
* pg, ioredis and node-redis wrappers in databaseTracing.js and redisTracing.js), never by host
|
|
442
|
+
* app code directly. A no-op, the same as every other automatic source, when there's no
|
|
443
|
+
* request-level trace currently open at all (trackTracing off, or genuinely outside any request
|
|
444
|
+
* the tracing integration ever wrapped): never fabricates a trace with no request to belong to.
|
|
443
445
|
*
|
|
444
446
|
* @param {string} name
|
|
445
447
|
* @param {string} kind
|
|
@@ -460,9 +462,52 @@ export function _recordSpan(name, kind, startedAt, durationMs, data = {}, spanId
|
|
|
460
462
|
}
|
|
461
463
|
|
|
462
464
|
/**
|
|
463
|
-
* Internal;
|
|
464
|
-
*
|
|
465
|
-
*
|
|
465
|
+
* Internal; whether a span recorded right now would land anywhere: trackTracing is on and the
|
|
466
|
+
* current async context is inside a traced request. The database and Redis wrappers check this
|
|
467
|
+
* before doing any work for a query or command, so outside a trace they cost nothing beyond the
|
|
468
|
+
* call itself.
|
|
469
|
+
*
|
|
470
|
+
* @returns {boolean}
|
|
471
|
+
*/
|
|
472
|
+
export function _isTracing() {
|
|
473
|
+
return getConfiguration().trackTracing && spanStorage.getStore() !== undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Records a "database" span and a "query" breadcrumb for every query run through this pg module
|
|
478
|
+
* (`import pg from "pg"`), for an app where init() couldn't find pg by itself: a bundled app, or
|
|
479
|
+
* one whose node_modules isn't under the directory it was started from. init() already does this for the pg it can
|
|
480
|
+
* find, so most apps never need to call it. Safe to call more than once.
|
|
481
|
+
*
|
|
482
|
+
* import pg from "pg";
|
|
483
|
+
* forgeOpsTracker.instrumentPg(pg);
|
|
484
|
+
*
|
|
485
|
+
* @param {unknown} pg
|
|
486
|
+
* @returns {boolean} whether it was a pg module this could patch
|
|
487
|
+
*/
|
|
488
|
+
export function instrumentPg(pg) {
|
|
489
|
+
return instrumentPgModule(pg, _recordSpan, _isTracing, _recordBreadcrumb, _ensureBreadcrumbTrail);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Records a "redis" span and a "redis" breadcrumb for every command sent through this ioredis
|
|
494
|
+
* Redis class, for an app where init() couldn't find ioredis by itself (see instrumentPg above).
|
|
495
|
+
* Safe to call more than once.
|
|
496
|
+
*
|
|
497
|
+
* import Redis from "ioredis";
|
|
498
|
+
* forgeOpsTracker.instrumentIoredis(Redis);
|
|
499
|
+
*
|
|
500
|
+
* @param {unknown} Redis
|
|
501
|
+
* @returns {boolean} whether it was an ioredis class this could patch
|
|
502
|
+
*/
|
|
503
|
+
export function instrumentIoredis(Redis) {
|
|
504
|
+
return instrumentIoredisClass(Redis, _recordSpan, _isTracing, _recordBreadcrumb, _ensureBreadcrumbTrail);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Internal; called by whatever this client already automatically times (the Express
|
|
509
|
+
* controller/route lifecycle, see integrations/performance.js, and the pg and Redis wrappers in
|
|
510
|
+
* databaseTracing.js and redisTracing.js), never by host app code directly, same reasoning as _recordPerformance above. Independent of trackPerformance: an app
|
|
466
511
|
* could want the trail without the timing data, or vice versa, and each call site already has
|
|
467
512
|
* to check its own flag regardless, so there's no real cost to keeping the two independent
|
|
468
513
|
* rather than tying breadcrumbs to whether performance monitoring happens to be on (mirrors
|
|
@@ -482,6 +527,28 @@ export function _recordBreadcrumb(message, category, level = "info", data = {})
|
|
|
482
527
|
addBreadcrumb(message, { category, level, data });
|
|
483
528
|
}
|
|
484
529
|
|
|
530
|
+
/**
|
|
531
|
+
* Internal; whether an automatic breadcrumb recorded from the current async context would be kept
|
|
532
|
+
* (trackBreadcrumbs is on and the client is enabled), for the pg and Redis wrappers to check before
|
|
533
|
+
* timing a query or command, the same way they check _isTracing above. When it would be and there's
|
|
534
|
+
* no trail yet (a query or command outside any request), starts the same standalone trail
|
|
535
|
+
* addBreadcrumb() below would, right now, from the app's own call: the breadcrumb itself is only
|
|
536
|
+
* recorded once the reply arrives, from a callback bound to this context, and a trail started
|
|
537
|
+
* there would never be seen again.
|
|
538
|
+
*
|
|
539
|
+
* @returns {boolean}
|
|
540
|
+
*/
|
|
541
|
+
export function _ensureBreadcrumbTrail() {
|
|
542
|
+
const config = getConfiguration();
|
|
543
|
+
if (!config.trackBreadcrumbs || !config.isEnabled()) {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
if (breadcrumbStorage.getStore() === undefined) {
|
|
547
|
+
breadcrumbStorage.enterWith(new BreadcrumbBuffer(config));
|
|
548
|
+
}
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
|
|
485
552
|
/**
|
|
486
553
|
* Configure the client. Call once at startup.
|
|
487
554
|
*
|
|
@@ -509,6 +576,10 @@ export function init(options = {}) {
|
|
|
509
576
|
// config, checking configuration.trackTracing fresh on every actual outbound call instead (see
|
|
510
577
|
// _recordSpan above), never at install time.
|
|
511
578
|
installHttpTracing(_recordSpan, _traceparentFor);
|
|
579
|
+
// The same for database queries (pg) and Redis commands (ioredis, node-redis), for whichever of
|
|
580
|
+
// those the app has installed; nothing is loaded for one it doesn't (see optionalModules.js).
|
|
581
|
+
installDatabaseTracing(_recordSpan, _isTracing, _recordBreadcrumb, _ensureBreadcrumbTrail);
|
|
582
|
+
installRedisTracing(_recordSpan, _isTracing, _recordBreadcrumb, _ensureBreadcrumbTrail);
|
|
512
583
|
|
|
513
584
|
startChangeSnapshot(config);
|
|
514
585
|
|
|
@@ -621,7 +692,9 @@ export function runWithUser(user, callback) {
|
|
|
621
692
|
*
|
|
622
693
|
* The statement is masked (every string and number replaced by "?") before it's ever stored on
|
|
623
694
|
* the span, and sent as data["db.statement"], with dbSystem lowercased as data["db.system"]. Bind
|
|
624
|
-
* values are never taken, and both options are ignored on any other kind of span.
|
|
695
|
+
* values are never taken, and both options are ignored on any other kind of span. Queries run
|
|
696
|
+
* through pg are already recorded this way on their own (see databaseTracing.js); a span wrapped
|
|
697
|
+
* around one just becomes its parent.
|
|
625
698
|
*
|
|
626
699
|
* @template T
|
|
627
700
|
* @param {string} name
|
|
@@ -669,7 +742,8 @@ export function span(name, callback, { kind = "service", data = {}, statement, d
|
|
|
669
742
|
/**
|
|
670
743
|
* The data a span is recorded with: unchanged unless it's a database span, which gets the masked
|
|
671
744
|
* statement as "db.statement" and the lowercased database name as "db.system". A "db.statement"
|
|
672
|
-
* passed in `data` directly is masked as well, so the raw SQL can't reach the payload either way
|
|
745
|
+
* passed in `data` directly is masked as well, so the raw SQL can't reach the payload either way,
|
|
746
|
+
* and masked for whichever system the span names (dbSystem, or a "db.system" in `data`).
|
|
673
747
|
* @param {string} kind
|
|
674
748
|
* @param {Record<string, unknown>} data
|
|
675
749
|
* @param {string | undefined} statement
|
|
@@ -681,15 +755,17 @@ function databaseSpanData(kind, data, statement, dbSystem) {
|
|
|
681
755
|
return data;
|
|
682
756
|
}
|
|
683
757
|
const result = { ...data };
|
|
684
|
-
|
|
758
|
+
if (typeof dbSystem === "string" && dbSystem.trim() !== "") {
|
|
759
|
+
result["db.system"] = dbSystem.trim().toLowerCase();
|
|
760
|
+
}
|
|
761
|
+
// The system matters to the masking: MySQL and MariaDB "double quoted" text is a value.
|
|
762
|
+
const system = typeof result["db.system"] === "string" ? result["db.system"].trim() : null;
|
|
763
|
+
const masked = maskSql(typeof statement === "string" ? statement : result["db.statement"], system);
|
|
685
764
|
if (masked === null) {
|
|
686
765
|
delete result["db.statement"];
|
|
687
766
|
} else {
|
|
688
767
|
result["db.statement"] = masked;
|
|
689
768
|
}
|
|
690
|
-
if (typeof dbSystem === "string" && dbSystem.trim() !== "") {
|
|
691
|
-
result["db.system"] = dbSystem.trim().toLowerCase();
|
|
692
|
-
}
|
|
693
769
|
return result;
|
|
694
770
|
}
|
|
695
771
|
|
|
@@ -775,4 +851,6 @@ export function _resetForTesting({ changeSnapshot = false } = {}) {
|
|
|
775
851
|
// on that instead of actually restoring the original functions would be fragile, not a genuine
|
|
776
852
|
// guarantee.
|
|
777
853
|
_uninstallHttpTracing();
|
|
854
|
+
_uninstallDatabaseTracing();
|
|
855
|
+
_uninstallRedisTracing();
|
|
778
856
|
}
|
|
@@ -27,14 +27,11 @@ import { _recordBreadcrumb, _recordPerformance } from "../index.js";
|
|
|
27
27
|
* mirroring gems/forge_ops_tracker/lib/forge_ops_tracker/railtie.rb's own
|
|
28
28
|
* process_action.action_controller subscription, which records both a performance sample and a
|
|
29
29
|
* breadcrumb from the one event): an app could want the trail without the timing data, or vice
|
|
30
|
-
* versa.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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.
|
|
30
|
+
* versa. The pg query and Redis command wrappers (databaseTracing.js, redisTracing.js) are the
|
|
31
|
+
* only other automatic breadcrumb sources, and they need no middleware: a query or command outside
|
|
32
|
+
* any request lands in the standalone trail addBreadcrumb() would start. The controller breadcrumb
|
|
33
|
+
* itself still needs this middleware installed, whether or not the app also wants the performance
|
|
34
|
+
* data.
|
|
38
35
|
*
|
|
39
36
|
* Deliberately recorded with the request's final status already known (from the same
|
|
40
37
|
* res.on("finish") firing this middleware already needed for timing), the same "recorded once
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Loads a library the host app may or may not have installed (pg, ioredis), for the automatic
|
|
6
|
+
* database and Redis span capture in databaseTracing.js and redisTracing.js. This SDK never
|
|
7
|
+
* depends on any of them: a library that isn't installed just resolves to nothing here, and that
|
|
8
|
+
* integration stays off.
|
|
9
|
+
*
|
|
10
|
+
* Resolved from the app's own directory (process.cwd()) first, then from this package's own
|
|
11
|
+
* location, since a package manager that isolates dependencies (pnpm) may not let this package see
|
|
12
|
+
* the app's copy at all. Every distinct copy found is returned (a monorepo can hold two), so each
|
|
13
|
+
* one gets patched. An app whose copy can't be found this way (a bundled app, or one started from
|
|
14
|
+
* another directory) can hand the module over itself instead: see instrumentPg/instrumentIoredis
|
|
15
|
+
* in index.js.
|
|
16
|
+
*
|
|
17
|
+
* Loading through require() also covers an app that imports the library as ESM: pg's ESM entry
|
|
18
|
+
* point and ioredis's CommonJS one both end up in the same require cache entry, so there's only
|
|
19
|
+
* one Client/Redis class to patch either way.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} name
|
|
22
|
+
* @param {{ via?: string }} [options] see resolveOptional
|
|
23
|
+
* @returns {unknown[]}
|
|
24
|
+
*/
|
|
25
|
+
export function requireOptional(name, options) {
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
return resolveOptional(name, options).map((resolved) => require(resolved));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where requireOptional would load `name` from: the absolute path of every distinct copy found,
|
|
32
|
+
* without loading any of them.
|
|
33
|
+
*
|
|
34
|
+
* `via` names the package the app actually installs when `name` is one of its own dependencies
|
|
35
|
+
* (node-redis's `redis` package depends on `@redis/client`): `name` is then also resolved from
|
|
36
|
+
* wherever each copy of `via` is, since an isolating package manager only lets `via` itself see
|
|
37
|
+
* it.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} name
|
|
40
|
+
* @param {{ via?: string }} [options]
|
|
41
|
+
* @returns {string[]}
|
|
42
|
+
*/
|
|
43
|
+
export function resolveOptional(name, { via } = {}) {
|
|
44
|
+
const bases = [path.join(process.cwd(), "package.json"), import.meta.url];
|
|
45
|
+
if (via !== undefined) {
|
|
46
|
+
bases.push(...bases.flatMap((base) => resolveFrom(base, via) ?? []));
|
|
47
|
+
}
|
|
48
|
+
const resolved = new Set();
|
|
49
|
+
for (const base of bases) {
|
|
50
|
+
const found = resolveFrom(base, name);
|
|
51
|
+
if (found !== null) {
|
|
52
|
+
resolved.add(found);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...resolved];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveFrom(base, name) {
|
|
59
|
+
try {
|
|
60
|
+
return createRequire(base).resolve(name);
|
|
61
|
+
} catch {
|
|
62
|
+
// Not installed where this base looks: nothing to patch from here.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { AsyncResource } from "node:async_hooks";
|
|
2
|
+
import diagnosticsChannel from "node:diagnostics_channel";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { requireOptional, resolveOptional } from "./optionalModules.js";
|
|
5
|
+
|
|
6
|
+
const WRAPPED = Symbol("forgeOpsTrackerWrapped");
|
|
7
|
+
const SEEN = Symbol("forgeOpsTrackerSeen");
|
|
8
|
+
const SPAN = Symbol("forgeOpsTrackerSpan");
|
|
9
|
+
const NODE_REDIS_COMMAND_CHANNEL = "node-redis:command";
|
|
10
|
+
// The package node-redis keeps its client in: @redis/client from node-redis 4.1 on, and
|
|
11
|
+
// @node-redis/client for 4.0.x.
|
|
12
|
+
const NODE_REDIS_CLIENT_PACKAGES = ["@redis/client", "@node-redis/client"];
|
|
13
|
+
|
|
14
|
+
let installed = false;
|
|
15
|
+
// Every patch made, so _uninstallRedisTracing can put each method back exactly as found.
|
|
16
|
+
const patches = [];
|
|
17
|
+
let nodeRedisSubscription = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Records one "redis" span and one "redis" breadcrumb per Redis command, for an app using ioredis
|
|
21
|
+
* or node-redis (the `redis` package); installed from index.js's init() the same way
|
|
22
|
+
* installHttpTracing is, exactly once for the life of the process, and checking whether a trace is
|
|
23
|
+
* being captured, and whether breadcrumbs are on, fresh on every command. Neither library is
|
|
24
|
+
* loaded or required unless the app has it installed.
|
|
25
|
+
*
|
|
26
|
+
* Every span is named "Redis <COMMAND>" (the command alone, upcased: GET, SET, LPUSH...) with no
|
|
27
|
+
* data at all: never a key or any other argument, since a key can easily carry a customer's own
|
|
28
|
+
* id or other sensitive value. The same name and kind gems/forge_ops_tracker's
|
|
29
|
+
* Integrations::RedisClientTiming records. The breadcrumb is the one that gem and the Python SDK's
|
|
30
|
+
* integrations/redis.py add: that same name as its message, category "redis", level "error" when
|
|
31
|
+
* the command failed, and only { duration_ms } as data. A span is only recorded inside a trace; a
|
|
32
|
+
* breadcrumb whenever automatic breadcrumbs are on, into whatever trail is current (a request's
|
|
33
|
+
* own, or the standalone one addBreadcrumb would start outside a request).
|
|
34
|
+
*
|
|
35
|
+
* ioredis (every version from 4.x on) is covered by wrapping Redis.prototype.sendCommand, which
|
|
36
|
+
* every command goes through, pipelined and MULTI ones included (see instrumentIoredis).
|
|
37
|
+
*
|
|
38
|
+
* node-redis from 5.12 on is covered through the "node-redis:command" diagnostics channel it
|
|
39
|
+
* publishes every command on (@redis/client), so nothing in it is patched and it's never loaded
|
|
40
|
+
* from here: subscribing by name works whether or not the app ever loads it. Earlier versions
|
|
41
|
+
* (4.x, and 5.x before 5.12) publish nothing there, so their command queue is patched instead
|
|
42
|
+
* (see instrumentNodeRedisClient); a copy new enough to publish is never patched, so no command
|
|
43
|
+
* is recorded twice.
|
|
44
|
+
*
|
|
45
|
+
* @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>) => void} recordSpan
|
|
46
|
+
* @param {() => boolean} isTracing whether the current async context has a trace to record into
|
|
47
|
+
* @param {(message: string, category: string, level?: string, data?: Record<string, unknown>) => void} [recordBreadcrumb]
|
|
48
|
+
* @param {() => boolean} [ensureBreadcrumbTrail] whether a breadcrumb recorded from the current
|
|
49
|
+
* async context would be kept (see index.js's _ensureBreadcrumbTrail)
|
|
50
|
+
*/
|
|
51
|
+
export function installRedisTracing(recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail) {
|
|
52
|
+
if (installed) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
installed = true;
|
|
56
|
+
for (const Redis of requireOptional("ioredis")) {
|
|
57
|
+
instrumentIoredis(Redis, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail);
|
|
58
|
+
}
|
|
59
|
+
for (const name of NODE_REDIS_CLIENT_PACKAGES) {
|
|
60
|
+
for (const packageJsonPath of resolveOptional(`${name}/package.json`, { via: "redis" })) {
|
|
61
|
+
instrumentNodeRedisClient(packageJsonPath, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
subscribeNodeRedis(hooksFor(recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Patches one ioredis Redis class (what `import Redis from "ioredis"` returns). Safe to call more
|
|
69
|
+
* than once for the same class.
|
|
70
|
+
*
|
|
71
|
+
* ioredis can pass the same command through sendCommand more than once (flushing its offline
|
|
72
|
+
* queue once connected, resending after a reconnect), so each command is only timed the first
|
|
73
|
+
* time, from when the app sent it until its reply. The span is recorded when ioredis settles the
|
|
74
|
+
* command (its own resolve/reject), rather than by attaching a handler to the command's promise:
|
|
75
|
+
* that would mark a rejection the app never handles as handled, and it would no longer be
|
|
76
|
+
* reported as an unhandled rejection.
|
|
77
|
+
*
|
|
78
|
+
* @param {any} Redis
|
|
79
|
+
* @param {Parameters<typeof installRedisTracing>[0]} recordSpan
|
|
80
|
+
* @param {() => boolean} isTracing
|
|
81
|
+
* @param {Parameters<typeof installRedisTracing>[2]} [recordBreadcrumb]
|
|
82
|
+
* @param {() => boolean} [ensureBreadcrumbTrail]
|
|
83
|
+
* @returns {boolean} whether there was a sendCommand to patch
|
|
84
|
+
*/
|
|
85
|
+
export function instrumentIoredis(Redis, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail) {
|
|
86
|
+
const prototype = Redis?.prototype;
|
|
87
|
+
if (typeof prototype?.sendCommand !== "function" || prototype.sendCommand[WRAPPED]) {
|
|
88
|
+
return typeof prototype?.sendCommand === "function";
|
|
89
|
+
}
|
|
90
|
+
const hooks = hooksFor(recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail);
|
|
91
|
+
const originalSendCommand = prototype.sendCommand;
|
|
92
|
+
|
|
93
|
+
const patchedSendCommand = function patchedSendCommand(command, ...rest) {
|
|
94
|
+
if (command && typeof command === "object" && !command[SEEN]) {
|
|
95
|
+
command[SEEN] = true;
|
|
96
|
+
const finish = startCommand(command.name, hooks);
|
|
97
|
+
if (finish !== null) {
|
|
98
|
+
observeCommand(command, finish);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return originalSendCommand.call(this, command, ...rest);
|
|
102
|
+
};
|
|
103
|
+
patchedSendCommand[WRAPPED] = true;
|
|
104
|
+
|
|
105
|
+
patches.push({ target: prototype, key: "sendCommand", original: originalSendCommand, own: Object.prototype.hasOwnProperty.call(prototype, "sendCommand") });
|
|
106
|
+
prototype.sendCommand = patchedSendCommand;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Patches one copy of node-redis's client package (@redis/client or @node-redis/client), given
|
|
112
|
+
* the path of its package.json, if it's a version that doesn't publish on the
|
|
113
|
+
* "node-redis:command" channel: @redis/client 1.x and @node-redis/client 1.x (node-redis 4.x),
|
|
114
|
+
* and @redis/client 5.x before 5.12. Anything newer is left alone for subscribeNodeRedis to cover.
|
|
115
|
+
*
|
|
116
|
+
* Wraps the client's command queue (RedisCommandsQueue.prototype.addCommand), the one method every
|
|
117
|
+
* command goes through in all of those versions: a plain command, sendCommand(), every command in
|
|
118
|
+
* a pipeline or a MULTI (MULTI and EXEC included), each one its own span, the same as ioredis
|
|
119
|
+
* pipelines get. The queue settles its promise from the socket's own handlers, so the patch
|
|
120
|
+
* returns a promise of its own in the queue's place, settling exactly like the queue's: a
|
|
121
|
+
* rejection nothing ever handles is still reported as unhandled.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} packageJsonPath
|
|
124
|
+
* @param {Parameters<typeof installRedisTracing>[0]} recordSpan
|
|
125
|
+
* @param {() => boolean} isTracing
|
|
126
|
+
* @param {Parameters<typeof installRedisTracing>[2]} [recordBreadcrumb]
|
|
127
|
+
* @param {() => boolean} [ensureBreadcrumbTrail]
|
|
128
|
+
* @returns {boolean} whether its command queue is patched now
|
|
129
|
+
*/
|
|
130
|
+
export function instrumentNodeRedisClient(packageJsonPath, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail) {
|
|
131
|
+
let prototype;
|
|
132
|
+
try {
|
|
133
|
+
const require = createRequire(packageJsonPath);
|
|
134
|
+
if (!publishesNothing(require(packageJsonPath).version)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
prototype = require("./dist/lib/client/commands-queue.js").default?.prototype;
|
|
138
|
+
} catch {
|
|
139
|
+
// Not a layout this knows: leave it alone rather than guess.
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (typeof prototype?.addCommand !== "function") {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (prototype.addCommand[WRAPPED]) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
const hooks = hooksFor(recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail);
|
|
149
|
+
const originalAddCommand = prototype.addCommand;
|
|
150
|
+
|
|
151
|
+
const patchedAddCommand = function patchedAddCommand(args) {
|
|
152
|
+
const finish = Array.isArray(args) ? startCommand(commandName(args[0]), hooks) : null;
|
|
153
|
+
const result = originalAddCommand.apply(this, arguments);
|
|
154
|
+
if (finish === null || typeof result?.then !== "function") {
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
return result.then(
|
|
158
|
+
(value) => {
|
|
159
|
+
finish(false);
|
|
160
|
+
return value;
|
|
161
|
+
},
|
|
162
|
+
(error) => {
|
|
163
|
+
finish(true);
|
|
164
|
+
throw error;
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
patchedAddCommand[WRAPPED] = true;
|
|
169
|
+
|
|
170
|
+
patches.push({ target: prototype, key: "addCommand", original: originalAddCommand, own: Object.prototype.hasOwnProperty.call(prototype, "addCommand") });
|
|
171
|
+
prototype.addCommand = patchedAddCommand;
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// node-redis 4.x ships its client as 1.x; 5.12 is the first version with the diagnostics channel.
|
|
176
|
+
function publishesNothing(version) {
|
|
177
|
+
const match = /^(\d+)\.(\d+)/.exec(String(version));
|
|
178
|
+
if (match === null) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const [major, minor] = [Number(match[1]), Number(match[2])];
|
|
182
|
+
return major === 1 || (major === 5 && minor < 12);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// node-redis passes each argument as a string or a Buffer; only the first (the command) is read.
|
|
186
|
+
function commandName(first) {
|
|
187
|
+
return String(first ?? "").split(" ", 1)[0];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @internal test-only: restores every method installRedisTracing replaced, and unsubscribes. */
|
|
191
|
+
export function _uninstallRedisTracing() {
|
|
192
|
+
while (patches.length > 0) {
|
|
193
|
+
const { target, key, original, own } = patches.pop();
|
|
194
|
+
if (own) {
|
|
195
|
+
target[key] = original;
|
|
196
|
+
} else {
|
|
197
|
+
delete target[key];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (nodeRedisSubscription !== null) {
|
|
201
|
+
diagnosticsChannel.tracingChannel(NODE_REDIS_COMMAND_CHANNEL).unsubscribe(nodeRedisSubscription);
|
|
202
|
+
nodeRedisSubscription = null;
|
|
203
|
+
}
|
|
204
|
+
installed = false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function hooksFor(recordSpan, isTracing, recordBreadcrumb = () => {}, ensureBreadcrumbTrail = () => false) {
|
|
208
|
+
return { recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function observeCommand(command, finish) {
|
|
212
|
+
const { resolve, reject } = command;
|
|
213
|
+
if (typeof resolve !== "function" || typeof reject !== "function") {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
command.resolve = function resolveWithSpan(...args) {
|
|
217
|
+
finish(false);
|
|
218
|
+
return resolve.apply(this, args);
|
|
219
|
+
};
|
|
220
|
+
command.reject = function rejectWithSpan(...args) {
|
|
221
|
+
finish(true);
|
|
222
|
+
return reject.apply(this, args);
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Starts timing one command now, from the app's own call, and returns the function that records
|
|
228
|
+
* it (given whether the command failed), or null when there's nothing to record it into: no trace
|
|
229
|
+
* being captured and no breadcrumb being kept. The function is bound to the caller's async
|
|
230
|
+
* context: the reply arrives from the connection's own socket handlers, where the trace and the
|
|
231
|
+
* breadcrumb trail the command belongs to aren't the current ones. Only ever records once.
|
|
232
|
+
*/
|
|
233
|
+
function startCommand(command, hooks) {
|
|
234
|
+
const span = hooks.isTracing();
|
|
235
|
+
const breadcrumb = hooks.ensureBreadcrumbTrail();
|
|
236
|
+
if (!span && !breadcrumb) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
const name = `Redis ${String(command).toUpperCase()}`;
|
|
240
|
+
const startedAt = new Date();
|
|
241
|
+
const start = performance.now();
|
|
242
|
+
let finished = false;
|
|
243
|
+
return AsyncResource.bind((failed) => {
|
|
244
|
+
if (finished) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
finished = true;
|
|
248
|
+
const durationMs = performance.now() - start;
|
|
249
|
+
if (breadcrumb) {
|
|
250
|
+
hooks.recordBreadcrumb(name, "redis", failed ? "error" : "info", { duration_ms: Math.round(durationMs * 10) / 10 });
|
|
251
|
+
}
|
|
252
|
+
if (span) {
|
|
253
|
+
hooks.recordSpan(name, "redis", startedAt, durationMs);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* node-redis runs each command inside TracingChannel#tracePromise: "start" is published
|
|
260
|
+
* synchronously from the app's own call (so isTracing() sees the right trace), and "asyncEnd" once
|
|
261
|
+
* the reply has settled the command either way, with `context.error` set if it failed. Only
|
|
262
|
+
* `context.command` (already upcased by node-redis) is read, never `context.args`.
|
|
263
|
+
*/
|
|
264
|
+
function subscribeNodeRedis(hooks) {
|
|
265
|
+
if (typeof diagnosticsChannel.tracingChannel !== "function") {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
nodeRedisSubscription = {
|
|
269
|
+
start(context) {
|
|
270
|
+
if (context && typeof context === "object") {
|
|
271
|
+
context[SPAN] = startCommand(context.command, hooks);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
asyncEnd(context) {
|
|
275
|
+
context?.[SPAN]?.("error" in context);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
diagnosticsChannel.tracingChannel(NODE_REDIS_COMMAND_CHANNEL).subscribe(nodeRedisSubscription);
|
|
279
|
+
}
|
package/src/sqlStatement.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Deliberately a single pass over a few patterns, not a SQL parser. No regex lookbehind either:
|
|
10
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
|
|
12
|
-
//
|
|
11
|
+
// fail to even parse one, so mask() checks the character before each match itself, and moves on
|
|
12
|
+
// one character when it doesn't count, exactly as the server's lookbehind would.
|
|
13
13
|
|
|
14
14
|
const MASK = "?";
|
|
15
15
|
const MAX_LENGTH = 4000;
|
|
@@ -17,9 +17,24 @@ const MAX_NAMES = 10;
|
|
|
17
17
|
const MAX_NAME_LENGTH = 200;
|
|
18
18
|
const MAX_CAUSE_DEPTH = 5;
|
|
19
19
|
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
// db.system values where "double quotes" are a string, not a name.
|
|
21
|
+
const DOUBLE_QUOTED_STRING_SYSTEMS = new Set(["mysql", "mariadb"]);
|
|
22
|
+
|
|
23
|
+
// A 'single quoted' string, '' and backslash escapes included, with its type prefix (E'', X'',
|
|
24
|
+
// N'', B'', U&''; group 1), or one cut off by truncation (masked to the end), even right after a
|
|
25
|
+
// backslash ('secret\ is still a string).
|
|
26
|
+
const STRING = String.raw`((?:[EeXxNnBb]|[Uu]&)?)'(?:[^'\\]|\\(?:[\s\S]|$)|'')*(?:'|$)`;
|
|
27
|
+
const DOUBLE_QUOTED_STRING = String.raw`"(?:[^"\\]|\\(?:[\s\S]|$)|"")*(?:"|$)`;
|
|
28
|
+
// A $tag$ dollar-quoted string (group 2 is the tag).
|
|
29
|
+
const DOLLAR_QUOTED = String.raw`(\$[A-Za-z_]*\$)[\s\S]*?(?:\2|$)`;
|
|
30
|
+
// Hex, binary, integer, decimal, leading-dot decimal, each with an optional exponent (group 3).
|
|
31
|
+
const NUMBER = String.raw`(0[xX][0-9A-Fa-f]+|0[bB][01]+|(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)(?!\w)`;
|
|
32
|
+
const LITERAL = new RegExp(`${STRING}|${DOLLAR_QUOTED}|${NUMBER}`, "g");
|
|
33
|
+
const LITERAL_WITH_DOUBLE_QUOTES = new RegExp(`${STRING}|${DOUBLE_QUOTED_STRING}|${DOLLAR_QUOTED}|${NUMBER}`, "g");
|
|
34
|
+
// What may not come right before a string's type prefix, or before a number: the end of a word,
|
|
35
|
+
// a $1 placeholder, or (for a number) a decimal point.
|
|
36
|
+
const WORD_END = /[\w$]/;
|
|
37
|
+
const NUMBER_PREFIX = /[\w$.]/;
|
|
23
38
|
|
|
24
39
|
const PART = '(?:[\\w$#@]+|"[^"]+"|\\[[^\\]]+\\]|`[^`]+`)';
|
|
25
40
|
const NAME = `${PART}(?:\\.${PART})*`;
|
|
@@ -72,14 +87,35 @@ export function findIn(error) {
|
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
/**
|
|
90
|
+
* Every string and number replaced by "?". `system` is the db.system the statement ran against,
|
|
91
|
+
* when known: for MySQL and MariaDB, "double quoted" text is a string there and masked too.
|
|
92
|
+
*
|
|
75
93
|
* @param {string | null | undefined} statement
|
|
94
|
+
* @param {string | null} [system]
|
|
76
95
|
* @returns {string | null}
|
|
77
96
|
*/
|
|
78
|
-
export function mask(statement) {
|
|
97
|
+
export function mask(statement, system = null) {
|
|
79
98
|
if (typeof statement !== "string" || statement.trim() === "") {
|
|
80
99
|
return null;
|
|
81
100
|
}
|
|
82
|
-
const
|
|
101
|
+
const pattern = new RegExp(DOUBLE_QUOTED_STRING_SYSTEMS.has(String(system ?? "").toLowerCase()) ? LITERAL_WITH_DOUBLE_QUOTES : LITERAL);
|
|
102
|
+
let masked = "";
|
|
103
|
+
let emitted = 0;
|
|
104
|
+
let match;
|
|
105
|
+
while ((match = pattern.exec(statement)) !== null) {
|
|
106
|
+
const before = statement[match.index - 1] ?? "";
|
|
107
|
+
const prefixed = match[1] !== undefined && match[1] !== "" && WORD_END.test(before);
|
|
108
|
+
if (prefixed || (match[3] !== undefined && NUMBER_PREFIX.test(before))) {
|
|
109
|
+
// Not a match where it starts: try again from the next character, the way a lookbehind
|
|
110
|
+
// failing there would. A prefix that's the end of a word stays, and the quote after it
|
|
111
|
+
// still starts a string (LIKE'%x%' masks to LIKE?).
|
|
112
|
+
pattern.lastIndex = match.index + 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
masked += statement.slice(emitted, match.index) + MASK;
|
|
116
|
+
emitted = pattern.lastIndex;
|
|
117
|
+
}
|
|
118
|
+
masked += statement.slice(emitted);
|
|
83
119
|
return masked.length > MAX_LENGTH ? `${masked.slice(0, MAX_LENGTH)}...` : masked;
|
|
84
120
|
}
|
|
85
121
|
|