@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 +28 -2
- package/package.json +2 -2
- package/src/configuration.js +15 -0
- package/src/eventBuilder.js +28 -0
- package/src/index.js +1 -0
- package/src/sqlStatement.js +159 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @forge-ops/tracker
|
|
2
2
|
|
|
3
|
-
Node.js error reporting client for
|
|
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>@
|
|
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.
|
|
4
|
-
"description": "ForgeOps error tracking client: captures unhandled exceptions (Express/Fastify integration, plus explicit capture anywhere else) and delivers them to
|
|
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": {
|
package/src/configuration.js
CHANGED
|
@@ -42,6 +42,21 @@ export class Configuration {
|
|
|
42
42
|
*/
|
|
43
43
|
captureSourceContext = true;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* When an error comes from a database call (Sequelize, TypeORM, mysql2, and anything that wraps
|
|
47
|
+
* one), send the names of the stored procedure, table and view its SQL touched, so an issue says
|
|
48
|
+
* where to start looking. Names are identifiers, never values, which is why this defaults on.
|
|
49
|
+
* captureSqlStatement is the separate, opt-in step of also sending the statement itself, with
|
|
50
|
+
* every string and number replaced by "?"; off by default because even a masked statement
|
|
51
|
+
* describes your schema, and ForgeOps' own per-project setting is what durably governs whether
|
|
52
|
+
* the server stores it. See sqlStatement.js.
|
|
53
|
+
* @type {boolean}
|
|
54
|
+
*/
|
|
55
|
+
captureSqlObjects = true;
|
|
56
|
+
|
|
57
|
+
/** @type {boolean} */
|
|
58
|
+
captureSqlStatement = false;
|
|
59
|
+
|
|
45
60
|
/** @type {((message: string) => void) | null} */
|
|
46
61
|
logger = null;
|
|
47
62
|
|
package/src/eventBuilder.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { scrub, scrubString } from "./piiScrubber.js";
|
|
4
|
+
import * as sqlStatement from "./sqlStatement.js";
|
|
4
5
|
|
|
5
6
|
const MAX_FRAMES = 500;
|
|
6
7
|
|
|
@@ -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
|
@@ -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
|
+
}
|