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.
@@ -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 = 500;
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
- // Ensure trace file is initialized
114
- getTraceFile();
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
  }
@@ -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
- emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, data);
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
- emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
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(this, req, res, wrappedNext);
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
- emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
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
- emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
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
- if (debug) {
297
- console.log(`[trickle/middleware] Intercepting ${req.method} ${req.originalUrl || req.url}`);
298
- }
299
- let captured = false;
300
- // We derive the route name lazily once the response is being sent,
301
- // because req.route is only populated after the handler matches.
302
- function getRouteName() {
303
- try {
304
- if (req.route && req.route.path) {
305
- return `${req.method} ${req.baseUrl || ''}${req.route.path}`;
306
- }
307
- }
308
- catch {
309
- // ignore
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
- const input = extractRequestInput(req);
314
- // Intercept res.json()
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
- // Intercept res.send()
336
- const originalSend = res.send;
337
- if (typeof originalSend === 'function') {
338
- res.send = function (data) {
339
- if (!captured) {
340
- captured = true;
341
- const routeName = getRouteName();
342
- if (debug) {
343
- console.log(`[trickle/middleware] Captured res.send for ${routeName}`);
344
- }
345
- const latestInput = extractRequestInput(req);
346
- const output = typeof data === 'string' ? { __html: true } : data;
347
- emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, output);
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
- res.send = originalSend;
350
- return originalSend.call(res, data);
351
- };
352
- }
353
- next();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.114",
3
+ "version": "0.2.116",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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 = 500;
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
- try { fs.writeFileSync(traceFile, ''); } catch {}
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
- // Ensure trace file is initialized
94
- getTraceFile();
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
  }
@@ -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
- emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, data);
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
- emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
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(this, req, res, wrappedNext);
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
- emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
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
- emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
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 `${req.method || 'UNKNOWN'} ${req.originalUrl || req.url || '/'}`;
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
+ }