@forge-ops/tracker 0.14.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 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. Shows up alongside the error on an
178
- issue's own detail page.
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
- There's currently no query-level automatic breadcrumb source: this client has no ORM/DB driver
216
- integration to hang one off of yet, the same reason there's no query-level performance
217
- instrumentation either. Fastify also gets no automatic `"controller"` breadcrumb today, since
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
@@ -570,6 +571,11 @@ on arrival. It's cut to 4000 characters. Bind values (the `[req.user.id]` array)
570
571
  sent. `db.system` is `"postgresql"`. A failed query is recorded too, and its error reaches your
571
572
  code exactly as before.
572
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
+
573
579
  For any other database library, wrap a query by hand with `kind: "database"` and pass the SQL as
574
580
  `statement`; it's masked the same way:
575
581
 
@@ -592,8 +598,14 @@ app.get("/orders", async (req, res) => {
592
598
 
593
599
  `dbSystem` is optional (`"postgresql"`, `"mysql"`, `"sqlite"`, `"mssql"`, `"oracle"`, or any other
594
600
  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.
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.
597
609
 
598
610
  ### Redis spans
599
611
 
@@ -615,14 +627,24 @@ app.get("/cart", async (req, res) => {
615
627
 
616
628
  A span is named after the command alone (`"Redis GET"`, `"Redis SET"`, `"Redis LPUSH"`) and
617
629
  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.
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.
621
643
 
622
644
  ### When `init()` can't find `pg` or `ioredis`
623
645
 
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
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
626
648
  simply skipped. If your app is bundled, or started from a directory its `node_modules` isn't
627
649
  under, hand the module over yourself, once, at startup:
628
650
 
@@ -637,13 +659,19 @@ forgeOpsTracker.instrumentIoredis(Redis);
637
659
  ```
638
660
 
639
661
  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.
662
+ an `ioredis` class. `redis` from 5.12 on never needs this.
641
663
 
642
664
  **Known gaps:**
643
665
 
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" })`.
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.
647
675
  - `pg-native` (`pg.native`), and cursors and streams passed to `query()` (`pg-cursor`,
648
676
  `pg-query-stream`), aren't captured.
649
677
  - Other database libraries (`mysql2`, `better-sqlite3`, Prisma, and so on) aren't captured
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.14.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",
@@ -49,6 +49,10 @@
49
49
  "fastify": "^5.0.0",
50
50
  "ioredis": "^6.0.0",
51
51
  "pg": "^8.23.0",
52
- "redis": "^6.2.1"
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"
53
57
  }
54
58
  }
@@ -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 (currently just the Express
93
- * controller/route lifecycle; see integrations/performance.js) also record a breadcrumb
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.
@@ -9,11 +9,12 @@ let installed = false;
9
9
  const patches = [];
10
10
 
11
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).
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).
17
18
  *
18
19
  * Wraps pg.Client.prototype.query, which pg.Pool's own query() also goes through, so a pooled
19
20
  * query is covered with no separate hook. Every calling style is handled: a callback (as the last
@@ -28,16 +29,25 @@ const patches = [];
28
29
  * same masking span({ statement }) and gems/forge_ops_tracker's database spans apply) and
29
30
  * "postgresql" as data["db.system"]. Bind values are never read.
30
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
+ *
31
38
  * @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>) => void} recordSpan
32
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)
33
43
  */
34
- export function installDatabaseTracing(recordSpan, isTracing) {
44
+ export function installDatabaseTracing(recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail) {
35
45
  if (installed) {
36
46
  return;
37
47
  }
38
48
  installed = true;
39
49
  for (const pg of requireOptional("pg")) {
40
- instrumentPg(pg, recordSpan, isTracing);
50
+ instrumentPg(pg, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail);
41
51
  }
42
52
  }
43
53
 
@@ -46,25 +56,28 @@ export function installDatabaseTracing(recordSpan, isTracing) {
46
56
  * same module: a method this already wrapped is left alone.
47
57
  *
48
58
  * 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.
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.
52
63
  *
53
64
  * @param {any} pg
54
65
  * @param {Parameters<typeof installDatabaseTracing>[0]} recordSpan
55
66
  * @param {() => boolean} isTracing
67
+ * @param {Parameters<typeof installDatabaseTracing>[2]} [recordBreadcrumb]
68
+ * @param {() => boolean} [ensureBreadcrumbTrail]
56
69
  * @returns {boolean} whether there was a pg Client to patch
57
70
  */
58
- export function instrumentPg(pg, recordSpan, isTracing) {
71
+ export function instrumentPg(pg, recordSpan, isTracing, recordBreadcrumb = () => {}, ensureBreadcrumbTrail = () => false) {
59
72
  const clientPrototype = pg?.Client?.prototype;
60
73
  if (typeof clientPrototype?.query !== "function") {
61
74
  return false;
62
75
  }
63
- patch(clientPrototype, "query", (original) => wrapQuery(original, recordSpan, isTracing));
76
+ patch(clientPrototype, "query", (original) => wrapQuery(original, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail));
64
77
 
65
78
  const poolPrototype = pg.Pool?.prototype;
66
79
  if (typeof poolPrototype?.connect === "function") {
67
- patch(poolPrototype, "connect", (original) => wrapConnect(original, isTracing));
80
+ patch(poolPrototype, "connect", (original) => wrapConnect(original, isTracing, ensureBreadcrumbTrail));
68
81
  }
69
82
  return true;
70
83
  }
@@ -93,30 +106,43 @@ function patch(target, key, wrapper) {
93
106
  target[key] = wrapped;
94
107
  }
95
108
 
96
- function wrapQuery(originalQuery, recordSpan, isTracing) {
109
+ function wrapQuery(originalQuery, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail) {
97
110
  return function patchedQuery(config, values, callback) {
98
- if (config == null || typeof config.submit === "function" || !isTracing()) {
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) {
99
117
  return originalQuery.apply(this, arguments);
100
118
  }
101
119
 
102
120
  const statement = typeof config === "string" ? config : config.text;
103
- const masked = typeof statement === "string" ? mask(statement) : null;
121
+ const masked = typeof statement === "string" ? mask(statement, "postgresql") : null;
104
122
  const startedAt = new Date();
105
123
  const start = performance.now();
106
124
 
107
125
  // 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.
126
+ // socket's own event handlers, where the trace and the breadcrumb trail this query belongs to
127
+ // aren't the current ones.
109
128
  let finished = false;
110
129
  const finish = AsyncResource.bind(() => {
111
130
  if (finished) {
112
131
  return;
113
132
  }
114
133
  finished = true;
115
- const data = { "db.system": "postgresql" };
116
- if (masked !== null) {
117
- data["db.statement"] = masked;
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);
118
145
  }
119
- recordSpan(queryName(masked), "database", startedAt, performance.now() - start, data);
120
146
  });
121
147
 
122
148
  const args = [...arguments];
@@ -160,9 +186,9 @@ function withFinish(callback, finish) {
160
186
  };
161
187
  }
162
188
 
163
- function wrapConnect(originalConnect, isTracing) {
189
+ function wrapConnect(originalConnect, isTracing, ensureBreadcrumbTrail) {
164
190
  return function patchedConnect(callback, ...rest) {
165
- if (typeof callback === "function" && isTracing()) {
191
+ if (typeof callback === "function" && (isTracing() || ensureBreadcrumbTrail())) {
166
192
  return originalConnect.call(this, AsyncResource.bind(callback), ...rest);
167
193
  }
168
194
  return originalConnect.apply(this, arguments);
package/src/index.js CHANGED
@@ -474,9 +474,9 @@ export function _isTracing() {
474
474
  }
475
475
 
476
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
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
480
  * find, so most apps never need to call it. Safe to call more than once.
481
481
  *
482
482
  * import pg from "pg";
@@ -486,13 +486,13 @@ export function _isTracing() {
486
486
  * @returns {boolean} whether it was a pg module this could patch
487
487
  */
488
488
  export function instrumentPg(pg) {
489
- return instrumentPgModule(pg, _recordSpan, _isTracing);
489
+ return instrumentPgModule(pg, _recordSpan, _isTracing, _recordBreadcrumb, _ensureBreadcrumbTrail);
490
490
  }
491
491
 
492
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.
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
496
  *
497
497
  * import Redis from "ioredis";
498
498
  * forgeOpsTracker.instrumentIoredis(Redis);
@@ -501,13 +501,13 @@ export function instrumentPg(pg) {
501
501
  * @returns {boolean} whether it was an ioredis class this could patch
502
502
  */
503
503
  export function instrumentIoredis(Redis) {
504
- return instrumentIoredisClass(Redis, _recordSpan, _isTracing);
504
+ return instrumentIoredisClass(Redis, _recordSpan, _isTracing, _recordBreadcrumb, _ensureBreadcrumbTrail);
505
505
  }
506
506
 
507
507
  /**
508
- * Internal; called by whatever this client already automatically times (currently just the
509
- * Express controller/route lifecycle; see integrations/performance.js), never by host app code
510
- * directly, same reasoning as _recordPerformance above. Independent of trackPerformance: an app
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
511
511
  * could want the trail without the timing data, or vice versa, and each call site already has
512
512
  * to check its own flag regardless, so there's no real cost to keeping the two independent
513
513
  * rather than tying breadcrumbs to whether performance monitoring happens to be on (mirrors
@@ -527,6 +527,28 @@ export function _recordBreadcrumb(message, category, level = "info", data = {})
527
527
  addBreadcrumb(message, { category, level, data });
528
528
  }
529
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
+
530
552
  /**
531
553
  * Configure the client. Call once at startup.
532
554
  *
@@ -556,8 +578,8 @@ export function init(options = {}) {
556
578
  installHttpTracing(_recordSpan, _traceparentFor);
557
579
  // The same for database queries (pg) and Redis commands (ioredis, node-redis), for whichever of
558
580
  // those the app has installed; nothing is loaded for one it doesn't (see optionalModules.js).
559
- installDatabaseTracing(_recordSpan, _isTracing);
560
- installRedisTracing(_recordSpan, _isTracing);
581
+ installDatabaseTracing(_recordSpan, _isTracing, _recordBreadcrumb, _ensureBreadcrumbTrail);
582
+ installRedisTracing(_recordSpan, _isTracing, _recordBreadcrumb, _ensureBreadcrumbTrail);
561
583
 
562
584
  startChangeSnapshot(config);
563
585
 
@@ -720,7 +742,8 @@ export function span(name, callback, { kind = "service", data = {}, statement, d
720
742
  /**
721
743
  * The data a span is recorded with: unchanged unless it's a database span, which gets the masked
722
744
  * 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.
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`).
724
747
  * @param {string} kind
725
748
  * @param {Record<string, unknown>} data
726
749
  * @param {string | undefined} statement
@@ -732,15 +755,17 @@ function databaseSpanData(kind, data, statement, dbSystem) {
732
755
  return data;
733
756
  }
734
757
  const result = { ...data };
735
- const masked = maskSql(typeof statement === "string" ? statement : result["db.statement"]);
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);
736
764
  if (masked === null) {
737
765
  delete result["db.statement"];
738
766
  } else {
739
767
  result["db.statement"] = masked;
740
768
  }
741
- if (typeof dbSystem === "string" && dbSystem.trim() !== "") {
742
- result["db.system"] = dbSystem.trim().toLowerCase();
743
- }
744
769
  return result;
745
770
  }
746
771
 
@@ -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. This is currently the only place this client automatically records a breadcrumb at all:
31
- * unlike Rails' framework-wide ActiveSupport::Notifications, this client has no automatic
32
- * instrumentation source that isn't also this same opt-in middleware, so an app that wants
33
- * automatic breadcrumbs (not just manual ones from addBreadcrumb()) needs this middleware
34
- * installed regardless of whether it also wants the performance data. There's no query-level
35
- * automatic breadcrumb source for the same reason there's no query-level performance
36
- * instrumentation in this client at all today (no ORM/DB driver integration exists yet to hang
37
- * one off of); this doesn't invent one.
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
@@ -19,21 +19,47 @@ import path from "node:path";
19
19
  * one Client/Redis class to patch either way.
20
20
  *
21
21
  * @param {string} name
22
+ * @param {{ via?: string }} [options] see resolveOptional
22
23
  * @returns {unknown[]}
23
24
  */
24
- export function requireOptional(name) {
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 } = {}) {
25
44
  const bases = [path.join(process.cwd(), "package.json"), import.meta.url];
26
- const loaded = new Map();
45
+ if (via !== undefined) {
46
+ bases.push(...bases.flatMap((base) => resolveFrom(base, via) ?? []));
47
+ }
48
+ const resolved = new Set();
27
49
  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.
50
+ const found = resolveFrom(base, name);
51
+ if (found !== null) {
52
+ resolved.add(found);
36
53
  }
37
54
  }
38
- return [...loaded.values()];
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
+ }
39
65
  }
@@ -1,11 +1,15 @@
1
1
  import { AsyncResource } from "node:async_hooks";
2
2
  import diagnosticsChannel from "node:diagnostics_channel";
3
- import { requireOptional } from "./optionalModules.js";
3
+ import { createRequire } from "node:module";
4
+ import { requireOptional, resolveOptional } from "./optionalModules.js";
4
5
 
5
6
  const WRAPPED = Symbol("forgeOpsTrackerWrapped");
6
7
  const SEEN = Symbol("forgeOpsTrackerSeen");
7
8
  const SPAN = Symbol("forgeOpsTrackerSpan");
8
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"];
9
13
 
10
14
  let installed = false;
11
15
  // Every patch made, so _uninstallRedisTracing can put each method back exactly as found.
@@ -13,36 +17,51 @@ const patches = [];
13
17
  let nodeRedisSubscription = null;
14
18
 
15
19
  /**
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
+ * 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.
20
25
  *
21
26
  * Every span is named "Redis <COMMAND>" (the command alone, upcased: GET, SET, LPUSH...) with no
22
27
  * data at all: never a key or any other argument, since a key can easily carry a customer's own
23
28
  * id or other sensitive value. The same name and kind gems/forge_ops_tracker's
24
- * Integrations::RedisClientTiming records.
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).
25
34
  *
26
35
  * ioredis (every version from 4.x on) is covered by wrapping Redis.prototype.sendCommand, which
27
36
  * every command goes through, pipelined and MULTI ones included (see instrumentIoredis).
28
37
  *
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.
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.
33
44
  *
34
45
  * @param {(name: string, kind: string, startedAt: Date, durationMs: number, data?: Record<string, unknown>) => void} recordSpan
35
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)
36
50
  */
37
- export function installRedisTracing(recordSpan, isTracing) {
51
+ export function installRedisTracing(recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail) {
38
52
  if (installed) {
39
53
  return;
40
54
  }
41
55
  installed = true;
42
56
  for (const Redis of requireOptional("ioredis")) {
43
- instrumentIoredis(Redis, recordSpan, isTracing);
57
+ instrumentIoredis(Redis, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail);
44
58
  }
45
- subscribeNodeRedis(recordSpan, isTracing);
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));
46
65
  }
47
66
 
48
67
  /**
@@ -59,19 +78,25 @@ export function installRedisTracing(recordSpan, isTracing) {
59
78
  * @param {any} Redis
60
79
  * @param {Parameters<typeof installRedisTracing>[0]} recordSpan
61
80
  * @param {() => boolean} isTracing
81
+ * @param {Parameters<typeof installRedisTracing>[2]} [recordBreadcrumb]
82
+ * @param {() => boolean} [ensureBreadcrumbTrail]
62
83
  * @returns {boolean} whether there was a sendCommand to patch
63
84
  */
64
- export function instrumentIoredis(Redis, recordSpan, isTracing) {
85
+ export function instrumentIoredis(Redis, recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail) {
65
86
  const prototype = Redis?.prototype;
66
87
  if (typeof prototype?.sendCommand !== "function" || prototype.sendCommand[WRAPPED]) {
67
88
  return typeof prototype?.sendCommand === "function";
68
89
  }
90
+ const hooks = hooksFor(recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail);
69
91
  const originalSendCommand = prototype.sendCommand;
70
92
 
71
93
  const patchedSendCommand = function patchedSendCommand(command, ...rest) {
72
- if (command && typeof command === "object" && !command[SEEN] && isTracing()) {
94
+ if (command && typeof command === "object" && !command[SEEN]) {
73
95
  command[SEEN] = true;
74
- observeCommand(command, recordSpan);
96
+ const finish = startCommand(command.name, hooks);
97
+ if (finish !== null) {
98
+ observeCommand(command, finish);
99
+ }
75
100
  }
76
101
  return originalSendCommand.call(this, command, ...rest);
77
102
  };
@@ -82,6 +107,86 @@ export function instrumentIoredis(Redis, recordSpan, isTracing) {
82
107
  return true;
83
108
  }
84
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
+
85
190
  /** @internal test-only: restores every method installRedisTracing replaced, and unsubscribes. */
86
191
  export function _uninstallRedisTracing() {
87
192
  while (patches.length > 0) {
@@ -99,59 +204,75 @@ export function _uninstallRedisTracing() {
99
204
  installed = false;
100
205
  }
101
206
 
102
- function observeCommand(command, recordSpan) {
207
+ function hooksFor(recordSpan, isTracing, recordBreadcrumb = () => {}, ensureBreadcrumbTrail = () => false) {
208
+ return { recordSpan, isTracing, recordBreadcrumb, ensureBreadcrumbTrail };
209
+ }
210
+
211
+ function observeCommand(command, finish) {
103
212
  const { resolve, reject } = command;
104
213
  if (typeof resolve !== "function" || typeof reject !== "function") {
105
214
  return;
106
215
  }
107
- const finish = startSpan(command.name, recordSpan);
108
216
  command.resolve = function resolveWithSpan(...args) {
109
- finish();
217
+ finish(false);
110
218
  return resolve.apply(this, args);
111
219
  };
112
220
  command.reject = function rejectWithSpan(...args) {
113
- finish();
221
+ finish(true);
114
222
  return reject.apply(this, args);
115
223
  };
116
224
  }
117
225
 
118
226
  /**
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.
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.
122
232
  */
123
- function startSpan(commandName, recordSpan) {
124
- const name = `Redis ${String(commandName).toUpperCase()}`;
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()}`;
125
240
  const startedAt = new Date();
126
241
  const start = performance.now();
127
242
  let finished = false;
128
- return AsyncResource.bind(() => {
243
+ return AsyncResource.bind((failed) => {
129
244
  if (finished) {
130
245
  return;
131
246
  }
132
247
  finished = true;
133
- recordSpan(name, "redis", startedAt, performance.now() - start);
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
+ }
134
255
  });
135
256
  }
136
257
 
137
258
  /**
138
259
  * node-redis runs each command inside TracingChannel#tracePromise: "start" is published
139
260
  * 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`.
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`.
142
263
  */
143
- function subscribeNodeRedis(recordSpan, isTracing) {
264
+ function subscribeNodeRedis(hooks) {
144
265
  if (typeof diagnosticsChannel.tracingChannel !== "function") {
145
266
  return;
146
267
  }
147
268
  nodeRedisSubscription = {
148
269
  start(context) {
149
- if (context && typeof context === "object" && isTracing()) {
150
- context[SPAN] = startSpan(context.command, recordSpan);
270
+ if (context && typeof context === "object") {
271
+ context[SPAN] = startCommand(context.command, hooks);
151
272
  }
152
273
  },
153
274
  asyncEnd(context) {
154
- context?.[SPAN]?.();
275
+ context?.[SPAN]?.("error" in context);
155
276
  },
156
277
  };
157
278
  diagnosticsChannel.tracingChannel(NODE_REDIS_COMMAND_CHANNEL).subscribe(nodeRedisSubscription);
@@ -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 the "not part of an identifier" check on numbers captures the
12
- // preceding character and puts it back instead.
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
- // 1: string literal (or one cut off by truncation) 2: dollar-quote tag 3: char before a number
21
- // 4: the number, not part of an identifier or a $1 placeholder
22
- const LITERAL = /'(?:[^']|'')*(?:'|$)|(\$[A-Za-z_]*\$)[\s\S]*?(?:\1|$)|(^|[^\w$.])(\d+(?:\.\d+)?)(?!\w)/g;
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 masked = statement.replace(LITERAL, (whole, _tag, before, number) => (number === undefined ? MASK : `${before}${MASK}`));
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