@forge-ops/tracker 0.12.0 → 0.14.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 +111 -6
- package/package.json +5 -2
- package/src/databaseTracing.js +196 -0
- package/src/index.js +101 -7
- package/src/optionalModules.js +39 -0
- package/src/redisTracing.js +158 -0
package/README.md
CHANGED
|
@@ -429,8 +429,8 @@ server-side and dropped, exactly like any other delivery failure.
|
|
|
429
429
|
|
|
430
430
|
For one slow or errored request, `forgeOpsTrackerTracingExpressMiddleware` (Express) and
|
|
431
431
|
`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.
|
|
432
|
+
call tree: the route span, plus every outbound HTTP call, database query, Redis command and
|
|
433
|
+
manually-wrapped span nested under it, so ForgeOps can render a waterfall for that one request.
|
|
434
434
|
|
|
435
435
|
```js
|
|
436
436
|
import { forgeOpsTrackerTracingExpressMiddleware } from "@forge-ops/tracker/integrations/tracing";
|
|
@@ -539,10 +539,115 @@ app.use(forgeOpsTrackerExpressMiddleware);
|
|
|
539
539
|
app.listen(3000);
|
|
540
540
|
```
|
|
541
541
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
span
|
|
542
|
+
### Database spans
|
|
543
|
+
|
|
544
|
+
Queries run through [`pg`](https://node-postgres.com) (node-postgres) are captured automatically,
|
|
545
|
+
one `"database"` span per query, with no extra setup: `init()` finds `pg` in your app and wraps
|
|
546
|
+
`Client#query`, which `Pool#query` goes through too. Callbacks, promises, and config objects
|
|
547
|
+
(`{ text, values, name }`) are all covered.
|
|
548
|
+
|
|
549
|
+
```js
|
|
550
|
+
import pg from "pg";
|
|
551
|
+
|
|
552
|
+
const pool = new pg.Pool();
|
|
553
|
+
|
|
554
|
+
app.get("/orders", async (req, res) => {
|
|
555
|
+
// Recorded as a "SELECT orders" span, nested under this request's route span.
|
|
556
|
+
const { rows } = await pool.query(
|
|
557
|
+
"SELECT id, total FROM orders WHERE customer_id = $1 AND status = 'open'",
|
|
558
|
+
[req.user.id],
|
|
559
|
+
);
|
|
560
|
+
res.json(rows);
|
|
561
|
+
});
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
The span is named after the statement's verb and table (`"SELECT orders"`, `"INSERT INTO orders"`,
|
|
565
|
+
`"UPDATE orders"`, `"DELETE sessions"`, or just the first keyword, like `"BEGIN"`), never the SQL
|
|
566
|
+
itself. It carries the statement in its data as `db.statement`, masked before it's stored: every
|
|
567
|
+
string and number becomes `?`, so the query above is sent as
|
|
568
|
+
`SELECT id, total FROM orders WHERE customer_id = $1 AND status = ?`, and ForgeOps masks it again
|
|
569
|
+
on arrival. It's cut to 4000 characters. Bind values (the `[req.user.id]` array) are never read or
|
|
570
|
+
sent. `db.system` is `"postgresql"`. A failed query is recorded too, and its error reaches your
|
|
571
|
+
code exactly as before.
|
|
572
|
+
|
|
573
|
+
For any other database library, wrap a query by hand with `kind: "database"` and pass the SQL as
|
|
574
|
+
`statement`; it's masked the same way:
|
|
575
|
+
|
|
576
|
+
```js
|
|
577
|
+
import mysql from "mysql2/promise";
|
|
578
|
+
import * as forgeOpsTracker from "@forge-ops/tracker";
|
|
579
|
+
|
|
580
|
+
const connection = await mysql.createConnection(process.env.DATABASE_URL);
|
|
581
|
+
|
|
582
|
+
app.get("/orders", async (req, res) => {
|
|
583
|
+
const sql = "SELECT id, total FROM orders WHERE customer_id = ? AND status = 'open'";
|
|
584
|
+
const [rows] = await forgeOpsTracker.span("Load open orders", () => connection.execute(sql, [req.user.id]), {
|
|
585
|
+
kind: "database",
|
|
586
|
+
statement: sql,
|
|
587
|
+
dbSystem: "mysql",
|
|
588
|
+
});
|
|
589
|
+
res.json(rows);
|
|
590
|
+
});
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
`dbSystem` is optional (`"postgresql"`, `"mysql"`, `"sqlite"`, `"mssql"`, `"oracle"`, or any other
|
|
594
|
+
lowercase name) and both options are ignored on spans of any other kind. They go out in the span's
|
|
595
|
+
data as `db.statement` and `db.system`. A hand-made span around a `pg` query still works: the
|
|
596
|
+
automatic query span just nests under it.
|
|
597
|
+
|
|
598
|
+
### Redis spans
|
|
599
|
+
|
|
600
|
+
Commands sent through [`ioredis`](https://github.com/redis/ioredis) or
|
|
601
|
+
[`redis`](https://github.com/redis/node-redis) (node-redis) are captured automatically too, one
|
|
602
|
+
`"redis"` span per command, pipelined ones included:
|
|
603
|
+
|
|
604
|
+
```js
|
|
605
|
+
import Redis from "ioredis";
|
|
606
|
+
|
|
607
|
+
const redis = new Redis(process.env.REDIS_URL);
|
|
608
|
+
|
|
609
|
+
app.get("/cart", async (req, res) => {
|
|
610
|
+
// Recorded as a "Redis GET" span.
|
|
611
|
+
const cart = await redis.get(`cart:${req.user.id}`);
|
|
612
|
+
res.json(JSON.parse(cart ?? "[]"));
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
A span is named after the command alone (`"Redis GET"`, `"Redis SET"`, `"Redis LPUSH"`) and
|
|
617
|
+
carries no data: never the key or any other argument, since a key can easily hold a user's id or
|
|
618
|
+
another sensitive value. A failed command is recorded too. `ioredis` is covered from version 4 on;
|
|
619
|
+
`redis` from 5.12 on (with Node 18.19 or later), through the diagnostics channel it publishes every
|
|
620
|
+
command on, so nothing in it is patched.
|
|
621
|
+
|
|
622
|
+
### When `init()` can't find `pg` or `ioredis`
|
|
623
|
+
|
|
624
|
+
`init()` looks for `pg` and `ioredis` from the directory your app was started in, and from where
|
|
625
|
+
this package is installed. Neither is a dependency of this package: one your app doesn't have is
|
|
626
|
+
simply skipped. If your app is bundled, or started from a directory its `node_modules` isn't
|
|
627
|
+
under, hand the module over yourself, once, at startup:
|
|
628
|
+
|
|
629
|
+
```js
|
|
630
|
+
import pg from "pg";
|
|
631
|
+
import Redis from "ioredis";
|
|
632
|
+
import * as forgeOpsTracker from "@forge-ops/tracker";
|
|
633
|
+
|
|
634
|
+
forgeOpsTracker.init({ dsn: "..." });
|
|
635
|
+
forgeOpsTracker.instrumentPg(pg);
|
|
636
|
+
forgeOpsTracker.instrumentIoredis(Redis);
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
Both are safe to call more than once, and return `false` when given something that isn't `pg` or
|
|
640
|
+
an `ioredis` class. `redis` never needs this.
|
|
641
|
+
|
|
642
|
+
**Known gaps:**
|
|
643
|
+
|
|
644
|
+
- `redis` (node-redis) before 5.12 (all of 4.x, and 5.0 to 5.11) publishes nothing to listen to,
|
|
645
|
+
so its commands don't show up as spans. Upgrade, or wrap the calls you care about with
|
|
646
|
+
`span(name, fn, { kind: "redis" })`.
|
|
647
|
+
- `pg-native` (`pg.native`), and cursors and streams passed to `query()` (`pg-cursor`,
|
|
648
|
+
`pg-query-stream`), aren't captured.
|
|
649
|
+
- Other database libraries (`mysql2`, `better-sqlite3`, Prisma, and so on) aren't captured
|
|
650
|
+
automatically; wrap their queries with `span()` as shown above.
|
|
546
651
|
|
|
547
652
|
## Database errors
|
|
548
653
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forge-ops/tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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,9 @@
|
|
|
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"
|
|
50
53
|
}
|
|
51
54
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
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 per query run through node-postgres (pg), for any app that has it
|
|
13
|
+
* installed; installed from index.js's init() the same way installHttpTracing is, exactly once
|
|
14
|
+
* for the life of the process (see the installed guard), and checking whether a trace is being
|
|
15
|
+
* captured fresh on every query rather than at install time. An app without pg gets nothing
|
|
16
|
+
* patched and nothing loaded (see requireOptional).
|
|
17
|
+
*
|
|
18
|
+
* Wraps pg.Client.prototype.query, which pg.Pool's own query() also goes through, so a pooled
|
|
19
|
+
* query is covered with no separate hook. Every calling style is handled: a callback (as the last
|
|
20
|
+
* argument, or on a config object's own `callback`), a returned promise, and a string or config
|
|
21
|
+
* object ({ text, values, name, ... }). A submittable (pg-cursor, pg-query-stream, or a raw
|
|
22
|
+
* pg.Query) is passed straight through unrecorded: it has no single "done" moment this could
|
|
23
|
+
* observe without adding an "error" listener, which would change what an unhandled stream error
|
|
24
|
+
* does in the host app.
|
|
25
|
+
*
|
|
26
|
+
* The span is named "<VERB> <table>" (see queryName) and carries the statement masked by
|
|
27
|
+
* sqlStatement.js's mask() as data["db.statement"] (every string and number replaced by "?", the
|
|
28
|
+
* same masking span({ statement }) and gems/forge_ops_tracker's database spans apply) and
|
|
29
|
+
* "postgresql" as data["db.system"]. Bind values are never read.
|
|
30
|
+
*
|
|
31
|
+
* @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>) => void} recordSpan
|
|
32
|
+
* @param {() => boolean} isTracing whether the current async context has a trace to record into
|
|
33
|
+
*/
|
|
34
|
+
export function installDatabaseTracing(recordSpan, isTracing) {
|
|
35
|
+
if (installed) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
installed = true;
|
|
39
|
+
for (const pg of requireOptional("pg")) {
|
|
40
|
+
instrumentPg(pg, recordSpan, isTracing);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Patches one pg module (what `import pg from "pg"` returns). Safe to call more than once for the
|
|
46
|
+
* same module: a method this already wrapped is left alone.
|
|
47
|
+
*
|
|
48
|
+
* Also binds a callback passed to pg.Pool#connect to the async context it was called from, while a
|
|
49
|
+
* trace is being captured. When every pooled client is busy, pg-pool queues the request and later
|
|
50
|
+
* hands it a client from inside whichever other request released one, so without this, a query
|
|
51
|
+
* run on that client would be recorded into the wrong request's trace.
|
|
52
|
+
*
|
|
53
|
+
* @param {any} pg
|
|
54
|
+
* @param {Parameters<typeof installDatabaseTracing>[0]} recordSpan
|
|
55
|
+
* @param {() => boolean} isTracing
|
|
56
|
+
* @returns {boolean} whether there was a pg Client to patch
|
|
57
|
+
*/
|
|
58
|
+
export function instrumentPg(pg, recordSpan, isTracing) {
|
|
59
|
+
const clientPrototype = pg?.Client?.prototype;
|
|
60
|
+
if (typeof clientPrototype?.query !== "function") {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
patch(clientPrototype, "query", (original) => wrapQuery(original, recordSpan, isTracing));
|
|
64
|
+
|
|
65
|
+
const poolPrototype = pg.Pool?.prototype;
|
|
66
|
+
if (typeof poolPrototype?.connect === "function") {
|
|
67
|
+
patch(poolPrototype, "connect", (original) => wrapConnect(original, isTracing));
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @internal test-only: restores every method installDatabaseTracing/instrumentPg replaced. */
|
|
73
|
+
export function _uninstallDatabaseTracing() {
|
|
74
|
+
while (patches.length > 0) {
|
|
75
|
+
const { target, key, original, own } = patches.pop();
|
|
76
|
+
if (own) {
|
|
77
|
+
target[key] = original;
|
|
78
|
+
} else {
|
|
79
|
+
delete target[key];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
installed = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function patch(target, key, wrapper) {
|
|
86
|
+
const original = target[key];
|
|
87
|
+
if (original[WRAPPED]) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const wrapped = wrapper(original);
|
|
91
|
+
wrapped[WRAPPED] = true;
|
|
92
|
+
patches.push({ target, key, original, own: Object.prototype.hasOwnProperty.call(target, key) });
|
|
93
|
+
target[key] = wrapped;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function wrapQuery(originalQuery, recordSpan, isTracing) {
|
|
97
|
+
return function patchedQuery(config, values, callback) {
|
|
98
|
+
if (config == null || typeof config.submit === "function" || !isTracing()) {
|
|
99
|
+
return originalQuery.apply(this, arguments);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const statement = typeof config === "string" ? config : config.text;
|
|
103
|
+
const masked = typeof statement === "string" ? mask(statement) : null;
|
|
104
|
+
const startedAt = new Date();
|
|
105
|
+
const start = performance.now();
|
|
106
|
+
|
|
107
|
+
// Bound to the caller's async context now: pg calls back (and settles its promise) from its
|
|
108
|
+
// socket's own event handlers, where the trace this query belongs to isn't the current one.
|
|
109
|
+
let finished = false;
|
|
110
|
+
const finish = AsyncResource.bind(() => {
|
|
111
|
+
if (finished) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
finished = true;
|
|
115
|
+
const data = { "db.system": "postgresql" };
|
|
116
|
+
if (masked !== null) {
|
|
117
|
+
data["db.statement"] = masked;
|
|
118
|
+
}
|
|
119
|
+
recordSpan(queryName(masked), "database", startedAt, performance.now() - start, data);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const args = [...arguments];
|
|
123
|
+
const callbackIndex = typeof callback === "function" ? 2 : typeof values === "function" ? 1 : -1;
|
|
124
|
+
if (callbackIndex !== -1) {
|
|
125
|
+
args[callbackIndex] = withFinish(args[callbackIndex], finish);
|
|
126
|
+
return originalQuery.apply(this, args);
|
|
127
|
+
}
|
|
128
|
+
if (typeof config === "object" && typeof config.callback === "function") {
|
|
129
|
+
// A new object inheriting from the caller's, with only `callback` replaced: pg reads the
|
|
130
|
+
// rest (text, values, name, rowMode...) through it unchanged, getters included.
|
|
131
|
+
args[0] = Object.create(config, {
|
|
132
|
+
callback: { value: withFinish(config.callback, finish), writable: true, enumerable: true, configurable: true },
|
|
133
|
+
});
|
|
134
|
+
return originalQuery.apply(this, args);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const result = originalQuery.apply(this, args);
|
|
138
|
+
if (!result || typeof result.then !== "function") {
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
// The promise returned in pg's place settles exactly like pg's own, so a rejection the app
|
|
142
|
+
// never handles is still reported as unhandled, the same as without this wrapper.
|
|
143
|
+
return result.then(
|
|
144
|
+
(value) => {
|
|
145
|
+
finish();
|
|
146
|
+
return value;
|
|
147
|
+
},
|
|
148
|
+
(error) => {
|
|
149
|
+
finish();
|
|
150
|
+
throw error;
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function withFinish(callback, finish) {
|
|
157
|
+
return function queryCallback(...args) {
|
|
158
|
+
finish();
|
|
159
|
+
return callback.apply(this, args);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function wrapConnect(originalConnect, isTracing) {
|
|
164
|
+
return function patchedConnect(callback, ...rest) {
|
|
165
|
+
if (typeof callback === "function" && isTracing()) {
|
|
166
|
+
return originalConnect.call(this, AsyncResource.bind(callback), ...rest);
|
|
167
|
+
}
|
|
168
|
+
return originalConnect.apply(this, arguments);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const SELECT_DELETE = /^\s*(SELECT|DELETE)\b[\s\S]*?\bFROM\s+["'`]?(\w+)/i;
|
|
173
|
+
const INSERT_UPDATE = /^\s*(INSERT INTO|UPDATE)\s+["'`]?(\w+)/i;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A low-cardinality span name for a query, never the SQL text itself: "<VERB> <table>" for the
|
|
177
|
+
* shapes that carry a table in a predictable place ("SELECT orders", "INSERT INTO orders",
|
|
178
|
+
* "UPDATE orders", "DELETE sessions"), just the first keyword otherwise ("BEGIN", "SELECT" for
|
|
179
|
+
* "SELECT 1"), and "SQL" for a blank or missing statement. A direct port of the Python SDK's
|
|
180
|
+
* _query_transaction_name (and the Elixir SDK's QueryNaming), so the same query gets the same name
|
|
181
|
+
* in every SDK. Given the masked statement, so a quoted value can never become part of a name.
|
|
182
|
+
*
|
|
183
|
+
* @param {string | null | undefined} sql
|
|
184
|
+
* @returns {string}
|
|
185
|
+
*/
|
|
186
|
+
export function queryName(sql) {
|
|
187
|
+
if (typeof sql !== "string") {
|
|
188
|
+
return "SQL";
|
|
189
|
+
}
|
|
190
|
+
const match = SELECT_DELETE.exec(sql) ?? INSERT_UPDATE.exec(sql);
|
|
191
|
+
if (match) {
|
|
192
|
+
return `${match[1].toUpperCase()} ${match[2]}`;
|
|
193
|
+
}
|
|
194
|
+
const first = sql.trim().split(/\s+/, 1)[0];
|
|
195
|
+
return first ? first.toUpperCase() : "SQL";
|
|
196
|
+
}
|
package/src/index.js
CHANGED
|
@@ -3,15 +3,18 @@ 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";
|
|
14
16
|
import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
|
|
17
|
+
import { mask as maskSql } from "./sqlStatement.js";
|
|
15
18
|
import { buildTraceparent } from "./traceParent.js";
|
|
16
19
|
|
|
17
20
|
export { Configuration };
|
|
@@ -434,11 +437,11 @@ export function _finishSpanTrace(name, startedAt, durationMs) {
|
|
|
434
437
|
|
|
435
438
|
/**
|
|
436
439
|
* Internal; called by whatever this client already automatically times end-to-end with no
|
|
437
|
-
* children of its own to nest anything under (
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
* 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.
|
|
442
445
|
*
|
|
443
446
|
* @param {string} name
|
|
444
447
|
* @param {string} kind
|
|
@@ -458,6 +461,49 @@ export function _recordSpan(name, kind, startedAt, durationMs, data = {}, spanId
|
|
|
458
461
|
buffer.record({ spanId, parentSpanId, name, kind, startedAt, durationMs, data });
|
|
459
462
|
}
|
|
460
463
|
|
|
464
|
+
/**
|
|
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 for every query run through this pg module (`import pg from "pg"`),
|
|
478
|
+
* for an app where init() couldn't find pg by itself: a bundled app, or one whose node_modules
|
|
479
|
+
* 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);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Records a "redis" span for every command sent through this ioredis Redis class, for an app
|
|
494
|
+
* where init() couldn't find ioredis by itself (see instrumentPg above). Safe to call more than
|
|
495
|
+
* 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);
|
|
505
|
+
}
|
|
506
|
+
|
|
461
507
|
/**
|
|
462
508
|
* Internal; called by whatever this client already automatically times (currently just the
|
|
463
509
|
* Express controller/route lifecycle; see integrations/performance.js), never by host app code
|
|
@@ -508,6 +554,10 @@ export function init(options = {}) {
|
|
|
508
554
|
// config, checking configuration.trackTracing fresh on every actual outbound call instead (see
|
|
509
555
|
// _recordSpan above), never at install time.
|
|
510
556
|
installHttpTracing(_recordSpan, _traceparentFor);
|
|
557
|
+
// The same for database queries (pg) and Redis commands (ioredis, node-redis), for whichever of
|
|
558
|
+
// those the app has installed; nothing is loaded for one it doesn't (see optionalModules.js).
|
|
559
|
+
installDatabaseTracing(_recordSpan, _isTracing);
|
|
560
|
+
installRedisTracing(_recordSpan, _isTracing);
|
|
511
561
|
|
|
512
562
|
startChangeSnapshot(config);
|
|
513
563
|
|
|
@@ -610,17 +660,32 @@ export function runWithUser(user, callback) {
|
|
|
610
660
|
* request-level trace around it, and no middleware left running to ever flush it, would just
|
|
611
661
|
* accumulate in memory forever with nothing to ever send it, worse than not recording it at all.
|
|
612
662
|
*
|
|
663
|
+
* A "database" span can also carry the SQL it ran, so ForgeOps can show which query was slow:
|
|
664
|
+
*
|
|
665
|
+
* await forgeOpsTracker.span("Load orders", () => pool.query(sql, [userId]), {
|
|
666
|
+
* kind: "database",
|
|
667
|
+
* statement: sql,
|
|
668
|
+
* dbSystem: "postgresql",
|
|
669
|
+
* });
|
|
670
|
+
*
|
|
671
|
+
* The statement is masked (every string and number replaced by "?") before it's ever stored on
|
|
672
|
+
* the span, and sent as data["db.statement"], with dbSystem lowercased as data["db.system"]. Bind
|
|
673
|
+
* values are never taken, and both options are ignored on any other kind of span. Queries run
|
|
674
|
+
* through pg are already recorded this way on their own (see databaseTracing.js); a span wrapped
|
|
675
|
+
* around one just becomes its parent.
|
|
676
|
+
*
|
|
613
677
|
* @template T
|
|
614
678
|
* @param {string} name
|
|
615
679
|
* @param {() => T} callback
|
|
616
|
-
* @param {{ kind?: string, data?: Record<string, unknown
|
|
680
|
+
* @param {{ kind?: string, data?: Record<string, unknown>, statement?: string, dbSystem?: string }} [options]
|
|
617
681
|
* @returns {T}
|
|
618
682
|
*/
|
|
619
|
-
export function span(name, callback, { kind = "service", data = {} } = {}) {
|
|
683
|
+
export function span(name, callback, { kind = "service", data = {}, statement, dbSystem } = {}) {
|
|
620
684
|
const buffer = spanStorage.getStore();
|
|
621
685
|
if (!buffer) {
|
|
622
686
|
return callback();
|
|
623
687
|
}
|
|
688
|
+
data = databaseSpanData(kind, data, statement, dbSystem);
|
|
624
689
|
|
|
625
690
|
const spanId = randomSpanId();
|
|
626
691
|
const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
|
|
@@ -652,6 +717,33 @@ export function span(name, callback, { kind = "service", data = {} } = {}) {
|
|
|
652
717
|
}
|
|
653
718
|
}
|
|
654
719
|
|
|
720
|
+
/**
|
|
721
|
+
* The data a span is recorded with: unchanged unless it's a database span, which gets the masked
|
|
722
|
+
* statement as "db.statement" and the lowercased database name as "db.system". A "db.statement"
|
|
723
|
+
* passed in `data` directly is masked as well, so the raw SQL can't reach the payload either way.
|
|
724
|
+
* @param {string} kind
|
|
725
|
+
* @param {Record<string, unknown>} data
|
|
726
|
+
* @param {string | undefined} statement
|
|
727
|
+
* @param {string | undefined} dbSystem
|
|
728
|
+
* @returns {Record<string, unknown>}
|
|
729
|
+
*/
|
|
730
|
+
function databaseSpanData(kind, data, statement, dbSystem) {
|
|
731
|
+
if (kind !== "database") {
|
|
732
|
+
return data;
|
|
733
|
+
}
|
|
734
|
+
const result = { ...data };
|
|
735
|
+
const masked = maskSql(typeof statement === "string" ? statement : result["db.statement"]);
|
|
736
|
+
if (masked === null) {
|
|
737
|
+
delete result["db.statement"];
|
|
738
|
+
} else {
|
|
739
|
+
result["db.statement"] = masked;
|
|
740
|
+
}
|
|
741
|
+
if (typeof dbSystem === "string" && dbSystem.trim() !== "") {
|
|
742
|
+
result["db.system"] = dbSystem.trim().toLowerCase();
|
|
743
|
+
}
|
|
744
|
+
return result;
|
|
745
|
+
}
|
|
746
|
+
|
|
655
747
|
/**
|
|
656
748
|
* Reports anything that would otherwise crash the process outright (a
|
|
657
749
|
* plain script, an unhandled promise rejection) with no further wiring:
|
|
@@ -734,4 +826,6 @@ export function _resetForTesting({ changeSnapshot = false } = {}) {
|
|
|
734
826
|
// on that instead of actually restoring the original functions would be fragile, not a genuine
|
|
735
827
|
// guarantee.
|
|
736
828
|
_uninstallHttpTracing();
|
|
829
|
+
_uninstallDatabaseTracing();
|
|
830
|
+
_uninstallRedisTracing();
|
|
737
831
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
* @returns {unknown[]}
|
|
23
|
+
*/
|
|
24
|
+
export function requireOptional(name) {
|
|
25
|
+
const bases = [path.join(process.cwd(), "package.json"), import.meta.url];
|
|
26
|
+
const loaded = new Map();
|
|
27
|
+
for (const base of bases) {
|
|
28
|
+
try {
|
|
29
|
+
const require = createRequire(base);
|
|
30
|
+
const resolved = require.resolve(name);
|
|
31
|
+
if (!loaded.has(resolved)) {
|
|
32
|
+
loaded.set(resolved, require(name));
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Not installed where this base looks: nothing to patch from here.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...loaded.values()];
|
|
39
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { AsyncResource } from "node:async_hooks";
|
|
2
|
+
import diagnosticsChannel from "node:diagnostics_channel";
|
|
3
|
+
import { requireOptional } from "./optionalModules.js";
|
|
4
|
+
|
|
5
|
+
const WRAPPED = Symbol("forgeOpsTrackerWrapped");
|
|
6
|
+
const SEEN = Symbol("forgeOpsTrackerSeen");
|
|
7
|
+
const SPAN = Symbol("forgeOpsTrackerSpan");
|
|
8
|
+
const NODE_REDIS_COMMAND_CHANNEL = "node-redis:command";
|
|
9
|
+
|
|
10
|
+
let installed = false;
|
|
11
|
+
// Every patch made, so _uninstallRedisTracing can put each method back exactly as found.
|
|
12
|
+
const patches = [];
|
|
13
|
+
let nodeRedisSubscription = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Records one "redis" span per Redis command, for an app using ioredis or node-redis (the `redis`
|
|
17
|
+
* package); installed from index.js's init() the same way installHttpTracing is, exactly once for
|
|
18
|
+
* the life of the process, and checking whether a trace is being captured fresh on every command.
|
|
19
|
+
* Neither library is loaded or required unless the app has it installed.
|
|
20
|
+
*
|
|
21
|
+
* Every span is named "Redis <COMMAND>" (the command alone, upcased: GET, SET, LPUSH...) with no
|
|
22
|
+
* data at all: never a key or any other argument, since a key can easily carry a customer's own
|
|
23
|
+
* id or other sensitive value. The same name and kind gems/forge_ops_tracker's
|
|
24
|
+
* Integrations::RedisClientTiming records.
|
|
25
|
+
*
|
|
26
|
+
* ioredis (every version from 4.x on) is covered by wrapping Redis.prototype.sendCommand, which
|
|
27
|
+
* every command goes through, pipelined and MULTI ones included (see instrumentIoredis).
|
|
28
|
+
*
|
|
29
|
+
* node-redis is covered through the "node-redis:command" diagnostics channel it publishes every
|
|
30
|
+
* command on from version 5.12 on (@redis/client), so nothing in it is patched and it's never
|
|
31
|
+
* loaded from here: subscribing by name works whether or not the app ever loads it. Earlier
|
|
32
|
+
* node-redis versions (4.x, and 5.x before 5.12) publish nothing there and aren't covered.
|
|
33
|
+
*
|
|
34
|
+
* @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>) => void} recordSpan
|
|
35
|
+
* @param {() => boolean} isTracing whether the current async context has a trace to record into
|
|
36
|
+
*/
|
|
37
|
+
export function installRedisTracing(recordSpan, isTracing) {
|
|
38
|
+
if (installed) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
installed = true;
|
|
42
|
+
for (const Redis of requireOptional("ioredis")) {
|
|
43
|
+
instrumentIoredis(Redis, recordSpan, isTracing);
|
|
44
|
+
}
|
|
45
|
+
subscribeNodeRedis(recordSpan, isTracing);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Patches one ioredis Redis class (what `import Redis from "ioredis"` returns). Safe to call more
|
|
50
|
+
* than once for the same class.
|
|
51
|
+
*
|
|
52
|
+
* ioredis can pass the same command through sendCommand more than once (flushing its offline
|
|
53
|
+
* queue once connected, resending after a reconnect), so each command is only timed the first
|
|
54
|
+
* time, from when the app sent it until its reply. The span is recorded when ioredis settles the
|
|
55
|
+
* command (its own resolve/reject), rather than by attaching a handler to the command's promise:
|
|
56
|
+
* that would mark a rejection the app never handles as handled, and it would no longer be
|
|
57
|
+
* reported as an unhandled rejection.
|
|
58
|
+
*
|
|
59
|
+
* @param {any} Redis
|
|
60
|
+
* @param {Parameters<typeof installRedisTracing>[0]} recordSpan
|
|
61
|
+
* @param {() => boolean} isTracing
|
|
62
|
+
* @returns {boolean} whether there was a sendCommand to patch
|
|
63
|
+
*/
|
|
64
|
+
export function instrumentIoredis(Redis, recordSpan, isTracing) {
|
|
65
|
+
const prototype = Redis?.prototype;
|
|
66
|
+
if (typeof prototype?.sendCommand !== "function" || prototype.sendCommand[WRAPPED]) {
|
|
67
|
+
return typeof prototype?.sendCommand === "function";
|
|
68
|
+
}
|
|
69
|
+
const originalSendCommand = prototype.sendCommand;
|
|
70
|
+
|
|
71
|
+
const patchedSendCommand = function patchedSendCommand(command, ...rest) {
|
|
72
|
+
if (command && typeof command === "object" && !command[SEEN] && isTracing()) {
|
|
73
|
+
command[SEEN] = true;
|
|
74
|
+
observeCommand(command, recordSpan);
|
|
75
|
+
}
|
|
76
|
+
return originalSendCommand.call(this, command, ...rest);
|
|
77
|
+
};
|
|
78
|
+
patchedSendCommand[WRAPPED] = true;
|
|
79
|
+
|
|
80
|
+
patches.push({ target: prototype, key: "sendCommand", original: originalSendCommand, own: Object.prototype.hasOwnProperty.call(prototype, "sendCommand") });
|
|
81
|
+
prototype.sendCommand = patchedSendCommand;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @internal test-only: restores every method installRedisTracing replaced, and unsubscribes. */
|
|
86
|
+
export function _uninstallRedisTracing() {
|
|
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
|
+
if (nodeRedisSubscription !== null) {
|
|
96
|
+
diagnosticsChannel.tracingChannel(NODE_REDIS_COMMAND_CHANNEL).unsubscribe(nodeRedisSubscription);
|
|
97
|
+
nodeRedisSubscription = null;
|
|
98
|
+
}
|
|
99
|
+
installed = false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function observeCommand(command, recordSpan) {
|
|
103
|
+
const { resolve, reject } = command;
|
|
104
|
+
if (typeof resolve !== "function" || typeof reject !== "function") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const finish = startSpan(command.name, recordSpan);
|
|
108
|
+
command.resolve = function resolveWithSpan(...args) {
|
|
109
|
+
finish();
|
|
110
|
+
return resolve.apply(this, args);
|
|
111
|
+
};
|
|
112
|
+
command.reject = function rejectWithSpan(...args) {
|
|
113
|
+
finish();
|
|
114
|
+
return reject.apply(this, args);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Starts timing one command now and returns the function that records it, bound to the caller's
|
|
120
|
+
* async context: the reply arrives from the connection's own socket handlers, where the trace the
|
|
121
|
+
* command belongs to isn't the current one. Only ever records once.
|
|
122
|
+
*/
|
|
123
|
+
function startSpan(commandName, recordSpan) {
|
|
124
|
+
const name = `Redis ${String(commandName).toUpperCase()}`;
|
|
125
|
+
const startedAt = new Date();
|
|
126
|
+
const start = performance.now();
|
|
127
|
+
let finished = false;
|
|
128
|
+
return AsyncResource.bind(() => {
|
|
129
|
+
if (finished) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
finished = true;
|
|
133
|
+
recordSpan(name, "redis", startedAt, performance.now() - start);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* node-redis runs each command inside TracingChannel#tracePromise: "start" is published
|
|
139
|
+
* synchronously from the app's own call (so isTracing() sees the right trace), and "asyncEnd" once
|
|
140
|
+
* the reply has settled the command either way. Only `context.command` (already upcased by
|
|
141
|
+
* node-redis) is read, never `context.args`.
|
|
142
|
+
*/
|
|
143
|
+
function subscribeNodeRedis(recordSpan, isTracing) {
|
|
144
|
+
if (typeof diagnosticsChannel.tracingChannel !== "function") {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
nodeRedisSubscription = {
|
|
148
|
+
start(context) {
|
|
149
|
+
if (context && typeof context === "object" && isTracing()) {
|
|
150
|
+
context[SPAN] = startSpan(context.command, recordSpan);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
asyncEnd(context) {
|
|
154
|
+
context?.[SPAN]?.();
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
diagnosticsChannel.tracingChannel(NODE_REDIS_COMMAND_CHANNEL).subscribe(nodeRedisSubscription);
|
|
158
|
+
}
|