@forge-ops/tracker 0.9.0 → 0.10.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
@@ -1,6 +1,6 @@
1
1
  # @forge-ops/tracker
2
2
 
3
- Node.js error reporting client for a [ForgeOps](../../) instance.
3
+ Node.js error reporting client for [ForgeOps](https://getforgeops.net).
4
4
  Requires Node 18+ (for global `fetch`). It captures uncaught exceptions, unhandled promise
5
5
  rejections, and explicitly reported errors, builds a backtrace, scrubs likely PII, and delivers
6
6
  events to ForgeOps over HTTP without blocking the request or process that raised them.
@@ -20,7 +20,7 @@ variable or explicitly:
20
20
  import * as forgeOpsTracker from "@forge-ops/tracker";
21
21
 
22
22
  forgeOpsTracker.init({
23
- dsn: "https://<api_key>@your-forgeops-host/api/v1/events", // or leave unset to read FORGE_OPS_DSN
23
+ dsn: "https://<api_key>@getforgeops.net/api/v1/events", // or leave unset to read FORGE_OPS_DSN
24
24
  release: "...",
25
25
  environment: "production",
26
26
  });
@@ -418,6 +418,32 @@ of any kind yet to extend) and no Redis span capture (no existing Redis hook or
418
418
  here either); a database call or Redis call inside a traced request just won't show up as its own
419
419
  span for now.
420
420
 
421
+ ## Database errors
422
+
423
+ When an error carries the SQL behind a failed database call, the event includes the names of the stored procedure, table and view that SQL touched, so the issue tells you where to start looking. This is on by default and sends identifiers only, never values. The statement is read from a `.sql` or `.query` string on the error or anything it wraps (`cause`, and Sequelize's `parent`/`original`), which covers Sequelize, mysql2 and TypeORM. node-postgres, better-sqlite3 and Prisma errors carry none, so attach it where you ran the query with `withSql`.
424
+
425
+ To also send the SQL statement itself, opt in. Every string and number is replaced by `?` before it
426
+ leaves your process (`WHERE email = 'a@b.co' AND id = 42` is sent as `WHERE email = ? AND id = ?`),
427
+ and ForgeOps masks it again on arrival:
428
+
429
+ ```js
430
+ import { withSql } from "@forge-ops/tracker";
431
+
432
+ try {
433
+ await pool.query(sql, params);
434
+ } catch (error) {
435
+ throw withSql(error, sql);
436
+ }
437
+
438
+ // Opt in to also sending the masked statement (default false).
439
+ forgeOpsTracker.init({ dsn: "...", captureSqlStatement: true });
440
+ ```
441
+
442
+ Each ForgeOps project also has its own "Capture the SQL behind database errors" setting. Turn it off
443
+ there and the statement is never stored for that project, whatever this flag says; the names are
444
+ still kept. A view and a table are written the same way in SQL, so both show as tables/views; the
445
+ database's own error message usually settles which it was.
446
+
421
447
  ## Running the tests
422
448
 
423
449
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.9.0",
4
- "description": "ForgeOps error tracking client: captures unhandled exceptions (Express/Fastify integration, plus explicit capture anywhere else) and delivers them to a ForgeOps instance over HTTP.",
3
+ "version": "0.10.0",
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",
7
7
  "exports": {
@@ -42,6 +42,21 @@ export class Configuration {
42
42
  */
43
43
  captureSourceContext = true;
44
44
 
45
+ /**
46
+ * When an error comes from a database call (Sequelize, TypeORM, mysql2, and anything that wraps
47
+ * one), send the names of the stored procedure, table and view its SQL touched, so an issue says
48
+ * where to start looking. Names are identifiers, never values, which is why this defaults on.
49
+ * captureSqlStatement is the separate, opt-in step of also sending the statement itself, with
50
+ * every string and number replaced by "?"; off by default because even a masked statement
51
+ * describes your schema, and ForgeOps' own per-project setting is what durably governs whether
52
+ * the server stores it. See sqlStatement.js.
53
+ * @type {boolean}
54
+ */
55
+ captureSqlObjects = true;
56
+
57
+ /** @type {boolean} */
58
+ captureSqlStatement = false;
59
+
45
60
  /** @type {((message: string) => void) | null} */
46
61
  logger = null;
47
62
 
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { scrub, scrubString } from "./piiScrubber.js";
4
+ import * as sqlStatement from "./sqlStatement.js";
4
5
 
5
6
  const MAX_FRAMES = 500;
6
7
 
@@ -68,6 +69,7 @@ export class EventBuilder {
68
69
  if (breadcrumbs && breadcrumbs.length > 0) {
69
70
  payload.breadcrumbs = [...breadcrumbs];
70
71
  }
72
+ this.#attachSql(payload, error);
71
73
 
72
74
  return this.#configuration.scrubPii ? this.#scrub(payload) : payload;
73
75
  }
@@ -94,9 +96,35 @@ export class EventBuilder {
94
96
  if ("breadcrumbs" in payload) {
95
97
  scrubbed.breadcrumbs = scrub(payload.breadcrumbs);
96
98
  }
99
+ if ("sql_statement" in payload) {
100
+ scrubbed.sql_statement = scrubString(payload.sql_statement);
101
+ }
97
102
  return scrubbed;
98
103
  }
99
104
 
105
+ // See sqlStatement.js for what's read off the error and how it's masked. The statement itself
106
+ // only goes out when captureSqlStatement is on; the extracted names go out on their own
107
+ // (captureSqlObjects) so an issue can still name the procedure or view involved.
108
+ #attachSql(payload, error) {
109
+ const config = this.#configuration;
110
+ if (!config.captureSqlObjects && !config.captureSqlStatement) {
111
+ return;
112
+ }
113
+
114
+ const masked = sqlStatement.mask(sqlStatement.findIn(error));
115
+ if (masked === null) {
116
+ return;
117
+ }
118
+
119
+ const found = sqlStatement.objects(masked);
120
+ if (found && config.captureSqlObjects) {
121
+ payload.sql_objects = found;
122
+ }
123
+ if (config.captureSqlStatement) {
124
+ payload.sql_statement = masked;
125
+ }
126
+ }
127
+
100
128
  #scrubFrame(frame) {
101
129
  const scrubbed = {
102
130
  ...frame,
package/src/index.js CHANGED
@@ -12,6 +12,7 @@ import { SessionFlusher } from "./sessionFlusher.js";
12
12
  import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
13
13
 
14
14
  export { Configuration };
15
+ export { withSql } from "./sqlStatement.js";
15
16
 
16
17
  let configuration = null;
17
18
  let reporter = null;
@@ -0,0 +1,159 @@
1
+ // Finds the SQL behind a database error and reduces it to something safe to send: the names of
2
+ // the stored procedures, tables and views it touched, and (only if configuration.
3
+ // captureSqlStatement is on) the statement itself with every string and number replaced by "?".
4
+ // Ported from gems/forge_ops_tracker's SqlStatement, which is itself ported from the server's own
5
+ // SqlStatementMasker/SqlObjectExtractor: same rules everywhere, and the server applies them again
6
+ // on arrival, so a difference here can only ever mean less is masked client-side, never that
7
+ // something unmasked gets stored.
8
+ //
9
+ // Deliberately a single pass over a few patterns, not a SQL parser. No regex lookbehind either:
10
+ // the same source is shared with the browser and React Native SDKs, where an older engine would
11
+ // fail to even parse one, so the "not part of an identifier" check on numbers captures the
12
+ // preceding character and puts it back instead.
13
+
14
+ const MASK = "?";
15
+ const MAX_LENGTH = 4000;
16
+ const MAX_NAMES = 10;
17
+ const MAX_NAME_LENGTH = 200;
18
+ const MAX_CAUSE_DEPTH = 5;
19
+
20
+ // 1: string literal (or one cut off by truncation) 2: dollar-quote tag 3: char before a number
21
+ // 4: the number, not part of an identifier or a $1 placeholder
22
+ const LITERAL = /'(?:[^']|'')*(?:'|$)|(\$[A-Za-z_]*\$)[\s\S]*?(?:\1|$)|(^|[^\w$.])(\d+(?:\.\d+)?)(?!\w)/g;
23
+
24
+ const PART = '(?:[\\w$#@]+|"[^"]+"|\\[[^\\]]+\\]|`[^`]+`)';
25
+ const NAME = `${PART}(?:\\.${PART})*`;
26
+ const OPERATIONS = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "WITH", "CALL", "EXEC", "EXECUTE", "CREATE", "ALTER", "DROP", "TRUNCATE"]);
27
+ const PROCEDURE_CALL = new RegExp(`\\b(?:CALL|EXEC(?:UTE)?|PERFORM)\\s+(?!IMMEDIATE\\b|FUNCTION\\b|PROCEDURE\\b)(${NAME})`, "gi");
28
+ const RELATION = new RegExp(`\\b(FROM|JOIN|INTO|UPDATE|TABLE)\\s+(${NAME})(\\s*\\()?`, "gi");
29
+ const SELECT_FUNCTION = new RegExp(`^\\s*SELECT\\s+(${NAME})\\s*\\(`, "i");
30
+ const BUILTINS = new Set([
31
+ "count", "sum", "min", "max", "avg", "now", "coalesce", "nullif", "lower", "upper", "length", "concat",
32
+ "cast", "date_trunc", "current_timestamp", "current_date", "row_number", "rank", "json_build_object",
33
+ "json_agg", "array_agg",
34
+ ]);
35
+ const FROM_INSIDE_FUNCTION = /\b(?:EXTRACT|SUBSTRING|TRIM|OVERLAY)\s*\([^()]*\)/gi;
36
+ const KEYWORDS_NOT_NAMES = new Set(["select", "set", "values", "where", "lateral", "only", "unnest", "generate_series"]);
37
+ const FULL_NAME = new RegExp(`^${NAME}$`);
38
+ const FROM_WORD = /\bFROM\b/i;
39
+
40
+ // Properties that carry the statement on the errors Node database libraries raise: Sequelize and
41
+ // mysql2 use .sql (Sequelize also nests the driver's own error under .parent/.original), TypeORM's
42
+ // QueryFailedError uses .query. Only a string counts, never an object that happens to share the
43
+ // name (a Sequelize query builder, say).
44
+ const STATEMENT_PROPERTIES = ["sql", "query"];
45
+
46
+ /**
47
+ * The raw statement off the error itself or, for an app that wraps a database error in its own
48
+ * exception, off whatever it was raised from (.cause, or Sequelize's .parent/.original).
49
+ * @param {unknown} error
50
+ * @returns {string | null}
51
+ */
52
+ export function findIn(error) {
53
+ const seen = new Set();
54
+ const queue = [error];
55
+ let depth = 0;
56
+ while (queue.length > 0 && depth < MAX_CAUSE_DEPTH * 3) {
57
+ const current = queue.shift();
58
+ depth += 1;
59
+ if (!current || typeof current !== "object" || seen.has(current)) {
60
+ continue;
61
+ }
62
+ seen.add(current);
63
+ for (const property of STATEMENT_PROPERTIES) {
64
+ const value = current[property];
65
+ if (typeof value === "string" && value.trim() !== "") {
66
+ return value;
67
+ }
68
+ }
69
+ queue.push(current.cause, current.parent, current.original);
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * @param {string | null | undefined} statement
76
+ * @returns {string | null}
77
+ */
78
+ export function mask(statement) {
79
+ if (typeof statement !== "string" || statement.trim() === "") {
80
+ return null;
81
+ }
82
+ const masked = statement.replace(LITERAL, (whole, _tag, before, number) => (number === undefined ? MASK : `${before}${MASK}`));
83
+ return masked.length > MAX_LENGTH ? `${masked.slice(0, MAX_LENGTH)}...` : masked;
84
+ }
85
+
86
+ /**
87
+ * Takes an already-masked statement (so a keyword inside a string value can't be mistaken for
88
+ * SQL). Returns null when nothing recognizable was found.
89
+ * @param {string | null} masked
90
+ * @returns {{ operation?: string, procedures: string[], relations: string[] } | null}
91
+ */
92
+ export function objects(masked) {
93
+ if (typeof masked !== "string" || masked.trim() === "") {
94
+ return null;
95
+ }
96
+
97
+ const sql = masked.replace(FROM_INSIDE_FUNCTION, " ");
98
+ const procedures = [...sql.matchAll(PROCEDURE_CALL)].map((m) => m[1]);
99
+ const relations = [];
100
+
101
+ for (const [, keyword, name, paren] of sql.matchAll(RELATION)) {
102
+ if (KEYWORDS_NOT_NAMES.has(name.toLowerCase())) {
103
+ continue;
104
+ }
105
+ const functionCall = Boolean(paren) && ["FROM", "JOIN"].includes(keyword.toUpperCase());
106
+ (functionCall ? procedures : relations).push(name);
107
+ }
108
+
109
+ const fn = SELECT_FUNCTION.exec(sql)?.[1];
110
+ if (fn && !BUILTINS.has(fn.toLowerCase()) && !FROM_WORD.test(sql)) {
111
+ procedures.push(fn);
112
+ }
113
+
114
+ const operation = (/^\s*(\w+)/.exec(sql)?.[1] ?? "").toUpperCase();
115
+ const result = {};
116
+ if (OPERATIONS.has(operation)) {
117
+ result.operation = operation;
118
+ }
119
+ result.procedures = clean(procedures);
120
+ result.relations = clean(relations);
121
+ if (result.procedures.length === 0 && result.relations.length === 0 && !("operation" in result)) {
122
+ return null;
123
+ }
124
+ return result;
125
+ }
126
+
127
+ function clean(names) {
128
+ const cleaned = [];
129
+ for (const raw of names) {
130
+ const name = raw.trim().slice(0, MAX_NAME_LENGTH);
131
+ if (FULL_NAME.test(name) && !cleaned.includes(name)) {
132
+ cleaned.push(name);
133
+ }
134
+ }
135
+ return cleaned.slice(0, MAX_NAMES);
136
+ }
137
+
138
+ /**
139
+ * Attaches the SQL statement that caused `error` to it and returns the same error, so the code
140
+ * that ran the query can hand it over where the reporter finds it with no extra call. Use it when
141
+ * the database library's own errors don't carry the statement (node-postgres, better-sqlite3 and
142
+ * Prisma don't):
143
+ *
144
+ * try { await pool.query(sql, params); }
145
+ * catch (error) { throw withSql(error, sql); }
146
+ *
147
+ * Only the names of the stored procedure, table and view are sent by default, and the statement
148
+ * itself only with `captureSqlStatement` on, with every string and number replaced by "?" first;
149
+ * the raw statement never leaves the process. Non-enumerable, so it doesn't show up when the error
150
+ * is logged or serialized.
151
+ * @template {object} E
152
+ * @param {E} error
153
+ * @param {string} statement
154
+ * @returns {E}
155
+ */
156
+ export function withSql(error, statement) {
157
+ Object.defineProperty(error, "sql", { value: statement, enumerable: false, configurable: true, writable: true });
158
+ return error;
159
+ }