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.
Files changed (57) hide show
  1. package/auto-env.js +13 -0
  2. package/auto-esm.mjs +128 -0
  3. package/auto.js +3 -0
  4. package/dist/auto-codegen.d.ts +29 -0
  5. package/dist/auto-codegen.js +999 -0
  6. package/dist/auto-register.d.ts +16 -0
  7. package/dist/auto-register.js +99 -0
  8. package/dist/cache.d.ts +27 -0
  9. package/dist/cache.js +52 -0
  10. package/dist/env-detect.d.ts +5 -0
  11. package/dist/env-detect.js +35 -0
  12. package/dist/express.d.ts +44 -0
  13. package/dist/express.js +342 -0
  14. package/dist/fetch-observer.d.ts +24 -0
  15. package/dist/fetch-observer.js +217 -0
  16. package/dist/index.d.ts +64 -0
  17. package/dist/index.js +172 -0
  18. package/dist/observe-register.d.ts +29 -0
  19. package/dist/observe-register.js +455 -0
  20. package/dist/observe.d.ts +44 -0
  21. package/dist/observe.js +109 -0
  22. package/dist/proxy-tracker.d.ts +15 -0
  23. package/dist/proxy-tracker.js +172 -0
  24. package/dist/register.d.ts +21 -0
  25. package/dist/register.js +105 -0
  26. package/dist/transport.d.ts +22 -0
  27. package/dist/transport.js +228 -0
  28. package/dist/type-hash.d.ts +5 -0
  29. package/dist/type-hash.js +60 -0
  30. package/dist/type-inference.d.ts +14 -0
  31. package/dist/type-inference.js +259 -0
  32. package/dist/types.d.ts +78 -0
  33. package/dist/types.js +2 -0
  34. package/dist/wrap.d.ts +10 -0
  35. package/dist/wrap.js +247 -0
  36. package/observe-esm-hooks.mjs +367 -0
  37. package/observe-esm.mjs +40 -0
  38. package/observe.js +2 -0
  39. package/package.json +26 -0
  40. package/register.js +2 -0
  41. package/src/auto-codegen.ts +1058 -0
  42. package/src/auto-register.ts +102 -0
  43. package/src/cache.ts +53 -0
  44. package/src/env-detect.ts +22 -0
  45. package/src/express.ts +386 -0
  46. package/src/fetch-observer.ts +226 -0
  47. package/src/index.ts +199 -0
  48. package/src/observe-register.ts +453 -0
  49. package/src/observe.ts +127 -0
  50. package/src/proxy-tracker.ts +208 -0
  51. package/src/register.ts +110 -0
  52. package/src/transport.ts +207 -0
  53. package/src/type-hash.ts +71 -0
  54. package/src/type-inference.ts +285 -0
  55. package/src/types.ts +61 -0
  56. package/src/wrap.ts +289 -0
  57. package/tsconfig.json +8 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Fetch observer — patches global.fetch to automatically capture
3
+ * request/response types from HTTP calls made by user code.
4
+ *
5
+ * When your app does:
6
+ * const data = await fetch('https://api.example.com/users').then(r => r.json());
7
+ *
8
+ * Trickle captures:
9
+ * - Function name: "GET /users" (method + path)
10
+ * - Module: "api.example.com" (hostname)
11
+ * - Input type: request body (for POST/PUT/PATCH)
12
+ * - Return type: inferred from JSON response
13
+ * - Sample data: actual response payload
14
+ *
15
+ * The observer only intercepts when .json() is called, so:
16
+ * - Non-JSON responses (HTML, binary) are ignored
17
+ * - The original response is never modified
18
+ * - No extra network requests are made
19
+ */
20
+
21
+ import { TypeNode, IngestPayload } from './types';
22
+ import { inferType } from './type-inference';
23
+ import { hashType } from './type-hash';
24
+ import { enqueue } from './transport';
25
+
26
+ // Track which type hashes we've already sent to avoid duplicates
27
+ const sentHashes = new Set<string>();
28
+
29
+ /**
30
+ * Patch global.fetch to observe JSON responses.
31
+ * Safe to call multiple times (idempotent).
32
+ */
33
+ export function patchFetch(environment: string, debugMode: boolean): void {
34
+ if (typeof globalThis.fetch !== 'function') return;
35
+
36
+ const originalFetch = globalThis.fetch;
37
+
38
+ // Guard against double-patching
39
+ if ((originalFetch as any).__trickle_patched) return;
40
+
41
+ globalThis.fetch = async function trickleObservedFetch(
42
+ input: any,
43
+ init?: any,
44
+ ): Promise<Response> {
45
+ // Extract URL and method before the request
46
+ let url: string;
47
+ let method: string;
48
+
49
+ if (typeof input === 'string') {
50
+ url = input;
51
+ method = init?.method?.toUpperCase() || 'GET';
52
+ } else if (input instanceof URL) {
53
+ url = input.href;
54
+ method = init?.method?.toUpperCase() || 'GET';
55
+ } else if (typeof input === 'object' && input !== null && 'url' in input) {
56
+ // Request object
57
+ url = (input as any).url;
58
+ method = (init?.method || (input as any).method || 'GET').toUpperCase();
59
+ } else {
60
+ url = String(input);
61
+ method = init?.method?.toUpperCase() || 'GET';
62
+ }
63
+
64
+ // Skip trickle's own backend calls
65
+ if (url.includes('/api/ingest') || url.includes('/api/functions') || url.includes('/api/health')) {
66
+ return originalFetch.call(globalThis, input, init);
67
+ }
68
+
69
+ // Make the actual request
70
+ const response = await originalFetch.call(globalThis, input, init);
71
+
72
+ // Only observe JSON responses
73
+ const contentType = response.headers.get('content-type') || '';
74
+ if (!contentType.includes('json')) {
75
+ return response;
76
+ }
77
+
78
+ // Clone and intercept: read the clone's JSON in background
79
+ try {
80
+ const cloned = response.clone();
81
+ cloned.json().then((data: any) => {
82
+ try {
83
+ captureHttpResponse(method, url, init?.body, data, environment, debugMode);
84
+ } catch {
85
+ // Never interfere
86
+ }
87
+ }).catch(() => {});
88
+ } catch {
89
+ // Clone/read failed — ignore
90
+ }
91
+
92
+ return response;
93
+ } as typeof fetch;
94
+
95
+ // Mark as patched
96
+ (globalThis.fetch as any).__trickle_patched = true;
97
+ }
98
+
99
+ /**
100
+ * Parse a URL into a clean function name and module name.
101
+ * "https://api.example.com/v1/users?limit=10"
102
+ * → functionName: "GET /v1/users", module: "api.example.com"
103
+ */
104
+ function parseUrl(method: string, rawUrl: string): { functionName: string; module: string } {
105
+ try {
106
+ const parsed = new URL(rawUrl);
107
+ const pathname = parsed.pathname || '/';
108
+ return {
109
+ functionName: `${method} ${pathname}`,
110
+ module: parsed.hostname || 'http',
111
+ };
112
+ } catch {
113
+ // Relative URL or invalid — use as-is
114
+ return {
115
+ functionName: `${method} ${rawUrl}`,
116
+ module: 'http',
117
+ };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Capture the HTTP response type and enqueue it to the backend.
123
+ */
124
+ function captureHttpResponse(
125
+ method: string,
126
+ url: string,
127
+ requestBody: any,
128
+ responseData: unknown,
129
+ environment: string,
130
+ debugMode: boolean,
131
+ ): void {
132
+ const { functionName, module: moduleName } = parseUrl(method, url);
133
+
134
+ // Infer types
135
+ const returnType = inferType(responseData, 5);
136
+
137
+ // Infer request body type (for POST/PUT/PATCH)
138
+ let argsType: TypeNode;
139
+ if (requestBody && typeof requestBody === 'string') {
140
+ try {
141
+ const parsed = JSON.parse(requestBody);
142
+ argsType = { kind: 'tuple', elements: [inferType(parsed, 5)] };
143
+ } catch {
144
+ argsType = { kind: 'tuple', elements: [] };
145
+ }
146
+ } else {
147
+ argsType = { kind: 'tuple', elements: [] };
148
+ }
149
+
150
+ const hash = hashType(argsType, returnType);
151
+
152
+ // Dedup — only send each unique type shape once
153
+ const key = `${functionName}::${hash}`;
154
+ if (sentHashes.has(key)) return;
155
+ sentHashes.add(key);
156
+
157
+ // Build sample input from request body
158
+ let sampleInput: unknown = undefined;
159
+ if (requestBody && typeof requestBody === 'string') {
160
+ try {
161
+ sampleInput = JSON.parse(requestBody);
162
+ } catch {
163
+ sampleInput = undefined;
164
+ }
165
+ }
166
+
167
+ const payload: IngestPayload = {
168
+ functionName,
169
+ module: moduleName,
170
+ language: 'js',
171
+ environment,
172
+ typeHash: hash,
173
+ argsType,
174
+ returnType,
175
+ sampleInput: sampleInput ? [sampleInput] : undefined,
176
+ sampleOutput: sanitizeSample(responseData),
177
+ };
178
+
179
+ enqueue(payload);
180
+
181
+ if (debugMode) {
182
+ console.log(`[trickle/fetch] Captured ${functionName} → ${describeType(returnType)}`);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Brief description of a type for debug logging.
188
+ */
189
+ function describeType(type: TypeNode): string {
190
+ if (type.kind === 'object') {
191
+ const props = Object.keys(type.properties || {});
192
+ if (props.length <= 4) return `{ ${props.join(', ')} }`;
193
+ return `{ ${props.slice(0, 3).join(', ')}, ... } (${props.length} props)`;
194
+ }
195
+ if (type.kind === 'array') return `${describeType(type.element)}[]`;
196
+ if (type.kind === 'primitive') return type.name;
197
+ return type.kind;
198
+ }
199
+
200
+ /**
201
+ * Sanitize sample data for storage (truncate large values).
202
+ */
203
+ function sanitizeSample(value: unknown, depth: number = 3): unknown {
204
+ if (depth <= 0) return '[truncated]';
205
+ if (value === null || value === undefined) return value;
206
+ const t = typeof value;
207
+ if (t === 'string') {
208
+ const s = value as string;
209
+ return s.length > 200 ? s.substring(0, 200) + '...' : s;
210
+ }
211
+ if (t === 'number' || t === 'boolean') return value;
212
+ if (t === 'function') return '[Function]';
213
+ if (Array.isArray(value)) {
214
+ return value.slice(0, 5).map(item => sanitizeSample(item, depth - 1));
215
+ }
216
+ if (t === 'object') {
217
+ const obj = value as Record<string, unknown>;
218
+ const result: Record<string, unknown> = {};
219
+ const keys = Object.keys(obj).slice(0, 20);
220
+ for (const key of keys) {
221
+ try { result[key] = sanitizeSample(obj[key], depth - 1); } catch { result[key] = '[unreadable]'; }
222
+ }
223
+ return result;
224
+ }
225
+ return String(value);
226
+ }
package/src/index.ts ADDED
@@ -0,0 +1,199 @@
1
+ import { configure as configureTransport, flush } from './transport';
2
+ import { wrapFunction } from './wrap';
3
+ import { detectEnvironment } from './env-detect';
4
+ import { GlobalOpts, TrickleOpts, WrapOptions } from './types';
5
+ import { instrumentExpress, trickleMiddleware } from './express';
6
+
7
+ let globalOpts: GlobalOpts = {
8
+ backendUrl: 'http://localhost:4888',
9
+ batchIntervalMs: 2000,
10
+ enabled: true,
11
+ environment: undefined,
12
+ };
13
+
14
+ /**
15
+ * Configure trickle global options.
16
+ * Call this before wrapping any functions if you need non-default settings.
17
+ */
18
+ export function configure(opts: Partial<GlobalOpts>): void {
19
+ Object.assign(globalOpts, opts);
20
+ configureTransport(globalOpts as GlobalOpts);
21
+ }
22
+
23
+ /**
24
+ * Wrap a function to capture runtime type information.
25
+ *
26
+ * Usage:
27
+ * const wrapped = trickle(myFunction);
28
+ * const wrapped = trickle(myFunction, { name: 'myFn', module: 'api' });
29
+ * const wrapped = trickle('myFunction', myFunction);
30
+ * const wrapped = trickle('myFunction', myFunction, { module: 'api' });
31
+ */
32
+ export function trickle<T extends (...args: any[]) => any>(fn: T, opts?: TrickleOpts): T;
33
+ export function trickle<T extends (...args: any[]) => any>(name: string, fn: T, opts?: TrickleOpts): T;
34
+ export function trickle(...args: any[]): any {
35
+ let fn: (...args: any[]) => any;
36
+ let opts: TrickleOpts = {};
37
+ let explicitName: string | undefined;
38
+
39
+ if (typeof args[0] === 'string') {
40
+ explicitName = args[0];
41
+ fn = args[1];
42
+ opts = args[2] || {};
43
+ } else {
44
+ fn = args[0];
45
+ opts = args[1] || {};
46
+ }
47
+
48
+ if (typeof fn !== 'function') {
49
+ throw new TypeError('trickle: expected a function argument');
50
+ }
51
+
52
+ const functionName = explicitName || opts.name || fn.name || 'anonymous';
53
+ const module = opts.module || inferModule();
54
+ const environment = globalOpts.environment || detectEnvironment();
55
+
56
+ const wrapOpts: WrapOptions = {
57
+ functionName,
58
+ module,
59
+ trackArgs: opts.trackArgs !== false,
60
+ trackReturn: opts.trackReturn !== false,
61
+ sampleRate: opts.sampleRate ?? 1,
62
+ maxDepth: opts.maxDepth ?? 5,
63
+ environment,
64
+ enabled: globalOpts.enabled,
65
+ };
66
+
67
+ return wrapFunction(fn, wrapOpts);
68
+ }
69
+
70
+ /**
71
+ * Wrap a Lambda handler function.
72
+ * Same as trickle() but automatically flushes the transport after each invocation,
73
+ * since Lambda may freeze the process between invocations.
74
+ */
75
+ export function trickleHandler<T extends (...args: any[]) => any>(handler: T, opts?: TrickleOpts): T {
76
+ const wrapped = trickle(handler, {
77
+ ...opts,
78
+ name: opts?.name || handler.name || 'handler',
79
+ });
80
+
81
+ const flushing = function (this: any, ...args: any[]): any {
82
+ const result = wrapped.apply(this, args);
83
+
84
+ // If the handler returns a promise, flush after it resolves
85
+ if (result !== null && result !== undefined && typeof result === 'object' && typeof result.then === 'function') {
86
+ return result.then(
87
+ async (resolved: unknown) => {
88
+ await flush().catch(() => {});
89
+ return resolved;
90
+ },
91
+ async (err: unknown) => {
92
+ await flush().catch(() => {});
93
+ throw err;
94
+ },
95
+ );
96
+ }
97
+
98
+ // Synchronous handler — flush and return
99
+ flush().catch(() => {});
100
+ return result;
101
+ };
102
+
103
+ Object.defineProperty(flushing, 'name', { value: handler.name || 'handler', configurable: true });
104
+ Object.defineProperty(flushing, 'length', { value: handler.length, configurable: true });
105
+
106
+ return flushing as unknown as T;
107
+ }
108
+
109
+ /**
110
+ * Instrument an Express app by monkey-patching route methods to capture types.
111
+ *
112
+ * Must be called BEFORE defining routes:
113
+ *
114
+ * const app = express();
115
+ * trickleExpress(app);
116
+ * app.get('/api/users', (req, res) => { ... });
117
+ *
118
+ * Each handler is wrapped to capture:
119
+ * - Input: `{ body, params, query }` from the request
120
+ * - Output: data passed to `res.json()` or `res.send()`
121
+ * - Errors: thrown exceptions or `next(err)` calls
122
+ */
123
+ export function trickleExpress(
124
+ app: any,
125
+ opts?: { enabled?: boolean; environment?: string; sampleRate?: number; maxDepth?: number },
126
+ ): void {
127
+ instrumentExpress(app, {
128
+ enabled: opts?.enabled ?? globalOpts.enabled,
129
+ environment: opts?.environment ?? globalOpts.environment ?? detectEnvironment(),
130
+ sampleRate: opts?.sampleRate ?? 1,
131
+ maxDepth: opts?.maxDepth ?? 5,
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Auto-instrument a framework app. Currently supports Express.
137
+ *
138
+ * Usage:
139
+ * import { instrument } from 'trickle';
140
+ * const app = express();
141
+ * instrument(app);
142
+ *
143
+ * Detects Express by checking for `app.listen` and `app.get` (function) on the object.
144
+ */
145
+ export function instrument(
146
+ app: any,
147
+ opts?: { enabled?: boolean; environment?: string; sampleRate?: number; maxDepth?: number },
148
+ ): void {
149
+ // Detect Express-like app
150
+ if (app && typeof app.listen === 'function' && typeof app.get === 'function' && typeof app.use === 'function') {
151
+ trickleExpress(app, opts);
152
+ return;
153
+ }
154
+
155
+ // Future: detect other frameworks here (Koa, Fastify, etc.)
156
+ if (typeof console !== 'undefined' && console.warn) {
157
+ console.warn('[trickle] instrument(): could not detect a supported framework on the provided object');
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Attempt to infer the module name from the call stack.
163
+ * Falls back to 'unknown' if we can't determine it.
164
+ */
165
+ function inferModule(): string {
166
+ try {
167
+ const stack = new Error().stack;
168
+ if (!stack) return 'unknown';
169
+
170
+ const lines = stack.split('\n');
171
+ // Skip first 3 lines: "Error", trickle internals
172
+ for (let i = 3; i < lines.length; i++) {
173
+ const line = lines[i].trim();
174
+ // Look for a file path
175
+ const match = line.match(/(?:at\s+)?(?:.*?\s+\()?(.+?)(?::\d+:\d+)?\)?$/);
176
+ if (match) {
177
+ let filePath = match[1];
178
+ // Strip node_modules paths
179
+ if (filePath.includes('node_modules')) continue;
180
+ // Extract just the filename or relative path
181
+ const parts = filePath.split('/');
182
+ const filename = parts[parts.length - 1];
183
+ if (filename && !filename.startsWith('<')) {
184
+ return filename.replace(/\.[jt]sx?$/, '');
185
+ }
186
+ }
187
+ }
188
+ } catch {
189
+ // Don't crash on stack inspection failure
190
+ }
191
+ return 'unknown';
192
+ }
193
+
194
+ // Re-export public types
195
+ export type { TypeNode, GlobalOpts, TrickleOpts, IngestPayload } from './types';
196
+ export { flush } from './transport';
197
+ export { instrumentExpress, trickleMiddleware } from './express';
198
+ export { observe, observeFn } from './observe';
199
+ export type { ObserveOpts } from './observe';