evergreen-sdk 1.0.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.
@@ -0,0 +1,21 @@
1
+ interface ApiCallMeta {
2
+ callee: string;
3
+ arg0?: string;
4
+ }
5
+ interface StepOptions {
6
+ input?: any;
7
+ children?: string[];
8
+ apiCalls?: ApiCallMeta[];
9
+ __not_async?: boolean;
10
+ }
11
+ export declare const evergreen: {
12
+ clear(): void;
13
+ run<T>(jobId: string, fn: () => T): T;
14
+ __autoStep<T>(name: string, location: {
15
+ file: string;
16
+ line: number;
17
+ column?: number;
18
+ }, fn: () => T | Promise<T>, options?: StepOptions): Promise<T>;
19
+ step<T>(name: string, fn: () => T | Promise<T>, options?: StepOptions): Promise<T>;
20
+ };
21
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,273 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.evergreen = void 0;
7
+ const node_async_hooks_1 = require("node:async_hooks");
8
+ const ws_1 = __importDefault(require("ws"));
9
+ const traceStorage = new node_async_hooks_1.AsyncLocalStorage();
10
+ let ws = null;
11
+ let isConnected = false;
12
+ const pendingTelemetry = [];
13
+ // Connect to the VS Code Extension Hub
14
+ function connect() {
15
+ if (ws &&
16
+ (ws.readyState === ws_1.default.CONNECTING || ws.readyState === ws_1.default.OPEN))
17
+ return;
18
+ try {
19
+ ws = new ws_1.default("ws://localhost:3000");
20
+ ws.on("open", () => {
21
+ isConnected = true;
22
+ // Flush any telemetry that was emitted before the socket became ready.
23
+ while (pendingTelemetry.length > 0) {
24
+ const queued = pendingTelemetry.shift();
25
+ if (queued) {
26
+ sendTelemetry(queued);
27
+ }
28
+ }
29
+ });
30
+ ws.on("close", () => {
31
+ isConnected = false;
32
+ // Optionally implement reconnect logic here
33
+ });
34
+ ws.on("error", () => {
35
+ isConnected = false;
36
+ // Silently fail if extension is not running
37
+ });
38
+ }
39
+ catch (error) {
40
+ isConnected = false;
41
+ }
42
+ }
43
+ // Ensure connection is attempted
44
+ connect();
45
+ function sanitizePayload(obj, maxDepth = 3) {
46
+ if (maxDepth <= 0)
47
+ return "[Max Depth Reached]";
48
+ if (obj === null || obj === undefined)
49
+ return obj;
50
+ if (typeof obj !== "object")
51
+ return obj;
52
+ if (obj instanceof Error) {
53
+ return { name: obj.name, message: obj.message, stack: obj.stack };
54
+ }
55
+ if (Array.isArray(obj)) {
56
+ return obj.map((item) => sanitizePayload(item, maxDepth - 1));
57
+ }
58
+ const sanitized = {};
59
+ for (const [key, value] of Object.entries(obj)) {
60
+ sanitized[key] = sanitizePayload(value, maxDepth - 1);
61
+ }
62
+ return sanitized;
63
+ }
64
+ function sendTelemetry(payload) {
65
+ console.log("TELEMETRY EMITTED:", payload.stepName, payload.status);
66
+ if (!ws || ws.readyState !== ws_1.default.OPEN) {
67
+ // Queue messages until the websocket is fully ready instead of dropping them.
68
+ pendingTelemetry.push(payload);
69
+ return;
70
+ }
71
+ try {
72
+ ws.send(JSON.stringify(payload));
73
+ }
74
+ catch (error) {
75
+ // Fire and forget, silently ignore send errors
76
+ }
77
+ }
78
+ function extractLocationFromStack(stack) {
79
+ if (!stack)
80
+ return undefined;
81
+ // The stack will look like:
82
+ // Error
83
+ // at Object.step (/path/to/sdk/index.ts:...)
84
+ // at myBusinessLogic (/path/to/business/logic.ts:42:15)
85
+ const lines = stack.split("\n");
86
+ if (lines.length >= 3) {
87
+ const callerLine = lines[2];
88
+ const match = callerLine.match(/\((.+):(\d+):\d+\)/) ||
89
+ callerLine.match(/at (.+):(\d+):\d+/);
90
+ if (match && match.length >= 3) {
91
+ return { file: match[1], line: parseInt(match[2], 10) };
92
+ }
93
+ }
94
+ return undefined;
95
+ }
96
+ exports.evergreen = {
97
+ clear() {
98
+ sendTelemetry({ type: "CLEAR" });
99
+ },
100
+ run(jobId, fn) {
101
+ return traceStorage.run({ jobId }, fn);
102
+ },
103
+ async __autoStep(name, location, fn, options) {
104
+ const store = traceStorage.getStore();
105
+ // If no context, just execute the function normally without tracing
106
+ if (!store) {
107
+ return await fn();
108
+ }
109
+ const { jobId, previousStep } = store;
110
+ const timestamp = Date.now();
111
+ const startTime = process.hrtime.bigint();
112
+ // Emit NODE_STARTED
113
+ sendTelemetry({
114
+ event: "NODE_STARTED",
115
+ status: "PENDING",
116
+ jobId,
117
+ stepName: name,
118
+ previousStep,
119
+ location,
120
+ children: options?.children || [],
121
+ apiCalls: options?.apiCalls || [],
122
+ __not_async: options?.__not_async ?? false,
123
+ timestamp,
124
+ });
125
+ // We must ensure subsequent nested steps know this step is their parent.
126
+ // To do this, we run the inner function in a nested context.
127
+ const newContext = { ...store, previousStep: name };
128
+ try {
129
+ // Execute the business logic within the new nested context
130
+ const result = await traceStorage.run(newContext, async () => {
131
+ return await fn();
132
+ });
133
+ const endTime = process.hrtime.bigint();
134
+ const durationMs = Number(endTime - startTime) / 1000000;
135
+ // Emit NODE_COMPLETED
136
+ sendTelemetry({
137
+ event: "NODE_COMPLETED",
138
+ status: "SUCCESS",
139
+ jobId,
140
+ stepName: name,
141
+ previousStep,
142
+ payload: {
143
+ input: sanitizePayload(options?.input),
144
+ output: sanitizePayload(result),
145
+ },
146
+ context: {
147
+ input: sanitizePayload(options?.input),
148
+ output: sanitizePayload(result),
149
+ duration: durationMs,
150
+ },
151
+ location,
152
+ // include children and apiCalls again so the UI can still render ghost edges & API badges even if NODE_STARTED was missed
153
+ children: options?.children || [],
154
+ apiCalls: options?.apiCalls || [],
155
+ __not_async: options?.__not_async ?? false,
156
+ timestamp: Date.now(),
157
+ });
158
+ return result;
159
+ }
160
+ catch (error) {
161
+ const endTime = process.hrtime.bigint();
162
+ const durationMs = Number(endTime - startTime) / 1000000;
163
+ // Emit NODE_FAILED
164
+ sendTelemetry({
165
+ event: "NODE_FAILED",
166
+ status: "FAILED",
167
+ jobId,
168
+ stepName: name,
169
+ previousStep,
170
+ payload: {
171
+ input: sanitizePayload(options?.input),
172
+ error: sanitizePayload(error),
173
+ },
174
+ context: {
175
+ input: sanitizePayload(options?.input),
176
+ error: sanitizePayload(error),
177
+ duration: durationMs,
178
+ },
179
+ location,
180
+ timestamp: Date.now(),
181
+ });
182
+ // Re-throw so backend logic behaves normally
183
+ throw error;
184
+ }
185
+ },
186
+ async step(name, fn, options) {
187
+ const store = traceStorage.getStore();
188
+ // If no context, just execute the function normally without tracing
189
+ if (!store) {
190
+ return await fn();
191
+ }
192
+ const { jobId, previousStep } = store;
193
+ // Capture location using a throwaway error
194
+ const locationError = new Error();
195
+ const location = extractLocationFromStack(locationError.stack) || {
196
+ file: "unknown",
197
+ line: 0,
198
+ };
199
+ const timestamp = Date.now();
200
+ const startTime = process.hrtime.bigint();
201
+ // Emit NODE_STARTED
202
+ sendTelemetry({
203
+ event: "NODE_STARTED",
204
+ status: "PENDING",
205
+ jobId,
206
+ stepName: name,
207
+ previousStep,
208
+ location,
209
+ timestamp,
210
+ });
211
+ // We must ensure subsequent nested steps know this step is their parent.
212
+ // To do this, we run the inner function in a nested context.
213
+ const newContext = { ...store, previousStep: name };
214
+ try {
215
+ // Execute the business logic within the new nested context
216
+ const result = await traceStorage.run(newContext, async () => {
217
+ return await fn();
218
+ });
219
+ const endTime = process.hrtime.bigint();
220
+ const durationMs = Number(endTime - startTime) / 1000000;
221
+ // Emit NODE_COMPLETED
222
+ sendTelemetry({
223
+ event: "NODE_COMPLETED",
224
+ status: "SUCCESS",
225
+ jobId,
226
+ stepName: name,
227
+ previousStep,
228
+ payload: {
229
+ input: sanitizePayload(options?.input),
230
+ output: sanitizePayload(result),
231
+ },
232
+ context: {
233
+ input: sanitizePayload(options?.input),
234
+ output: sanitizePayload(result),
235
+ duration: durationMs,
236
+ },
237
+ location,
238
+ timestamp: Date.now(),
239
+ });
240
+ return result;
241
+ }
242
+ catch (error) {
243
+ const endTime = process.hrtime.bigint();
244
+ const durationMs = Number(endTime - startTime) / 1000000;
245
+ // Emit NODE_FAILED
246
+ sendTelemetry({
247
+ event: "NODE_FAILED",
248
+ status: "FAILED",
249
+ jobId,
250
+ stepName: name,
251
+ previousStep,
252
+ payload: {
253
+ input: sanitizePayload(options?.input),
254
+ error: sanitizePayload(error),
255
+ },
256
+ context: {
257
+ input: sanitizePayload(options?.input),
258
+ error: sanitizePayload(error),
259
+ duration: durationMs,
260
+ },
261
+ location,
262
+ timestamp: Date.now(),
263
+ });
264
+ // Re-throw so backend logic behaves normally
265
+ throw error;
266
+ }
267
+ },
268
+ };
269
+ // Test hooks to allow unit tests to observe telemetry behavior without
270
+ // depending on console output.
271
+ exports.evergreen.__test__ = {
272
+ sendTelemetry,
273
+ };
@@ -0,0 +1 @@
1
+ export declare function testAlsPropagation(): Promise<void>;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.testAlsPropagation = testAlsPropagation;
4
+ const index_1 = require("./index");
5
+ // Simple Node-style test (compatible with node --test once compiled)
6
+ // exported for use in dist/index.test.js
7
+ async function testAlsPropagation() {
8
+ const events = [];
9
+ const originalSend = index_1.evergreen.__test__?.sendTelemetry;
10
+ if (originalSend) {
11
+ index_1.evergreen.__test__.sendTelemetry = (payload) => {
12
+ events.push(payload);
13
+ originalSend(payload);
14
+ };
15
+ }
16
+ try {
17
+ await index_1.evergreen.run("job-123", async () => {
18
+ await index_1.evergreen.__autoStep("outer", { file: "test.ts", line: 1, column: 0 }, async () => {
19
+ await index_1.evergreen.__autoStep("inner", { file: "test.ts", line: 5, column: 0 }, async () => 42);
20
+ }, {});
21
+ });
22
+ const started = events.filter((e) => e.event === "NODE_STARTED");
23
+ const outerStarted = started.find((e) => e.stepName === "outer");
24
+ const innerStarted = started.find((e) => e.stepName === "inner");
25
+ if (!outerStarted || !innerStarted) {
26
+ throw new Error("Expected NODE_STARTED events for outer and inner");
27
+ }
28
+ if (outerStarted.jobId !== "job-123") {
29
+ throw new Error("outer.jobId mismatch");
30
+ }
31
+ if (innerStarted.jobId !== "job-123") {
32
+ throw new Error("inner.jobId mismatch");
33
+ }
34
+ if (outerStarted.previousStep !== undefined) {
35
+ throw new Error("outer.previousStep should be undefined");
36
+ }
37
+ if (innerStarted.previousStep !== "outer") {
38
+ throw new Error("inner.previousStep should be 'outer'");
39
+ }
40
+ }
41
+ finally {
42
+ if (originalSend) {
43
+ index_1.evergreen.__test__.sendTelemetry = originalSend;
44
+ }
45
+ }
46
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "evergreen-sdk",
3
+ "version": "1.0.0",
4
+ "description": "Evergreen Trace Backend SDK",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "watch": "tsc -w",
10
+ "test": "NODE_ENV=test node --test dist/index.test.js"
11
+ },
12
+ "dependencies": {
13
+ "ws": "^8.14.2"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^20.0.0",
17
+ "@types/ws": "^8.5.5",
18
+ "typescript": "^5.0.0"
19
+ }
20
+ }
package/src/index.js ADDED
@@ -0,0 +1,160 @@
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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ import { evergreen } from "./index";
2
+
3
+ // Simple Node-style test (compatible with node --test once compiled)
4
+ // exported for use in dist/index.test.js
5
+ export async function testAlsPropagation() {
6
+ const events: any[] = [];
7
+ const originalSend = (evergreen as any).__test__?.sendTelemetry;
8
+
9
+ if (originalSend) {
10
+ (evergreen as any).__test__.sendTelemetry = (payload: any) => {
11
+ events.push(payload);
12
+ originalSend(payload);
13
+ };
14
+ }
15
+
16
+ try {
17
+ await evergreen.run("job-123", async () => {
18
+ await evergreen.__autoStep(
19
+ "outer",
20
+ { file: "test.ts", line: 1, column: 0 },
21
+ async () => {
22
+ await evergreen.__autoStep(
23
+ "inner",
24
+ { file: "test.ts", line: 5, column: 0 },
25
+ async () => 42,
26
+ );
27
+ },
28
+ {},
29
+ );
30
+ });
31
+
32
+ const started = events.filter((e) => e.event === "NODE_STARTED");
33
+ const outerStarted = started.find((e) => e.stepName === "outer");
34
+ const innerStarted = started.find((e) => e.stepName === "inner");
35
+
36
+ if (!outerStarted || !innerStarted) {
37
+ throw new Error("Expected NODE_STARTED events for outer and inner");
38
+ }
39
+
40
+ if (outerStarted.jobId !== "job-123") {
41
+ throw new Error("outer.jobId mismatch");
42
+ }
43
+ if (innerStarted.jobId !== "job-123") {
44
+ throw new Error("inner.jobId mismatch");
45
+ }
46
+ if (outerStarted.previousStep !== undefined) {
47
+ throw new Error("outer.previousStep should be undefined");
48
+ }
49
+ if (innerStarted.previousStep !== "outer") {
50
+ throw new Error("inner.previousStep should be 'outer'");
51
+ }
52
+ } finally {
53
+ if (originalSend) {
54
+ (evergreen as any).__test__.sendTelemetry = originalSend;
55
+ }
56
+ }
57
+ }
58
+
package/src/index.ts ADDED
@@ -0,0 +1,334 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import WebSocket from "ws";
3
+
4
+ interface EvergreenContext {
5
+ jobId: string;
6
+ previousStep?: string;
7
+ }
8
+
9
+ interface ApiCallMeta {
10
+ callee: string;
11
+ arg0?: string;
12
+ }
13
+
14
+ interface StepOptions {
15
+ input?: any;
16
+ children?: string[];
17
+ apiCalls?: ApiCallMeta[];
18
+ __not_async?: boolean;
19
+ }
20
+
21
+ const traceStorage = new AsyncLocalStorage<EvergreenContext>();
22
+ let ws: WebSocket | null = null;
23
+ let isConnected = false;
24
+ const pendingTelemetry: any[] = [];
25
+
26
+ // Connect to the VS Code Extension Hub
27
+ function connect() {
28
+ if (
29
+ ws &&
30
+ (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
31
+ )
32
+ return;
33
+
34
+ try {
35
+ ws = new WebSocket("ws://localhost:4321");
36
+
37
+ ws.on("open", () => {
38
+ isConnected = true;
39
+ // Flush any telemetry that was emitted before the socket became ready.
40
+ while (pendingTelemetry.length > 0) {
41
+ const queued = pendingTelemetry.shift();
42
+ if (queued) {
43
+ sendTelemetry(queued);
44
+ }
45
+ }
46
+ });
47
+
48
+ ws.on("close", () => {
49
+ isConnected = false;
50
+ // Optionally implement reconnect logic here
51
+ });
52
+
53
+ ws.on("error", () => {
54
+ isConnected = false;
55
+ // Silently fail if extension is not running
56
+ });
57
+ } catch (error) {
58
+ isConnected = false;
59
+ }
60
+ }
61
+
62
+ // Ensure connection is attempted
63
+ connect();
64
+
65
+ function sanitizePayload(obj: any, maxDepth = 3): any {
66
+ if (maxDepth <= 0) return "[Max Depth Reached]";
67
+
68
+ if (obj === null || obj === undefined) return obj;
69
+ if (typeof obj !== "object") return obj;
70
+
71
+ if (obj instanceof Error) {
72
+ return { name: obj.name, message: obj.message, stack: obj.stack };
73
+ }
74
+
75
+ if (Array.isArray(obj)) {
76
+ return obj.map((item) => sanitizePayload(item, maxDepth - 1));
77
+ }
78
+
79
+ const sanitized: Record<string, any> = {};
80
+ for (const [key, value] of Object.entries(obj)) {
81
+ sanitized[key] = sanitizePayload(value, maxDepth - 1);
82
+ }
83
+ return sanitized;
84
+ }
85
+
86
+ function sendTelemetry(payload: any) {
87
+ console.log("TELEMETRY EMITTED:", payload.stepName, payload.status);
88
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
89
+ // Queue messages until the websocket is fully ready instead of dropping them.
90
+ pendingTelemetry.push(payload);
91
+ return;
92
+ }
93
+
94
+ try {
95
+ ws.send(JSON.stringify(payload));
96
+ } catch (error) {
97
+ // Fire and forget, silently ignore send errors
98
+ }
99
+ }
100
+
101
+ function extractLocationFromStack(
102
+ stack?: string,
103
+ ): { file: string; line: number } | undefined {
104
+ if (!stack) return undefined;
105
+
106
+ // The stack will look like:
107
+ // Error
108
+ // at Object.step (/path/to/sdk/index.ts:...)
109
+ // at myBusinessLogic (/path/to/business/logic.ts:42:15)
110
+
111
+ const lines = stack.split("\n");
112
+ if (lines.length >= 3) {
113
+ const callerLine = lines[2];
114
+ const match =
115
+ callerLine.match(/\((.+):(\d+):\d+\)/) ||
116
+ callerLine.match(/at (.+):(\d+):\d+/);
117
+ if (match && match.length >= 3) {
118
+ return { file: match[1], line: parseInt(match[2], 10) };
119
+ }
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ export const evergreen = {
125
+ clear() {
126
+ sendTelemetry({ type: "CLEAR" });
127
+ },
128
+
129
+ run<T>(jobId: string, fn: () => T): T {
130
+ return traceStorage.run({ jobId }, fn);
131
+ },
132
+
133
+ async __autoStep<T>(
134
+ name: string,
135
+ location: { file: string; line: number; column?: number },
136
+ fn: () => T | Promise<T>,
137
+ options?: StepOptions,
138
+ ): Promise<T> {
139
+ const store = traceStorage.getStore();
140
+
141
+ // If no context, just execute the function normally without tracing
142
+ if (!store) {
143
+ return await fn();
144
+ }
145
+
146
+ const { jobId, previousStep } = store;
147
+
148
+ const timestamp = Date.now();
149
+ const startTime = process.hrtime.bigint();
150
+
151
+ // Emit NODE_STARTED
152
+ sendTelemetry({
153
+ event: "NODE_STARTED",
154
+ status: "PENDING",
155
+ jobId,
156
+ stepName: name,
157
+ previousStep,
158
+ location,
159
+ children: options?.children || [],
160
+ apiCalls: options?.apiCalls || [],
161
+ __not_async: options?.__not_async ?? false,
162
+ timestamp,
163
+ });
164
+
165
+ // We must ensure subsequent nested steps know this step is their parent.
166
+ // To do this, we run the inner function in a nested context.
167
+ const newContext = { ...store, previousStep: name };
168
+
169
+ try {
170
+ // Execute the business logic within the new nested context
171
+ const result = await traceStorage.run(newContext, async () => {
172
+ return await fn();
173
+ });
174
+
175
+ const endTime = process.hrtime.bigint();
176
+ const durationMs = Number(endTime - startTime) / 1000000;
177
+
178
+ // Emit NODE_COMPLETED
179
+ sendTelemetry({
180
+ event: "NODE_COMPLETED",
181
+ status: "SUCCESS",
182
+ jobId,
183
+ stepName: name,
184
+ previousStep,
185
+ payload: {
186
+ input: sanitizePayload(options?.input),
187
+ output: sanitizePayload(result),
188
+ },
189
+ context: {
190
+ input: sanitizePayload(options?.input),
191
+ output: sanitizePayload(result),
192
+ duration: durationMs,
193
+ },
194
+ location,
195
+ // include children and apiCalls again so the UI can still render ghost edges & API badges even if NODE_STARTED was missed
196
+ children: options?.children || [],
197
+ apiCalls: options?.apiCalls || [],
198
+ __not_async: options?.__not_async ?? false,
199
+ timestamp: Date.now(),
200
+ });
201
+
202
+ return result;
203
+ } catch (error) {
204
+ const endTime = process.hrtime.bigint();
205
+ const durationMs = Number(endTime - startTime) / 1000000;
206
+
207
+ // Emit NODE_FAILED
208
+ sendTelemetry({
209
+ event: "NODE_FAILED",
210
+ status: "FAILED",
211
+ jobId,
212
+ stepName: name,
213
+ previousStep,
214
+ payload: {
215
+ input: sanitizePayload(options?.input),
216
+ error: sanitizePayload(error),
217
+ },
218
+ context: {
219
+ input: sanitizePayload(options?.input),
220
+ error: sanitizePayload(error),
221
+ duration: durationMs,
222
+ },
223
+ location,
224
+ timestamp: Date.now(),
225
+ });
226
+
227
+ // Re-throw so backend logic behaves normally
228
+ throw error;
229
+ }
230
+ },
231
+
232
+ async step<T>(
233
+ name: string,
234
+ fn: () => T | Promise<T>,
235
+ options?: StepOptions,
236
+ ): Promise<T> {
237
+ const store = traceStorage.getStore();
238
+
239
+ // If no context, just execute the function normally without tracing
240
+ if (!store) {
241
+ return await fn();
242
+ }
243
+
244
+ const { jobId, previousStep } = store;
245
+
246
+ // Capture location using a throwaway error
247
+ const locationError = new Error();
248
+ const location = extractLocationFromStack(locationError.stack) || {
249
+ file: "unknown",
250
+ line: 0,
251
+ };
252
+ const timestamp = Date.now();
253
+ const startTime = process.hrtime.bigint();
254
+
255
+ // Emit NODE_STARTED
256
+ sendTelemetry({
257
+ event: "NODE_STARTED",
258
+ status: "PENDING",
259
+ jobId,
260
+ stepName: name,
261
+ previousStep,
262
+ location,
263
+ timestamp,
264
+ });
265
+
266
+ // We must ensure subsequent nested steps know this step is their parent.
267
+ // To do this, we run the inner function in a nested context.
268
+ const newContext = { ...store, previousStep: name };
269
+
270
+ try {
271
+ // Execute the business logic within the new nested context
272
+ const result = await traceStorage.run(newContext, async () => {
273
+ return await fn();
274
+ });
275
+
276
+ const endTime = process.hrtime.bigint();
277
+ const durationMs = Number(endTime - startTime) / 1000000;
278
+
279
+ // Emit NODE_COMPLETED
280
+ sendTelemetry({
281
+ event: "NODE_COMPLETED",
282
+ status: "SUCCESS",
283
+ jobId,
284
+ stepName: name,
285
+ previousStep,
286
+ payload: {
287
+ input: sanitizePayload(options?.input),
288
+ output: sanitizePayload(result),
289
+ },
290
+ context: {
291
+ input: sanitizePayload(options?.input),
292
+ output: sanitizePayload(result),
293
+ duration: durationMs,
294
+ },
295
+ location,
296
+ timestamp: Date.now(),
297
+ });
298
+
299
+ return result;
300
+ } catch (error) {
301
+ const endTime = process.hrtime.bigint();
302
+ const durationMs = Number(endTime - startTime) / 1000000;
303
+
304
+ // Emit NODE_FAILED
305
+ sendTelemetry({
306
+ event: "NODE_FAILED",
307
+ status: "FAILED",
308
+ jobId,
309
+ stepName: name,
310
+ previousStep,
311
+ payload: {
312
+ input: sanitizePayload(options?.input),
313
+ error: sanitizePayload(error),
314
+ },
315
+ context: {
316
+ input: sanitizePayload(options?.input),
317
+ error: sanitizePayload(error),
318
+ duration: durationMs,
319
+ },
320
+ location,
321
+ timestamp: Date.now(),
322
+ });
323
+
324
+ // Re-throw so backend logic behaves normally
325
+ throw error;
326
+ }
327
+ },
328
+ };
329
+
330
+ // Test hooks to allow unit tests to observe telemetry behavior without
331
+ // depending on console output.
332
+ (evergreen as any).__test__ = {
333
+ sendTelemetry,
334
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2020",
4
+ "module": "commonjs",
5
+ "declaration": true,
6
+ "outDir": "./dist",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true
11
+ },
12
+ "include": ["src/**/*"]
13
+ }