trickle-observe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/auto-env.js +13 -0
- package/auto-esm.mjs +128 -0
- package/auto.js +3 -0
- package/dist/auto-codegen.d.ts +29 -0
- package/dist/auto-codegen.js +999 -0
- package/dist/auto-register.d.ts +16 -0
- package/dist/auto-register.js +99 -0
- package/dist/cache.d.ts +27 -0
- package/dist/cache.js +52 -0
- package/dist/env-detect.d.ts +5 -0
- package/dist/env-detect.js +35 -0
- package/dist/express.d.ts +44 -0
- package/dist/express.js +342 -0
- package/dist/fetch-observer.d.ts +24 -0
- package/dist/fetch-observer.js +217 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +172 -0
- package/dist/observe-register.d.ts +29 -0
- package/dist/observe-register.js +455 -0
- package/dist/observe.d.ts +44 -0
- package/dist/observe.js +109 -0
- package/dist/proxy-tracker.d.ts +15 -0
- package/dist/proxy-tracker.js +172 -0
- package/dist/register.d.ts +21 -0
- package/dist/register.js +105 -0
- package/dist/transport.d.ts +22 -0
- package/dist/transport.js +228 -0
- package/dist/type-hash.d.ts +5 -0
- package/dist/type-hash.js +60 -0
- package/dist/type-inference.d.ts +14 -0
- package/dist/type-inference.js +259 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +2 -0
- package/dist/wrap.d.ts +10 -0
- package/dist/wrap.js +247 -0
- package/observe-esm-hooks.mjs +367 -0
- package/observe-esm.mjs +40 -0
- package/observe.js +2 -0
- package/package.json +26 -0
- package/register.js +2 -0
- package/src/auto-codegen.ts +1058 -0
- package/src/auto-register.ts +102 -0
- package/src/cache.ts +53 -0
- package/src/env-detect.ts +22 -0
- package/src/express.ts +386 -0
- package/src/fetch-observer.ts +226 -0
- package/src/index.ts +199 -0
- package/src/observe-register.ts +453 -0
- package/src/observe.ts +127 -0
- package/src/proxy-tracker.ts +208 -0
- package/src/register.ts +110 -0
- package/src/transport.ts +207 -0
- package/src/type-hash.ts +71 -0
- package/src/type-inference.ts +285 -0
- package/src/types.ts +61 -0
- package/src/wrap.ts +289 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trickle/auto — Zero-config type generation.
|
|
3
|
+
*
|
|
4
|
+
* Add ONE LINE to your app and types appear automatically:
|
|
5
|
+
*
|
|
6
|
+
* require('trickle/auto');
|
|
7
|
+
*
|
|
8
|
+
* This module:
|
|
9
|
+
* 1. Forces local mode (no backend needed)
|
|
10
|
+
* 2. Activates the observe-register hooks (instruments all user functions)
|
|
11
|
+
* 3. Runs a background timer that generates .d.ts files from observations
|
|
12
|
+
* 4. On process exit, does a final type generation
|
|
13
|
+
*
|
|
14
|
+
* No CLI. No backend. No configuration. Just types.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Force local mode BEFORE importing observe-register (which calls configure)
|
|
18
|
+
process.env.TRICKLE_LOCAL = '1';
|
|
19
|
+
|
|
20
|
+
// Import the observe-register hooks (instruments all functions)
|
|
21
|
+
import './observe-register';
|
|
22
|
+
|
|
23
|
+
// Import the auto codegen
|
|
24
|
+
import { generateTypes, injectTypes, generateCoverageReport, generateTypeSummary } from './auto-codegen';
|
|
25
|
+
|
|
26
|
+
const debug = process.env.TRICKLE_DEBUG === '1' || process.env.TRICKLE_DEBUG === 'true';
|
|
27
|
+
let lastFunctionCount = 0;
|
|
28
|
+
let generationCount = 0;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run type generation and optionally log results.
|
|
32
|
+
*/
|
|
33
|
+
function runGeneration(isFinal: boolean): void {
|
|
34
|
+
try {
|
|
35
|
+
const count = generateTypes();
|
|
36
|
+
if (count === -1) return; // no change
|
|
37
|
+
|
|
38
|
+
if (count > 0) {
|
|
39
|
+
generationCount++;
|
|
40
|
+
if (debug || (count > lastFunctionCount)) {
|
|
41
|
+
const newTypes = count - lastFunctionCount;
|
|
42
|
+
if (newTypes > 0 && generationCount > 1) {
|
|
43
|
+
// Only log after first generation (avoid noise on startup)
|
|
44
|
+
console.log(`[trickle/auto] +${newTypes} type(s) generated (${count} total)`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
lastFunctionCount = count;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (isFinal && lastFunctionCount > 0) {
|
|
51
|
+
console.log(`[trickle/auto] ${lastFunctionCount} function type(s) written to .d.ts`);
|
|
52
|
+
// Inject JSDoc into source files if TRICKLE_INJECT=1
|
|
53
|
+
try {
|
|
54
|
+
const injected = injectTypes();
|
|
55
|
+
if (injected > 0) {
|
|
56
|
+
console.log(`[trickle/auto] ${injected} function(s) annotated with JSDoc in source`);
|
|
57
|
+
}
|
|
58
|
+
} catch { /* don't crash */ }
|
|
59
|
+
// Print coverage report if TRICKLE_COVERAGE=1
|
|
60
|
+
try {
|
|
61
|
+
const report = generateCoverageReport();
|
|
62
|
+
if (report) console.log(report);
|
|
63
|
+
} catch { /* don't crash */ }
|
|
64
|
+
// Print type summary if TRICKLE_SUMMARY=1
|
|
65
|
+
try {
|
|
66
|
+
const summary = generateTypeSummary();
|
|
67
|
+
if (summary) console.log(summary);
|
|
68
|
+
} catch { /* don't crash */ }
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Never crash user's app
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Background timer — regenerate types every 3 seconds
|
|
76
|
+
const timer = setInterval(() => runGeneration(false), 3000);
|
|
77
|
+
|
|
78
|
+
// Don't keep the process alive just for type generation
|
|
79
|
+
if (timer && typeof timer === 'object' && 'unref' in timer) {
|
|
80
|
+
(timer as any).unref();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Also do a first check after 1 second
|
|
84
|
+
const initialTimer = setTimeout(() => runGeneration(false), 1000);
|
|
85
|
+
if (initialTimer && typeof initialTimer === 'object' && 'unref' in initialTimer) {
|
|
86
|
+
(initialTimer as any).unref();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Final generation on exit
|
|
90
|
+
if (typeof process !== 'undefined' && process.on) {
|
|
91
|
+
process.on('beforeExit', () => {
|
|
92
|
+
runGeneration(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// On SIGTERM/SIGINT, do final generation
|
|
96
|
+
const exitHandler = () => {
|
|
97
|
+
clearInterval(timer);
|
|
98
|
+
runGeneration(true);
|
|
99
|
+
};
|
|
100
|
+
process.on('SIGTERM', exitHandler);
|
|
101
|
+
process.on('SIGINT', exitHandler);
|
|
102
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache to avoid sending redundant type data to the backend.
|
|
3
|
+
* Keyed by function identity (name + module), stores the last type hash sent.
|
|
4
|
+
* Re-sends if the hash changes or if the entry becomes stale.
|
|
5
|
+
*/
|
|
6
|
+
export class TypeCache {
|
|
7
|
+
private cache: Map<string, { hash: string; lastSentAt: number }>;
|
|
8
|
+
private maxStalenessMs: number;
|
|
9
|
+
|
|
10
|
+
constructor(maxStalenessMs: number = 5 * 60 * 1000) {
|
|
11
|
+
this.cache = new Map();
|
|
12
|
+
this.maxStalenessMs = maxStalenessMs;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns true if this type observation should be sent to the backend.
|
|
17
|
+
* Returns false if we recently sent the same hash for this function.
|
|
18
|
+
*/
|
|
19
|
+
shouldSend(functionKey: string, hash: string): boolean {
|
|
20
|
+
const entry = this.cache.get(functionKey);
|
|
21
|
+
if (!entry) return true;
|
|
22
|
+
|
|
23
|
+
// Different type shape — always send
|
|
24
|
+
if (entry.hash !== hash) return true;
|
|
25
|
+
|
|
26
|
+
// Same shape but stale — re-send to confirm it's still alive
|
|
27
|
+
const age = Date.now() - entry.lastSentAt;
|
|
28
|
+
if (age > this.maxStalenessMs) return true;
|
|
29
|
+
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Mark that we just sent data for this function with this hash.
|
|
35
|
+
*/
|
|
36
|
+
markSent(functionKey: string, hash: string): void {
|
|
37
|
+
this.cache.set(functionKey, { hash, lastSentAt: Date.now() });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Clear the cache (useful for testing or reconfiguration).
|
|
42
|
+
*/
|
|
43
|
+
clear(): void {
|
|
44
|
+
this.cache.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the number of cached entries.
|
|
49
|
+
*/
|
|
50
|
+
get size(): number {
|
|
51
|
+
return this.cache.size;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect the runtime environment from environment variables.
|
|
3
|
+
* Returns a human-readable string identifying where this code is running.
|
|
4
|
+
*/
|
|
5
|
+
export function detectEnvironment(): string {
|
|
6
|
+
const env = typeof process !== 'undefined' && process.env ? process.env : {};
|
|
7
|
+
|
|
8
|
+
if (env.AWS_LAMBDA_FUNCTION_NAME) return 'lambda';
|
|
9
|
+
if (env.VERCEL) return 'vercel';
|
|
10
|
+
if (env.RAILWAY_ENVIRONMENT) return 'railway';
|
|
11
|
+
if (env.K_SERVICE) return 'cloud-run';
|
|
12
|
+
if (env.AZURE_FUNCTIONS_ENVIRONMENT) return 'azure-functions';
|
|
13
|
+
if (env.GOOGLE_CLOUD_PROJECT) return 'gcp';
|
|
14
|
+
if (env.ECS_CONTAINER_METADATA_URI) return 'ecs';
|
|
15
|
+
if (env.FLY_APP_NAME) return 'fly';
|
|
16
|
+
if (env.RENDER_SERVICE_ID) return 'render';
|
|
17
|
+
if (env.HEROKU_APP_NAME || env.DYNO) return 'heroku';
|
|
18
|
+
if (env.CODESPACE_NAME) return 'codespace';
|
|
19
|
+
if (env.GITPOD_WORKSPACE_ID) return 'gitpod';
|
|
20
|
+
|
|
21
|
+
return 'node';
|
|
22
|
+
}
|
package/src/express.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { TypeNode, IngestPayload, WrapOptions } from './types';
|
|
2
|
+
import { inferType } from './type-inference';
|
|
3
|
+
import { hashType } from './type-hash';
|
|
4
|
+
import { TypeCache } from './cache';
|
|
5
|
+
import { enqueue } from './transport';
|
|
6
|
+
import { detectEnvironment } from './env-detect';
|
|
7
|
+
|
|
8
|
+
const expressCache = new TypeCache();
|
|
9
|
+
|
|
10
|
+
/** Options shared across Express instrumentation. */
|
|
11
|
+
interface ExpressInstrumentOpts {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
environment: string;
|
|
14
|
+
sampleRate: number;
|
|
15
|
+
maxDepth: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract the interesting parts of an Express request as a plain object
|
|
20
|
+
* suitable for type inference. We deliberately avoid the full req object
|
|
21
|
+
* because it is enormous and contains circular references.
|
|
22
|
+
*/
|
|
23
|
+
function extractRequestInput(req: any): Record<string, unknown> {
|
|
24
|
+
const input: Record<string, unknown> = {};
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
if (req.body !== undefined && req.body !== null && Object.keys(req.body).length > 0) {
|
|
28
|
+
input.body = req.body;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// body might not be readable
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
if (req.params && Object.keys(req.params).length > 0) {
|
|
36
|
+
input.params = req.params;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// ignore
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
if (req.query && Object.keys(req.query).length > 0) {
|
|
44
|
+
input.query = req.query;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return input;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sanitize a sample value for safe serialization (local copy to avoid circular import).
|
|
55
|
+
*/
|
|
56
|
+
function sanitizeSample(value: unknown, depth: number = 3): unknown {
|
|
57
|
+
if (depth <= 0) return '[truncated]';
|
|
58
|
+
if (value === null || value === undefined) return value;
|
|
59
|
+
|
|
60
|
+
const t = typeof value;
|
|
61
|
+
if (t === 'string') {
|
|
62
|
+
const s = value as string;
|
|
63
|
+
return s.length > 200 ? s.substring(0, 200) + '...' : s;
|
|
64
|
+
}
|
|
65
|
+
if (t === 'number' || t === 'boolean') return value;
|
|
66
|
+
if (t === 'bigint') return String(value);
|
|
67
|
+
if (t === 'symbol') return String(value);
|
|
68
|
+
if (t === 'function') return `[Function: ${(value as Function).name || 'anonymous'}]`;
|
|
69
|
+
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
return value.slice(0, 5).map(item => sanitizeSample(item, depth - 1));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (t === 'object') {
|
|
75
|
+
if (value instanceof Date) return value.toISOString();
|
|
76
|
+
if (value instanceof RegExp) return String(value);
|
|
77
|
+
if (value instanceof Error) return { error: value.message };
|
|
78
|
+
if (value instanceof Map) return `[Map: ${value.size} entries]`;
|
|
79
|
+
if (value instanceof Set) return `[Set: ${value.size} items]`;
|
|
80
|
+
|
|
81
|
+
const obj = value as Record<string, unknown>;
|
|
82
|
+
const result: Record<string, unknown> = {};
|
|
83
|
+
const keys = Object.keys(obj).slice(0, 20);
|
|
84
|
+
for (const key of keys) {
|
|
85
|
+
try {
|
|
86
|
+
result[key] = sanitizeSample(obj[key], depth - 1);
|
|
87
|
+
} catch {
|
|
88
|
+
result[key] = '[unreadable]';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return String(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Emit a type payload for a single Express route invocation.
|
|
99
|
+
*/
|
|
100
|
+
function emitExpressPayload(
|
|
101
|
+
functionName: string,
|
|
102
|
+
environment: string,
|
|
103
|
+
maxDepth: number,
|
|
104
|
+
input: Record<string, unknown>,
|
|
105
|
+
output: unknown,
|
|
106
|
+
error?: unknown,
|
|
107
|
+
): void {
|
|
108
|
+
try {
|
|
109
|
+
const functionKey = `express::${functionName}`;
|
|
110
|
+
const argsType = inferType(input, maxDepth);
|
|
111
|
+
const returnType = error ? ({ kind: 'unknown' } as TypeNode) : inferType(output, maxDepth);
|
|
112
|
+
const hash = hashType(argsType, returnType);
|
|
113
|
+
|
|
114
|
+
// For errors, always send. For success, use cache.
|
|
115
|
+
if (!error && !expressCache.shouldSend(functionKey, hash)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!error) {
|
|
120
|
+
expressCache.markSent(functionKey, hash);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const payload: IngestPayload = {
|
|
124
|
+
functionName,
|
|
125
|
+
module: 'express',
|
|
126
|
+
language: 'js',
|
|
127
|
+
environment,
|
|
128
|
+
typeHash: hash,
|
|
129
|
+
argsType,
|
|
130
|
+
returnType,
|
|
131
|
+
sampleInput: sanitizeSample(input),
|
|
132
|
+
sampleOutput: error ? undefined : sanitizeSample(output),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (error) {
|
|
136
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
137
|
+
payload.error = {
|
|
138
|
+
type: err.constructor?.name || 'Error',
|
|
139
|
+
message: err.message,
|
|
140
|
+
stackTrace: err.stack,
|
|
141
|
+
argsSnapshot: sanitizeSample(input),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
enqueue(payload);
|
|
146
|
+
} catch {
|
|
147
|
+
// Never crash the user's app
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Wrap a single Express route handler so that it captures request input
|
|
153
|
+
* (body, params, query) and response output (json/send payloads) as type data.
|
|
154
|
+
*/
|
|
155
|
+
function wrapExpressHandler(
|
|
156
|
+
handler: Function,
|
|
157
|
+
routeName: string,
|
|
158
|
+
opts: ExpressInstrumentOpts,
|
|
159
|
+
): Function {
|
|
160
|
+
const wrapped = function (this: any, req: any, res: any, next: any) {
|
|
161
|
+
// Sample rate check
|
|
162
|
+
if (opts.sampleRate < 1 && Math.random() > opts.sampleRate) {
|
|
163
|
+
return handler.call(this, req, res, next);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const input = extractRequestInput(req);
|
|
167
|
+
let captured = false;
|
|
168
|
+
|
|
169
|
+
// Intercept res.json()
|
|
170
|
+
const originalJson = res.json;
|
|
171
|
+
if (typeof originalJson === 'function') {
|
|
172
|
+
res.json = function (data: any) {
|
|
173
|
+
if (!captured) {
|
|
174
|
+
captured = true;
|
|
175
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, data);
|
|
176
|
+
}
|
|
177
|
+
res.json = originalJson; // restore
|
|
178
|
+
return originalJson.call(res, data);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Intercept res.send()
|
|
183
|
+
const originalSend = res.send;
|
|
184
|
+
if (typeof originalSend === 'function') {
|
|
185
|
+
res.send = function (data: any) {
|
|
186
|
+
if (!captured) {
|
|
187
|
+
captured = true;
|
|
188
|
+
// Only capture non-string data as typed output; strings are usually HTML
|
|
189
|
+
const output = typeof data === 'string' ? { __html: true } : data;
|
|
190
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, output);
|
|
191
|
+
}
|
|
192
|
+
res.send = originalSend; // restore
|
|
193
|
+
return originalSend.call(res, data);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Wrap next to capture errors passed via next(err)
|
|
198
|
+
const wrappedNext = function (err?: any) {
|
|
199
|
+
if (err && !captured) {
|
|
200
|
+
captured = true;
|
|
201
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
|
|
202
|
+
}
|
|
203
|
+
if (typeof next === 'function') {
|
|
204
|
+
return next(err);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const result = handler.call(this, req, res, wrappedNext);
|
|
210
|
+
|
|
211
|
+
// Handle async handlers that return a promise
|
|
212
|
+
if (result && typeof result === 'object' && typeof result.then === 'function') {
|
|
213
|
+
return result.catch((err: unknown) => {
|
|
214
|
+
if (!captured) {
|
|
215
|
+
captured = true;
|
|
216
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
|
|
217
|
+
}
|
|
218
|
+
// Re-throw so Express error handling picks it up
|
|
219
|
+
throw err;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return result;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (!captured) {
|
|
226
|
+
captured = true;
|
|
227
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, input, undefined, err);
|
|
228
|
+
}
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Preserve function metadata
|
|
234
|
+
Object.defineProperty(wrapped, 'name', { value: handler.name || routeName, configurable: true });
|
|
235
|
+
Object.defineProperty(wrapped, 'length', { value: handler.length, configurable: true });
|
|
236
|
+
|
|
237
|
+
return wrapped;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Instrument an Express application by monkey-patching route registration methods.
|
|
242
|
+
*
|
|
243
|
+
* Must be called BEFORE routes are defined:
|
|
244
|
+
*
|
|
245
|
+
* import express from 'express';
|
|
246
|
+
* import { instrumentExpress } from 'trickle';
|
|
247
|
+
*
|
|
248
|
+
* const app = express();
|
|
249
|
+
* instrumentExpress(app);
|
|
250
|
+
*
|
|
251
|
+
* app.get('/api/users', (req, res) => { ... });
|
|
252
|
+
*
|
|
253
|
+
* Each registered handler is wrapped to capture:
|
|
254
|
+
* - Input: `{ body, params, query }` from the request
|
|
255
|
+
* - Output: the data passed to `res.json()` or `res.send()`
|
|
256
|
+
* - Errors: exceptions or `next(err)` calls
|
|
257
|
+
*/
|
|
258
|
+
export function instrumentExpress(
|
|
259
|
+
app: any,
|
|
260
|
+
userOpts?: { enabled?: boolean; environment?: string; sampleRate?: number; maxDepth?: number },
|
|
261
|
+
): void {
|
|
262
|
+
const opts: ExpressInstrumentOpts = {
|
|
263
|
+
enabled: userOpts?.enabled !== false,
|
|
264
|
+
environment: userOpts?.environment || detectEnvironment(),
|
|
265
|
+
sampleRate: userOpts?.sampleRate ?? 1,
|
|
266
|
+
maxDepth: userOpts?.maxDepth ?? 5,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
if (!opts.enabled) return;
|
|
270
|
+
|
|
271
|
+
const methods = ['get', 'post', 'put', 'delete', 'patch', 'all'] as const;
|
|
272
|
+
|
|
273
|
+
for (const method of methods) {
|
|
274
|
+
const original = app[method];
|
|
275
|
+
if (typeof original !== 'function') continue;
|
|
276
|
+
|
|
277
|
+
app[method] = function (this: any, path: string | RegExp, ...handlers: any[]) {
|
|
278
|
+
// Express allows non-string first args (RegExp, array of paths, etc.)
|
|
279
|
+
// We only label with a string path; otherwise fall back to the method name.
|
|
280
|
+
const pathStr = typeof path === 'string' ? path : String(path);
|
|
281
|
+
const routeName = `${method.toUpperCase()} ${pathStr}`;
|
|
282
|
+
|
|
283
|
+
const wrapped = handlers.map((handler: any) => {
|
|
284
|
+
if (typeof handler !== 'function') return handler;
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
return wrapExpressHandler(handler, routeName, opts);
|
|
288
|
+
} catch {
|
|
289
|
+
// If wrapping fails for any reason, return the original handler
|
|
290
|
+
return handler;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return original.call(this, path, ...wrapped);
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Express middleware that intercepts responses to capture type information.
|
|
301
|
+
*
|
|
302
|
+
* Use this when you prefer middleware over monkey-patching:
|
|
303
|
+
*
|
|
304
|
+
* import express from 'express';
|
|
305
|
+
* import { trickleMiddleware } from 'trickle';
|
|
306
|
+
*
|
|
307
|
+
* const app = express();
|
|
308
|
+
* app.use(trickleMiddleware());
|
|
309
|
+
*
|
|
310
|
+
* The middleware captures the route `METHOD /path` once the response is sent,
|
|
311
|
+
* by intercepting `res.json()` and `res.send()`.
|
|
312
|
+
*/
|
|
313
|
+
export function trickleMiddleware(
|
|
314
|
+
userOpts?: { enabled?: boolean; environment?: string; sampleRate?: number; maxDepth?: number },
|
|
315
|
+
): (req: any, res: any, next: (...args: any[]) => void) => void {
|
|
316
|
+
const opts: ExpressInstrumentOpts = {
|
|
317
|
+
enabled: userOpts?.enabled !== false,
|
|
318
|
+
environment: userOpts?.environment || detectEnvironment(),
|
|
319
|
+
sampleRate: userOpts?.sampleRate ?? 1,
|
|
320
|
+
maxDepth: userOpts?.maxDepth ?? 5,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
return function trickleMiddlewareHandler(req: any, res: any, next: (...args: any[]) => void): void {
|
|
324
|
+
if (!opts.enabled) {
|
|
325
|
+
next();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Sample rate check
|
|
330
|
+
if (opts.sampleRate < 1 && Math.random() > opts.sampleRate) {
|
|
331
|
+
next();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let captured = false;
|
|
336
|
+
|
|
337
|
+
// We derive the route name lazily once the response is being sent,
|
|
338
|
+
// because req.route is only populated after the handler matches.
|
|
339
|
+
function getRouteName(): string {
|
|
340
|
+
try {
|
|
341
|
+
if (req.route && req.route.path) {
|
|
342
|
+
return `${req.method} ${req.baseUrl || ''}${req.route.path}`;
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
// ignore
|
|
346
|
+
}
|
|
347
|
+
return `${req.method || 'UNKNOWN'} ${req.originalUrl || req.url || '/'}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const input = extractRequestInput(req);
|
|
351
|
+
|
|
352
|
+
// Intercept res.json()
|
|
353
|
+
const originalJson = res.json;
|
|
354
|
+
if (typeof originalJson === 'function') {
|
|
355
|
+
res.json = function (data: any) {
|
|
356
|
+
if (!captured) {
|
|
357
|
+
captured = true;
|
|
358
|
+
const routeName = getRouteName();
|
|
359
|
+
// Re-extract input here because body parsers may have run since middleware was entered
|
|
360
|
+
const latestInput = extractRequestInput(req);
|
|
361
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, data);
|
|
362
|
+
}
|
|
363
|
+
res.json = originalJson;
|
|
364
|
+
return originalJson.call(res, data);
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Intercept res.send()
|
|
369
|
+
const originalSend = res.send;
|
|
370
|
+
if (typeof originalSend === 'function') {
|
|
371
|
+
res.send = function (data: any) {
|
|
372
|
+
if (!captured) {
|
|
373
|
+
captured = true;
|
|
374
|
+
const routeName = getRouteName();
|
|
375
|
+
const latestInput = extractRequestInput(req);
|
|
376
|
+
const output = typeof data === 'string' ? { __html: true } : data;
|
|
377
|
+
emitExpressPayload(routeName, opts.environment, opts.maxDepth, latestInput, output);
|
|
378
|
+
}
|
|
379
|
+
res.send = originalSend;
|
|
380
|
+
return originalSend.call(res, data);
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
next();
|
|
385
|
+
};
|
|
386
|
+
}
|