evergreen-sdk 1.0.1 → 1.0.3

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/index.d.ts CHANGED
@@ -2,15 +2,23 @@ interface ApiCallMeta {
2
2
  callee: string;
3
3
  arg0?: string;
4
4
  }
5
+ interface RunOptions {
6
+ callTrace?: {
7
+ include?: string[];
8
+ exclude?: string[];
9
+ };
10
+ }
5
11
  interface StepOptions {
6
12
  input?: any;
7
13
  children?: string[];
8
14
  apiCalls?: ApiCallMeta[];
9
15
  __not_async?: boolean;
10
16
  }
17
+ declare function runWithContext<T>(jobId: string, fn: () => T): T;
18
+ declare function runWithContext<T>(jobId: string, options: RunOptions, fn: () => T): T;
11
19
  export declare const evergreen: {
12
20
  clear(): void;
13
- run<T>(jobId: string, fn: () => T): T;
21
+ run: typeof runWithContext;
14
22
  __autoStep<T>(name: string, location: {
15
23
  file: string;
16
24
  line: number;
package/dist/index.js CHANGED
@@ -10,15 +10,44 @@ const traceStorage = new node_async_hooks_1.AsyncLocalStorage();
10
10
  let ws = null;
11
11
  let isConnected = false;
12
12
  const pendingTelemetry = [];
13
+ function getTelemetryWsUrl() {
14
+ const explicit = process.env.EVERGREEN_TRACE_WS_URL;
15
+ if (explicit && explicit.trim())
16
+ return explicit.trim();
17
+ return "ws://localhost:4321";
18
+ }
19
+ function shouldDebugTelemetryConnection() {
20
+ return (process.env.EVERGREEN_TRACE_DEBUG === "1" ||
21
+ process.env.EVERGREEN_TRACE_DEBUG === "true");
22
+ }
23
+ function shouldDisableWs() {
24
+ // In tests, keeping a websocket instance around can keep the Node test runner alive.
25
+ if (process.env.NODE_ENV === "test")
26
+ return true;
27
+ return (process.env.EVERGREEN_TRACE_DISABLE_WS === "1" ||
28
+ process.env.EVERGREEN_TRACE_DISABLE_WS === "true");
29
+ }
13
30
  // Connect to the VS Code Extension Hub
14
31
  function connect() {
32
+ if (shouldDisableWs())
33
+ return;
15
34
  if (ws &&
16
35
  (ws.readyState === ws_1.default.CONNECTING || ws.readyState === ws_1.default.OPEN))
17
36
  return;
37
+ const url = getTelemetryWsUrl();
38
+ const debug = shouldDebugTelemetryConnection();
18
39
  try {
19
- ws = new ws_1.default("ws://localhost:3000");
40
+ if (debug) {
41
+ // eslint-disable-next-line no-console
42
+ console.log(`🌲 Evergreen Trace: connecting to ${url}`);
43
+ }
44
+ ws = new ws_1.default(url);
20
45
  ws.on("open", () => {
21
46
  isConnected = true;
47
+ if (debug) {
48
+ // eslint-disable-next-line no-console
49
+ console.log(`🌲 Evergreen Trace: connected to ${url}`);
50
+ }
22
51
  // Flush any telemetry that was emitted before the socket became ready.
23
52
  while (pendingTelemetry.length > 0) {
24
53
  const queued = pendingTelemetry.shift();
@@ -75,6 +104,41 @@ function sendTelemetry(payload) {
75
104
  // Fire and forget, silently ignore send errors
76
105
  }
77
106
  }
107
+ function escapeRegexLiteral(input) {
108
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
109
+ }
110
+ function matchesPattern(value, pattern) {
111
+ // Support:
112
+ // - exact match: "app.listen"
113
+ // - simple wildcard: "redis.*" or "prisma.*.findMany" etc.
114
+ if (!pattern)
115
+ return false;
116
+ if (!pattern.includes("*"))
117
+ return value === pattern;
118
+ const re = new RegExp("^" +
119
+ pattern
120
+ .split("*")
121
+ .map((part) => escapeRegexLiteral(part))
122
+ .join(".*") +
123
+ "$");
124
+ return re.test(value);
125
+ }
126
+ function filterApiCallsForRun(apiCalls, store) {
127
+ if (!apiCalls || apiCalls.length === 0)
128
+ return [];
129
+ const include = store.callTrace?.include?.filter(Boolean) ?? [];
130
+ const exclude = store.callTrace?.exclude?.filter(Boolean) ?? [];
131
+ return apiCalls.filter((c) => {
132
+ if (exclude.length && exclude.some((p) => matchesPattern(c.callee, p))) {
133
+ return false;
134
+ }
135
+ if (include.length) {
136
+ return include.some((p) => matchesPattern(c.callee, p));
137
+ }
138
+ // default: include everything that was collected
139
+ return true;
140
+ });
141
+ }
78
142
  function extractLocationFromStack(stack) {
79
143
  if (!stack)
80
144
  return undefined;
@@ -93,13 +157,19 @@ function extractLocationFromStack(stack) {
93
157
  }
94
158
  return undefined;
95
159
  }
160
+ function runWithContext(jobId, a, b) {
161
+ const fn = typeof a === "function" ? a : b;
162
+ const options = typeof a === "function" ? undefined : a;
163
+ if (!fn) {
164
+ throw new Error("evergreen.run(jobId, fn) requires a function");
165
+ }
166
+ return traceStorage.run({ jobId, callTrace: options?.callTrace }, fn);
167
+ }
96
168
  exports.evergreen = {
97
169
  clear() {
98
170
  sendTelemetry({ type: "CLEAR" });
99
171
  },
100
- run(jobId, fn) {
101
- return traceStorage.run({ jobId }, fn);
102
- },
172
+ run: runWithContext,
103
173
  async __autoStep(name, location, fn, options) {
104
174
  const store = traceStorage.getStore();
105
175
  // If no context, just execute the function normally without tracing
@@ -107,6 +177,7 @@ exports.evergreen = {
107
177
  return await fn();
108
178
  }
109
179
  const { jobId, previousStep } = store;
180
+ const filteredApiCalls = filterApiCallsForRun(options?.apiCalls, store);
110
181
  const timestamp = Date.now();
111
182
  const startTime = process.hrtime.bigint();
112
183
  // Emit NODE_STARTED
@@ -118,7 +189,7 @@ exports.evergreen = {
118
189
  previousStep,
119
190
  location,
120
191
  children: options?.children || [],
121
- apiCalls: options?.apiCalls || [],
192
+ apiCalls: filteredApiCalls,
122
193
  __not_async: options?.__not_async ?? false,
123
194
  timestamp,
124
195
  });
@@ -151,7 +222,7 @@ exports.evergreen = {
151
222
  location,
152
223
  // include children and apiCalls again so the UI can still render ghost edges & API badges even if NODE_STARTED was missed
153
224
  children: options?.children || [],
154
- apiCalls: options?.apiCalls || [],
225
+ apiCalls: filteredApiCalls,
155
226
  __not_async: options?.__not_async ?? false,
156
227
  timestamp: Date.now(),
157
228
  });
@@ -1 +1,2 @@
1
1
  export declare function testAlsPropagation(): Promise<void>;
2
+ export declare function testRunCallTraceFiltering(): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.testAlsPropagation = testAlsPropagation;
4
+ exports.testRunCallTraceFiltering = testRunCallTraceFiltering;
4
5
  const index_1 = require("./index");
5
6
  // Simple Node-style test (compatible with node --test once compiled)
6
7
  // exported for use in dist/index.test.js
@@ -44,3 +45,39 @@ async function testAlsPropagation() {
44
45
  }
45
46
  }
46
47
  }
48
+ async function testRunCallTraceFiltering() {
49
+ const events = [];
50
+ const originalSend = index_1.evergreen.__test__?.sendTelemetry;
51
+ if (originalSend) {
52
+ index_1.evergreen.__test__.sendTelemetry = (payload) => {
53
+ events.push(payload);
54
+ originalSend(payload);
55
+ };
56
+ }
57
+ try {
58
+ await index_1.evergreen.run("job-filter", { callTrace: { include: ["app.listen", "redis.*"], exclude: ["redis.ping"] } }, async () => {
59
+ await index_1.evergreen.__autoStep("step", { file: "test.ts", line: 1, column: 0 }, async () => 1, {
60
+ apiCalls: [
61
+ { callee: "app.listen", arg0: "3000" },
62
+ { callee: "redis.ping" },
63
+ { callee: "redis.get", arg0: "k" },
64
+ { callee: "prisma.user.findMany" },
65
+ ],
66
+ });
67
+ });
68
+ const started = events.find((e) => e.event === "NODE_STARTED" && e.stepName === "step");
69
+ if (!started) {
70
+ throw new Error("Expected NODE_STARTED for step");
71
+ }
72
+ const callees = (started.apiCalls || []).map((c) => c.callee);
73
+ const expected = ["app.listen", "redis.get"];
74
+ if (JSON.stringify(callees) !== JSON.stringify(expected)) {
75
+ throw new Error(`Unexpected filtered callees: ${JSON.stringify(callees)} expected ${JSON.stringify(expected)}`);
76
+ }
77
+ }
78
+ finally {
79
+ if (originalSend) {
80
+ index_1.evergreen.__test__.sendTelemetry = originalSend;
81
+ }
82
+ }
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evergreen-sdk",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Evergreen Trace Backend SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.test.ts CHANGED
@@ -56,3 +56,56 @@ export async function testAlsPropagation() {
56
56
  }
57
57
  }
58
58
 
59
+ export async function testRunCallTraceFiltering() {
60
+ const events: any[] = [];
61
+ const originalSend = (evergreen as any).__test__?.sendTelemetry;
62
+
63
+ if (originalSend) {
64
+ (evergreen as any).__test__.sendTelemetry = (payload: any) => {
65
+ events.push(payload);
66
+ originalSend(payload);
67
+ };
68
+ }
69
+
70
+ try {
71
+ await evergreen.run(
72
+ "job-filter",
73
+ { callTrace: { include: ["app.listen", "redis.*"], exclude: ["redis.ping"] } },
74
+ async () => {
75
+ await evergreen.__autoStep(
76
+ "step",
77
+ { file: "test.ts", line: 1, column: 0 },
78
+ async () => 1,
79
+ {
80
+ apiCalls: [
81
+ { callee: "app.listen", arg0: "3000" },
82
+ { callee: "redis.ping" },
83
+ { callee: "redis.get", arg0: "k" },
84
+ { callee: "prisma.user.findMany" },
85
+ ],
86
+ },
87
+ );
88
+ },
89
+ );
90
+
91
+ const started = events.find(
92
+ (e) => e.event === "NODE_STARTED" && e.stepName === "step",
93
+ );
94
+ if (!started) {
95
+ throw new Error("Expected NODE_STARTED for step");
96
+ }
97
+
98
+ const callees = (started.apiCalls || []).map((c: any) => c.callee);
99
+ const expected = ["app.listen", "redis.get"];
100
+ if (JSON.stringify(callees) !== JSON.stringify(expected)) {
101
+ throw new Error(
102
+ `Unexpected filtered callees: ${JSON.stringify(callees)} expected ${JSON.stringify(expected)}`,
103
+ );
104
+ }
105
+ } finally {
106
+ if (originalSend) {
107
+ (evergreen as any).__test__.sendTelemetry = originalSend;
108
+ }
109
+ }
110
+ }
111
+
package/src/index.ts CHANGED
@@ -4,6 +4,10 @@ import WebSocket from "ws";
4
4
  interface EvergreenContext {
5
5
  jobId: string;
6
6
  previousStep?: string;
7
+ callTrace?: {
8
+ include?: string[];
9
+ exclude?: string[];
10
+ };
7
11
  }
8
12
 
9
13
  interface ApiCallMeta {
@@ -11,6 +15,13 @@ interface ApiCallMeta {
11
15
  arg0?: string;
12
16
  }
13
17
 
18
+ interface RunOptions {
19
+ callTrace?: {
20
+ include?: string[]; // patterns like "app.listen" or "redis.*"
21
+ exclude?: string[];
22
+ };
23
+ }
24
+
14
25
  interface StepOptions {
15
26
  input?: any;
16
27
  children?: string[];
@@ -36,8 +47,18 @@ function shouldDebugTelemetryConnection(): boolean {
36
47
  );
37
48
  }
38
49
 
50
+ function shouldDisableWs(): boolean {
51
+ // In tests, keeping a websocket instance around can keep the Node test runner alive.
52
+ if (process.env.NODE_ENV === "test") return true;
53
+ return (
54
+ process.env.EVERGREEN_TRACE_DISABLE_WS === "1" ||
55
+ process.env.EVERGREEN_TRACE_DISABLE_WS === "true"
56
+ );
57
+ }
58
+
39
59
  // Connect to the VS Code Extension Hub
40
60
  function connect() {
61
+ if (shouldDisableWs()) return;
41
62
  if (
42
63
  ws &&
43
64
  (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
@@ -123,6 +144,48 @@ function sendTelemetry(payload: any) {
123
144
  }
124
145
  }
125
146
 
147
+ function escapeRegexLiteral(input: string): string {
148
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ }
150
+
151
+ function matchesPattern(value: string, pattern: string): boolean {
152
+ // Support:
153
+ // - exact match: "app.listen"
154
+ // - simple wildcard: "redis.*" or "prisma.*.findMany" etc.
155
+ if (!pattern) return false;
156
+ if (!pattern.includes("*")) return value === pattern;
157
+
158
+ const re = new RegExp(
159
+ "^" +
160
+ pattern
161
+ .split("*")
162
+ .map((part) => escapeRegexLiteral(part))
163
+ .join(".*") +
164
+ "$",
165
+ );
166
+ return re.test(value);
167
+ }
168
+
169
+ function filterApiCallsForRun(
170
+ apiCalls: ApiCallMeta[] | undefined,
171
+ store: EvergreenContext,
172
+ ): ApiCallMeta[] {
173
+ if (!apiCalls || apiCalls.length === 0) return [];
174
+ const include = store.callTrace?.include?.filter(Boolean) ?? [];
175
+ const exclude = store.callTrace?.exclude?.filter(Boolean) ?? [];
176
+
177
+ return apiCalls.filter((c) => {
178
+ if (exclude.length && exclude.some((p) => matchesPattern(c.callee, p))) {
179
+ return false;
180
+ }
181
+ if (include.length) {
182
+ return include.some((p) => matchesPattern(c.callee, p));
183
+ }
184
+ // default: include everything that was collected
185
+ return true;
186
+ });
187
+ }
188
+
126
189
  function extractLocationFromStack(
127
190
  stack?: string,
128
191
  ): { file: string; line: number } | undefined {
@@ -146,14 +209,27 @@ function extractLocationFromStack(
146
209
  return undefined;
147
210
  }
148
211
 
212
+ function runWithContext<T>(jobId: string, fn: () => T): T;
213
+ function runWithContext<T>(jobId: string, options: RunOptions, fn: () => T): T;
214
+ function runWithContext<T>(
215
+ jobId: string,
216
+ a: RunOptions | (() => T),
217
+ b?: () => T,
218
+ ): T {
219
+ const fn = typeof a === "function" ? a : b;
220
+ const options = typeof a === "function" ? undefined : a;
221
+ if (!fn) {
222
+ throw new Error("evergreen.run(jobId, fn) requires a function");
223
+ }
224
+ return traceStorage.run({ jobId, callTrace: options?.callTrace }, fn);
225
+ }
226
+
149
227
  export const evergreen = {
150
228
  clear() {
151
229
  sendTelemetry({ type: "CLEAR" });
152
230
  },
153
231
 
154
- run<T>(jobId: string, fn: () => T): T {
155
- return traceStorage.run({ jobId }, fn);
156
- },
232
+ run: runWithContext,
157
233
 
158
234
  async __autoStep<T>(
159
235
  name: string,
@@ -169,6 +245,7 @@ export const evergreen = {
169
245
  }
170
246
 
171
247
  const { jobId, previousStep } = store;
248
+ const filteredApiCalls = filterApiCallsForRun(options?.apiCalls, store);
172
249
 
173
250
  const timestamp = Date.now();
174
251
  const startTime = process.hrtime.bigint();
@@ -182,7 +259,7 @@ export const evergreen = {
182
259
  previousStep,
183
260
  location,
184
261
  children: options?.children || [],
185
- apiCalls: options?.apiCalls || [],
262
+ apiCalls: filteredApiCalls,
186
263
  __not_async: options?.__not_async ?? false,
187
264
  timestamp,
188
265
  });
@@ -219,7 +296,7 @@ export const evergreen = {
219
296
  location,
220
297
  // include children and apiCalls again so the UI can still render ghost edges & API badges even if NODE_STARTED was missed
221
298
  children: options?.children || [],
222
- apiCalls: options?.apiCalls || [],
299
+ apiCalls: filteredApiCalls,
223
300
  __not_async: options?.__not_async ?? false,
224
301
  timestamp: Date.now(),
225
302
  });
package/src/index.js DELETED
@@ -1,160 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.evergreen = void 0;
4
- const node_async_hooks_1 = require("node:async_hooks");
5
- const ws_1 = require("ws");
6
- const traceStorage = new node_async_hooks_1.AsyncLocalStorage();
7
- let ws = null;
8
- let isConnected = false;
9
- // Connect to the VS Code Extension Hub
10
- function connect() {
11
- if (ws && (ws.readyState === ws_1.default.CONNECTING || ws.readyState === ws_1.default.OPEN))
12
- return;
13
- try {
14
- ws = new ws_1.default('ws://localhost:3000');
15
- ws.on('open', () => {
16
- isConnected = true;
17
- });
18
- ws.on('close', () => {
19
- isConnected = false;
20
- // Optionally implement reconnect logic here
21
- });
22
- ws.on('error', () => {
23
- isConnected = false;
24
- // Silently fail if extension is not running
25
- });
26
- }
27
- catch (error) {
28
- isConnected = false;
29
- }
30
- }
31
- // Ensure connection is attempted
32
- connect();
33
- function sanitizePayload(obj, maxDepth = 3) {
34
- if (maxDepth <= 0)
35
- return '[Max Depth Reached]';
36
- if (obj === null || obj === undefined)
37
- return obj;
38
- if (typeof obj !== 'object')
39
- return obj;
40
- if (obj instanceof Error) {
41
- return { name: obj.name, message: obj.message, stack: obj.stack };
42
- }
43
- if (Array.isArray(obj)) {
44
- return obj.map(item => sanitizePayload(item, maxDepth - 1));
45
- }
46
- const sanitized = {};
47
- for (const [key, value] of Object.entries(obj)) {
48
- sanitized[key] = sanitizePayload(value, maxDepth - 1);
49
- }
50
- return sanitized;
51
- }
52
- function sendTelemetry(payload) {
53
- if (!isConnected || !ws || ws.readyState !== ws_1.default.OPEN)
54
- return;
55
- try {
56
- ws.send(JSON.stringify(payload));
57
- }
58
- catch (error) {
59
- // Fire and forget, silently ignore send errors
60
- }
61
- }
62
- function extractLocationFromStack(stack) {
63
- if (!stack)
64
- return undefined;
65
- // The stack will look like:
66
- // Error
67
- // at Object.step (/path/to/sdk/index.ts:...)
68
- // at myBusinessLogic (/path/to/business/logic.ts:42:15)
69
- const lines = stack.split('\n');
70
- if (lines.length >= 3) {
71
- const callerLine = lines[2];
72
- const match = callerLine.match(/\((.+):(\d+):\d+\)/) || callerLine.match(/at (.+):(\d+):\d+/);
73
- if (match && match.length >= 3) {
74
- return { file: match[1], line: parseInt(match[2], 10) };
75
- }
76
- }
77
- return undefined;
78
- }
79
- exports.evergreen = {
80
- clear() {
81
- sendTelemetry({ type: 'CLEAR' });
82
- },
83
- run(jobId, fn) {
84
- return traceStorage.run({ jobId }, fn);
85
- },
86
- async step(name, fn, options) {
87
- const store = traceStorage.getStore();
88
- // If no context, just execute the function normally without tracing
89
- if (!store) {
90
- return await fn();
91
- }
92
- const { jobId, previousStep } = store;
93
- // Capture location using a throwaway error
94
- const locationError = new Error();
95
- const location = extractLocationFromStack(locationError.stack) || { file: 'unknown', line: 0 };
96
- const timestamp = Date.now();
97
- const startTime = process.hrtime.bigint();
98
- // Emit NODE_STARTED
99
- sendTelemetry({
100
- event: 'NODE_STARTED',
101
- status: 'PENDING',
102
- jobId,
103
- stepName: name,
104
- previousStep,
105
- location,
106
- timestamp
107
- });
108
- // We must ensure subsequent nested steps know this step is their parent.
109
- // To do this, we run the inner function in a nested context.
110
- const newContext = { ...store, previousStep: name };
111
- try {
112
- // Execute the business logic within the new nested context
113
- const result = await traceStorage.run(newContext, async () => {
114
- return await fn();
115
- });
116
- const endTime = process.hrtime.bigint();
117
- const durationMs = Number(endTime - startTime) / 1000000;
118
- // Emit NODE_COMPLETED
119
- sendTelemetry({
120
- event: 'NODE_COMPLETED',
121
- status: 'SUCCESS',
122
- jobId,
123
- stepName: name,
124
- previousStep,
125
- payload: { input: sanitizePayload(options?.input), output: sanitizePayload(result) },
126
- context: {
127
- input: sanitizePayload(options?.input),
128
- output: sanitizePayload(result),
129
- duration: durationMs
130
- },
131
- location,
132
- timestamp: Date.now()
133
- });
134
- return result;
135
- }
136
- catch (error) {
137
- const endTime = process.hrtime.bigint();
138
- const durationMs = Number(endTime - startTime) / 1000000;
139
- // Emit NODE_FAILED
140
- sendTelemetry({
141
- event: 'NODE_FAILED',
142
- status: 'FAILED',
143
- jobId,
144
- stepName: name,
145
- previousStep,
146
- payload: { input: sanitizePayload(options?.input), error: sanitizePayload(error) },
147
- context: {
148
- input: sanitizePayload(options?.input),
149
- error: sanitizePayload(error),
150
- duration: durationMs
151
- },
152
- location,
153
- timestamp: Date.now()
154
- });
155
- // Re-throw so backend logic behaves normally
156
- throw error;
157
- }
158
- }
159
- };
160
- //# sourceMappingURL=index.js.map