peekinside 0.0.0-stage → 1.2.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/bin/peekinside.js +8 -0
  4. package/index.d.ts +389 -0
  5. package/index.js +3 -0
  6. package/package.json +77 -4
  7. package/src/PeekInside.js +731 -0
  8. package/src/capture/body-capture.js +86 -0
  9. package/src/capture/body-decoder.js +188 -0
  10. package/src/capture/exchange-recorder.js +263 -0
  11. package/src/capture/http-message.js +138 -0
  12. package/src/capture/ignored-paths.js +49 -0
  13. package/src/capture/request-body-tap.js +40 -0
  14. package/src/capture/response-tap.js +83 -0
  15. package/src/cli.js +254 -0
  16. package/src/config.js +183 -0
  17. package/src/crypto.js +189 -0
  18. package/src/deployment.js +183 -0
  19. package/src/exceptions/exception-builder.js +339 -0
  20. package/src/exceptions/exception-capture.js +407 -0
  21. package/src/exceptions/exception-record.js +118 -0
  22. package/src/exceptions/fingerprint.js +60 -0
  23. package/src/exceptions/frame-classifier.js +114 -0
  24. package/src/exceptions/limits.js +95 -0
  25. package/src/exceptions/process-monitor.js +192 -0
  26. package/src/exceptions/rate-limiter.js +50 -0
  27. package/src/exceptions/request-context.js +337 -0
  28. package/src/exceptions/safe-json.js +186 -0
  29. package/src/exceptions/source-context.js +144 -0
  30. package/src/exceptions/source-maps.js +313 -0
  31. package/src/exceptions/stack-parser.js +179 -0
  32. package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
  33. package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
  34. package/src/hub/client.js +184 -0
  35. package/src/hub/entry.js +4 -0
  36. package/src/hub/exception-state.js +97 -0
  37. package/src/hub/ingest-payload.js +362 -0
  38. package/src/hub/process.js +175 -0
  39. package/src/hub/project-log.js +559 -0
  40. package/src/hub/segment-log.js +282 -0
  41. package/src/hub/server.js +538 -0
  42. package/src/hub/spool-ingester.js +110 -0
  43. package/src/hub/store.js +404 -0
  44. package/src/instrumentation/console.js +83 -0
  45. package/src/instrumentation/http.js +146 -0
  46. package/src/instrumentation/index.js +234 -0
  47. package/src/instrumentation/jobs.js +186 -0
  48. package/src/instrumentation/loggers.js +135 -0
  49. package/src/instrumentation/mongodb.js +221 -0
  50. package/src/instrumentation/mysql2.js +161 -0
  51. package/src/instrumentation/operation.js +70 -0
  52. package/src/instrumentation/patch.js +72 -0
  53. package/src/instrumentation/pg.js +218 -0
  54. package/src/instrumentation/prisma.js +181 -0
  55. package/src/integrations/express-errors.js +135 -0
  56. package/src/integrations/express.js +57 -0
  57. package/src/integrations/fastify.js +72 -0
  58. package/src/integrations/framework-adapter.js +53 -0
  59. package/src/integrations/http-server.js +32 -0
  60. package/src/integrations/koa.js +72 -0
  61. package/src/integrations/server-binder.js +120 -0
  62. package/src/jobs/job-context.js +40 -0
  63. package/src/jobs/job-run-record.js +104 -0
  64. package/src/jobs/job-runner.js +173 -0
  65. package/src/logs/log-capture.js +143 -0
  66. package/src/logs/log-record.js +74 -0
  67. package/src/project.js +76 -0
  68. package/src/record.js +181 -0
  69. package/src/release.js +157 -0
  70. package/src/runtime/loop-monitor.js +162 -0
  71. package/src/runtime/sampler.js +93 -0
  72. package/src/spool.js +195 -0
  73. package/src/timeline/caller.js +129 -0
  74. package/src/timeline/execution-hash.js +110 -0
  75. package/src/timeline/lru.js +50 -0
  76. package/src/timeline/masking.js +266 -0
  77. package/src/timeline/shape.js +77 -0
  78. package/src/timeline/sql.js +396 -0
  79. package/src/timeline/timeline.js +642 -0
  80. package/src/transport/batch.js +324 -0
  81. package/src/transport/cloud.js +363 -0
  82. package/src/transport/local.js +354 -0
  83. package/src/user.js +83 -0
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Reads SQL statements the way the timeline needs them, without a full parser:
3
+ *
4
+ * - normalize: the statement without its values, so executions of the same statement group
5
+ * together (string literals, numbers and placeholders become `?`, `in (?, ?)` becomes `in (?)`,
6
+ * `values (?, ?), (?, ?)` becomes `values (?)`, whitespace is collapsed, words are lowercased,
7
+ * comments are dropped);
8
+ * - columns: which column each placeholder ($1 / ?) is bound to, so a value bound to a password
9
+ * column can be masked;
10
+ * - masked text: the statement with string literals compared to (or assigned to) sensitive columns
11
+ * replaced by '[masked]'.
12
+ *
13
+ * Everything works on a token stream that knows string literals, quoted identifiers, comments and
14
+ * dollar-quoted strings, so a `?` or `$1` inside a literal or a comment is never mistaken for a
15
+ * placeholder. Results are cached per statement text: an app sends the same few statements over
16
+ * and over.
17
+ */
18
+
19
+ import { createHash } from 'crypto';
20
+ import { Masker } from './masking.js';
21
+ import { LruCache } from './lru.js';
22
+
23
+ // Statements longer than this are analyzed on their start only: the timeline keeps 4 KB of text,
24
+ // and a bulk insert of megabytes must not cost megabytes of tokenizing per execution.
25
+ const MAX_ANALYZED = 64 * 1024;
26
+ // Text kept per analysis (what the timeline shows), and the longest statement cached: longer ones
27
+ // are mostly one-offs (bulk inserts with inline values) that would only push the others out.
28
+ const MAX_KEPT = 4096;
29
+ const MAX_CACHED = 4096;
30
+ const COMPARISON = new Set(['=', '<>', '!=', '<', '>', '<=', '>=']);
31
+ const COMPARISON_WORDS = new Set(['like', 'ilike']);
32
+ const QUANTIFIERS = new Set(['any', 'all', 'some']);
33
+
34
+ /**
35
+ * @typedef {Object} SqlAnalysis
36
+ * @property {string} fingerprint - 16 hex chars: sha256 of the normalized text (see normalize())
37
+ * @property {string} maskedText - The text with sensitive literals masked, at most 4096 characters
38
+ * @property {Map<number, string>} columns - Placeholder index (0-based, params[0] is $1 / the first
39
+ * ?) -> the column it is bound to
40
+ * @property {Set<number>} sensitive - Placeholder indexes bound to sensitive columns
41
+ */
42
+
43
+ /**
44
+ * SQL analysis with a cache.
45
+ */
46
+ export class SqlText {
47
+ /**
48
+ * @param {Object} [options]
49
+ * @param {number} [options.cacheSize=500] - Distinct statements kept analyzed
50
+ */
51
+ constructor({ cacheSize = 500 } = {}) {
52
+ this.cache = new LruCache(cacheSize);
53
+ }
54
+
55
+ /**
56
+ * Analyzes a statement, from the cache when it was seen before.
57
+ *
58
+ * @param {string} text - SQL as the app sent it
59
+ * @param {'postgresql'|'mysql'|string} dialect - Decides what "..." means: an identifier in
60
+ * PostgreSQL, a string in MySQL
61
+ * @returns {SqlAnalysis}
62
+ */
63
+ analyze(text, dialect) {
64
+ if (typeof text !== 'string' || text.length > MAX_CACHED) return SqlText.analyzeUncached(text, dialect);
65
+ const key = `${dialect}\u0000${text}`;
66
+ let analysis = this.cache.get(key);
67
+ if (!analysis) {
68
+ analysis = SqlText.analyzeUncached(text, dialect);
69
+ this.cache.set(key, analysis);
70
+ }
71
+ return analysis;
72
+ }
73
+
74
+ /**
75
+ * @param {string} text
76
+ * @param {string} dialect
77
+ * @returns {SqlAnalysis}
78
+ */
79
+ static analyzeUncached(text, dialect) {
80
+ const source = typeof text === 'string' ? text.slice(0, MAX_ANALYZED) : String(text);
81
+ const tokens = tokenize(source, dialectOf(dialect));
82
+ const normalized = normalize(tokens);
83
+ const { columns, sensitive, literalColumns } = bindColumns(tokens);
84
+ return {
85
+ fingerprint: createHash('sha256').update(normalized).digest('hex').slice(0, 16),
86
+ maskedText: maskLiterals(source, tokens, literalColumns).slice(0, MAX_KEPT),
87
+ columns,
88
+ sensitive
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Normalizes a statement (see the module comment).
94
+ *
95
+ * @param {string} text
96
+ * @param {string} [dialect='postgresql']
97
+ * @returns {string}
98
+ */
99
+ static normalize(text, dialect = 'postgresql') {
100
+ return normalize(tokenize(String(text).slice(0, MAX_ANALYZED), dialectOf(dialect)));
101
+ }
102
+ }
103
+
104
+ // What the dialect changes in tokenizing: "..." is a string in MySQL and an identifier elsewhere;
105
+ // PostgreSQL numbers its placeholders ($1) and uses ? as an operator; others use ? placeholders.
106
+ function dialectOf(name) {
107
+ return { mysql: name === 'mysql', pg: name === 'postgresql' };
108
+ }
109
+
110
+ // --- Tokens ---------------------------------------------------------------------------------
111
+
112
+ // kind: space, comment, string, ident (quoted identifier), number, placeholder, word, punct.
113
+ // For placeholders, `index` is the 0-based parameter index.
114
+ function tokenize(text, { mysql, pg }) {
115
+ const tokens = [];
116
+ let i = 0;
117
+ let nextQuestion = 0;
118
+ const push = (kind, start, end, extra) => tokens.push({ kind, start, end, text: text.slice(start, end), ...extra });
119
+
120
+ while (i < text.length) {
121
+ const ch = text[i];
122
+ const next = text[i + 1];
123
+ const start = i;
124
+
125
+ if (/\s/.test(ch)) {
126
+ while (i < text.length && /\s/.test(text[i])) i++;
127
+ push('space', start, i);
128
+ } else if ((ch === '-' && next === '-') || (mysql && ch === '#')) {
129
+ while (i < text.length && text[i] !== '\n') i++;
130
+ push('comment', start, i);
131
+ } else if (ch === '/' && next === '*') {
132
+ const end = text.indexOf('*/', i + 2);
133
+ i = end === -1 ? text.length : end + 2;
134
+ push('comment', start, i);
135
+ } else if (ch === "'" || ((ch === 'E' || ch === 'e') && next === "'" && pg) || (mysql && ch === '"')) {
136
+ // 'it''s' always; backslash escapes in MySQL and in PostgreSQL E'' strings.
137
+ const quote = ch === '"' ? '"' : "'";
138
+ const backslash = mysql || ch === 'E' || ch === 'e';
139
+ i = ch === 'E' || ch === 'e' ? i + 2 : i + 1;
140
+ while (i < text.length) {
141
+ if (backslash && text[i] === '\\') {
142
+ i += 2;
143
+ } else if (text[i] === quote) {
144
+ if (text[i + 1] === quote) i += 2;
145
+ else {
146
+ i++;
147
+ break;
148
+ }
149
+ } else {
150
+ i++;
151
+ }
152
+ }
153
+ push('string', start, i);
154
+ } else if (ch === '"' || ch === '`') {
155
+ const end = closingQuote(text, i, ch);
156
+ i = end;
157
+ push('ident', start, i, { name: text.slice(start + 1, i - 1).split(ch + ch).join(ch) });
158
+ } else if (ch === '$' && pg && /[0-9]/.test(next || '')) {
159
+ i++;
160
+ while (i < text.length && /[0-9]/.test(text[i])) i++;
161
+ push('placeholder', start, i, { index: Number(text.slice(start + 1, i)) - 1 });
162
+ } else if (ch === '$' && pg && /[A-Za-z_$]/.test(next || '')) {
163
+ // Dollar-quoted string: $$...$$ or $tag$...$tag$.
164
+ const tagEnd = text.indexOf('$', i + 1);
165
+ const tag = tagEnd === -1 ? null : text.slice(i, tagEnd + 1);
166
+ if (tag && /^\$[A-Za-z_]*\$$/.test(tag)) {
167
+ const end = text.indexOf(tag, tagEnd + 1);
168
+ i = end === -1 ? text.length : end + tag.length;
169
+ push('string', start, i);
170
+ } else {
171
+ i++;
172
+ push('punct', start, i);
173
+ }
174
+ } else if (ch === '?' && !pg) {
175
+ // mysql2's ?? (an identifier) consumes a value too.
176
+ i += next === '?' ? 2 : 1;
177
+ push('placeholder', start, i, { index: nextQuestion++ });
178
+ } else if (/[0-9]/.test(ch) || (ch === '.' && /[0-9]/.test(next || ''))) {
179
+ if (ch === '0' && (next === 'x' || next === 'X')) {
180
+ i += 2;
181
+ while (i < text.length && /[0-9a-fA-F]/.test(text[i])) i++;
182
+ } else {
183
+ while (i < text.length && /[0-9.]/.test(text[i])) i++;
184
+ if ((text[i] === 'e' || text[i] === 'E') && /[0-9+-]/.test(text[i + 1] || '')) {
185
+ i += 2;
186
+ while (i < text.length && /[0-9]/.test(text[i])) i++;
187
+ }
188
+ }
189
+ push('number', start, i);
190
+ } else if (/[A-Za-z_\u0080-￿]/.test(ch)) {
191
+ while (i < text.length && /[A-Za-z0-9_$\u0080-￿]/.test(text[i])) i++;
192
+ push('word', start, i);
193
+ } else {
194
+ // Two-character operators, then single characters.
195
+ const pair = ch + (next || '');
196
+ i += ['<>', '!=', '<=', '>=', '::', '||'].includes(pair) ? 2 : 1;
197
+ push('punct', start, i);
198
+ }
199
+ }
200
+ return tokens;
201
+ }
202
+
203
+ function closingQuote(text, open, quote) {
204
+ let i = open + 1;
205
+ while (i < text.length) {
206
+ if (text[i] === quote) {
207
+ if (text[i + 1] === quote) i += 2;
208
+ else return i + 1;
209
+ } else {
210
+ i++;
211
+ }
212
+ }
213
+ return text.length;
214
+ }
215
+
216
+ // --- Normalization ----------------------------------------------------------------------------
217
+
218
+ function normalize(tokens) {
219
+ let out = '';
220
+ let space = false;
221
+ for (const token of tokens) {
222
+ if (token.kind === 'space' || token.kind === 'comment') {
223
+ space = out.length > 0;
224
+ continue;
225
+ }
226
+ if (space) out += ' ';
227
+ space = false;
228
+ if (token.kind === 'string' || token.kind === 'number' || token.kind === 'placeholder') out += '?';
229
+ else if (token.kind === 'ident') out += token.text;
230
+ else out += token.text.toLowerCase();
231
+ }
232
+ return out
233
+ .replace(/\bin \(\s*\?(?:\s*,\s*\?)*\s*\)/g, 'in (?)')
234
+ .replace(/\bin\(\s*\?(?:\s*,\s*\?)*\s*\)/g, 'in(?)')
235
+ .replace(/\bvalues ?\(\s*\?(?:\s*,\s*\?)*\s*\)(?:\s*,\s*\(\s*\?(?:\s*,\s*\?)*\s*\))*/g, 'values (?)');
236
+ }
237
+
238
+ // --- Columns ----------------------------------------------------------------------------------
239
+
240
+ // The column each placeholder (by index) and each string literal (by token) is bound to. A
241
+ // placeholder used twice ($1 in two places) is sensitive when either place is.
242
+ function bindColumns(tokens) {
243
+ const significant = tokens.filter((token) => token.kind !== 'space' && token.kind !== 'comment');
244
+ const columns = new Map();
245
+ const sensitive = new Set();
246
+ const literalColumns = new Map();
247
+ const bind = (token, column) => {
248
+ if (!column) return;
249
+ if (token.kind === 'placeholder') {
250
+ if (!columns.has(token.index)) columns.set(token.index, column);
251
+ if (Masker.isSensitiveName(column)) sensitive.add(token.index);
252
+ } else if (token.kind === 'string') {
253
+ if (!literalColumns.has(token) || Masker.isSensitiveName(column)) literalColumns.set(token, column);
254
+ }
255
+ };
256
+
257
+ for (let i = 0; i < significant.length; i++) {
258
+ const token = significant[i];
259
+ if (token.kind !== 'placeholder' && token.kind !== 'string') continue;
260
+ bind(token, comparedColumn(significant, i) || reversedColumn(significant, i));
261
+ }
262
+ for (const { token, column } of insertedColumns(significant)) bind(token, column);
263
+ return { columns, sensitive, literalColumns };
264
+ }
265
+
266
+ // `X <op> <column>`: the value first, as in `where $1 = email`.
267
+ function reversedColumn(tokens, at) {
268
+ const op = tokens[at + 1];
269
+ if (!op || !COMPARISON.has(op.text)) return null;
270
+ const token = tokens[at + 2];
271
+ if (!token) return null;
272
+ // t.col: the last part.
273
+ let i = at + 2;
274
+ while (tokens[i + 1] && tokens[i + 1].text === '.' && tokens[i + 2] && (tokens[i + 2].kind === 'word' || tokens[i + 2].kind === 'ident')) i += 2;
275
+ const last = tokens[i];
276
+ if (last.kind === 'ident') return last.name;
277
+ if (last.kind === 'word') return last.text;
278
+ return null;
279
+ }
280
+
281
+ // `<column> <op> X`, `<column> [not] like X`, `<column> [not] in (X, ...)`, `<column> = any(X)`.
282
+ function comparedColumn(tokens, at) {
283
+ let i = at - 1;
284
+ // Inside parentheses: in (...), any(...).
285
+ if (tokens[i] && (tokens[i].text === '(' || tokens[i].text === ',')) {
286
+ let depth = 0;
287
+ for (; i >= 0; i--) {
288
+ const text = tokens[i].text;
289
+ if (text === ')') depth++;
290
+ else if (text === '(') {
291
+ if (depth === 0) break;
292
+ depth--;
293
+ }
294
+ }
295
+ if (i < 0) return null;
296
+ i--; // before "("
297
+ const word = tokens[i] && tokens[i].kind === 'word' ? tokens[i].text.toLowerCase() : '';
298
+ if (word === 'in') {
299
+ i--;
300
+ if (tokens[i] && tokens[i].kind === 'word' && tokens[i].text.toLowerCase() === 'not') i--;
301
+ return columnBefore(tokens, i);
302
+ }
303
+ if (QUANTIFIERS.has(word)) {
304
+ i--;
305
+ if (!tokens[i] || !COMPARISON.has(tokens[i].text)) return null;
306
+ return columnBefore(tokens, i - 1);
307
+ }
308
+ return null;
309
+ }
310
+
311
+ const op = tokens[i];
312
+ if (!op) return null;
313
+ if (COMPARISON.has(op.text)) return columnBefore(tokens, i - 1);
314
+ if (op.kind === 'word' && COMPARISON_WORDS.has(op.text.toLowerCase())) {
315
+ i--;
316
+ if (tokens[i] && tokens[i].kind === 'word' && tokens[i].text.toLowerCase() === 'not') i--;
317
+ return columnBefore(tokens, i);
318
+ }
319
+ return null;
320
+ }
321
+
322
+ // The column expression ending at `i`: `col`, `"col"`, `t.col`, `` `t`.`col` ``, `col::type`.
323
+ function columnBefore(tokens, i) {
324
+ // Skip a cast: col::text
325
+ if (tokens[i] && tokens[i].kind === 'word' && tokens[i - 1] && tokens[i - 1].text === '::') i -= 2;
326
+ const token = tokens[i];
327
+ if (!token) return null;
328
+ if (token.kind === 'ident') return token.name;
329
+ if (token.kind === 'word') return token.text;
330
+ return null;
331
+ }
332
+
333
+ // insert into t (c1, c2) values (X, Y), (X, Y): each value is bound to the column at its position.
334
+ function insertedColumns(tokens) {
335
+ const bound = [];
336
+ for (let i = 0; i < tokens.length; i++) {
337
+ if (tokens[i].kind !== 'word' || tokens[i].text.toLowerCase() !== 'insert') continue;
338
+ // The column list: the first "(" after the table name, before "values".
339
+ let j = i + 1;
340
+ while (j < tokens.length && tokens[j].text !== '(' && !(tokens[j].kind === 'word' && /^(values|select|set|default)$/i.test(tokens[j].text))) j++;
341
+ if (!tokens[j] || tokens[j].text !== '(') continue;
342
+ const names = [];
343
+ j++;
344
+ for (; j < tokens.length && tokens[j].text !== ')'; j++) {
345
+ if (tokens[j].kind === 'ident') names.push(tokens[j].name);
346
+ else if (tokens[j].kind === 'word') {
347
+ // t.col: the last part wins.
348
+ if (tokens[j - 1] && tokens[j - 1].text === '.') names[names.length - 1] = tokens[j].text;
349
+ else names.push(tokens[j].text);
350
+ }
351
+ }
352
+ j++;
353
+ if (!tokens[j] || tokens[j].kind !== 'word' || !/^values?$/i.test(tokens[j].text)) continue;
354
+ j++;
355
+ // Tuples: ( a, b ), ( c, d )
356
+ while (tokens[j] && tokens[j].text === '(') {
357
+ let depth = 0;
358
+ let position = 0;
359
+ for (j++; j < tokens.length; j++) {
360
+ const text = tokens[j].text;
361
+ if (text === '(') depth++;
362
+ else if (text === ')') {
363
+ if (depth === 0) break;
364
+ depth--;
365
+ } else if (text === ',' && depth === 0) {
366
+ position++;
367
+ } else if (tokens[j].kind === 'placeholder' || tokens[j].kind === 'string') {
368
+ if (names[position]) bound.push({ token: tokens[j], column: names[position] });
369
+ }
370
+ }
371
+ j++;
372
+ if (tokens[j] && tokens[j].text === ',') j++;
373
+ else break;
374
+ }
375
+ }
376
+ return bound;
377
+ }
378
+
379
+ // --- Masked text ------------------------------------------------------------------------------
380
+
381
+ // Replaces literals bound to sensitive columns, and literals that look like secrets, with
382
+ // '[masked]'. Returns the source itself when nothing needed masking.
383
+ function maskLiterals(source, tokens, literalColumns) {
384
+ let out = '';
385
+ let last = 0;
386
+ for (const token of tokens) {
387
+ if (token.kind !== 'string') continue;
388
+ const column = literalColumns.get(token);
389
+ const value = token.text.replace(/^[Ee]?['"]|['"]$/g, '');
390
+ if ((column && Masker.isSensitiveName(column)) || Masker.isSecretString(value)) {
391
+ out += `${source.slice(last, token.start)}'[masked]'`;
392
+ last = token.end;
393
+ }
394
+ }
395
+ return last === 0 ? source : out + source.slice(last);
396
+ }