trickle-observe 0.2.59 → 0.2.61
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/lambda.d.ts +61 -0
- package/dist/lambda.js +177 -0
- package/dist/trace-var.js +4 -1
- package/dist/transport.js +4 -1
- package/dist/vite-plugin.js +62 -2
- package/dist-esm/vite-plugin.js +62 -2
- package/package.json +4 -3
- package/src/lambda.test.ts +147 -0
- package/src/lambda.ts +160 -0
- package/src/trace-var.ts +4 -1
- package/src/transport.ts +4 -1
- package/src/vite-plugin.test.ts +58 -0
- package/src/vite-plugin.ts +96 -2
package/dist/lambda.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trickle/lambda — Zero-config observability for AWS Lambda functions.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a Lambda handler to:
|
|
5
|
+
* 1. Auto-instrument all functions with type observation
|
|
6
|
+
* 2. Write observations to /tmp/.trickle/ (Lambda's writable filesystem)
|
|
7
|
+
* OR send to TRICKLE_BACKEND_URL if set
|
|
8
|
+
* 3. Flush all pending observations synchronously before Lambda freezes
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
*
|
|
12
|
+
* import { wrapLambda } from 'trickle-observe/lambda';
|
|
13
|
+
*
|
|
14
|
+
* export const handler = wrapLambda(async (event, context) => {
|
|
15
|
+
* const result = await processOrder(event.orderId);
|
|
16
|
+
* return { statusCode: 200, body: JSON.stringify(result) };
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* OR zero-code via NODE_OPTIONS:
|
|
20
|
+
*
|
|
21
|
+
* NODE_OPTIONS="--require trickle-observe/auto-env" TRICKLE_AUTO=1
|
|
22
|
+
*
|
|
23
|
+
* Environment variables:
|
|
24
|
+
* TRICKLE_BACKEND_URL — Send observations to HTTP backend (optional)
|
|
25
|
+
* TRICKLE_LOCAL_DIR — Override local dir (default: /tmp/.trickle)
|
|
26
|
+
* TRICKLE_DEBUG — Set to "1" for debug logging
|
|
27
|
+
* TRICKLE_OBSERVE_INCLUDE — Comma-separated substrings to observe
|
|
28
|
+
* TRICKLE_OBSERVE_EXCLUDE — Comma-separated substrings to skip
|
|
29
|
+
*/
|
|
30
|
+
import './observe-register';
|
|
31
|
+
export type LambdaEvent = Record<string, unknown>;
|
|
32
|
+
export type LambdaContext = {
|
|
33
|
+
functionName: string;
|
|
34
|
+
functionVersion: string;
|
|
35
|
+
invokedFunctionArn: string;
|
|
36
|
+
awsRequestId: string;
|
|
37
|
+
remainingTimeInMillis?: () => number;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
};
|
|
40
|
+
export type LambdaHandler<E = LambdaEvent, R = unknown> = (event: E, context: LambdaContext) => Promise<R>;
|
|
41
|
+
/**
|
|
42
|
+
* Wrap a Lambda handler with trickle observability.
|
|
43
|
+
*
|
|
44
|
+
* Instruments all called functions automatically, writes type observations
|
|
45
|
+
* to /tmp/.trickle/, and flushes before Lambda freezes the process.
|
|
46
|
+
*/
|
|
47
|
+
export declare function wrapLambda<E = LambdaEvent, R = unknown>(handler: LambdaHandler<E, R>): LambdaHandler<E, R>;
|
|
48
|
+
/**
|
|
49
|
+
* Print the contents of the local trickle observations directory to stdout
|
|
50
|
+
* as newline-delimited JSON. Useful for inspecting Lambda observations in
|
|
51
|
+
* CloudWatch Logs when TRICKLE_BACKEND_URL is not set.
|
|
52
|
+
*
|
|
53
|
+
* Call at the end of your handler to stream observations to CloudWatch:
|
|
54
|
+
*
|
|
55
|
+
* export const handler = wrapLambda(async (event) => {
|
|
56
|
+
* const result = await processOrder(event.orderId);
|
|
57
|
+
* printObservations(); // → streamed to CloudWatch Logs
|
|
58
|
+
* return result;
|
|
59
|
+
* });
|
|
60
|
+
*/
|
|
61
|
+
export declare function printObservations(): void;
|
package/dist/lambda.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* trickle/lambda — Zero-config observability for AWS Lambda functions.
|
|
4
|
+
*
|
|
5
|
+
* Wraps a Lambda handler to:
|
|
6
|
+
* 1. Auto-instrument all functions with type observation
|
|
7
|
+
* 2. Write observations to /tmp/.trickle/ (Lambda's writable filesystem)
|
|
8
|
+
* OR send to TRICKLE_BACKEND_URL if set
|
|
9
|
+
* 3. Flush all pending observations synchronously before Lambda freezes
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
*
|
|
13
|
+
* import { wrapLambda } from 'trickle-observe/lambda';
|
|
14
|
+
*
|
|
15
|
+
* export const handler = wrapLambda(async (event, context) => {
|
|
16
|
+
* const result = await processOrder(event.orderId);
|
|
17
|
+
* return { statusCode: 200, body: JSON.stringify(result) };
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* OR zero-code via NODE_OPTIONS:
|
|
21
|
+
*
|
|
22
|
+
* NODE_OPTIONS="--require trickle-observe/auto-env" TRICKLE_AUTO=1
|
|
23
|
+
*
|
|
24
|
+
* Environment variables:
|
|
25
|
+
* TRICKLE_BACKEND_URL — Send observations to HTTP backend (optional)
|
|
26
|
+
* TRICKLE_LOCAL_DIR — Override local dir (default: /tmp/.trickle)
|
|
27
|
+
* TRICKLE_DEBUG — Set to "1" for debug logging
|
|
28
|
+
* TRICKLE_OBSERVE_INCLUDE — Comma-separated substrings to observe
|
|
29
|
+
* TRICKLE_OBSERVE_EXCLUDE — Comma-separated substrings to skip
|
|
30
|
+
*/
|
|
31
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
32
|
+
if (k2 === undefined) k2 = k;
|
|
33
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
34
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
35
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
36
|
+
}
|
|
37
|
+
Object.defineProperty(o, k2, desc);
|
|
38
|
+
}) : (function(o, m, k, k2) {
|
|
39
|
+
if (k2 === undefined) k2 = k;
|
|
40
|
+
o[k2] = m[k];
|
|
41
|
+
}));
|
|
42
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
43
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
44
|
+
}) : function(o, v) {
|
|
45
|
+
o["default"] = v;
|
|
46
|
+
});
|
|
47
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
48
|
+
var ownKeys = function(o) {
|
|
49
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
50
|
+
var ar = [];
|
|
51
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
52
|
+
return ar;
|
|
53
|
+
};
|
|
54
|
+
return ownKeys(o);
|
|
55
|
+
};
|
|
56
|
+
return function (mod) {
|
|
57
|
+
if (mod && mod.__esModule) return mod;
|
|
58
|
+
var result = {};
|
|
59
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
60
|
+
__setModuleDefault(result, mod);
|
|
61
|
+
return result;
|
|
62
|
+
};
|
|
63
|
+
})();
|
|
64
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
65
|
+
exports.wrapLambda = wrapLambda;
|
|
66
|
+
exports.printObservations = printObservations;
|
|
67
|
+
const path = __importStar(require("path"));
|
|
68
|
+
const fs = __importStar(require("fs"));
|
|
69
|
+
const transport_1 = require("./transport");
|
|
70
|
+
const trace_var_1 = require("./trace-var");
|
|
71
|
+
// Install Module._compile and Module._load hooks for auto-instrumentation
|
|
72
|
+
require("./observe-register");
|
|
73
|
+
let initialized = false;
|
|
74
|
+
function initOnce() {
|
|
75
|
+
if (initialized)
|
|
76
|
+
return;
|
|
77
|
+
initialized = true;
|
|
78
|
+
const debug = process.env.TRICKLE_DEBUG === '1';
|
|
79
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
|
|
80
|
+
// Ensure /tmp/.trickle exists (writable in Lambda, unlike /var/task)
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
catch { }
|
|
85
|
+
if (process.env.TRICKLE_BACKEND_URL) {
|
|
86
|
+
// HTTP mode: send to developer's backend (e.g. via ngrok)
|
|
87
|
+
(0, transport_1.configure)({
|
|
88
|
+
backendUrl: process.env.TRICKLE_BACKEND_URL,
|
|
89
|
+
batchIntervalMs: 100, // aggressive batching for Lambda
|
|
90
|
+
enabled: true,
|
|
91
|
+
debug,
|
|
92
|
+
environment: 'node',
|
|
93
|
+
});
|
|
94
|
+
if (debug)
|
|
95
|
+
console.log(`[trickle/lambda] HTTP mode → ${process.env.TRICKLE_BACKEND_URL}`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
// Local file mode: write to /tmp/.trickle/observations.jsonl
|
|
99
|
+
process.env.TRICKLE_LOCAL = '1';
|
|
100
|
+
process.env.TRICKLE_LOCAL_DIR = dir;
|
|
101
|
+
(0, transport_1.configure)({ backendUrl: 'http://localhost:4888', batchIntervalMs: 2000, enabled: true, debug, environment: 'node' });
|
|
102
|
+
if (debug)
|
|
103
|
+
console.log(`[trickle/lambda] Local mode → ${dir}/observations.jsonl`);
|
|
104
|
+
}
|
|
105
|
+
// Initialize variable tracer (writes to variables.jsonl)
|
|
106
|
+
(0, trace_var_1.initVarTracer)({ debug });
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Wrap a Lambda handler with trickle observability.
|
|
110
|
+
*
|
|
111
|
+
* Instruments all called functions automatically, writes type observations
|
|
112
|
+
* to /tmp/.trickle/, and flushes before Lambda freezes the process.
|
|
113
|
+
*/
|
|
114
|
+
function wrapLambda(handler) {
|
|
115
|
+
return async (event, context) => {
|
|
116
|
+
initOnce();
|
|
117
|
+
const debug = process.env.TRICKLE_DEBUG === '1';
|
|
118
|
+
const startMs = Date.now();
|
|
119
|
+
try {
|
|
120
|
+
const result = await handler(event, context);
|
|
121
|
+
// Flush pending HTTP observations before Lambda freezes the process.
|
|
122
|
+
// File-mode writes are already synchronous, so no flush needed there.
|
|
123
|
+
if (process.env.TRICKLE_BACKEND_URL) {
|
|
124
|
+
await (0, transport_1.flush)();
|
|
125
|
+
}
|
|
126
|
+
if (debug) {
|
|
127
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
|
|
128
|
+
const elapsed = Date.now() - startMs;
|
|
129
|
+
const varsFile = path.join(dir, 'variables.jsonl');
|
|
130
|
+
try {
|
|
131
|
+
const size = fs.statSync(varsFile).size;
|
|
132
|
+
console.log(`[trickle/lambda] Flushed in ${elapsed}ms, observations: ${varsFile} (${size}b)`);
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
// Flush even on errors so partial observations are captured
|
|
140
|
+
if (process.env.TRICKLE_BACKEND_URL) {
|
|
141
|
+
await (0, transport_1.flush)().catch(() => { });
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Print the contents of the local trickle observations directory to stdout
|
|
149
|
+
* as newline-delimited JSON. Useful for inspecting Lambda observations in
|
|
150
|
+
* CloudWatch Logs when TRICKLE_BACKEND_URL is not set.
|
|
151
|
+
*
|
|
152
|
+
* Call at the end of your handler to stream observations to CloudWatch:
|
|
153
|
+
*
|
|
154
|
+
* export const handler = wrapLambda(async (event) => {
|
|
155
|
+
* const result = await processOrder(event.orderId);
|
|
156
|
+
* printObservations(); // → streamed to CloudWatch Logs
|
|
157
|
+
* return result;
|
|
158
|
+
* });
|
|
159
|
+
*/
|
|
160
|
+
function printObservations() {
|
|
161
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
|
|
162
|
+
for (const file of ['variables.jsonl', 'observations.jsonl']) {
|
|
163
|
+
const filePath = path.join(dir, file);
|
|
164
|
+
try {
|
|
165
|
+
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
|
166
|
+
if (content) {
|
|
167
|
+
for (const line of content.split('\n')) {
|
|
168
|
+
if (line.trim()) {
|
|
169
|
+
// Prefix so trickle CLI can grep from CloudWatch Logs
|
|
170
|
+
console.log(`[trickle] ${line}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch { /* file may not exist yet */ }
|
|
176
|
+
}
|
|
177
|
+
}
|
package/dist/trace-var.js
CHANGED
|
@@ -70,7 +70,10 @@ const MAX_BUFFER_SIZE = 100;
|
|
|
70
70
|
*/
|
|
71
71
|
function initVarTracer(opts = {}) {
|
|
72
72
|
debugMode = opts.debug === true;
|
|
73
|
-
|
|
73
|
+
// Auto-detect Lambda: use /tmp/.trickle (writable) instead of cwd (read-only in Lambda)
|
|
74
|
+
const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME;
|
|
75
|
+
const defaultDir = isLambda ? '/tmp/.trickle' : path.join(process.cwd(), '.trickle');
|
|
76
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || defaultDir;
|
|
74
77
|
try {
|
|
75
78
|
fs.mkdirSync(dir, { recursive: true });
|
|
76
79
|
}
|
package/dist/transport.js
CHANGED
|
@@ -66,7 +66,10 @@ function configure(opts) {
|
|
|
66
66
|
// Check for local/file-based mode
|
|
67
67
|
if (process.env.TRICKLE_LOCAL === '1') {
|
|
68
68
|
localMode = true;
|
|
69
|
-
|
|
69
|
+
// Auto-detect Lambda: use /tmp/.trickle (writable) instead of cwd (read-only in Lambda)
|
|
70
|
+
const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME;
|
|
71
|
+
const defaultDir = isLambda ? '/tmp/.trickle' : pathMod.join(process.cwd(), '.trickle');
|
|
72
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || defaultDir;
|
|
70
73
|
try {
|
|
71
74
|
fs.mkdirSync(dir, { recursive: true });
|
|
72
75
|
}
|
package/dist/vite-plugin.js
CHANGED
|
@@ -689,11 +689,57 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
689
689
|
hookInsertions.push({ wrapStart: afterParen, wrapEnd: closeBrace + 1, hookName, lineNo });
|
|
690
690
|
}
|
|
691
691
|
}
|
|
692
|
+
const stateInsertions = [];
|
|
693
|
+
if (isReactFile) {
|
|
694
|
+
const useStateRegex = /const\s+\[([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\]\s*=\s*(?:React\.)?useState\s*(?:<[^(]*>)?\s*\(/gm;
|
|
695
|
+
let sm;
|
|
696
|
+
while ((sm = useStateRegex.exec(source)) !== null) {
|
|
697
|
+
const stateName = sm[1];
|
|
698
|
+
const setterName = sm[2];
|
|
699
|
+
// Find the position of setterName within the match (after the comma)
|
|
700
|
+
const matchStr = sm[0];
|
|
701
|
+
const commaIdx = matchStr.indexOf(',');
|
|
702
|
+
const setterInMatch = matchStr.indexOf(setterName, commaIdx);
|
|
703
|
+
if (setterInMatch === -1)
|
|
704
|
+
continue;
|
|
705
|
+
const renamePos = sm.index + setterInMatch;
|
|
706
|
+
// Skip the useState(...) argument list to find the end of the statement
|
|
707
|
+
let pos = sm.index + sm[0].length;
|
|
708
|
+
let depth = 1;
|
|
709
|
+
while (pos < source.length && depth > 0) {
|
|
710
|
+
const ch = source[pos];
|
|
711
|
+
if (ch === '(')
|
|
712
|
+
depth++;
|
|
713
|
+
else if (ch === ')')
|
|
714
|
+
depth--;
|
|
715
|
+
else if (ch === '"' || ch === "'" || ch === '`') {
|
|
716
|
+
const q = ch;
|
|
717
|
+
pos++;
|
|
718
|
+
while (pos < source.length && source[pos] !== q) {
|
|
719
|
+
if (source[pos] === '\\')
|
|
720
|
+
pos++;
|
|
721
|
+
pos++;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
pos++;
|
|
725
|
+
}
|
|
726
|
+
// Skip to end of line (past semicolon or newline)
|
|
727
|
+
while (pos < source.length && source[pos] !== ';' && source[pos] !== '\n')
|
|
728
|
+
pos++;
|
|
729
|
+
const afterLine = pos + 1;
|
|
730
|
+
let lineNo = 1;
|
|
731
|
+
for (let i = 0; i < sm.index; i++) {
|
|
732
|
+
if (source[i] === '\n')
|
|
733
|
+
lineNo++;
|
|
734
|
+
}
|
|
735
|
+
stateInsertions.push({ renamePos, afterLine, stateName, setterName, lineNo });
|
|
736
|
+
}
|
|
737
|
+
}
|
|
692
738
|
// Find variable declarations for tracing
|
|
693
739
|
const varInsertions = traceVars ? findVarDeclarations(source) : [];
|
|
694
740
|
// Find destructured variable declarations for tracing
|
|
695
741
|
const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
|
|
696
|
-
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0)
|
|
742
|
+
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0)
|
|
697
743
|
return source;
|
|
698
744
|
// Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
|
|
699
745
|
// Map transformed line numbers to original source line numbers.
|
|
@@ -718,7 +764,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
718
764
|
const importLines = [
|
|
719
765
|
`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
|
|
720
766
|
];
|
|
721
|
-
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
|
|
767
|
+
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0) {
|
|
722
768
|
importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
|
|
723
769
|
}
|
|
724
770
|
const prefixLines = [
|
|
@@ -753,6 +799,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
753
799
|
if (hookInsertions.length > 0) {
|
|
754
800
|
prefixLines.push(`if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`, `function __trickle_hw(hookName, line, cb) {`, ` return function(...args) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`, ` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_hook_counts.set(key, n);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`, ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`, ` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, ` return cb(...args);`, ` };`, `}`);
|
|
755
801
|
}
|
|
802
|
+
// Add useState setter tracker if needed
|
|
803
|
+
if (stateInsertions.length > 0) {
|
|
804
|
+
prefixLines.push(`if (!globalThis.__trickle_state_counts) { globalThis.__trickle_state_counts = new Map(); }`, `function __trickle_ss(stateName, line, origSetter) {`, ` return function(newVal) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + stateName;`, ` const n = (globalThis.__trickle_state_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_state_counts.set(key, n);`, ` const t = typeof newVal;`, ` let sample;`, ` if (t === 'function') sample = '[fn updater]';`, ` else if (t === 'string') sample = newVal.length > 40 ? newVal.slice(0,40)+'...' : newVal;`, ` else if (t === 'number' || t === 'boolean') sample = newVal;`, ` else if (newVal === null || newVal === undefined) sample = newVal;`, ` else if (Array.isArray(newVal)) sample = '[arr:'+newVal.length+']';`, ` else sample = '[object]';`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`, ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`, ` JSON.stringify({ kind: 'react_state', file: ${JSON.stringify(filename)}, line, stateName, updateCount: n, value: sample, timestamp: Date.now()/1000 }) + '\\n');`, ` } catch(e) {}`, ` return origSetter(newVal);`, ` };`, `}`);
|
|
805
|
+
}
|
|
756
806
|
prefixLines.push('');
|
|
757
807
|
const prefix = prefixLines.join('\n');
|
|
758
808
|
const allInsertions = [];
|
|
@@ -787,6 +837,16 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
787
837
|
allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
|
|
788
838
|
allInsertions.push({ position: wrapEnd, code: `)` });
|
|
789
839
|
}
|
|
840
|
+
// useState insertions: TWO insertions per useState
|
|
841
|
+
// 1. Prefix setter name with '__trickle_s_' (rename in destructuring)
|
|
842
|
+
// 2. After statement end, declare tracked wrapper: const setter = __trickle_ss(...)
|
|
843
|
+
for (const { renamePos, afterLine, stateName, setterName, lineNo } of stateInsertions) {
|
|
844
|
+
allInsertions.push({ position: renamePos, code: `__trickle_s_` });
|
|
845
|
+
allInsertions.push({
|
|
846
|
+
position: afterLine,
|
|
847
|
+
code: `const ${setterName}=__trickle_ss(${JSON.stringify(stateName)},${lineNo},__trickle_s_${setterName});\n`,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
790
850
|
// Sort by position descending (insert from end to preserve earlier positions)
|
|
791
851
|
allInsertions.sort((a, b) => b.position - a.position);
|
|
792
852
|
let result = source;
|
package/dist-esm/vite-plugin.js
CHANGED
|
@@ -683,11 +683,57 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
683
683
|
hookInsertions.push({ wrapStart: afterParen, wrapEnd: closeBrace + 1, hookName, lineNo });
|
|
684
684
|
}
|
|
685
685
|
}
|
|
686
|
+
const stateInsertions = [];
|
|
687
|
+
if (isReactFile) {
|
|
688
|
+
const useStateRegex = /const\s+\[([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\]\s*=\s*(?:React\.)?useState\s*(?:<[^(]*>)?\s*\(/gm;
|
|
689
|
+
let sm;
|
|
690
|
+
while ((sm = useStateRegex.exec(source)) !== null) {
|
|
691
|
+
const stateName = sm[1];
|
|
692
|
+
const setterName = sm[2];
|
|
693
|
+
// Find the position of setterName within the match (after the comma)
|
|
694
|
+
const matchStr = sm[0];
|
|
695
|
+
const commaIdx = matchStr.indexOf(',');
|
|
696
|
+
const setterInMatch = matchStr.indexOf(setterName, commaIdx);
|
|
697
|
+
if (setterInMatch === -1)
|
|
698
|
+
continue;
|
|
699
|
+
const renamePos = sm.index + setterInMatch;
|
|
700
|
+
// Skip the useState(...) argument list to find the end of the statement
|
|
701
|
+
let pos = sm.index + sm[0].length;
|
|
702
|
+
let depth = 1;
|
|
703
|
+
while (pos < source.length && depth > 0) {
|
|
704
|
+
const ch = source[pos];
|
|
705
|
+
if (ch === '(')
|
|
706
|
+
depth++;
|
|
707
|
+
else if (ch === ')')
|
|
708
|
+
depth--;
|
|
709
|
+
else if (ch === '"' || ch === "'" || ch === '`') {
|
|
710
|
+
const q = ch;
|
|
711
|
+
pos++;
|
|
712
|
+
while (pos < source.length && source[pos] !== q) {
|
|
713
|
+
if (source[pos] === '\\')
|
|
714
|
+
pos++;
|
|
715
|
+
pos++;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
pos++;
|
|
719
|
+
}
|
|
720
|
+
// Skip to end of line (past semicolon or newline)
|
|
721
|
+
while (pos < source.length && source[pos] !== ';' && source[pos] !== '\n')
|
|
722
|
+
pos++;
|
|
723
|
+
const afterLine = pos + 1;
|
|
724
|
+
let lineNo = 1;
|
|
725
|
+
for (let i = 0; i < sm.index; i++) {
|
|
726
|
+
if (source[i] === '\n')
|
|
727
|
+
lineNo++;
|
|
728
|
+
}
|
|
729
|
+
stateInsertions.push({ renamePos, afterLine, stateName, setterName, lineNo });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
686
732
|
// Find variable declarations for tracing
|
|
687
733
|
const varInsertions = traceVars ? findVarDeclarations(source) : [];
|
|
688
734
|
// Find destructured variable declarations for tracing
|
|
689
735
|
const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
|
|
690
|
-
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0)
|
|
736
|
+
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0)
|
|
691
737
|
return source;
|
|
692
738
|
// Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
|
|
693
739
|
// Map transformed line numbers to original source line numbers.
|
|
@@ -712,7 +758,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
712
758
|
const importLines = [
|
|
713
759
|
`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
|
|
714
760
|
];
|
|
715
|
-
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
|
|
761
|
+
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0) {
|
|
716
762
|
importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
|
|
717
763
|
}
|
|
718
764
|
const prefixLines = [
|
|
@@ -747,6 +793,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
747
793
|
if (hookInsertions.length > 0) {
|
|
748
794
|
prefixLines.push(`if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`, `function __trickle_hw(hookName, line, cb) {`, ` return function(...args) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`, ` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_hook_counts.set(key, n);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`, ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`, ` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, ` return cb(...args);`, ` };`, `}`);
|
|
749
795
|
}
|
|
796
|
+
// Add useState setter tracker if needed
|
|
797
|
+
if (stateInsertions.length > 0) {
|
|
798
|
+
prefixLines.push(`if (!globalThis.__trickle_state_counts) { globalThis.__trickle_state_counts = new Map(); }`, `function __trickle_ss(stateName, line, origSetter) {`, ` return function(newVal) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + stateName;`, ` const n = (globalThis.__trickle_state_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_state_counts.set(key, n);`, ` const t = typeof newVal;`, ` let sample;`, ` if (t === 'function') sample = '[fn updater]';`, ` else if (t === 'string') sample = newVal.length > 40 ? newVal.slice(0,40)+'...' : newVal;`, ` else if (t === 'number' || t === 'boolean') sample = newVal;`, ` else if (newVal === null || newVal === undefined) sample = newVal;`, ` else if (Array.isArray(newVal)) sample = '[arr:'+newVal.length+']';`, ` else sample = '[object]';`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`, ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`, ` JSON.stringify({ kind: 'react_state', file: ${JSON.stringify(filename)}, line, stateName, updateCount: n, value: sample, timestamp: Date.now()/1000 }) + '\\n');`, ` } catch(e) {}`, ` return origSetter(newVal);`, ` };`, `}`);
|
|
799
|
+
}
|
|
750
800
|
prefixLines.push('');
|
|
751
801
|
const prefix = prefixLines.join('\n');
|
|
752
802
|
const allInsertions = [];
|
|
@@ -781,6 +831,16 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
781
831
|
allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
|
|
782
832
|
allInsertions.push({ position: wrapEnd, code: `)` });
|
|
783
833
|
}
|
|
834
|
+
// useState insertions: TWO insertions per useState
|
|
835
|
+
// 1. Prefix setter name with '__trickle_s_' (rename in destructuring)
|
|
836
|
+
// 2. After statement end, declare tracked wrapper: const setter = __trickle_ss(...)
|
|
837
|
+
for (const { renamePos, afterLine, stateName, setterName, lineNo } of stateInsertions) {
|
|
838
|
+
allInsertions.push({ position: renamePos, code: `__trickle_s_` });
|
|
839
|
+
allInsertions.push({
|
|
840
|
+
position: afterLine,
|
|
841
|
+
code: `const ${setterName}=__trickle_ss(${JSON.stringify(stateName)},${lineNo},__trickle_s_${setterName});\n`,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
784
844
|
// Sort by position descending (insert from end to preserve earlier positions)
|
|
785
845
|
allInsertions.sort((a, b) => b.position - a.position);
|
|
786
846
|
let result = source;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trickle-observe",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.61",
|
|
4
4
|
"description": "Runtime type observability for JavaScript applications",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
"import": "./dist-esm/vite-plugin.js",
|
|
17
17
|
"require": "./dist/vite-plugin.js"
|
|
18
18
|
},
|
|
19
|
-
"./trace-var": "./dist/trace-var.js"
|
|
19
|
+
"./trace-var": "./dist/trace-var.js",
|
|
20
|
+
"./lambda": "./dist/lambda.js"
|
|
20
21
|
},
|
|
21
22
|
"scripts": {
|
|
22
23
|
"build": "tsc && tsc -p tsconfig.esm.json",
|
|
23
|
-
"test": "npm run build && node --experimental-strip-types --test src/vite-plugin.test.ts",
|
|
24
|
+
"test": "npm run build && node --experimental-strip-types --test src/vite-plugin.test.ts src/lambda.test.ts",
|
|
24
25
|
"prepublishOnly": "npm run build"
|
|
25
26
|
},
|
|
26
27
|
"optionalDependencies": {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for wrapLambda and printObservations.
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --experimental-strip-types --test src/lambda.test.ts
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, before, after } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
|
|
12
|
+
// ── wrapLambda ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe('wrapLambda', () => {
|
|
15
|
+
const makeContext = () => ({
|
|
16
|
+
functionName: 'test-fn',
|
|
17
|
+
functionVersion: '$LATEST',
|
|
18
|
+
invokedFunctionArn: 'arn:aws:lambda:us-east-1:123:function:test-fn',
|
|
19
|
+
awsRequestId: 'req-1',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let tmpDir: string;
|
|
23
|
+
|
|
24
|
+
before(() => {
|
|
25
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trickle-lambda-test-'));
|
|
26
|
+
process.env.TRICKLE_LOCAL_DIR = tmpDir;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
after(() => {
|
|
30
|
+
delete process.env.TRICKLE_LOCAL_DIR;
|
|
31
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns the handler result unchanged', async () => {
|
|
35
|
+
// Import fresh to avoid module cache issues with env vars
|
|
36
|
+
const { wrapLambda } = await import('../dist/lambda.js');
|
|
37
|
+
const handler = async (_event: unknown, _ctx: unknown) => ({ statusCode: 200, body: 'ok' });
|
|
38
|
+
const wrapped = wrapLambda(handler);
|
|
39
|
+
const result = await wrapped({ orderId: '123' }, makeContext());
|
|
40
|
+
assert.deepEqual(result, { statusCode: 200, body: 'ok' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('passes event and context to the handler', async () => {
|
|
44
|
+
const { wrapLambda } = await import('../dist/lambda.js');
|
|
45
|
+
let receivedEvent: unknown;
|
|
46
|
+
let receivedCtx: unknown;
|
|
47
|
+
const handler = async (event: unknown, ctx: unknown) => {
|
|
48
|
+
receivedEvent = event;
|
|
49
|
+
receivedCtx = ctx;
|
|
50
|
+
return 'done';
|
|
51
|
+
};
|
|
52
|
+
const wrapped = wrapLambda(handler);
|
|
53
|
+
const ctx = makeContext();
|
|
54
|
+
await wrapped({ hello: 'world' }, ctx);
|
|
55
|
+
assert.deepEqual(receivedEvent, { hello: 'world' });
|
|
56
|
+
assert.equal((receivedCtx as typeof ctx).awsRequestId, 'req-1');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('re-throws errors from the handler', async () => {
|
|
60
|
+
const { wrapLambda } = await import('../dist/lambda.js');
|
|
61
|
+
const handler = async () => { throw new Error('Lambda error'); };
|
|
62
|
+
const wrapped = wrapLambda(handler);
|
|
63
|
+
await assert.rejects(() => wrapped({}, makeContext()), /Lambda error/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not throw if TRICKLE_BACKEND_URL is not set', async () => {
|
|
67
|
+
delete process.env.TRICKLE_BACKEND_URL;
|
|
68
|
+
const { wrapLambda } = await import('../dist/lambda.js');
|
|
69
|
+
const handler = async () => 42;
|
|
70
|
+
const wrapped = wrapLambda(handler);
|
|
71
|
+
const result = await wrapped({}, makeContext());
|
|
72
|
+
assert.equal(result, 42);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── printObservations ─────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
describe('printObservations', () => {
|
|
79
|
+
let tmpDir: string;
|
|
80
|
+
let originalDir: string | undefined;
|
|
81
|
+
let logged: string[];
|
|
82
|
+
let origLog: typeof console.log;
|
|
83
|
+
|
|
84
|
+
before(() => {
|
|
85
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trickle-print-test-'));
|
|
86
|
+
originalDir = process.env.TRICKLE_LOCAL_DIR;
|
|
87
|
+
process.env.TRICKLE_LOCAL_DIR = tmpDir;
|
|
88
|
+
// Capture console.log output
|
|
89
|
+
origLog = console.log;
|
|
90
|
+
logged = [];
|
|
91
|
+
console.log = (...args: unknown[]) => { logged.push(args.join(' ')); };
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
after(() => {
|
|
95
|
+
console.log = origLog;
|
|
96
|
+
if (originalDir !== undefined) {
|
|
97
|
+
process.env.TRICKLE_LOCAL_DIR = originalDir;
|
|
98
|
+
} else {
|
|
99
|
+
delete process.env.TRICKLE_LOCAL_DIR;
|
|
100
|
+
}
|
|
101
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('prints [trickle] prefixed JSON lines from variables.jsonl', async () => {
|
|
105
|
+
const record = JSON.stringify({ kind: 'var', name: 'x', value: 1 });
|
|
106
|
+
fs.writeFileSync(path.join(tmpDir, 'variables.jsonl'), record + '\n');
|
|
107
|
+
|
|
108
|
+
const { printObservations } = await import('../dist/lambda.js');
|
|
109
|
+
logged = [];
|
|
110
|
+
printObservations();
|
|
111
|
+
|
|
112
|
+
assert.ok(logged.some(l => l.startsWith('[trickle]')), 'should prefix with [trickle]');
|
|
113
|
+
assert.ok(logged.some(l => l.includes('"kind"')), 'should include JSON content');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('does not crash when no files exist', async () => {
|
|
117
|
+
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trickle-empty-'));
|
|
118
|
+
const prevDir = process.env.TRICKLE_LOCAL_DIR;
|
|
119
|
+
process.env.TRICKLE_LOCAL_DIR = emptyDir;
|
|
120
|
+
try {
|
|
121
|
+
const { printObservations } = await import('../dist/lambda.js');
|
|
122
|
+
assert.doesNotThrow(() => printObservations());
|
|
123
|
+
} finally {
|
|
124
|
+
process.env.TRICKLE_LOCAL_DIR = prevDir;
|
|
125
|
+
fs.rmSync(emptyDir, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('prints observations from both variables.jsonl and observations.jsonl', async () => {
|
|
130
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trickle-both-'));
|
|
131
|
+
const prevDir = process.env.TRICKLE_LOCAL_DIR;
|
|
132
|
+
process.env.TRICKLE_LOCAL_DIR = dir;
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(path.join(dir, 'variables.jsonl'), JSON.stringify({ kind: 'var' }) + '\n');
|
|
135
|
+
fs.writeFileSync(path.join(dir, 'observations.jsonl'), JSON.stringify({ kind: 'obs' }) + '\n');
|
|
136
|
+
|
|
137
|
+
const { printObservations } = await import('../dist/lambda.js');
|
|
138
|
+
logged = [];
|
|
139
|
+
printObservations();
|
|
140
|
+
const count = logged.filter(l => l.startsWith('[trickle]')).length;
|
|
141
|
+
assert.ok(count >= 2, `should print at least 2 [trickle] lines, got ${count}`);
|
|
142
|
+
} finally {
|
|
143
|
+
process.env.TRICKLE_LOCAL_DIR = prevDir;
|
|
144
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/lambda.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trickle/lambda — Zero-config observability for AWS Lambda functions.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a Lambda handler to:
|
|
5
|
+
* 1. Auto-instrument all functions with type observation
|
|
6
|
+
* 2. Write observations to /tmp/.trickle/ (Lambda's writable filesystem)
|
|
7
|
+
* OR send to TRICKLE_BACKEND_URL if set
|
|
8
|
+
* 3. Flush all pending observations synchronously before Lambda freezes
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
*
|
|
12
|
+
* import { wrapLambda } from 'trickle-observe/lambda';
|
|
13
|
+
*
|
|
14
|
+
* export const handler = wrapLambda(async (event, context) => {
|
|
15
|
+
* const result = await processOrder(event.orderId);
|
|
16
|
+
* return { statusCode: 200, body: JSON.stringify(result) };
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* OR zero-code via NODE_OPTIONS:
|
|
20
|
+
*
|
|
21
|
+
* NODE_OPTIONS="--require trickle-observe/auto-env" TRICKLE_AUTO=1
|
|
22
|
+
*
|
|
23
|
+
* Environment variables:
|
|
24
|
+
* TRICKLE_BACKEND_URL — Send observations to HTTP backend (optional)
|
|
25
|
+
* TRICKLE_LOCAL_DIR — Override local dir (default: /tmp/.trickle)
|
|
26
|
+
* TRICKLE_DEBUG — Set to "1" for debug logging
|
|
27
|
+
* TRICKLE_OBSERVE_INCLUDE — Comma-separated substrings to observe
|
|
28
|
+
* TRICKLE_OBSERVE_EXCLUDE — Comma-separated substrings to skip
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import * as path from 'path';
|
|
32
|
+
import * as fs from 'fs';
|
|
33
|
+
import { configure, flush } from './transport';
|
|
34
|
+
import { initVarTracer } from './trace-var';
|
|
35
|
+
// Install Module._compile and Module._load hooks for auto-instrumentation
|
|
36
|
+
import './observe-register';
|
|
37
|
+
|
|
38
|
+
export type LambdaEvent = Record<string, unknown>;
|
|
39
|
+
export type LambdaContext = {
|
|
40
|
+
functionName: string;
|
|
41
|
+
functionVersion: string;
|
|
42
|
+
invokedFunctionArn: string;
|
|
43
|
+
awsRequestId: string;
|
|
44
|
+
remainingTimeInMillis?: () => number;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
};
|
|
47
|
+
export type LambdaHandler<E = LambdaEvent, R = unknown> = (
|
|
48
|
+
event: E,
|
|
49
|
+
context: LambdaContext,
|
|
50
|
+
) => Promise<R>;
|
|
51
|
+
|
|
52
|
+
let initialized = false;
|
|
53
|
+
|
|
54
|
+
function initOnce() {
|
|
55
|
+
if (initialized) return;
|
|
56
|
+
initialized = true;
|
|
57
|
+
|
|
58
|
+
const debug = process.env.TRICKLE_DEBUG === '1';
|
|
59
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
|
|
60
|
+
|
|
61
|
+
// Ensure /tmp/.trickle exists (writable in Lambda, unlike /var/task)
|
|
62
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
63
|
+
|
|
64
|
+
if (process.env.TRICKLE_BACKEND_URL) {
|
|
65
|
+
// HTTP mode: send to developer's backend (e.g. via ngrok)
|
|
66
|
+
configure({
|
|
67
|
+
backendUrl: process.env.TRICKLE_BACKEND_URL,
|
|
68
|
+
batchIntervalMs: 100, // aggressive batching for Lambda
|
|
69
|
+
enabled: true,
|
|
70
|
+
debug,
|
|
71
|
+
environment: 'node',
|
|
72
|
+
});
|
|
73
|
+
if (debug) console.log(`[trickle/lambda] HTTP mode → ${process.env.TRICKLE_BACKEND_URL}`);
|
|
74
|
+
} else {
|
|
75
|
+
// Local file mode: write to /tmp/.trickle/observations.jsonl
|
|
76
|
+
process.env.TRICKLE_LOCAL = '1';
|
|
77
|
+
process.env.TRICKLE_LOCAL_DIR = dir;
|
|
78
|
+
configure({ backendUrl: 'http://localhost:4888', batchIntervalMs: 2000, enabled: true, debug, environment: 'node' });
|
|
79
|
+
if (debug) console.log(`[trickle/lambda] Local mode → ${dir}/observations.jsonl`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Initialize variable tracer (writes to variables.jsonl)
|
|
83
|
+
initVarTracer({ debug });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wrap a Lambda handler with trickle observability.
|
|
88
|
+
*
|
|
89
|
+
* Instruments all called functions automatically, writes type observations
|
|
90
|
+
* to /tmp/.trickle/, and flushes before Lambda freezes the process.
|
|
91
|
+
*/
|
|
92
|
+
export function wrapLambda<E = LambdaEvent, R = unknown>(
|
|
93
|
+
handler: LambdaHandler<E, R>,
|
|
94
|
+
): LambdaHandler<E, R> {
|
|
95
|
+
return async (event: E, context: LambdaContext): Promise<R> => {
|
|
96
|
+
initOnce();
|
|
97
|
+
|
|
98
|
+
const debug = process.env.TRICKLE_DEBUG === '1';
|
|
99
|
+
const startMs = Date.now();
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const result = await handler(event, context);
|
|
103
|
+
|
|
104
|
+
// Flush pending HTTP observations before Lambda freezes the process.
|
|
105
|
+
// File-mode writes are already synchronous, so no flush needed there.
|
|
106
|
+
if (process.env.TRICKLE_BACKEND_URL) {
|
|
107
|
+
await flush();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (debug) {
|
|
111
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
|
|
112
|
+
const elapsed = Date.now() - startMs;
|
|
113
|
+
const varsFile = path.join(dir, 'variables.jsonl');
|
|
114
|
+
try {
|
|
115
|
+
const size = fs.statSync(varsFile).size;
|
|
116
|
+
console.log(`[trickle/lambda] Flushed in ${elapsed}ms, observations: ${varsFile} (${size}b)`);
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Flush even on errors so partial observations are captured
|
|
123
|
+
if (process.env.TRICKLE_BACKEND_URL) {
|
|
124
|
+
await flush().catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Print the contents of the local trickle observations directory to stdout
|
|
133
|
+
* as newline-delimited JSON. Useful for inspecting Lambda observations in
|
|
134
|
+
* CloudWatch Logs when TRICKLE_BACKEND_URL is not set.
|
|
135
|
+
*
|
|
136
|
+
* Call at the end of your handler to stream observations to CloudWatch:
|
|
137
|
+
*
|
|
138
|
+
* export const handler = wrapLambda(async (event) => {
|
|
139
|
+
* const result = await processOrder(event.orderId);
|
|
140
|
+
* printObservations(); // → streamed to CloudWatch Logs
|
|
141
|
+
* return result;
|
|
142
|
+
* });
|
|
143
|
+
*/
|
|
144
|
+
export function printObservations(): void {
|
|
145
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || '/tmp/.trickle';
|
|
146
|
+
for (const file of ['variables.jsonl', 'observations.jsonl']) {
|
|
147
|
+
const filePath = path.join(dir, file);
|
|
148
|
+
try {
|
|
149
|
+
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
|
150
|
+
if (content) {
|
|
151
|
+
for (const line of content.split('\n')) {
|
|
152
|
+
if (line.trim()) {
|
|
153
|
+
// Prefix so trickle CLI can grep from CloudWatch Logs
|
|
154
|
+
console.log(`[trickle] ${line}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch { /* file may not exist yet */ }
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/trace-var.ts
CHANGED
|
@@ -50,7 +50,10 @@ export interface VariableObservation {
|
|
|
50
50
|
*/
|
|
51
51
|
export function initVarTracer(opts: { debug?: boolean } = {}): void {
|
|
52
52
|
debugMode = opts.debug === true;
|
|
53
|
-
|
|
53
|
+
// Auto-detect Lambda: use /tmp/.trickle (writable) instead of cwd (read-only in Lambda)
|
|
54
|
+
const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME;
|
|
55
|
+
const defaultDir = isLambda ? '/tmp/.trickle' : path.join(process.cwd(), '.trickle');
|
|
56
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || defaultDir;
|
|
54
57
|
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
55
58
|
varsFilePath = path.join(dir, 'variables.jsonl');
|
|
56
59
|
|
package/src/transport.ts
CHANGED
|
@@ -32,7 +32,10 @@ export function configure(opts: GlobalOpts): void {
|
|
|
32
32
|
// Check for local/file-based mode
|
|
33
33
|
if (process.env.TRICKLE_LOCAL === '1') {
|
|
34
34
|
localMode = true;
|
|
35
|
-
|
|
35
|
+
// Auto-detect Lambda: use /tmp/.trickle (writable) instead of cwd (read-only in Lambda)
|
|
36
|
+
const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME;
|
|
37
|
+
const defaultDir = isLambda ? '/tmp/.trickle' : pathMod.join(process.cwd(), '.trickle');
|
|
38
|
+
const dir = process.env.TRICKLE_LOCAL_DIR || defaultDir;
|
|
36
39
|
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
37
40
|
localFilePath = pathMod.join(dir, 'observations.jsonl');
|
|
38
41
|
if (debug) {
|
package/src/vite-plugin.test.ts
CHANGED
|
@@ -202,6 +202,64 @@ describe('Correct function body brace detection', () => {
|
|
|
202
202
|
});
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
// ── useState change tracking ──────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
describe('useState change tracking', () => {
|
|
208
|
+
it('renames setter and injects __trickle_ss wrapper for simple useState', () => {
|
|
209
|
+
const code = `function App() {\n const [count, setCount] = useState(0);\n return null;\n}`;
|
|
210
|
+
const out = transformTsx(code);
|
|
211
|
+
assert.ok(out, 'should transform');
|
|
212
|
+
assert.ok(out!.includes('__trickle_ss'), 'should inject state setter wrapper');
|
|
213
|
+
assert.ok(out!.includes('__trickle_s_setCount'), 'should rename original setter');
|
|
214
|
+
assert.ok(out!.includes('const setCount=__trickle_ss'), 'should declare tracked setter');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('tracks state name in the wrapper call', () => {
|
|
218
|
+
const code = `function App() {\n const [isOpen, setIsOpen] = useState(false);\n return null;\n}`;
|
|
219
|
+
const out = transformTsx(code);
|
|
220
|
+
assert.ok(out, 'should transform');
|
|
221
|
+
assert.ok(out!.includes('"isOpen"'), 'should include state variable name');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('handles TypeScript generic useState<T>', () => {
|
|
225
|
+
const code = `function App() {\n const [name, setName] = useState<string>('');\n return null;\n}`;
|
|
226
|
+
const out = transformTsx(code);
|
|
227
|
+
assert.ok(out, 'should transform');
|
|
228
|
+
assert.ok(out!.includes('__trickle_ss'), 'should inject state setter wrapper for generic useState');
|
|
229
|
+
assert.ok(out!.includes('"name"'), 'should include state name');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('tracks multiple useState calls in one component', () => {
|
|
233
|
+
const code = [
|
|
234
|
+
`function Dashboard() {`,
|
|
235
|
+
` const [count, setCount] = useState(0);`,
|
|
236
|
+
` const [name, setName] = useState('');`,
|
|
237
|
+
` const [active, setActive] = useState(false);`,
|
|
238
|
+
` return null;`,
|
|
239
|
+
`}`,
|
|
240
|
+
].join('\n');
|
|
241
|
+
const out = transformTsx(code);
|
|
242
|
+
assert.ok(out, 'should transform');
|
|
243
|
+
const ssCount = (out!.match(/const \w+=__trickle_ss/g) || []).length;
|
|
244
|
+
assert.equal(ssCount, 3, 'should wrap all 3 useState setters');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('emits react_state kind in preamble code', () => {
|
|
248
|
+
const code = `function App() {\n const [x, setX] = useState(0);\n return null;\n}`;
|
|
249
|
+
const out = transformTsx(code);
|
|
250
|
+
assert.ok(out, 'should transform');
|
|
251
|
+
assert.ok(out!.includes("'react_state'"), 'emitted record should have kind react_state');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('does NOT inject useState tracking in .ts files', () => {
|
|
255
|
+
const code = `function helper() {\n const [x, setX] = useState(0);\n return null;\n}`;
|
|
256
|
+
const out = transformTs(code);
|
|
257
|
+
if (out) {
|
|
258
|
+
assert.ok(!out.includes('__trickle_ss'), 'should NOT inject state tracking in .ts files');
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
205
263
|
// ── Re-render cause detection ─────────────────────────────────────────────────
|
|
206
264
|
|
|
207
265
|
describe('Re-render cause detection', () => {
|
package/src/vite-plugin.ts
CHANGED
|
@@ -681,13 +681,67 @@ function transformEsmSource(
|
|
|
681
681
|
}
|
|
682
682
|
}
|
|
683
683
|
|
|
684
|
+
// React useState tracking — rename setter to __trickle_s_X and declare tracked wrapper.
|
|
685
|
+
// Detects: const [stateVar, setter] = useState(...) or useState<T>(...)
|
|
686
|
+
interface StateInsertion {
|
|
687
|
+
renamePos: number; // position in source to insert '__trickle_s_' before setter name
|
|
688
|
+
afterLine: number; // position after end of useState statement to insert declaration
|
|
689
|
+
stateName: string;
|
|
690
|
+
setterName: string;
|
|
691
|
+
lineNo: number;
|
|
692
|
+
}
|
|
693
|
+
const stateInsertions: StateInsertion[] = [];
|
|
694
|
+
|
|
695
|
+
if (isReactFile) {
|
|
696
|
+
const useStateRegex = /const\s+\[([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\]\s*=\s*(?:React\.)?useState\s*(?:<[^(]*>)?\s*\(/gm;
|
|
697
|
+
let sm;
|
|
698
|
+
while ((sm = useStateRegex.exec(source)) !== null) {
|
|
699
|
+
const stateName = sm[1];
|
|
700
|
+
const setterName = sm[2];
|
|
701
|
+
|
|
702
|
+
// Find the position of setterName within the match (after the comma)
|
|
703
|
+
const matchStr = sm[0];
|
|
704
|
+
const commaIdx = matchStr.indexOf(',');
|
|
705
|
+
const setterInMatch = matchStr.indexOf(setterName, commaIdx);
|
|
706
|
+
if (setterInMatch === -1) continue;
|
|
707
|
+
const renamePos = sm.index + setterInMatch;
|
|
708
|
+
|
|
709
|
+
// Skip the useState(...) argument list to find the end of the statement
|
|
710
|
+
let pos = sm.index + sm[0].length;
|
|
711
|
+
let depth = 1;
|
|
712
|
+
while (pos < source.length && depth > 0) {
|
|
713
|
+
const ch = source[pos];
|
|
714
|
+
if (ch === '(') depth++;
|
|
715
|
+
else if (ch === ')') depth--;
|
|
716
|
+
else if (ch === '"' || ch === "'" || ch === '`') {
|
|
717
|
+
const q = ch; pos++;
|
|
718
|
+
while (pos < source.length && source[pos] !== q) {
|
|
719
|
+
if (source[pos] === '\\') pos++;
|
|
720
|
+
pos++;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
pos++;
|
|
724
|
+
}
|
|
725
|
+
// Skip to end of line (past semicolon or newline)
|
|
726
|
+
while (pos < source.length && source[pos] !== ';' && source[pos] !== '\n') pos++;
|
|
727
|
+
const afterLine = pos + 1;
|
|
728
|
+
|
|
729
|
+
let lineNo = 1;
|
|
730
|
+
for (let i = 0; i < sm.index; i++) {
|
|
731
|
+
if (source[i] === '\n') lineNo++;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
stateInsertions.push({ renamePos, afterLine, stateName, setterName, lineNo });
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
684
738
|
// Find variable declarations for tracing
|
|
685
739
|
const varInsertions = traceVars ? findVarDeclarations(source) : [];
|
|
686
740
|
|
|
687
741
|
// Find destructured variable declarations for tracing
|
|
688
742
|
const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
|
|
689
743
|
|
|
690
|
-
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0) return source;
|
|
744
|
+
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0) return source;
|
|
691
745
|
|
|
692
746
|
// Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
|
|
693
747
|
// Map transformed line numbers to original source line numbers.
|
|
@@ -712,7 +766,7 @@ function transformEsmSource(
|
|
|
712
766
|
const importLines: string[] = [
|
|
713
767
|
`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
|
|
714
768
|
];
|
|
715
|
-
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
|
|
769
|
+
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0) {
|
|
716
770
|
importLines.push(
|
|
717
771
|
`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
|
|
718
772
|
`import { join as __trickle_join } from 'node:path';`,
|
|
@@ -869,6 +923,35 @@ function transformEsmSource(
|
|
|
869
923
|
);
|
|
870
924
|
}
|
|
871
925
|
|
|
926
|
+
// Add useState setter tracker if needed
|
|
927
|
+
if (stateInsertions.length > 0) {
|
|
928
|
+
prefixLines.push(
|
|
929
|
+
`if (!globalThis.__trickle_state_counts) { globalThis.__trickle_state_counts = new Map(); }`,
|
|
930
|
+
`function __trickle_ss(stateName, line, origSetter) {`,
|
|
931
|
+
` return function(newVal) {`,
|
|
932
|
+
` try {`,
|
|
933
|
+
` const key = ${JSON.stringify(filename)} + ':' + line + ':' + stateName;`,
|
|
934
|
+
` const n = (globalThis.__trickle_state_counts.get(key) || 0) + 1;`,
|
|
935
|
+
` globalThis.__trickle_state_counts.set(key, n);`,
|
|
936
|
+
` const t = typeof newVal;`,
|
|
937
|
+
` let sample;`,
|
|
938
|
+
` if (t === 'function') sample = '[fn updater]';`,
|
|
939
|
+
` else if (t === 'string') sample = newVal.length > 40 ? newVal.slice(0,40)+'...' : newVal;`,
|
|
940
|
+
` else if (t === 'number' || t === 'boolean') sample = newVal;`,
|
|
941
|
+
` else if (newVal === null || newVal === undefined) sample = newVal;`,
|
|
942
|
+
` else if (Array.isArray(newVal)) sample = '[arr:'+newVal.length+']';`,
|
|
943
|
+
` else sample = '[object]';`,
|
|
944
|
+
` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
|
|
945
|
+
` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`,
|
|
946
|
+
` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`,
|
|
947
|
+
` JSON.stringify({ kind: 'react_state', file: ${JSON.stringify(filename)}, line, stateName, updateCount: n, value: sample, timestamp: Date.now()/1000 }) + '\\n');`,
|
|
948
|
+
` } catch(e) {}`,
|
|
949
|
+
` return origSetter(newVal);`,
|
|
950
|
+
` };`,
|
|
951
|
+
`}`,
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
872
955
|
prefixLines.push('');
|
|
873
956
|
const prefix = prefixLines.join('\n');
|
|
874
957
|
|
|
@@ -912,6 +995,17 @@ function transformEsmSource(
|
|
|
912
995
|
allInsertions.push({ position: wrapEnd, code: `)` });
|
|
913
996
|
}
|
|
914
997
|
|
|
998
|
+
// useState insertions: TWO insertions per useState
|
|
999
|
+
// 1. Prefix setter name with '__trickle_s_' (rename in destructuring)
|
|
1000
|
+
// 2. After statement end, declare tracked wrapper: const setter = __trickle_ss(...)
|
|
1001
|
+
for (const { renamePos, afterLine, stateName, setterName, lineNo } of stateInsertions) {
|
|
1002
|
+
allInsertions.push({ position: renamePos, code: `__trickle_s_` });
|
|
1003
|
+
allInsertions.push({
|
|
1004
|
+
position: afterLine,
|
|
1005
|
+
code: `const ${setterName}=__trickle_ss(${JSON.stringify(stateName)},${lineNo},__trickle_s_${setterName});\n`,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
915
1009
|
// Sort by position descending (insert from end to preserve earlier positions)
|
|
916
1010
|
allInsertions.sort((a, b) => b.position - a.position);
|
|
917
1011
|
|