trickle-observe 0.2.114 → 0.2.116
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/dist/call-trace.js +30 -5
- package/dist/db-observer.js +8 -0
- package/dist/express.js +184 -63
- package/dist/request-context.d.ts +32 -0
- package/dist/request-context.js +61 -0
- package/package.json +1 -1
- package/src/call-trace.ts +26 -4
- package/src/db-observer.ts +7 -0
- package/src/express.ts +112 -11
- package/src/request-context.ts +64 -0
package/dist/call-trace.js
CHANGED
|
@@ -51,7 +51,7 @@ let traceFile = null;
|
|
|
51
51
|
let callCounter = 0;
|
|
52
52
|
let currentCallId = 0; // 0 = top level
|
|
53
53
|
const callStack = [0];
|
|
54
|
-
const MAX_TRACE_EVENTS =
|
|
54
|
+
const MAX_TRACE_EVENTS = 1000;
|
|
55
55
|
let eventCount = 0;
|
|
56
56
|
function getTraceFile() {
|
|
57
57
|
if (traceFile)
|
|
@@ -62,12 +62,17 @@ function getTraceFile() {
|
|
|
62
62
|
}
|
|
63
63
|
catch { }
|
|
64
64
|
traceFile = path.join(dir, 'calltrace.jsonl');
|
|
65
|
+
// Only create the file if it doesn't already exist — another module instance
|
|
66
|
+
// (e.g. the observe hook) may have already initialised it.
|
|
65
67
|
try {
|
|
66
|
-
fs.writeFileSync(traceFile, '');
|
|
68
|
+
fs.writeFileSync(traceFile, '', { flag: 'wx' });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// File already exists — that's fine
|
|
67
72
|
}
|
|
68
|
-
catch { }
|
|
69
73
|
return traceFile;
|
|
70
74
|
}
|
|
75
|
+
let _initialized = false;
|
|
71
76
|
function writeEvent(event) {
|
|
72
77
|
if (eventCount >= MAX_TRACE_EVENTS)
|
|
73
78
|
return;
|
|
@@ -93,6 +98,13 @@ function traceCall(functionName, moduleName) {
|
|
|
93
98
|
function traceReturn(callId, functionName, moduleName, durationMs, error) {
|
|
94
99
|
const parentId = callStack.length >= 2 ? callStack[callStack.length - 2] : 0;
|
|
95
100
|
const depth = callStack.length - 1;
|
|
101
|
+
// Get request ID from async context (if inside an Express request)
|
|
102
|
+
let requestId;
|
|
103
|
+
try {
|
|
104
|
+
const { getRequestId } = require('./request-context');
|
|
105
|
+
requestId = getRequestId();
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
96
108
|
writeEvent({
|
|
97
109
|
kind: 'call',
|
|
98
110
|
function: functionName,
|
|
@@ -103,6 +115,7 @@ function traceReturn(callId, functionName, moduleName, durationMs, error) {
|
|
|
103
115
|
timestamp: Date.now(),
|
|
104
116
|
durationMs: Math.round(durationMs * 100) / 100,
|
|
105
117
|
...(error ? { error } : {}),
|
|
118
|
+
...(requestId ? { requestId } : {}),
|
|
106
119
|
});
|
|
107
120
|
// Pop from stack
|
|
108
121
|
if (callStack[callStack.length - 1] === callId) {
|
|
@@ -110,6 +123,18 @@ function traceReturn(callId, functionName, moduleName, durationMs, error) {
|
|
|
110
123
|
}
|
|
111
124
|
}
|
|
112
125
|
function initCallTrace() {
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
if (_initialized)
|
|
127
|
+
return;
|
|
128
|
+
_initialized = true;
|
|
129
|
+
// Truncate the file on explicit init (only the observe hook calls this)
|
|
130
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
131
|
+
try {
|
|
132
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
traceFile = path.join(dir, 'calltrace.jsonl');
|
|
136
|
+
try {
|
|
137
|
+
fs.writeFileSync(traceFile, '');
|
|
138
|
+
}
|
|
139
|
+
catch { }
|
|
115
140
|
}
|
package/dist/db-observer.js
CHANGED
|
@@ -80,6 +80,14 @@ function writeQuery(record) {
|
|
|
80
80
|
if (queryCount >= MAX_QUERIES)
|
|
81
81
|
return;
|
|
82
82
|
queryCount++;
|
|
83
|
+
// Add request ID from async context
|
|
84
|
+
try {
|
|
85
|
+
const { getRequestId } = require('./request-context');
|
|
86
|
+
const reqId = getRequestId();
|
|
87
|
+
if (reqId)
|
|
88
|
+
record.requestId = reqId;
|
|
89
|
+
}
|
|
90
|
+
catch { }
|
|
83
91
|
try {
|
|
84
92
|
fs.appendFileSync(getQueriesFile(), JSON.stringify(record) + '\n');
|
|
85
93
|
}
|
package/dist/express.js
CHANGED
|
@@ -1,12 +1,49 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.instrumentExpress = instrumentExpress;
|
|
4
37
|
exports.trickleMiddleware = trickleMiddleware;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const pathMod = __importStar(require("path"));
|
|
5
40
|
const type_inference_1 = require("./type-inference");
|
|
6
41
|
const type_hash_1 = require("./type-hash");
|
|
7
42
|
const cache_1 = require("./cache");
|
|
8
43
|
const transport_1 = require("./transport");
|
|
9
44
|
const env_detect_1 = require("./env-detect");
|
|
45
|
+
const request_context_1 = require("./request-context");
|
|
46
|
+
const call_trace_1 = require("./call-trace");
|
|
10
47
|
const expressCache = new cache_1.TypeCache();
|
|
11
48
|
/**
|
|
12
49
|
* Extract the interesting parts of an Express request as a plain object
|
|
@@ -91,10 +128,55 @@ function sanitizeSample(value, depth = 3) {
|
|
|
91
128
|
}
|
|
92
129
|
return String(value);
|
|
93
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Write an error record to .trickle/errors.jsonl so that `trickle monitor`
|
|
133
|
+
* and `trickle heal` can detect runtime errors from Express handlers.
|
|
134
|
+
*/
|
|
135
|
+
function writeErrorToFile(error, input, routeName) {
|
|
136
|
+
try {
|
|
137
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
138
|
+
const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME;
|
|
139
|
+
const defaultDir = isLambda ? '/tmp/.trickle' : pathMod.join(process.cwd(), '.trickle');
|
|
140
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || defaultDir;
|
|
141
|
+
try {
|
|
142
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
catch { }
|
|
145
|
+
// Extract file and line from stack trace
|
|
146
|
+
const stackLines = (err.stack || '').split('\n');
|
|
147
|
+
let errorFile;
|
|
148
|
+
let errorLine;
|
|
149
|
+
for (const sl of stackLines.slice(1)) {
|
|
150
|
+
const m = sl.match(/\((.+):(\d+):\d+\)/) || sl.match(/at (.+):(\d+):\d+/);
|
|
151
|
+
if (m && !m[1].includes('node_modules') && !m[1].includes('node:') && !m[1].includes('trickle')) {
|
|
152
|
+
errorFile = m[1];
|
|
153
|
+
errorLine = parseInt(m[2]);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const record = {
|
|
158
|
+
kind: 'error',
|
|
159
|
+
error: err.message,
|
|
160
|
+
type: err.constructor?.name || 'Error',
|
|
161
|
+
message: err.message,
|
|
162
|
+
file: errorFile,
|
|
163
|
+
line: errorLine,
|
|
164
|
+
stack: stackLines.slice(0, 6).join('\n'),
|
|
165
|
+
route: routeName,
|
|
166
|
+
request: input,
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
const errorsFile = pathMod.join(dir, 'errors.jsonl');
|
|
170
|
+
fs.appendFileSync(errorsFile, JSON.stringify(record) + '\n');
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Never crash the user's app
|
|
174
|
+
}
|
|
175
|
+
}
|
|
94
176
|
/**
|
|
95
177
|
* Emit a type payload for a single Express route invocation.
|
|
96
178
|
*/
|
|
97
|
-
function emitExpressPayload(functionName, environment, maxDepth, input, output, error) {
|
|
179
|
+
function emitExpressPayload(functionName, environment, maxDepth, input, output, error, durationMs) {
|
|
98
180
|
try {
|
|
99
181
|
const functionKey = `express::${functionName}`;
|
|
100
182
|
const argsType = (0, type_inference_1.inferType)(input, maxDepth);
|
|
@@ -118,6 +200,9 @@ function emitExpressPayload(functionName, environment, maxDepth, input, output,
|
|
|
118
200
|
sampleInput: sanitizeSample(input),
|
|
119
201
|
sampleOutput: error ? undefined : sanitizeSample(output),
|
|
120
202
|
};
|
|
203
|
+
if (durationMs !== undefined) {
|
|
204
|
+
payload.durationMs = Math.round(durationMs * 100) / 100;
|
|
205
|
+
}
|
|
121
206
|
if (error) {
|
|
122
207
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
123
208
|
payload.error = {
|
|
@@ -126,6 +211,8 @@ function emitExpressPayload(functionName, environment, maxDepth, input, output,
|
|
|
126
211
|
stackTrace: err.stack,
|
|
127
212
|
argsSnapshot: sanitizeSample(input),
|
|
128
213
|
};
|
|
214
|
+
// Also write to errors.jsonl for monitor/heal detection
|
|
215
|
+
writeErrorToFile(error, input, functionName);
|
|
129
216
|
}
|
|
130
217
|
(0, transport_1.enqueue)(payload);
|
|
131
218
|
}
|
|
@@ -143,15 +230,28 @@ function wrapExpressHandler(handler, routeName, opts) {
|
|
|
143
230
|
if (opts.sampleRate < 1 && Math.random() > opts.sampleRate) {
|
|
144
231
|
return handler.call(this, req, res, next);
|
|
145
232
|
}
|
|
233
|
+
const self = this;
|
|
234
|
+
// Wrap entire handler execution in request context so downstream calls get a request ID
|
|
235
|
+
let returnValue;
|
|
236
|
+
(0, request_context_1.withRequestContext)(req, () => {
|
|
237
|
+
returnValue = _executeHandler(self, handler, req, res, next, routeName, opts);
|
|
238
|
+
});
|
|
239
|
+
return returnValue;
|
|
240
|
+
};
|
|
241
|
+
function _executeHandler(self, handler, req, res, next, routeName, opts) {
|
|
146
242
|
const input = extractRequestInput(req);
|
|
147
243
|
let captured = false;
|
|
244
|
+
const startTime = performance.now();
|
|
245
|
+
const callId = (0, call_trace_1.traceCall)(routeName, 'express');
|
|
148
246
|
// Intercept res.json()
|
|
149
247
|
const originalJson = res.json;
|
|
150
248
|
if (typeof originalJson === 'function') {
|
|
151
249
|
res.json = function (data) {
|
|
152
250
|
if (!captured) {
|
|
153
251
|
captured = true;
|
|
154
|
-
|
|
252
|
+
const durationMs = performance.now() - startTime;
|
|
253
|
+
(0, call_trace_1.traceReturn)(callId, routeName, 'express', durationMs);
|
|
254
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, data, undefined, durationMs);
|
|
155
255
|
}
|
|
156
256
|
res.json = originalJson; // restore
|
|
157
257
|
return originalJson.call(res, data);
|
|
@@ -163,9 +263,11 @@ function wrapExpressHandler(handler, routeName, opts) {
|
|
|
163
263
|
res.send = function (data) {
|
|
164
264
|
if (!captured) {
|
|
165
265
|
captured = true;
|
|
266
|
+
const durationMs = performance.now() - startTime;
|
|
267
|
+
(0, call_trace_1.traceReturn)(callId, routeName, 'express', durationMs);
|
|
166
268
|
// Only capture non-string data as typed output; strings are usually HTML
|
|
167
269
|
const output = typeof data === 'string' ? { __html: true } : data;
|
|
168
|
-
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, output);
|
|
270
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, output, undefined, durationMs);
|
|
169
271
|
}
|
|
170
272
|
res.send = originalSend; // restore
|
|
171
273
|
return originalSend.call(res, data);
|
|
@@ -175,20 +277,24 @@ function wrapExpressHandler(handler, routeName, opts) {
|
|
|
175
277
|
const wrappedNext = function (err) {
|
|
176
278
|
if (err && !captured) {
|
|
177
279
|
captured = true;
|
|
178
|
-
|
|
280
|
+
const durationMs = performance.now() - startTime;
|
|
281
|
+
(0, call_trace_1.traceReturn)(callId, routeName, 'express', durationMs, (err instanceof Error ? err : new Error(String(err))).message);
|
|
282
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err, durationMs);
|
|
179
283
|
}
|
|
180
284
|
if (typeof next === 'function') {
|
|
181
285
|
return next(err);
|
|
182
286
|
}
|
|
183
287
|
};
|
|
184
288
|
try {
|
|
185
|
-
const result = handler.call(
|
|
289
|
+
const result = handler.call(self, req, res, wrappedNext);
|
|
186
290
|
// Handle async handlers that return a promise
|
|
187
291
|
if (result && typeof result === 'object' && typeof result.then === 'function') {
|
|
188
292
|
return result.catch((err) => {
|
|
189
293
|
if (!captured) {
|
|
190
294
|
captured = true;
|
|
191
|
-
|
|
295
|
+
const durationMs = performance.now() - startTime;
|
|
296
|
+
(0, call_trace_1.traceReturn)(callId, routeName, 'express', durationMs, (err instanceof Error ? err : new Error(String(err))).message);
|
|
297
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err, durationMs);
|
|
192
298
|
}
|
|
193
299
|
// Re-throw so Express error handling picks it up
|
|
194
300
|
throw err;
|
|
@@ -199,11 +305,13 @@ function wrapExpressHandler(handler, routeName, opts) {
|
|
|
199
305
|
catch (err) {
|
|
200
306
|
if (!captured) {
|
|
201
307
|
captured = true;
|
|
202
|
-
|
|
308
|
+
const durationMs = performance.now() - startTime;
|
|
309
|
+
(0, call_trace_1.traceReturn)(callId, routeName, 'express', durationMs, (err instanceof Error ? err : new Error(String(err))).message);
|
|
310
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err, durationMs);
|
|
203
311
|
}
|
|
204
312
|
throw err;
|
|
205
313
|
}
|
|
206
|
-
}
|
|
314
|
+
}
|
|
207
315
|
// Preserve function metadata
|
|
208
316
|
Object.defineProperty(wrapped, 'name', { value: handler.name || routeName, configurable: true });
|
|
209
317
|
Object.defineProperty(wrapped, 'length', { value: handler.length, configurable: true });
|
|
@@ -293,63 +401,76 @@ function trickleMiddleware(userOpts) {
|
|
|
293
401
|
next();
|
|
294
402
|
return;
|
|
295
403
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
404
|
+
// Wrap in request context for per-request correlation
|
|
405
|
+
(0, request_context_1.withRequestContext)(req, () => {
|
|
406
|
+
_handleRequest(req, res, next, opts, debug);
|
|
407
|
+
});
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function _handleRequest(req, res, next, opts, debug) {
|
|
411
|
+
if (debug) {
|
|
412
|
+
console.log(`[trickle/middleware] Intercepting ${req.method} ${req.originalUrl || req.url}`);
|
|
413
|
+
}
|
|
414
|
+
let captured = false;
|
|
415
|
+
const startTime = performance.now();
|
|
416
|
+
const prelimRouteName = `${req.method || 'UNKNOWN'} ${req.originalUrl || req.url || '/'}`;
|
|
417
|
+
const callId = (0, call_trace_1.traceCall)(prelimRouteName, 'express');
|
|
418
|
+
// We derive the route name lazily once the response is being sent,
|
|
419
|
+
// because req.route is only populated after the handler matches.
|
|
420
|
+
function getRouteName() {
|
|
421
|
+
try {
|
|
422
|
+
if (req.route && req.route.path) {
|
|
423
|
+
return `${req.method} ${req.baseUrl || ''}${req.route.path}`;
|
|
310
424
|
}
|
|
311
|
-
return `${req.method || 'UNKNOWN'} ${req.originalUrl || req.url || '/'}`;
|
|
312
425
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const originalJson = res.json;
|
|
316
|
-
if (typeof originalJson === 'function') {
|
|
317
|
-
res.json = function (data) {
|
|
318
|
-
if (!captured) {
|
|
319
|
-
captured = true;
|
|
320
|
-
const routeName = getRouteName();
|
|
321
|
-
if (debug) {
|
|
322
|
-
console.log(`[trickle/middleware] Captured res.json for ${routeName}`);
|
|
323
|
-
}
|
|
324
|
-
// Re-extract input here because body parsers may have run since middleware was entered
|
|
325
|
-
const latestInput = extractRequestInput(req);
|
|
326
|
-
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, data);
|
|
327
|
-
}
|
|
328
|
-
res.json = originalJson;
|
|
329
|
-
return originalJson.call(res, data);
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
else if (debug) {
|
|
333
|
-
console.log(`[trickle/middleware] res.json is not a function: ${typeof originalJson}`);
|
|
426
|
+
catch {
|
|
427
|
+
// ignore
|
|
334
428
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
429
|
+
return prelimRouteName;
|
|
430
|
+
}
|
|
431
|
+
const input = extractRequestInput(req);
|
|
432
|
+
// Intercept res.json()
|
|
433
|
+
const originalJson = res.json;
|
|
434
|
+
if (typeof originalJson === 'function') {
|
|
435
|
+
res.json = function (data) {
|
|
436
|
+
if (!captured) {
|
|
437
|
+
captured = true;
|
|
438
|
+
const routeName = getRouteName();
|
|
439
|
+
const durationMs = performance.now() - startTime;
|
|
440
|
+
(0, call_trace_1.traceReturn)(callId, routeName, 'express', durationMs);
|
|
441
|
+
if (debug) {
|
|
442
|
+
console.log(`[trickle/middleware] Captured res.json for ${routeName}`);
|
|
348
443
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
444
|
+
// Re-extract input here because body parsers may have run since middleware was entered
|
|
445
|
+
const latestInput = extractRequestInput(req);
|
|
446
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, data, undefined, durationMs);
|
|
447
|
+
}
|
|
448
|
+
res.json = originalJson;
|
|
449
|
+
return originalJson.call(res, data);
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
else if (debug) {
|
|
453
|
+
console.log(`[trickle/middleware] res.json is not a function: ${typeof originalJson}`);
|
|
454
|
+
}
|
|
455
|
+
// Intercept res.send()
|
|
456
|
+
const originalSend = res.send;
|
|
457
|
+
if (typeof originalSend === 'function') {
|
|
458
|
+
res.send = function (data) {
|
|
459
|
+
if (!captured) {
|
|
460
|
+
captured = true;
|
|
461
|
+
const routeName = getRouteName();
|
|
462
|
+
const durationMs = performance.now() - startTime;
|
|
463
|
+
(0, call_trace_1.traceReturn)(callId, routeName, 'express', durationMs);
|
|
464
|
+
if (debug) {
|
|
465
|
+
console.log(`[trickle/middleware] Captured res.send for ${routeName}`);
|
|
466
|
+
}
|
|
467
|
+
const latestInput = extractRequestInput(req);
|
|
468
|
+
const output = typeof data === 'string' ? { __html: true } : data;
|
|
469
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, output, undefined, durationMs);
|
|
470
|
+
}
|
|
471
|
+
res.send = originalSend;
|
|
472
|
+
return originalSend.call(res, data);
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
next();
|
|
355
476
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request context — propagates a request ID through async call chains.
|
|
3
|
+
*
|
|
4
|
+
* Uses Node.js AsyncLocalStorage so that all functions, queries, and logs
|
|
5
|
+
* within a single HTTP request share the same requestId, enabling
|
|
6
|
+
* per-request tracing (like Jaeger but with trickle's richer data).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { withRequestContext, getRequestId } from './request-context';
|
|
10
|
+
* // In Express middleware:
|
|
11
|
+
* app.use((req, res, next) => withRequestContext(req, next));
|
|
12
|
+
* // Anywhere in the call chain:
|
|
13
|
+
* const id = getRequestId(); // returns the current request's ID
|
|
14
|
+
*/
|
|
15
|
+
export interface RequestContext {
|
|
16
|
+
requestId: string;
|
|
17
|
+
method?: string;
|
|
18
|
+
path?: string;
|
|
19
|
+
startTime: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Run a callback within a request context.
|
|
23
|
+
*/
|
|
24
|
+
export declare function withRequestContext(req: any, callback: () => void): void;
|
|
25
|
+
/**
|
|
26
|
+
* Get the current request ID (if inside a request context).
|
|
27
|
+
*/
|
|
28
|
+
export declare function getRequestId(): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Get the full request context.
|
|
31
|
+
*/
|
|
32
|
+
export declare function getRequestContext(): RequestContext | undefined;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Request context — propagates a request ID through async call chains.
|
|
4
|
+
*
|
|
5
|
+
* Uses Node.js AsyncLocalStorage so that all functions, queries, and logs
|
|
6
|
+
* within a single HTTP request share the same requestId, enabling
|
|
7
|
+
* per-request tracing (like Jaeger but with trickle's richer data).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { withRequestContext, getRequestId } from './request-context';
|
|
11
|
+
* // In Express middleware:
|
|
12
|
+
* app.use((req, res, next) => withRequestContext(req, next));
|
|
13
|
+
* // Anywhere in the call chain:
|
|
14
|
+
* const id = getRequestId(); // returns the current request's ID
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.withRequestContext = withRequestContext;
|
|
18
|
+
exports.getRequestId = getRequestId;
|
|
19
|
+
exports.getRequestContext = getRequestContext;
|
|
20
|
+
let als = null;
|
|
21
|
+
let counter = 0;
|
|
22
|
+
try {
|
|
23
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
24
|
+
als = new AsyncLocalStorage();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// AsyncLocalStorage not available (older Node versions)
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Run a callback within a request context.
|
|
31
|
+
*/
|
|
32
|
+
function withRequestContext(req, callback) {
|
|
33
|
+
if (!als) {
|
|
34
|
+
callback();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const ctx = {
|
|
38
|
+
requestId: `req-${++counter}-${Date.now().toString(36)}`,
|
|
39
|
+
method: req?.method,
|
|
40
|
+
path: req?.path || req?.url,
|
|
41
|
+
startTime: Date.now(),
|
|
42
|
+
};
|
|
43
|
+
als.run(ctx, callback);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Get the current request ID (if inside a request context).
|
|
47
|
+
*/
|
|
48
|
+
function getRequestId() {
|
|
49
|
+
if (!als)
|
|
50
|
+
return undefined;
|
|
51
|
+
const ctx = als.getStore();
|
|
52
|
+
return ctx?.requestId;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Get the full request context.
|
|
56
|
+
*/
|
|
57
|
+
function getRequestContext() {
|
|
58
|
+
if (!als)
|
|
59
|
+
return undefined;
|
|
60
|
+
return als.getStore();
|
|
61
|
+
}
|
package/package.json
CHANGED
package/src/call-trace.ts
CHANGED
|
@@ -21,13 +21,14 @@ interface CallEvent {
|
|
|
21
21
|
timestamp: number;
|
|
22
22
|
durationMs: number;
|
|
23
23
|
error?: string;
|
|
24
|
+
requestId?: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
let traceFile: string | null = null;
|
|
27
28
|
let callCounter = 0;
|
|
28
29
|
let currentCallId = 0; // 0 = top level
|
|
29
30
|
const callStack: number[] = [0];
|
|
30
|
-
const MAX_TRACE_EVENTS =
|
|
31
|
+
const MAX_TRACE_EVENTS = 1000;
|
|
31
32
|
let eventCount = 0;
|
|
32
33
|
|
|
33
34
|
function getTraceFile(): string {
|
|
@@ -35,10 +36,18 @@ function getTraceFile(): string {
|
|
|
35
36
|
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
36
37
|
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
37
38
|
traceFile = path.join(dir, 'calltrace.jsonl');
|
|
38
|
-
|
|
39
|
+
// Only create the file if it doesn't already exist — another module instance
|
|
40
|
+
// (e.g. the observe hook) may have already initialised it.
|
|
41
|
+
try {
|
|
42
|
+
fs.writeFileSync(traceFile, '', { flag: 'wx' });
|
|
43
|
+
} catch {
|
|
44
|
+
// File already exists — that's fine
|
|
45
|
+
}
|
|
39
46
|
return traceFile;
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
let _initialized = false;
|
|
50
|
+
|
|
42
51
|
function writeEvent(event: CallEvent): void {
|
|
43
52
|
if (eventCount >= MAX_TRACE_EVENTS) return;
|
|
44
53
|
eventCount++;
|
|
@@ -71,6 +80,13 @@ export function traceReturn(
|
|
|
71
80
|
const parentId = callStack.length >= 2 ? callStack[callStack.length - 2] : 0;
|
|
72
81
|
const depth = callStack.length - 1;
|
|
73
82
|
|
|
83
|
+
// Get request ID from async context (if inside an Express request)
|
|
84
|
+
let requestId: string | undefined;
|
|
85
|
+
try {
|
|
86
|
+
const { getRequestId } = require('./request-context');
|
|
87
|
+
requestId = getRequestId();
|
|
88
|
+
} catch {}
|
|
89
|
+
|
|
74
90
|
writeEvent({
|
|
75
91
|
kind: 'call',
|
|
76
92
|
function: functionName,
|
|
@@ -81,6 +97,7 @@ export function traceReturn(
|
|
|
81
97
|
timestamp: Date.now(),
|
|
82
98
|
durationMs: Math.round(durationMs * 100) / 100,
|
|
83
99
|
...(error ? { error } : {}),
|
|
100
|
+
...(requestId ? { requestId } : {}),
|
|
84
101
|
});
|
|
85
102
|
|
|
86
103
|
// Pop from stack
|
|
@@ -90,6 +107,11 @@ export function traceReturn(
|
|
|
90
107
|
}
|
|
91
108
|
|
|
92
109
|
export function initCallTrace(): void {
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
if (_initialized) return;
|
|
111
|
+
_initialized = true;
|
|
112
|
+
// Truncate the file on explicit init (only the observe hook calls this)
|
|
113
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
114
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
115
|
+
traceFile = path.join(dir, 'calltrace.jsonl');
|
|
116
|
+
try { fs.writeFileSync(traceFile, ''); } catch {}
|
|
95
117
|
}
|
package/src/db-observer.ts
CHANGED
|
@@ -21,6 +21,7 @@ interface QueryRecord {
|
|
|
21
21
|
columns?: string[];
|
|
22
22
|
error?: string;
|
|
23
23
|
timestamp: number;
|
|
24
|
+
requestId?: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
let queriesFile: string | null = null;
|
|
@@ -42,6 +43,12 @@ function getQueriesFile(): string {
|
|
|
42
43
|
function writeQuery(record: QueryRecord): void {
|
|
43
44
|
if (queryCount >= MAX_QUERIES) return;
|
|
44
45
|
queryCount++;
|
|
46
|
+
// Add request ID from async context
|
|
47
|
+
try {
|
|
48
|
+
const { getRequestId } = require('./request-context');
|
|
49
|
+
const reqId = getRequestId();
|
|
50
|
+
if (reqId) record.requestId = reqId;
|
|
51
|
+
} catch {}
|
|
45
52
|
try {
|
|
46
53
|
fs.appendFileSync(getQueriesFile(), JSON.stringify(record) + '\n');
|
|
47
54
|
} catch {}
|
package/src/express.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as pathMod from 'path';
|
|
1
3
|
import { TypeNode, IngestPayload, WrapOptions } from './types';
|
|
2
4
|
import { inferType } from './type-inference';
|
|
3
5
|
import { hashType } from './type-hash';
|
|
4
6
|
import { TypeCache } from './cache';
|
|
5
7
|
import { enqueue } from './transport';
|
|
6
8
|
import { detectEnvironment } from './env-detect';
|
|
9
|
+
import { withRequestContext } from './request-context';
|
|
10
|
+
import { traceCall, traceReturn } from './call-trace';
|
|
7
11
|
|
|
8
12
|
const expressCache = new TypeCache();
|
|
9
13
|
|
|
@@ -94,6 +98,51 @@ function sanitizeSample(value: unknown, depth: number = 3): unknown {
|
|
|
94
98
|
return String(value);
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Write an error record to .trickle/errors.jsonl so that `trickle monitor`
|
|
103
|
+
* and `trickle heal` can detect runtime errors from Express handlers.
|
|
104
|
+
*/
|
|
105
|
+
function writeErrorToFile(error: unknown, input: Record<string, unknown>, routeName: string): void {
|
|
106
|
+
try {
|
|
107
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
108
|
+
const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME;
|
|
109
|
+
const defaultDir = isLambda ? '/tmp/.trickle' : pathMod.join(process.cwd(), '.trickle');
|
|
110
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || defaultDir;
|
|
111
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
112
|
+
|
|
113
|
+
// Extract file and line from stack trace
|
|
114
|
+
const stackLines = (err.stack || '').split('\n');
|
|
115
|
+
let errorFile: string | undefined;
|
|
116
|
+
let errorLine: number | undefined;
|
|
117
|
+
for (const sl of stackLines.slice(1)) {
|
|
118
|
+
const m = sl.match(/\((.+):(\d+):\d+\)/) || sl.match(/at (.+):(\d+):\d+/);
|
|
119
|
+
if (m && !m[1].includes('node_modules') && !m[1].includes('node:') && !m[1].includes('trickle')) {
|
|
120
|
+
errorFile = m[1];
|
|
121
|
+
errorLine = parseInt(m[2]);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const record = {
|
|
127
|
+
kind: 'error',
|
|
128
|
+
error: err.message,
|
|
129
|
+
type: err.constructor?.name || 'Error',
|
|
130
|
+
message: err.message,
|
|
131
|
+
file: errorFile,
|
|
132
|
+
line: errorLine,
|
|
133
|
+
stack: stackLines.slice(0, 6).join('\n'),
|
|
134
|
+
route: routeName,
|
|
135
|
+
request: input,
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const errorsFile = pathMod.join(dir, 'errors.jsonl');
|
|
140
|
+
fs.appendFileSync(errorsFile, JSON.stringify(record) + '\n');
|
|
141
|
+
} catch {
|
|
142
|
+
// Never crash the user's app
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
97
146
|
/**
|
|
98
147
|
* Emit a type payload for a single Express route invocation.
|
|
99
148
|
*/
|
|
@@ -104,6 +153,7 @@ function emitExpressPayload(
|
|
|
104
153
|
input: Record<string, unknown>,
|
|
105
154
|
output: unknown,
|
|
106
155
|
error?: unknown,
|
|
156
|
+
durationMs?: number,
|
|
107
157
|
): void {
|
|
108
158
|
try {
|
|
109
159
|
const functionKey = `express::${functionName}`;
|
|
@@ -132,6 +182,10 @@ function emitExpressPayload(
|
|
|
132
182
|
sampleOutput: error ? undefined : sanitizeSample(output),
|
|
133
183
|
};
|
|
134
184
|
|
|
185
|
+
if (durationMs !== undefined) {
|
|
186
|
+
payload.durationMs = Math.round(durationMs * 100) / 100;
|
|
187
|
+
}
|
|
188
|
+
|
|
135
189
|
if (error) {
|
|
136
190
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
137
191
|
payload.error = {
|
|
@@ -140,6 +194,8 @@ function emitExpressPayload(
|
|
|
140
194
|
stackTrace: err.stack,
|
|
141
195
|
argsSnapshot: sanitizeSample(input),
|
|
142
196
|
};
|
|
197
|
+
// Also write to errors.jsonl for monitor/heal detection
|
|
198
|
+
writeErrorToFile(error, input, functionName);
|
|
143
199
|
}
|
|
144
200
|
|
|
145
201
|
enqueue(payload);
|
|
@@ -163,8 +219,29 @@ function wrapExpressHandler(
|
|
|
163
219
|
return handler.call(this, req, res, next);
|
|
164
220
|
}
|
|
165
221
|
|
|
222
|
+
const self = this;
|
|
223
|
+
|
|
224
|
+
// Wrap entire handler execution in request context so downstream calls get a request ID
|
|
225
|
+
let returnValue: any;
|
|
226
|
+
withRequestContext(req, () => {
|
|
227
|
+
returnValue = _executeHandler(self, handler, req, res, next, routeName, opts);
|
|
228
|
+
});
|
|
229
|
+
return returnValue;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
function _executeHandler(
|
|
233
|
+
self: any,
|
|
234
|
+
handler: Function,
|
|
235
|
+
req: any,
|
|
236
|
+
res: any,
|
|
237
|
+
next: any,
|
|
238
|
+
routeName: string,
|
|
239
|
+
opts: ExpressInstrumentOpts,
|
|
240
|
+
): any {
|
|
166
241
|
const input = extractRequestInput(req);
|
|
167
242
|
let captured = false;
|
|
243
|
+
const startTime = performance.now();
|
|
244
|
+
const callId = traceCall(routeName, 'express');
|
|
168
245
|
|
|
169
246
|
// Intercept res.json()
|
|
170
247
|
const originalJson = res.json;
|
|
@@ -172,7 +249,9 @@ function wrapExpressHandler(
|
|
|
172
249
|
res.json = function (data: any) {
|
|
173
250
|
if (!captured) {
|
|
174
251
|
captured = true;
|
|
175
|
-
|
|
252
|
+
const durationMs = performance.now() - startTime;
|
|
253
|
+
traceReturn(callId, routeName, 'express', durationMs);
|
|
254
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, data, undefined, durationMs);
|
|
176
255
|
}
|
|
177
256
|
res.json = originalJson; // restore
|
|
178
257
|
return originalJson.call(res, data);
|
|
@@ -185,9 +264,11 @@ function wrapExpressHandler(
|
|
|
185
264
|
res.send = function (data: any) {
|
|
186
265
|
if (!captured) {
|
|
187
266
|
captured = true;
|
|
267
|
+
const durationMs = performance.now() - startTime;
|
|
268
|
+
traceReturn(callId, routeName, 'express', durationMs);
|
|
188
269
|
// Only capture non-string data as typed output; strings are usually HTML
|
|
189
270
|
const output = typeof data === 'string' ? { __html: true } : data;
|
|
190
|
-
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, output);
|
|
271
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, output, undefined, durationMs);
|
|
191
272
|
}
|
|
192
273
|
res.send = originalSend; // restore
|
|
193
274
|
return originalSend.call(res, data);
|
|
@@ -198,7 +279,9 @@ function wrapExpressHandler(
|
|
|
198
279
|
const wrappedNext = function (err?: any) {
|
|
199
280
|
if (err && !captured) {
|
|
200
281
|
captured = true;
|
|
201
|
-
|
|
282
|
+
const durationMs = performance.now() - startTime;
|
|
283
|
+
traceReturn(callId, routeName, 'express', durationMs, (err instanceof Error ? err : new Error(String(err))).message);
|
|
284
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err, durationMs);
|
|
202
285
|
}
|
|
203
286
|
if (typeof next === 'function') {
|
|
204
287
|
return next(err);
|
|
@@ -206,14 +289,16 @@ function wrapExpressHandler(
|
|
|
206
289
|
};
|
|
207
290
|
|
|
208
291
|
try {
|
|
209
|
-
const result = handler.call(
|
|
292
|
+
const result = handler.call(self, req, res, wrappedNext);
|
|
210
293
|
|
|
211
294
|
// Handle async handlers that return a promise
|
|
212
295
|
if (result && typeof result === 'object' && typeof result.then === 'function') {
|
|
213
296
|
return result.catch((err: unknown) => {
|
|
214
297
|
if (!captured) {
|
|
215
298
|
captured = true;
|
|
216
|
-
|
|
299
|
+
const durationMs = performance.now() - startTime;
|
|
300
|
+
traceReturn(callId, routeName, 'express', durationMs, (err instanceof Error ? err : new Error(String(err))).message);
|
|
301
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err, durationMs);
|
|
217
302
|
}
|
|
218
303
|
// Re-throw so Express error handling picks it up
|
|
219
304
|
throw err;
|
|
@@ -224,11 +309,13 @@ function wrapExpressHandler(
|
|
|
224
309
|
} catch (err) {
|
|
225
310
|
if (!captured) {
|
|
226
311
|
captured = true;
|
|
227
|
-
|
|
312
|
+
const durationMs = performance.now() - startTime;
|
|
313
|
+
traceReturn(callId, routeName, 'express', durationMs, (err instanceof Error ? err : new Error(String(err))).message);
|
|
314
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err, durationMs);
|
|
228
315
|
}
|
|
229
316
|
throw err;
|
|
230
317
|
}
|
|
231
|
-
}
|
|
318
|
+
}
|
|
232
319
|
|
|
233
320
|
// Preserve function metadata
|
|
234
321
|
Object.defineProperty(wrapped, 'name', { value: handler.name || routeName, configurable: true });
|
|
@@ -334,11 +421,22 @@ export function trickleMiddleware(
|
|
|
334
421
|
return;
|
|
335
422
|
}
|
|
336
423
|
|
|
424
|
+
// Wrap in request context for per-request correlation
|
|
425
|
+
withRequestContext(req, () => {
|
|
426
|
+
_handleRequest(req, res, next, opts, debug);
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function _handleRequest(req: any, res: any, next: any, opts: any, debug: boolean): void {
|
|
337
432
|
if (debug) {
|
|
338
433
|
console.log(`[trickle/middleware] Intercepting ${req.method} ${req.originalUrl || req.url}`);
|
|
339
434
|
}
|
|
340
435
|
|
|
341
436
|
let captured = false;
|
|
437
|
+
const startTime = performance.now();
|
|
438
|
+
const prelimRouteName = `${req.method || 'UNKNOWN'} ${req.originalUrl || req.url || '/'}`;
|
|
439
|
+
const callId = traceCall(prelimRouteName, 'express');
|
|
342
440
|
|
|
343
441
|
// We derive the route name lazily once the response is being sent,
|
|
344
442
|
// because req.route is only populated after the handler matches.
|
|
@@ -350,7 +448,7 @@ export function trickleMiddleware(
|
|
|
350
448
|
} catch {
|
|
351
449
|
// ignore
|
|
352
450
|
}
|
|
353
|
-
return
|
|
451
|
+
return prelimRouteName;
|
|
354
452
|
}
|
|
355
453
|
|
|
356
454
|
const input = extractRequestInput(req);
|
|
@@ -362,12 +460,14 @@ export function trickleMiddleware(
|
|
|
362
460
|
if (!captured) {
|
|
363
461
|
captured = true;
|
|
364
462
|
const routeName = getRouteName();
|
|
463
|
+
const durationMs = performance.now() - startTime;
|
|
464
|
+
traceReturn(callId, routeName, 'express', durationMs);
|
|
365
465
|
if (debug) {
|
|
366
466
|
console.log(`[trickle/middleware] Captured res.json for ${routeName}`);
|
|
367
467
|
}
|
|
368
468
|
// Re-extract input here because body parsers may have run since middleware was entered
|
|
369
469
|
const latestInput = extractRequestInput(req);
|
|
370
|
-
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, data);
|
|
470
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, data, undefined, durationMs);
|
|
371
471
|
}
|
|
372
472
|
res.json = originalJson;
|
|
373
473
|
return originalJson.call(res, data);
|
|
@@ -383,12 +483,14 @@ export function trickleMiddleware(
|
|
|
383
483
|
if (!captured) {
|
|
384
484
|
captured = true;
|
|
385
485
|
const routeName = getRouteName();
|
|
486
|
+
const durationMs = performance.now() - startTime;
|
|
487
|
+
traceReturn(callId, routeName, 'express', durationMs);
|
|
386
488
|
if (debug) {
|
|
387
489
|
console.log(`[trickle/middleware] Captured res.send for ${routeName}`);
|
|
388
490
|
}
|
|
389
491
|
const latestInput = extractRequestInput(req);
|
|
390
492
|
const output = typeof data === 'string' ? { __html: true } : data;
|
|
391
|
-
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, output);
|
|
493
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, output, undefined, durationMs);
|
|
392
494
|
}
|
|
393
495
|
res.send = originalSend;
|
|
394
496
|
return originalSend.call(res, data);
|
|
@@ -396,5 +498,4 @@ export function trickleMiddleware(
|
|
|
396
498
|
}
|
|
397
499
|
|
|
398
500
|
next();
|
|
399
|
-
};
|
|
400
501
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request context — propagates a request ID through async call chains.
|
|
3
|
+
*
|
|
4
|
+
* Uses Node.js AsyncLocalStorage so that all functions, queries, and logs
|
|
5
|
+
* within a single HTTP request share the same requestId, enabling
|
|
6
|
+
* per-request tracing (like Jaeger but with trickle's richer data).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { withRequestContext, getRequestId } from './request-context';
|
|
10
|
+
* // In Express middleware:
|
|
11
|
+
* app.use((req, res, next) => withRequestContext(req, next));
|
|
12
|
+
* // Anywhere in the call chain:
|
|
13
|
+
* const id = getRequestId(); // returns the current request's ID
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
let als: any = null;
|
|
17
|
+
let counter = 0;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
21
|
+
als = new AsyncLocalStorage();
|
|
22
|
+
} catch {
|
|
23
|
+
// AsyncLocalStorage not available (older Node versions)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RequestContext {
|
|
27
|
+
requestId: string;
|
|
28
|
+
method?: string;
|
|
29
|
+
path?: string;
|
|
30
|
+
startTime: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run a callback within a request context.
|
|
35
|
+
*/
|
|
36
|
+
export function withRequestContext(req: any, callback: () => void): void {
|
|
37
|
+
if (!als) { callback(); return; }
|
|
38
|
+
|
|
39
|
+
const ctx: RequestContext = {
|
|
40
|
+
requestId: `req-${++counter}-${Date.now().toString(36)}`,
|
|
41
|
+
method: req?.method,
|
|
42
|
+
path: req?.path || req?.url,
|
|
43
|
+
startTime: Date.now(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
als.run(ctx, callback);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get the current request ID (if inside a request context).
|
|
51
|
+
*/
|
|
52
|
+
export function getRequestId(): string | undefined {
|
|
53
|
+
if (!als) return undefined;
|
|
54
|
+
const ctx = als.getStore() as RequestContext | undefined;
|
|
55
|
+
return ctx?.requestId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the full request context.
|
|
60
|
+
*/
|
|
61
|
+
export function getRequestContext(): RequestContext | undefined {
|
|
62
|
+
if (!als) return undefined;
|
|
63
|
+
return als.getStore() as RequestContext | undefined;
|
|
64
|
+
}
|