trickle-observe 0.2.96 → 0.2.98
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.d.ts +18 -0
- package/dist/call-trace.js +115 -0
- package/dist/observe-register.js +18 -0
- package/dist/wrap.js +6 -0
- package/dist/ws-observer.d.ts +21 -0
- package/dist/ws-observer.js +220 -0
- package/package.json +1 -1
- package/src/call-trace.ts +95 -0
- package/src/observe-register.ts +18 -0
- package/src/wrap.ts +6 -0
- package/src/ws-observer.ts +190 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call trace recorder — captures function call/return events with timing
|
|
3
|
+
* and parent-child relationships for building call graphs.
|
|
4
|
+
*
|
|
5
|
+
* Written to .trickle/calltrace.jsonl as:
|
|
6
|
+
* { "kind": "call", "function": "createUser", "module": "api",
|
|
7
|
+
* "parentId": 0, "callId": 1, "timestamp": 1710516000,
|
|
8
|
+
* "durationMs": 2.5, "args": ["Alice"], "result": {...} }
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Record a function call event. Returns the callId for pairing with traceReturn.
|
|
12
|
+
*/
|
|
13
|
+
export declare function traceCall(functionName: string, moduleName: string): number;
|
|
14
|
+
/**
|
|
15
|
+
* Record a function return event with timing.
|
|
16
|
+
*/
|
|
17
|
+
export declare function traceReturn(callId: number, functionName: string, moduleName: string, durationMs: number, error?: string): void;
|
|
18
|
+
export declare function initCallTrace(): void;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Call trace recorder — captures function call/return events with timing
|
|
4
|
+
* and parent-child relationships for building call graphs.
|
|
5
|
+
*
|
|
6
|
+
* Written to .trickle/calltrace.jsonl as:
|
|
7
|
+
* { "kind": "call", "function": "createUser", "module": "api",
|
|
8
|
+
* "parentId": 0, "callId": 1, "timestamp": 1710516000,
|
|
9
|
+
* "durationMs": 2.5, "args": ["Alice"], "result": {...} }
|
|
10
|
+
*/
|
|
11
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
+
}
|
|
17
|
+
Object.defineProperty(o, k2, desc);
|
|
18
|
+
}) : (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
o[k2] = m[k];
|
|
21
|
+
}));
|
|
22
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
+
}) : function(o, v) {
|
|
25
|
+
o["default"] = v;
|
|
26
|
+
});
|
|
27
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
+
var ownKeys = function(o) {
|
|
29
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
+
var ar = [];
|
|
31
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
+
return ar;
|
|
33
|
+
};
|
|
34
|
+
return ownKeys(o);
|
|
35
|
+
};
|
|
36
|
+
return function (mod) {
|
|
37
|
+
if (mod && mod.__esModule) return mod;
|
|
38
|
+
var result = {};
|
|
39
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
+
__setModuleDefault(result, mod);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.traceCall = traceCall;
|
|
46
|
+
exports.traceReturn = traceReturn;
|
|
47
|
+
exports.initCallTrace = initCallTrace;
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
let traceFile = null;
|
|
51
|
+
let callCounter = 0;
|
|
52
|
+
let currentCallId = 0; // 0 = top level
|
|
53
|
+
const callStack = [0];
|
|
54
|
+
const MAX_TRACE_EVENTS = 500;
|
|
55
|
+
let eventCount = 0;
|
|
56
|
+
function getTraceFile() {
|
|
57
|
+
if (traceFile)
|
|
58
|
+
return traceFile;
|
|
59
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
traceFile = path.join(dir, 'calltrace.jsonl');
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(traceFile, '');
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
return traceFile;
|
|
70
|
+
}
|
|
71
|
+
function writeEvent(event) {
|
|
72
|
+
if (eventCount >= MAX_TRACE_EVENTS)
|
|
73
|
+
return;
|
|
74
|
+
eventCount++;
|
|
75
|
+
try {
|
|
76
|
+
fs.appendFileSync(getTraceFile(), JSON.stringify(event) + '\n');
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Record a function call event. Returns the callId for pairing with traceReturn.
|
|
82
|
+
*/
|
|
83
|
+
function traceCall(functionName, moduleName) {
|
|
84
|
+
const id = ++callCounter;
|
|
85
|
+
const parentId = callStack[callStack.length - 1] || 0;
|
|
86
|
+
callStack.push(id);
|
|
87
|
+
// We record a placeholder — duration is filled in by traceReturn
|
|
88
|
+
return id;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Record a function return event with timing.
|
|
92
|
+
*/
|
|
93
|
+
function traceReturn(callId, functionName, moduleName, durationMs, error) {
|
|
94
|
+
const parentId = callStack.length >= 2 ? callStack[callStack.length - 2] : 0;
|
|
95
|
+
const depth = callStack.length - 1;
|
|
96
|
+
writeEvent({
|
|
97
|
+
kind: 'call',
|
|
98
|
+
function: functionName,
|
|
99
|
+
module: moduleName,
|
|
100
|
+
callId,
|
|
101
|
+
parentId,
|
|
102
|
+
depth,
|
|
103
|
+
timestamp: Date.now(),
|
|
104
|
+
durationMs: Math.round(durationMs * 100) / 100,
|
|
105
|
+
...(error ? { error } : {}),
|
|
106
|
+
});
|
|
107
|
+
// Pop from stack
|
|
108
|
+
if (callStack[callStack.length - 1] === callId) {
|
|
109
|
+
callStack.pop();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function initCallTrace() {
|
|
113
|
+
// Ensure trace file is initialized
|
|
114
|
+
getTraceFile();
|
|
115
|
+
}
|
package/dist/observe-register.js
CHANGED
|
@@ -1166,6 +1166,24 @@ if (enabled) {
|
|
|
1166
1166
|
}
|
|
1167
1167
|
catch { /* not critical */ }
|
|
1168
1168
|
}
|
|
1169
|
+
// WebSocket (ws)
|
|
1170
|
+
if (request === 'ws' && !expressPatched.has('ws')) {
|
|
1171
|
+
expressPatched.add('ws');
|
|
1172
|
+
try {
|
|
1173
|
+
const { patchWs } = require(path_1.default.join(__dirname, 'ws-observer.js'));
|
|
1174
|
+
patchWs(exports, debug);
|
|
1175
|
+
}
|
|
1176
|
+
catch { /* not critical */ }
|
|
1177
|
+
}
|
|
1178
|
+
// socket.io-client
|
|
1179
|
+
if (request === 'socket.io-client' && !expressPatched.has('socket.io-client')) {
|
|
1180
|
+
expressPatched.add('socket.io-client');
|
|
1181
|
+
try {
|
|
1182
|
+
const { patchSocketIo } = require(path_1.default.join(__dirname, 'ws-observer.js'));
|
|
1183
|
+
patchSocketIo(exports, debug);
|
|
1184
|
+
}
|
|
1185
|
+
catch { /* not critical */ }
|
|
1186
|
+
}
|
|
1169
1187
|
// Resolve to absolute path for dedup — do this FIRST since bundlers like
|
|
1170
1188
|
// tsx/esbuild may use path aliases (e.g., @config/env) that don't start
|
|
1171
1189
|
// with './' or '/'. We need the resolved path to decide if it's user code.
|
package/dist/wrap.js
CHANGED
|
@@ -6,6 +6,7 @@ const type_inference_1 = require("./type-inference");
|
|
|
6
6
|
const type_hash_1 = require("./type-hash");
|
|
7
7
|
const cache_1 = require("./cache");
|
|
8
8
|
const transport_1 = require("./transport");
|
|
9
|
+
const call_trace_1 = require("./call-trace");
|
|
9
10
|
const typeCache = new cache_1.TypeCache();
|
|
10
11
|
exports.typeCache = typeCache;
|
|
11
12
|
/** Symbol to mark already-wrapped functions, preventing double-wrap. */
|
|
@@ -33,6 +34,7 @@ function wrapFunction(fn, opts) {
|
|
|
33
34
|
let caughtError;
|
|
34
35
|
const trackers = [];
|
|
35
36
|
const startTime = performance.now();
|
|
37
|
+
const callId = (0, call_trace_1.traceCall)(opts.functionName, opts.module);
|
|
36
38
|
try {
|
|
37
39
|
// Always pass ORIGINAL args to the function — never proxied ones.
|
|
38
40
|
// Proxied args can break framework internals (Express Router, DI containers, etc.)
|
|
@@ -45,6 +47,7 @@ function wrapFunction(fn, opts) {
|
|
|
45
47
|
try {
|
|
46
48
|
const durationMs = performance.now() - startTime;
|
|
47
49
|
captureErrorPayload(functionKey, opts, args, trackers, err, durationMs);
|
|
50
|
+
(0, call_trace_1.traceReturn)(callId, opts.functionName, opts.module, durationMs, err?.message);
|
|
48
51
|
}
|
|
49
52
|
catch {
|
|
50
53
|
// Never let our instrumentation interfere
|
|
@@ -58,6 +61,7 @@ function wrapFunction(fn, opts) {
|
|
|
58
61
|
try {
|
|
59
62
|
const durationMs = performance.now() - startTime;
|
|
60
63
|
capturePayload(functionKey, opts, args, trackers, resolved, true, durationMs);
|
|
64
|
+
(0, call_trace_1.traceReturn)(callId, opts.functionName, opts.module, durationMs);
|
|
61
65
|
}
|
|
62
66
|
catch {
|
|
63
67
|
// Never let our instrumentation interfere
|
|
@@ -67,6 +71,7 @@ function wrapFunction(fn, opts) {
|
|
|
67
71
|
try {
|
|
68
72
|
const durationMs = performance.now() - startTime;
|
|
69
73
|
captureErrorPayload(functionKey, opts, args, trackers, err, durationMs);
|
|
74
|
+
(0, call_trace_1.traceReturn)(callId, opts.functionName, opts.module, durationMs, err?.message);
|
|
70
75
|
}
|
|
71
76
|
catch {
|
|
72
77
|
// Never let our instrumentation interfere
|
|
@@ -79,6 +84,7 @@ function wrapFunction(fn, opts) {
|
|
|
79
84
|
try {
|
|
80
85
|
const durationMs = performance.now() - startTime;
|
|
81
86
|
capturePayload(functionKey, opts, args, trackers, result, false, durationMs);
|
|
87
|
+
(0, call_trace_1.traceReturn)(callId, opts.functionName, opts.module, durationMs);
|
|
82
88
|
}
|
|
83
89
|
catch {
|
|
84
90
|
// Never let our instrumentation interfere
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket observer — patches popular WebSocket libraries to capture
|
|
3
|
+
* message events, connection lifecycle, and timing.
|
|
4
|
+
*
|
|
5
|
+
* Supports:
|
|
6
|
+
* - ws (most popular Node.js WebSocket library)
|
|
7
|
+
* - Native WebSocket (browser/Deno/Bun)
|
|
8
|
+
* - socket.io (real-time framework)
|
|
9
|
+
*
|
|
10
|
+
* Written to .trickle/websocket.jsonl as:
|
|
11
|
+
* { "kind": "ws", "event": "message", "direction": "in",
|
|
12
|
+
* "data": "...", "timestamp": 1710516000, "url": "ws://..." }
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Patch ws (node WebSocket library) to capture messages.
|
|
16
|
+
*/
|
|
17
|
+
export declare function patchWs(wsModule: any, debug: boolean): void;
|
|
18
|
+
/**
|
|
19
|
+
* Patch socket.io client to capture emit/on events.
|
|
20
|
+
*/
|
|
21
|
+
export declare function patchSocketIo(ioModule: any, debug: boolean): void;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* WebSocket observer — patches popular WebSocket libraries to capture
|
|
4
|
+
* message events, connection lifecycle, and timing.
|
|
5
|
+
*
|
|
6
|
+
* Supports:
|
|
7
|
+
* - ws (most popular Node.js WebSocket library)
|
|
8
|
+
* - Native WebSocket (browser/Deno/Bun)
|
|
9
|
+
* - socket.io (real-time framework)
|
|
10
|
+
*
|
|
11
|
+
* Written to .trickle/websocket.jsonl as:
|
|
12
|
+
* { "kind": "ws", "event": "message", "direction": "in",
|
|
13
|
+
* "data": "...", "timestamp": 1710516000, "url": "ws://..." }
|
|
14
|
+
*/
|
|
15
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
18
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
19
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(o, k2, desc);
|
|
22
|
+
}) : (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
o[k2] = m[k];
|
|
25
|
+
}));
|
|
26
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
27
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
28
|
+
}) : function(o, v) {
|
|
29
|
+
o["default"] = v;
|
|
30
|
+
});
|
|
31
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
32
|
+
var ownKeys = function(o) {
|
|
33
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
34
|
+
var ar = [];
|
|
35
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
36
|
+
return ar;
|
|
37
|
+
};
|
|
38
|
+
return ownKeys(o);
|
|
39
|
+
};
|
|
40
|
+
return function (mod) {
|
|
41
|
+
if (mod && mod.__esModule) return mod;
|
|
42
|
+
var result = {};
|
|
43
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
44
|
+
__setModuleDefault(result, mod);
|
|
45
|
+
return result;
|
|
46
|
+
};
|
|
47
|
+
})();
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.patchWs = patchWs;
|
|
50
|
+
exports.patchSocketIo = patchSocketIo;
|
|
51
|
+
const fs = __importStar(require("fs"));
|
|
52
|
+
const path = __importStar(require("path"));
|
|
53
|
+
let wsFile = null;
|
|
54
|
+
const MAX_EVENTS = 200;
|
|
55
|
+
let eventCount = 0;
|
|
56
|
+
const MAX_DATA_LENGTH = 500;
|
|
57
|
+
function getWsFile() {
|
|
58
|
+
if (wsFile)
|
|
59
|
+
return wsFile;
|
|
60
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
wsFile = path.join(dir, 'websocket.jsonl');
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(wsFile, '');
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
;
|
|
71
|
+
return wsFile;
|
|
72
|
+
}
|
|
73
|
+
function writeWsEvent(event) {
|
|
74
|
+
if (eventCount >= MAX_EVENTS)
|
|
75
|
+
return;
|
|
76
|
+
eventCount++;
|
|
77
|
+
try {
|
|
78
|
+
fs.appendFileSync(getWsFile(), JSON.stringify(event) + '\n');
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
}
|
|
82
|
+
function truncateData(data) {
|
|
83
|
+
if (data === undefined || data === null)
|
|
84
|
+
return data;
|
|
85
|
+
if (typeof data === 'string') {
|
|
86
|
+
return data.length > MAX_DATA_LENGTH ? data.substring(0, MAX_DATA_LENGTH) + '...' : data;
|
|
87
|
+
}
|
|
88
|
+
if (Buffer.isBuffer(data)) {
|
|
89
|
+
return `<Buffer(${data.length} bytes)>`;
|
|
90
|
+
}
|
|
91
|
+
if (typeof data === 'object') {
|
|
92
|
+
try {
|
|
93
|
+
const s = JSON.stringify(data);
|
|
94
|
+
return s.length > MAX_DATA_LENGTH ? JSON.parse(s.substring(0, MAX_DATA_LENGTH) + '..."}}') : data;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return String(data).substring(0, MAX_DATA_LENGTH);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Patch ws (node WebSocket library) to capture messages.
|
|
104
|
+
*/
|
|
105
|
+
function patchWs(wsModule, debug) {
|
|
106
|
+
const WsClass = wsModule.WebSocket || wsModule;
|
|
107
|
+
if (!WsClass?.prototype || WsClass.prototype.__trickle_ws_patched)
|
|
108
|
+
return;
|
|
109
|
+
const origSend = WsClass.prototype.send;
|
|
110
|
+
if (origSend) {
|
|
111
|
+
WsClass.prototype.send = function patchedSend(data, ...args) {
|
|
112
|
+
writeWsEvent({
|
|
113
|
+
kind: 'ws',
|
|
114
|
+
event: 'message',
|
|
115
|
+
direction: 'out',
|
|
116
|
+
url: this.url || this._url,
|
|
117
|
+
data: truncateData(data),
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
});
|
|
120
|
+
return origSend.call(this, data, ...args);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// Patch 'on' to capture incoming messages
|
|
124
|
+
const origOn = WsClass.prototype.on;
|
|
125
|
+
if (origOn) {
|
|
126
|
+
WsClass.prototype.on = function patchedOn(event, listener, ...args) {
|
|
127
|
+
if (event === 'message') {
|
|
128
|
+
const wrappedListener = (data, ...rest) => {
|
|
129
|
+
writeWsEvent({
|
|
130
|
+
kind: 'ws',
|
|
131
|
+
event: 'message',
|
|
132
|
+
direction: 'in',
|
|
133
|
+
url: this.url || this._url,
|
|
134
|
+
data: truncateData(data),
|
|
135
|
+
timestamp: Date.now(),
|
|
136
|
+
});
|
|
137
|
+
return listener(data, ...rest);
|
|
138
|
+
};
|
|
139
|
+
return origOn.call(this, event, wrappedListener, ...args);
|
|
140
|
+
}
|
|
141
|
+
if (event === 'open') {
|
|
142
|
+
const wrappedListener = (...rest) => {
|
|
143
|
+
writeWsEvent({
|
|
144
|
+
kind: 'ws',
|
|
145
|
+
event: 'connect',
|
|
146
|
+
url: this.url || this._url,
|
|
147
|
+
timestamp: Date.now(),
|
|
148
|
+
});
|
|
149
|
+
return listener(...rest);
|
|
150
|
+
};
|
|
151
|
+
return origOn.call(this, event, wrappedListener, ...args);
|
|
152
|
+
}
|
|
153
|
+
if (event === 'close') {
|
|
154
|
+
const wrappedListener = (code, reason, ...rest) => {
|
|
155
|
+
writeWsEvent({
|
|
156
|
+
kind: 'ws',
|
|
157
|
+
event: 'close',
|
|
158
|
+
url: this.url || this._url,
|
|
159
|
+
data: { code, reason: String(reason || '').substring(0, 100) },
|
|
160
|
+
timestamp: Date.now(),
|
|
161
|
+
});
|
|
162
|
+
return listener(code, reason, ...rest);
|
|
163
|
+
};
|
|
164
|
+
return origOn.call(this, event, wrappedListener, ...args);
|
|
165
|
+
}
|
|
166
|
+
return origOn.call(this, event, listener, ...args);
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
WsClass.prototype.__trickle_ws_patched = true;
|
|
170
|
+
if (debug)
|
|
171
|
+
console.log('[trickle/ws] WebSocket tracing enabled');
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Patch socket.io client to capture emit/on events.
|
|
175
|
+
*/
|
|
176
|
+
function patchSocketIo(ioModule, debug) {
|
|
177
|
+
// socket.io-client: the default export is a function that returns a Socket
|
|
178
|
+
const Socket = ioModule.Socket || (ioModule.io && ioModule.io.Socket);
|
|
179
|
+
if (!Socket?.prototype || Socket.prototype.__trickle_sio_patched)
|
|
180
|
+
return;
|
|
181
|
+
const origEmit = Socket.prototype.emit;
|
|
182
|
+
if (origEmit) {
|
|
183
|
+
Socket.prototype.emit = function patchedEmit(event, ...args) {
|
|
184
|
+
if (event !== 'connect' && event !== 'disconnect' && !event.startsWith('__')) {
|
|
185
|
+
writeWsEvent({
|
|
186
|
+
kind: 'ws',
|
|
187
|
+
event: 'emit',
|
|
188
|
+
direction: 'out',
|
|
189
|
+
channel: event,
|
|
190
|
+
data: truncateData(args[0]),
|
|
191
|
+
timestamp: Date.now(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return origEmit.call(this, event, ...args);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const origOn = Socket.prototype.on;
|
|
198
|
+
if (origOn) {
|
|
199
|
+
Socket.prototype.on = function patchedOn(event, listener, ...rest) {
|
|
200
|
+
if (event !== 'connect' && event !== 'disconnect' && !event.startsWith('__')) {
|
|
201
|
+
const wrappedListener = (...args) => {
|
|
202
|
+
writeWsEvent({
|
|
203
|
+
kind: 'ws',
|
|
204
|
+
event: 'emit',
|
|
205
|
+
direction: 'in',
|
|
206
|
+
channel: event,
|
|
207
|
+
data: truncateData(args[0]),
|
|
208
|
+
timestamp: Date.now(),
|
|
209
|
+
});
|
|
210
|
+
return listener(...args);
|
|
211
|
+
};
|
|
212
|
+
return origOn.call(this, event, wrappedListener, ...rest);
|
|
213
|
+
}
|
|
214
|
+
return origOn.call(this, event, listener, ...rest);
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
Socket.prototype.__trickle_sio_patched = true;
|
|
218
|
+
if (debug)
|
|
219
|
+
console.log('[trickle/ws] socket.io tracing enabled');
|
|
220
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call trace recorder — captures function call/return events with timing
|
|
3
|
+
* and parent-child relationships for building call graphs.
|
|
4
|
+
*
|
|
5
|
+
* Written to .trickle/calltrace.jsonl as:
|
|
6
|
+
* { "kind": "call", "function": "createUser", "module": "api",
|
|
7
|
+
* "parentId": 0, "callId": 1, "timestamp": 1710516000,
|
|
8
|
+
* "durationMs": 2.5, "args": ["Alice"], "result": {...} }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
|
|
14
|
+
interface CallEvent {
|
|
15
|
+
kind: 'call';
|
|
16
|
+
function: string;
|
|
17
|
+
module: string;
|
|
18
|
+
callId: number;
|
|
19
|
+
parentId: number;
|
|
20
|
+
depth: number;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
durationMs: number;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let traceFile: string | null = null;
|
|
27
|
+
let callCounter = 0;
|
|
28
|
+
let currentCallId = 0; // 0 = top level
|
|
29
|
+
const callStack: number[] = [0];
|
|
30
|
+
const MAX_TRACE_EVENTS = 500;
|
|
31
|
+
let eventCount = 0;
|
|
32
|
+
|
|
33
|
+
function getTraceFile(): string {
|
|
34
|
+
if (traceFile) return traceFile;
|
|
35
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
36
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
37
|
+
traceFile = path.join(dir, 'calltrace.jsonl');
|
|
38
|
+
try { fs.writeFileSync(traceFile, ''); } catch {}
|
|
39
|
+
return traceFile;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeEvent(event: CallEvent): void {
|
|
43
|
+
if (eventCount >= MAX_TRACE_EVENTS) return;
|
|
44
|
+
eventCount++;
|
|
45
|
+
try {
|
|
46
|
+
fs.appendFileSync(getTraceFile(), JSON.stringify(event) + '\n');
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record a function call event. Returns the callId for pairing with traceReturn.
|
|
52
|
+
*/
|
|
53
|
+
export function traceCall(functionName: string, moduleName: string): number {
|
|
54
|
+
const id = ++callCounter;
|
|
55
|
+
const parentId = callStack[callStack.length - 1] || 0;
|
|
56
|
+
callStack.push(id);
|
|
57
|
+
// We record a placeholder — duration is filled in by traceReturn
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Record a function return event with timing.
|
|
63
|
+
*/
|
|
64
|
+
export function traceReturn(
|
|
65
|
+
callId: number,
|
|
66
|
+
functionName: string,
|
|
67
|
+
moduleName: string,
|
|
68
|
+
durationMs: number,
|
|
69
|
+
error?: string,
|
|
70
|
+
): void {
|
|
71
|
+
const parentId = callStack.length >= 2 ? callStack[callStack.length - 2] : 0;
|
|
72
|
+
const depth = callStack.length - 1;
|
|
73
|
+
|
|
74
|
+
writeEvent({
|
|
75
|
+
kind: 'call',
|
|
76
|
+
function: functionName,
|
|
77
|
+
module: moduleName,
|
|
78
|
+
callId,
|
|
79
|
+
parentId,
|
|
80
|
+
depth,
|
|
81
|
+
timestamp: Date.now(),
|
|
82
|
+
durationMs: Math.round(durationMs * 100) / 100,
|
|
83
|
+
...(error ? { error } : {}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Pop from stack
|
|
87
|
+
if (callStack[callStack.length - 1] === callId) {
|
|
88
|
+
callStack.pop();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function initCallTrace(): void {
|
|
93
|
+
// Ensure trace file is initialized
|
|
94
|
+
getTraceFile();
|
|
95
|
+
}
|
package/src/observe-register.ts
CHANGED
|
@@ -1154,6 +1154,24 @@ if (enabled) {
|
|
|
1154
1154
|
} catch { /* not critical */ }
|
|
1155
1155
|
}
|
|
1156
1156
|
|
|
1157
|
+
// WebSocket (ws)
|
|
1158
|
+
if (request === 'ws' && !expressPatched.has('ws')) {
|
|
1159
|
+
expressPatched.add('ws');
|
|
1160
|
+
try {
|
|
1161
|
+
const { patchWs } = require(path.join(__dirname, 'ws-observer.js'));
|
|
1162
|
+
patchWs(exports, debug);
|
|
1163
|
+
} catch { /* not critical */ }
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// socket.io-client
|
|
1167
|
+
if (request === 'socket.io-client' && !expressPatched.has('socket.io-client')) {
|
|
1168
|
+
expressPatched.add('socket.io-client');
|
|
1169
|
+
try {
|
|
1170
|
+
const { patchSocketIo } = require(path.join(__dirname, 'ws-observer.js'));
|
|
1171
|
+
patchSocketIo(exports, debug);
|
|
1172
|
+
} catch { /* not critical */ }
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1157
1175
|
// Resolve to absolute path for dedup — do this FIRST since bundlers like
|
|
1158
1176
|
// tsx/esbuild may use path aliases (e.g., @config/env) that don't start
|
|
1159
1177
|
// with './' or '/'. We need the resolved path to decide if it's user code.
|
package/src/wrap.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { hashType } from './type-hash';
|
|
|
4
4
|
import { createTracker } from './proxy-tracker';
|
|
5
5
|
import { TypeCache } from './cache';
|
|
6
6
|
import { enqueue } from './transport';
|
|
7
|
+
import { traceCall, traceReturn } from './call-trace';
|
|
7
8
|
|
|
8
9
|
const typeCache = new TypeCache();
|
|
9
10
|
|
|
@@ -35,6 +36,7 @@ export function wrapFunction<T extends (...args: any[]) => any>(fn: T, opts: Wra
|
|
|
35
36
|
let caughtError: unknown;
|
|
36
37
|
const trackers: Array<{ proxy: unknown; getAccessedPaths: () => Map<string, TypeNode> }> = [];
|
|
37
38
|
const startTime = performance.now();
|
|
39
|
+
const callId = traceCall(opts.functionName, opts.module);
|
|
38
40
|
|
|
39
41
|
try {
|
|
40
42
|
// Always pass ORIGINAL args to the function — never proxied ones.
|
|
@@ -48,6 +50,7 @@ export function wrapFunction<T extends (...args: any[]) => any>(fn: T, opts: Wra
|
|
|
48
50
|
try {
|
|
49
51
|
const durationMs = performance.now() - startTime;
|
|
50
52
|
captureErrorPayload(functionKey, opts, args, trackers, err, durationMs);
|
|
53
|
+
traceReturn(callId, opts.functionName, opts.module, durationMs, (err as Error)?.message);
|
|
51
54
|
} catch {
|
|
52
55
|
// Never let our instrumentation interfere
|
|
53
56
|
}
|
|
@@ -63,6 +66,7 @@ export function wrapFunction<T extends (...args: any[]) => any>(fn: T, opts: Wra
|
|
|
63
66
|
try {
|
|
64
67
|
const durationMs = performance.now() - startTime;
|
|
65
68
|
capturePayload(functionKey, opts, args, trackers, resolved, true, durationMs);
|
|
69
|
+
traceReturn(callId, opts.functionName, opts.module, durationMs);
|
|
66
70
|
} catch {
|
|
67
71
|
// Never let our instrumentation interfere
|
|
68
72
|
}
|
|
@@ -72,6 +76,7 @@ export function wrapFunction<T extends (...args: any[]) => any>(fn: T, opts: Wra
|
|
|
72
76
|
try {
|
|
73
77
|
const durationMs = performance.now() - startTime;
|
|
74
78
|
captureErrorPayload(functionKey, opts, args, trackers, err, durationMs);
|
|
79
|
+
traceReturn(callId, opts.functionName, opts.module, durationMs, (err as Error)?.message);
|
|
75
80
|
} catch {
|
|
76
81
|
// Never let our instrumentation interfere
|
|
77
82
|
}
|
|
@@ -85,6 +90,7 @@ export function wrapFunction<T extends (...args: any[]) => any>(fn: T, opts: Wra
|
|
|
85
90
|
try {
|
|
86
91
|
const durationMs = performance.now() - startTime;
|
|
87
92
|
capturePayload(functionKey, opts, args, trackers, result, false, durationMs);
|
|
93
|
+
traceReturn(callId, opts.functionName, opts.module, durationMs);
|
|
88
94
|
} catch {
|
|
89
95
|
// Never let our instrumentation interfere
|
|
90
96
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket observer — patches popular WebSocket libraries to capture
|
|
3
|
+
* message events, connection lifecycle, and timing.
|
|
4
|
+
*
|
|
5
|
+
* Supports:
|
|
6
|
+
* - ws (most popular Node.js WebSocket library)
|
|
7
|
+
* - Native WebSocket (browser/Deno/Bun)
|
|
8
|
+
* - socket.io (real-time framework)
|
|
9
|
+
*
|
|
10
|
+
* Written to .trickle/websocket.jsonl as:
|
|
11
|
+
* { "kind": "ws", "event": "message", "direction": "in",
|
|
12
|
+
* "data": "...", "timestamp": 1710516000, "url": "ws://..." }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
|
|
18
|
+
interface WsEvent {
|
|
19
|
+
kind: 'ws';
|
|
20
|
+
event: 'connect' | 'message' | 'close' | 'error' | 'emit';
|
|
21
|
+
direction?: 'in' | 'out';
|
|
22
|
+
url?: string;
|
|
23
|
+
data?: unknown;
|
|
24
|
+
channel?: string;
|
|
25
|
+
timestamp: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let wsFile: string | null = null;
|
|
29
|
+
const MAX_EVENTS = 200;
|
|
30
|
+
let eventCount = 0;
|
|
31
|
+
const MAX_DATA_LENGTH = 500;
|
|
32
|
+
|
|
33
|
+
function getWsFile(): string {
|
|
34
|
+
if (wsFile) return wsFile;
|
|
35
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), '.trickle');
|
|
36
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
37
|
+
wsFile = path.join(dir, 'websocket.jsonl');
|
|
38
|
+
try { fs.writeFileSync(wsFile, ''); } catch {};
|
|
39
|
+
return wsFile;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeWsEvent(event: WsEvent): void {
|
|
43
|
+
if (eventCount >= MAX_EVENTS) return;
|
|
44
|
+
eventCount++;
|
|
45
|
+
try {
|
|
46
|
+
fs.appendFileSync(getWsFile(), JSON.stringify(event) + '\n');
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function truncateData(data: unknown): unknown {
|
|
51
|
+
if (data === undefined || data === null) return data;
|
|
52
|
+
if (typeof data === 'string') {
|
|
53
|
+
return data.length > MAX_DATA_LENGTH ? data.substring(0, MAX_DATA_LENGTH) + '...' : data;
|
|
54
|
+
}
|
|
55
|
+
if (Buffer.isBuffer(data)) {
|
|
56
|
+
return `<Buffer(${data.length} bytes)>`;
|
|
57
|
+
}
|
|
58
|
+
if (typeof data === 'object') {
|
|
59
|
+
try {
|
|
60
|
+
const s = JSON.stringify(data);
|
|
61
|
+
return s.length > MAX_DATA_LENGTH ? JSON.parse(s.substring(0, MAX_DATA_LENGTH) + '..."}}') : data;
|
|
62
|
+
} catch {
|
|
63
|
+
return String(data).substring(0, MAX_DATA_LENGTH);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Patch ws (node WebSocket library) to capture messages.
|
|
71
|
+
*/
|
|
72
|
+
export function patchWs(wsModule: any, debug: boolean): void {
|
|
73
|
+
const WsClass = wsModule.WebSocket || wsModule;
|
|
74
|
+
if (!WsClass?.prototype || (WsClass.prototype as any).__trickle_ws_patched) return;
|
|
75
|
+
|
|
76
|
+
const origSend = WsClass.prototype.send;
|
|
77
|
+
if (origSend) {
|
|
78
|
+
WsClass.prototype.send = function patchedSend(data: any, ...args: any[]) {
|
|
79
|
+
writeWsEvent({
|
|
80
|
+
kind: 'ws',
|
|
81
|
+
event: 'message',
|
|
82
|
+
direction: 'out',
|
|
83
|
+
url: this.url || this._url,
|
|
84
|
+
data: truncateData(data),
|
|
85
|
+
timestamp: Date.now(),
|
|
86
|
+
});
|
|
87
|
+
return origSend.call(this, data, ...args);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Patch 'on' to capture incoming messages
|
|
92
|
+
const origOn = WsClass.prototype.on;
|
|
93
|
+
if (origOn) {
|
|
94
|
+
WsClass.prototype.on = function patchedOn(event: string, listener: any, ...args: any[]) {
|
|
95
|
+
if (event === 'message') {
|
|
96
|
+
const wrappedListener = (data: any, ...rest: any[]) => {
|
|
97
|
+
writeWsEvent({
|
|
98
|
+
kind: 'ws',
|
|
99
|
+
event: 'message',
|
|
100
|
+
direction: 'in',
|
|
101
|
+
url: this.url || this._url,
|
|
102
|
+
data: truncateData(data),
|
|
103
|
+
timestamp: Date.now(),
|
|
104
|
+
});
|
|
105
|
+
return listener(data, ...rest);
|
|
106
|
+
};
|
|
107
|
+
return origOn.call(this, event, wrappedListener, ...args);
|
|
108
|
+
}
|
|
109
|
+
if (event === 'open') {
|
|
110
|
+
const wrappedListener = (...rest: any[]) => {
|
|
111
|
+
writeWsEvent({
|
|
112
|
+
kind: 'ws',
|
|
113
|
+
event: 'connect',
|
|
114
|
+
url: this.url || this._url,
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
});
|
|
117
|
+
return listener(...rest);
|
|
118
|
+
};
|
|
119
|
+
return origOn.call(this, event, wrappedListener, ...args);
|
|
120
|
+
}
|
|
121
|
+
if (event === 'close') {
|
|
122
|
+
const wrappedListener = (code: number, reason: string, ...rest: any[]) => {
|
|
123
|
+
writeWsEvent({
|
|
124
|
+
kind: 'ws',
|
|
125
|
+
event: 'close',
|
|
126
|
+
url: this.url || this._url,
|
|
127
|
+
data: { code, reason: String(reason || '').substring(0, 100) },
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
});
|
|
130
|
+
return listener(code, reason, ...rest);
|
|
131
|
+
};
|
|
132
|
+
return origOn.call(this, event, wrappedListener, ...args);
|
|
133
|
+
}
|
|
134
|
+
return origOn.call(this, event, listener, ...args);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
(WsClass.prototype as any).__trickle_ws_patched = true;
|
|
139
|
+
if (debug) console.log('[trickle/ws] WebSocket tracing enabled');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Patch socket.io client to capture emit/on events.
|
|
144
|
+
*/
|
|
145
|
+
export function patchSocketIo(ioModule: any, debug: boolean): void {
|
|
146
|
+
// socket.io-client: the default export is a function that returns a Socket
|
|
147
|
+
const Socket = ioModule.Socket || (ioModule.io && ioModule.io.Socket);
|
|
148
|
+
if (!Socket?.prototype || (Socket.prototype as any).__trickle_sio_patched) return;
|
|
149
|
+
|
|
150
|
+
const origEmit = Socket.prototype.emit;
|
|
151
|
+
if (origEmit) {
|
|
152
|
+
Socket.prototype.emit = function patchedEmit(event: string, ...args: any[]) {
|
|
153
|
+
if (event !== 'connect' && event !== 'disconnect' && !event.startsWith('__')) {
|
|
154
|
+
writeWsEvent({
|
|
155
|
+
kind: 'ws',
|
|
156
|
+
event: 'emit',
|
|
157
|
+
direction: 'out',
|
|
158
|
+
channel: event,
|
|
159
|
+
data: truncateData(args[0]),
|
|
160
|
+
timestamp: Date.now(),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return origEmit.call(this, event, ...args);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const origOn = Socket.prototype.on;
|
|
168
|
+
if (origOn) {
|
|
169
|
+
Socket.prototype.on = function patchedOn(event: string, listener: any, ...rest: any[]) {
|
|
170
|
+
if (event !== 'connect' && event !== 'disconnect' && !event.startsWith('__')) {
|
|
171
|
+
const wrappedListener = (...args: any[]) => {
|
|
172
|
+
writeWsEvent({
|
|
173
|
+
kind: 'ws',
|
|
174
|
+
event: 'emit',
|
|
175
|
+
direction: 'in',
|
|
176
|
+
channel: event,
|
|
177
|
+
data: truncateData(args[0]),
|
|
178
|
+
timestamp: Date.now(),
|
|
179
|
+
});
|
|
180
|
+
return listener(...args);
|
|
181
|
+
};
|
|
182
|
+
return origOn.call(this, event, wrappedListener, ...rest);
|
|
183
|
+
}
|
|
184
|
+
return origOn.call(this, event, listener, ...rest);
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
(Socket.prototype as any).__trickle_sio_patched = true;
|
|
189
|
+
if (debug) console.log('[trickle/ws] socket.io tracing enabled');
|
|
190
|
+
}
|