elasticdash-sdk 0.2.6 → 0.2.7-beta-2
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/README.md +133 -26
- package/dist/dashboard-server.d.ts.map +1 -1
- package/dist/dashboard-server.js +76 -3
- package/dist/dashboard-server.js.map +1 -1
- package/dist/execution/tool-runner.d.ts.map +1 -1
- package/dist/execution/tool-runner.js +66 -5
- package/dist/execution/tool-runner.js.map +1 -1
- package/dist/index.cjs +57 -6
- package/dist/tool-runner-worker.js +27 -2
- package/dist/tool-runner-worker.js.map +1 -1
- package/dist/trigger-executor.d.ts.map +1 -1
- package/dist/trigger-executor.js +3 -1
- package/dist/trigger-executor.js.map +1 -1
- package/dist/workflow-runner-worker.js +24 -0
- package/dist/workflow-runner-worker.js.map +1 -1
- package/docs/agent-coding-instructions.md +8 -5
- package/docs/agent-integration-guide.md +158 -16
- package/docs/partial-mocking.md +10 -4
- package/docs/workflow-modes.md +6 -7
- package/package.json +1 -1
- package/src/dashboard-server.ts +71 -3
- package/src/execution/tool-runner.ts +62 -5
- package/src/tool-runner-worker.ts +22 -2
- package/src/trigger-executor.ts +3 -1
- package/src/workflow-runner-worker.ts +23 -0
|
@@ -49,16 +49,19 @@ Add to `.gitignore`:
|
|
|
49
49
|
.ed_traces/
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
> **Do not stop here — Step 1 is not a complete integration.** Without Steps 2–4, no tool calls are wrapped and no traces are produced. Continue to Step 2 before reporting "done" to the user.
|
|
53
|
+
|
|
52
54
|
---
|
|
53
55
|
|
|
54
56
|
## Step 2: Create `ed_tools.ts`
|
|
55
57
|
|
|
56
|
-
Create `ed_tools.ts` in the project root. This file wraps each tool function with `
|
|
58
|
+
Create `ed_tools.ts` in the project root. This file wraps each tool function with `edTool()` for automatic tracing, mocking, telemetry, and CLI/MCP rerun discovery. (`edTool` is `wrapTool` + global registry registration — prefer it as the default. Drop down to `wrapTool` only for inline closures that should NOT be discoverable by name.)
|
|
57
59
|
|
|
58
60
|
### Template
|
|
59
61
|
|
|
60
62
|
```ts
|
|
61
63
|
// ed_tools.ts
|
|
64
|
+
import { createRequire } from 'node:module'
|
|
62
65
|
import { setElasticDashModule } from './ed_workflows'
|
|
63
66
|
|
|
64
67
|
// Import original tool implementations from the actual source files
|
|
@@ -70,29 +73,39 @@ import { originalTool2 } from './utils/YOUR_SOURCE_2'
|
|
|
70
73
|
// ---------------------------------------------------------------------------
|
|
71
74
|
|
|
72
75
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
73
|
-
type
|
|
76
|
+
type EdToolFn = <T extends (...args: any[]) => any>(name: string, fn: T) => T
|
|
74
77
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
-
let
|
|
78
|
+
let edTool: EdToolFn = (_name: string, fn: any) => fn
|
|
79
|
+
|
|
80
|
+
// `createRequire` works in BOTH ESM (`"type": "module"`) and CJS projects —
|
|
81
|
+
// it's the only require shape that survives both. The older `eval('require')`
|
|
82
|
+
// trick silently throws in ESM ("require is not defined"), the catch swallows
|
|
83
|
+
// it, _ed stays null, and every helper in ed_workflows.ts returns silently —
|
|
84
|
+
// you get zero traces and zero error logs. Always use createRequire.
|
|
85
|
+
const nodeRequire = createRequire(import.meta.url)
|
|
76
86
|
|
|
77
87
|
try {
|
|
78
88
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
-
const _edModule = (
|
|
80
|
-
|
|
89
|
+
const _edModule = nodeRequire('elasticdash-sdk')
|
|
90
|
+
edTool = _edModule.edTool ?? _edModule.wrapTool ?? edTool
|
|
81
91
|
// Share the module instance with ed_workflows.ts so trace hooks use the same context
|
|
82
92
|
setElasticDashModule(_edModule)
|
|
83
|
-
} catch {
|
|
84
|
-
//
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Surface load failures explicitly — silent failure here is the #1 cause of
|
|
95
|
+
// "I installed the SDK but no traces appear". Logging the error means a user
|
|
96
|
+
// running with ELASTICDASH_DEBUG=1 (or any time) sees what went wrong.
|
|
97
|
+
console.error('[ed_tools] failed to load elasticdash-sdk:', err)
|
|
85
98
|
}
|
|
86
99
|
|
|
87
100
|
// ---------------------------------------------------------------------------
|
|
88
101
|
// Wrapped tools — one export per tool
|
|
89
102
|
// ---------------------------------------------------------------------------
|
|
90
103
|
|
|
91
|
-
export const myTool1 =
|
|
104
|
+
export const myTool1 = edTool('myTool1', async (input: any) => {
|
|
92
105
|
return await originalTool1(input)
|
|
93
106
|
})
|
|
94
107
|
|
|
95
|
-
export const myTool2 =
|
|
108
|
+
export const myTool2 = edTool('myTool2', async (input: any) => {
|
|
96
109
|
const { someField } = input as { someField: string }
|
|
97
110
|
return await originalTool2(someField)
|
|
98
111
|
})
|
|
@@ -100,14 +113,14 @@ export const myTool2 = wrapTool('myTool2', async (input: any) => {
|
|
|
100
113
|
|
|
101
114
|
### Key patterns
|
|
102
115
|
|
|
103
|
-
- **`
|
|
104
|
-
- **`
|
|
105
|
-
- **`setElasticDashModule`** shares the loaded module with `ed_workflows.ts` so `edStartTrace`/`edEndTrace` use the same tracing context as `
|
|
116
|
+
- **`edTool(name, fn)`** wraps the function with automatic tracing, mocking, telemetry, and global registry registration so the CLI `run-tool <name>` and MCP `run_tool` can rerun it by name. Falls back to a passthrough if `elasticdash-sdk` is not installed.
|
|
117
|
+
- **`createRequire(import.meta.url)`** is used instead of static `import()` to share the same module instance across `ed_tools.ts` and `ed_workflows.ts`. This avoids ESM/CJS dual-instance issues. Do not substitute `eval('require')` — that throws "require is not defined" in ESM projects (any project with `"type": "module"` in `package.json`), the catch swallows it, and the entire integration silently no-ops.
|
|
118
|
+
- **`setElasticDashModule`** shares the loaded module with `ed_workflows.ts` so `edStartTrace`/`edEndTrace` use the same tracing context as `edTool`.
|
|
106
119
|
- The exported name (e.g., `myTool1`) can differ from the original function name (e.g., `originalTool1`). The call sites in existing source files will be updated to use the new name in Step 4.
|
|
107
120
|
|
|
108
121
|
### Important rules
|
|
109
122
|
|
|
110
|
-
- The string name passed to `wrapTool()` **must match** the exported function name exactly.
|
|
123
|
+
- The string name passed to `edTool()` (or `wrapTool()`) **must match** the exported function name exactly.
|
|
111
124
|
- Each tool function must accept a single input object and return a plain value (JSON-serializable).
|
|
112
125
|
- Tool functions must not close over HTTP context, framework state, or database clients — extract pure logic first.
|
|
113
126
|
|
|
@@ -139,17 +152,56 @@ Every `ed_workflows.ts` should export `edStartTrace` and `edEndTrace`. These are
|
|
|
139
152
|
```ts
|
|
140
153
|
// ed_workflows.ts — trace hooks (copy as-is)
|
|
141
154
|
|
|
142
|
-
//
|
|
143
|
-
|
|
155
|
+
// `var` (not `let`) is intentional. `ed_tools.ts` imports `setElasticDashModule`
|
|
156
|
+
// from this file and calls it during its own module load. If this module's
|
|
157
|
+
// re-export of YOUR_WORKFLOW (below) sits in a transitive import chain that
|
|
158
|
+
// reaches `ed_tools.ts`, ESM evaluates `ed_tools.ts` BEFORE this module's body
|
|
159
|
+
// runs — and `let _ed = null` would be in TDZ (temporal dead zone) at that
|
|
160
|
+
// moment, throwing `Cannot access '_ed' before initialization` inside
|
|
161
|
+
// setElasticDashModule. `var _ed` hoists with `undefined` so the assignment
|
|
162
|
+
// works during circular import. After this module's body runs, the value set
|
|
163
|
+
// during the circular call is preserved (no re-initialiser overwrites it).
|
|
164
|
+
|
|
165
|
+
// eslint-disable-next-line no-var, @typescript-eslint/no-explicit-any
|
|
166
|
+
var _ed: any
|
|
167
|
+
// eslint-disable-next-line no-var
|
|
168
|
+
var _obsInitialised: boolean
|
|
144
169
|
|
|
145
170
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
146
171
|
export function setElasticDashModule(mod: any): void {
|
|
147
172
|
_ed = mod
|
|
148
173
|
}
|
|
149
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Initialise observability through the SHARED SDK module instance.
|
|
177
|
+
* Call this once at process startup (e.g. from main.ts or your server
|
|
178
|
+
* entry point) BEFORE any workflow runs. If env vars are set this is
|
|
179
|
+
* also called lazily from edStartTrace, so this explicit form is for
|
|
180
|
+
* projects that want predictable, fail-fast init.
|
|
181
|
+
*/
|
|
182
|
+
export function edInitObservability(opts?: { serverUrl?: string; apiKey?: string }): void {
|
|
183
|
+
if (!_ed || _obsInitialised) return
|
|
184
|
+
const serverUrl = opts?.serverUrl
|
|
185
|
+
?? process.env.ELASTICDASH_API_URL
|
|
186
|
+
?? process.env.ELASTICDASH_SERVER_URL
|
|
187
|
+
?? process.env.ELASTICDASH_SERVER
|
|
188
|
+
const apiKey = opts?.apiKey ?? process.env.ELASTICDASH_API_KEY
|
|
189
|
+
if (!serverUrl || !apiKey) return
|
|
190
|
+
try {
|
|
191
|
+
_ed.initObservability({ serverUrl, apiKey })
|
|
192
|
+
_obsInitialised = true
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error('[ed_workflows] edInitObservability error:', err)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
150
198
|
export const edStartTrace = async (workflowName: string): Promise<void> => {
|
|
151
199
|
if (!_ed) return
|
|
152
200
|
try {
|
|
201
|
+
// Lazy init from env vars on first trace — keeps the simple case
|
|
202
|
+
// ("set ELASTICDASH_API_URL + ELASTICDASH_API_KEY, just run") working
|
|
203
|
+
// without an explicit init call.
|
|
204
|
+
if (!_obsInitialised) edInitObservability()
|
|
153
205
|
await _ed.tryAutoInitHttpContext()
|
|
154
206
|
_ed.startTrace(workflowName)
|
|
155
207
|
} catch (err) {
|
|
@@ -165,8 +217,32 @@ export const edEndTrace = (): void => {
|
|
|
165
217
|
console.error('[ed_workflows] edEndTrace error:', err)
|
|
166
218
|
}
|
|
167
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Flush remaining trace events and close the backend connection.
|
|
223
|
+
* Call from a `finally` block at the end of your process lifecycle
|
|
224
|
+
* (CLI: in main() finally; HTTP server: rarely needed — the SDK
|
|
225
|
+
* auto-registers SIGTERM/SIGINT handlers that call this).
|
|
226
|
+
*
|
|
227
|
+
* The SDK's auto-exit hooks (registered by initObservability) are
|
|
228
|
+
* async; for short-lived CLI scripts the process can terminate BEFORE
|
|
229
|
+
* those hooks complete and drop the final event batch. Explicit
|
|
230
|
+
* shutdown via this helper is the only guarantee that the last batch
|
|
231
|
+
* lands.
|
|
232
|
+
*/
|
|
233
|
+
export const edShutdownObservability = async (): Promise<void> => {
|
|
234
|
+
if (!_ed || !_obsInitialised) return
|
|
235
|
+
try {
|
|
236
|
+
await _ed.shutdownObservability()
|
|
237
|
+
_obsInitialised = false
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.error('[ed_workflows] edShutdownObservability error:', err)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
168
242
|
```
|
|
169
243
|
|
|
244
|
+
> **Why route init through `_ed` instead of importing `initObservability` directly?** The SDK uses AsyncLocalStorage to correlate events. Both `ed_tools.ts` and `ed_workflows.ts` must share the same SDK module instance — that's why `ed_tools.ts` loads the SDK via `createRequire(import.meta.url)` and passes it through `setElasticDashModule`. If `main.ts` does `import { initObservability } from 'elasticdash-sdk'` directly, the ESM-loaded copy is a **different module instance** from the CJS-loaded copy that `_ed` references — init writes to one store, `startTrace` reads from another, and you get `[elasticdash] startTrace: observability not initialised` at runtime. Always init through `edInitObservability` from `ed_workflows.ts`.
|
|
245
|
+
|
|
170
246
|
### Workflow exports — simple case
|
|
171
247
|
|
|
172
248
|
For non-framework projects where the workflow can be imported directly:
|
|
@@ -221,7 +297,7 @@ export const YOUR_WORKFLOW = async (input: {
|
|
|
221
297
|
```
|
|
222
298
|
ed_tools.ts
|
|
223
299
|
├── imports original functions from services/utils
|
|
224
|
-
├── wraps each with
|
|
300
|
+
├── wraps each with edTool() for tracing + rerun registration
|
|
225
301
|
└── exports wrapped versions with the SAME or similar names
|
|
226
302
|
|
|
227
303
|
ed_workflows.ts
|
|
@@ -239,6 +315,32 @@ Existing source files (MODIFIED):
|
|
|
239
315
|
|
|
240
316
|
### What to do
|
|
241
317
|
|
|
318
|
+
**0. Add `edInitObservability` to your entry point.**
|
|
319
|
+
|
|
320
|
+
Call `edInitObservability` once at process startup so observability is wired up through the SAME SDK module instance that `ed_tools.ts` and `ed_workflows.ts` share. Do this BEFORE any workflow runs. Skipping this is the #1 cause of `[elasticdash] startTrace: observability not initialised` errors at runtime.
|
|
321
|
+
|
|
322
|
+
For a CLI / standalone Node script — init at the top, shutdown in a `finally` block:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
// src/main.ts
|
|
326
|
+
import 'dotenv/config'
|
|
327
|
+
import { edInitObservability, edShutdownObservability } from '../ed_workflows.js'
|
|
328
|
+
import { researchManager } from './manager.js'
|
|
329
|
+
|
|
330
|
+
async function main() {
|
|
331
|
+
edInitObservability() // env vars: ELASTICDASH_API_URL + ELASTICDASH_API_KEY
|
|
332
|
+
try {
|
|
333
|
+
// ... rest of your main() ...
|
|
334
|
+
} finally {
|
|
335
|
+
await edShutdownObservability() // guarantees the final batch lands
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The `finally + edShutdownObservability` block is **required for CLIs**. The SDK auto-registers `beforeExit`/`SIGTERM`/`SIGINT` handlers, but those are async; for short-lived scripts the process can tear down before they complete, dropping the final batch.
|
|
341
|
+
|
|
342
|
+
For Next.js / Remix / SvelteKit / Express, call `edInitObservability()` in your framework's instrumentation hook OR at the very top of your server entry file before any route handler is registered. Explicit shutdown is rarely needed — the server stays up; the auto-registered SIGTERM handler covers graceful restarts. Do NOT replace `edInitObservability` with `import { initObservability } from 'elasticdash-sdk'` — that hits a different module instance and the error returns. See Step 3's "Why route init through `_ed`?" callout.
|
|
343
|
+
|
|
242
344
|
**1. Find every file that calls a tool function and update its imports:**
|
|
243
345
|
|
|
244
346
|
For each tool exported from `ed_tools.ts`, search the codebase for files that import the original function. Update the import to come from `ed_tools` instead.
|
|
@@ -433,6 +535,46 @@ This confirms:
|
|
|
433
535
|
|
|
434
536
|
**If it fails:** Check that `.env` has valid `ELASTICDASH_API_URL` and `ELASTICDASH_API_KEY` values. If the API key is rejected, the user needs to get a new one from https://app.elasticdash.com.
|
|
435
537
|
|
|
538
|
+
### Verifying programmatic init from the integrated app
|
|
539
|
+
|
|
540
|
+
`npx elasticdash observe` is the *CLI* path — it runs `initObservability` in its own process. The actual integrated app (via `edInitObservability` in `ed_workflows.ts`) also calls `initObservability` programmatically, opens the same socket, installs the same AI interceptor, and pushes events on the same batcher. **You do not need `npx elasticdash observe` running for the integrated app to produce traces.**
|
|
541
|
+
|
|
542
|
+
To verify the programmatic path is working, set `ELASTICDASH_DEBUG=1` in the user's `.env` and run the user's app once. You should see these lines on stderr on the first trace:
|
|
543
|
+
|
|
544
|
+
```
|
|
545
|
+
[elasticdash] Observability initialised — sessionId=<uuid> workflow=<name> server=<url>
|
|
546
|
+
[elasticdash] Socket connected: <socket-id>
|
|
547
|
+
[elasticdash] startTrace: <workflow-name>, capture=false
|
|
548
|
+
[elasticdash] Flushed N events (status 200)
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
If you see NONE of them, init is not running. The most common causes are the dual-module-instance trap (someone imported `initObservability` directly instead of using `edInitObservability`), missing env vars, or `ed_tools.ts` was never imported by the running code path (so `setElasticDashModule` was never called and `_ed` is null — `edStartTrace` / `edInitObservability` silently return).
|
|
552
|
+
|
|
553
|
+
If you see the first three lines but not the "Flushed" line, the final batch is being dropped on exit — confirm that the entry point calls `edShutdownObservability()` in a `finally` block.
|
|
554
|
+
|
|
555
|
+
### Where to view the traces
|
|
556
|
+
|
|
557
|
+
Once `[elasticdash] Flushed N events` appears on stderr, the events are live on the backend. Two ways to view them:
|
|
558
|
+
|
|
559
|
+
1. **Cloud dashboard** — open `https://app.elasticdash.com`, sign in with the project the API key belongs to, and the run shows up in the traces list. This is the simplest path for users who already have a cloud project.
|
|
560
|
+
2. **Local dashboard** — run `npx elasticdash dashboard` (opens `http://localhost:4573` and proxies the same backend). Useful for debugging since it lets the agent rerun individual workflow steps with mocked tools, browse the captured tool/AI events, and edit prompts in place. See [Workflows Dashboard](dashboard.md) for the full feature set.
|
|
561
|
+
|
|
562
|
+
For framework projects (Next.js / Remix / etc.), the dashboard can also **trigger workflow runs directly against the running dev server** via HTTP-mode reruns — see [HTTP Workflow Mode](../README.md#http-workflow-mode) in the README. Confirm by running `npm run dev` in the user's project, then `npx elasticdash dashboard` in a second terminal, and triggering a rerun from the dashboard UI.
|
|
563
|
+
|
|
564
|
+
**End-to-end flow recap** for the agent reporting "done" to the user:
|
|
565
|
+
|
|
566
|
+
```
|
|
567
|
+
1. npm install elasticdash-sdk ← Step 1
|
|
568
|
+
2. ed_tools.ts wraps tool functions ← Step 2
|
|
569
|
+
3. ed_workflows.ts has init/start/end/shutdown helpers + workflow exports ← Step 3
|
|
570
|
+
4. Entry point calls edInitObservability() then runs the workflow, finally edShutdownObservability() ← Step 4
|
|
571
|
+
5. .env has ELASTICDASH_API_URL + ELASTICDASH_API_KEY ← Step 6
|
|
572
|
+
6. User runs their app → sees [elasticdash] ... logs on stderr ← this section
|
|
573
|
+
7. User opens https://app.elasticdash.com or `npx elasticdash dashboard` → sees the trace
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
Only after step 7 has been confirmed is the integration end-to-end. If step 7 fails (logs say "Flushed" but trace doesn't appear), the most likely cause is the API key belongs to a different project than the one the user is viewing — check the project picker in the dashboard.
|
|
577
|
+
|
|
436
578
|
After validation, stop the observe process (Ctrl+C) and inform the user that ElasticDash is integrated. Provide these commands for ongoing use:
|
|
437
579
|
|
|
438
580
|
```bash
|
package/docs/partial-mocking.md
CHANGED
|
@@ -107,16 +107,22 @@ Concurrent requests with different mock configs (or no mocks at all) run in para
|
|
|
107
107
|
|
|
108
108
|
#### Next.js + Turbopack note
|
|
109
109
|
|
|
110
|
-
If Turbopack flags `elasticdash-sdk` as "Module not found" at build time (some configs do this for `serverExternalPackages`), use `
|
|
110
|
+
If Turbopack flags `elasticdash-sdk` as "Module not found" at build time (some configs do this for `serverExternalPackages`), use `createRequire(import.meta.url)`:
|
|
111
111
|
|
|
112
112
|
```ts
|
|
113
|
+
import { createRequire } from 'node:module';
|
|
114
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
115
|
+
|
|
113
116
|
try {
|
|
114
|
-
const { applyInboundMockConfig } = (
|
|
117
|
+
const { applyInboundMockConfig } = nodeRequire('elasticdash-sdk');
|
|
115
118
|
applyInboundMockConfig(request);
|
|
116
|
-
} catch {
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('[mocking] failed to load elasticdash-sdk:', err);
|
|
121
|
+
/* SDK not installed — proceed live */
|
|
122
|
+
}
|
|
117
123
|
```
|
|
118
124
|
|
|
119
|
-
This is the same pattern users already use for `initHttpRunContext` for dashboard mode. The `try/catch` makes the SDK a soft dependency: if it's not present in prod, the route still works.
|
|
125
|
+
This is the same pattern users already use for `initHttpRunContext` for dashboard mode. The `try/catch` makes the SDK a soft dependency: if it's not present in prod, the route still works. **Do not substitute `eval('require')`** — it silently throws in ESM projects (`"type": "module"` in `package.json`) and the catch hides the failure, leaving the route running live in what looks like mock mode.
|
|
120
126
|
|
|
121
127
|
#### Polymorphic input
|
|
122
128
|
|
package/docs/workflow-modes.md
CHANGED
|
@@ -232,18 +232,17 @@ If `runWithInitializedHttpContext` is called outside `start()` (e.g., in the han
|
|
|
232
232
|
|
|
233
233
|
## Importing the SDK in Next.js / Turbopack
|
|
234
234
|
|
|
235
|
-
Turbopack statically analyzes `import()` and `require()` calls, which causes `Module not found` errors for `serverExternalPackages`. Use `
|
|
235
|
+
Turbopack statically analyzes `import()` and `require()` calls, which causes `Module not found` errors for `serverExternalPackages`. Use `createRequire(import.meta.url)` to bypass this:
|
|
236
236
|
|
|
237
237
|
```ts
|
|
238
|
-
//
|
|
239
|
-
const { wrapTool } = (eval('require') as (id: string) => any)('elasticdash-sdk')
|
|
240
|
-
|
|
241
|
-
// Option 2: createRequire — cleaner, no eval
|
|
238
|
+
// Recommended: createRequire — works in both ESM ("type": "module") and CJS
|
|
242
239
|
import { createRequire } from 'node:module'
|
|
243
|
-
const nodeRequire = createRequire(
|
|
244
|
-
const {
|
|
240
|
+
const nodeRequire = createRequire(import.meta.url)
|
|
241
|
+
const { edTool } = nodeRequire('elasticdash-sdk')
|
|
245
242
|
```
|
|
246
243
|
|
|
244
|
+
> **Do not use `eval('require')`.** It looks simpler but silently throws "require is not defined" in any ESM project — the typical `try/catch` wrapping it swallows the error, the SDK never loads, and the integration produces zero traces with zero logs. Use `createRequire` unconditionally.
|
|
245
|
+
|
|
247
246
|
Also add the SDK to `serverExternalPackages` in `next.config.js`:
|
|
248
247
|
|
|
249
248
|
```js
|
package/package.json
CHANGED
package/src/dashboard-server.ts
CHANGED
|
@@ -453,6 +453,11 @@ function runWorkflowInSubprocess(
|
|
|
453
453
|
},
|
|
454
454
|
): Promise<WorkflowSubprocessResult> {
|
|
455
455
|
return new Promise((resolve) => {
|
|
456
|
+
const startMs = Date.now()
|
|
457
|
+
const elapsed = () => Date.now() - startMs
|
|
458
|
+
const debug = (...a: unknown[]) => {
|
|
459
|
+
if (process.env.ELASTICDASH_DEBUG === '1') console.error(...a)
|
|
460
|
+
}
|
|
456
461
|
const workerScript = new URL('./workflow-runner-worker.js', import.meta.url).pathname
|
|
457
462
|
const projectDir = path.dirname(workflowsModulePath)
|
|
458
463
|
const denoProject = isDenoProject(projectDir)
|
|
@@ -472,15 +477,52 @@ function runWorkflowInSubprocess(
|
|
|
472
477
|
cwd: projectDir,
|
|
473
478
|
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
|
|
474
479
|
})
|
|
480
|
+
const pid = child.pid ?? -1
|
|
481
|
+
debug(`[elasticdash dashboard] workflow subprocess stage=spawned pid=${pid} elapsedMs=${elapsed()} workflow=${workflowName}`)
|
|
482
|
+
|
|
483
|
+
// Heartbeat — workflows can be long; without this the dashboard is blind.
|
|
484
|
+
// 0 disables. Default 5s.
|
|
485
|
+
const heartbeatMs = Number(process.env.ELASTICDASH_HEARTBEAT_MS ?? 5000)
|
|
486
|
+
const heartbeat = heartbeatMs > 0
|
|
487
|
+
? setInterval(() => {
|
|
488
|
+
debug(`[elasticdash dashboard] workflow subprocess heartbeat pid=${pid} elapsedMs=${elapsed()} workflow=${workflowName}`)
|
|
489
|
+
}, heartbeatMs)
|
|
490
|
+
: null
|
|
491
|
+
|
|
492
|
+
// Optional kill switch. Default unset = no timeout (preserves prior behavior).
|
|
493
|
+
let timedOut = false
|
|
494
|
+
const timeoutMs = Number(process.env.ELASTICDASH_WORKFLOW_TIMEOUT_MS ?? 0)
|
|
495
|
+
const timeout = timeoutMs > 0
|
|
496
|
+
? setTimeout(() => {
|
|
497
|
+
timedOut = true
|
|
498
|
+
debug(`[elasticdash dashboard] workflow subprocess TIMEOUT pid=${pid} after ${timeoutMs}ms — sending SIGTERM`)
|
|
499
|
+
try { child.kill('SIGTERM') } catch { /* already dead */ }
|
|
500
|
+
setTimeout(() => {
|
|
501
|
+
try { child.kill('SIGKILL') } catch { /* already dead */ }
|
|
502
|
+
}, 2000)
|
|
503
|
+
}, timeoutMs)
|
|
504
|
+
: null
|
|
505
|
+
|
|
506
|
+
const cleanup = () => {
|
|
507
|
+
if (heartbeat) clearInterval(heartbeat)
|
|
508
|
+
if (timeout) clearTimeout(timeout)
|
|
509
|
+
}
|
|
475
510
|
|
|
476
511
|
let fd3Data = ''
|
|
477
512
|
let stderr = ''
|
|
513
|
+
let sawFd3 = false
|
|
514
|
+
let sawStdout = false
|
|
515
|
+
let sawStderr = false
|
|
478
516
|
|
|
479
517
|
// Line-buffer stdout so that large result JSON lines split across multiple
|
|
480
518
|
// data events are reassembled before processing.
|
|
481
519
|
const WORKFLOW_RESULT_PREFIX = '__ELASTICDASH_RESULT__:'
|
|
482
520
|
let stdoutBuf = ''
|
|
483
521
|
child.stdout.on('data', (chunk) => {
|
|
522
|
+
if (!sawStdout) {
|
|
523
|
+
sawStdout = true
|
|
524
|
+
debug(`[elasticdash dashboard] workflow subprocess stage=first-stdout pid=${pid} elapsedMs=${elapsed()}`)
|
|
525
|
+
}
|
|
484
526
|
stdoutBuf += chunk.toString()
|
|
485
527
|
const lines = stdoutBuf.split('\n')
|
|
486
528
|
stdoutBuf = lines.pop() ?? '' // keep last (possibly incomplete) line
|
|
@@ -494,15 +536,27 @@ function runWorkflowInSubprocess(
|
|
|
494
536
|
}
|
|
495
537
|
})
|
|
496
538
|
child.stderr.on('data', (chunk) => {
|
|
539
|
+
if (!sawStderr) {
|
|
540
|
+
sawStderr = true
|
|
541
|
+
debug(`[elasticdash dashboard] workflow subprocess stage=first-stderr pid=${pid} elapsedMs=${elapsed()}`)
|
|
542
|
+
}
|
|
497
543
|
stderr += chunk.toString()
|
|
498
544
|
process.stderr.write(chunk)
|
|
499
545
|
})
|
|
500
546
|
const fd3 = child.stdio[3] as import('stream').Readable | null
|
|
501
547
|
fd3?.on('data', (chunk: Buffer | string) => {
|
|
548
|
+
if (!sawFd3) {
|
|
549
|
+
sawFd3 = true
|
|
550
|
+
debug(`[elasticdash dashboard] workflow subprocess stage=first-fd3 pid=${pid} elapsedMs=${elapsed()}`)
|
|
551
|
+
}
|
|
502
552
|
fd3Data += chunk.toString()
|
|
503
553
|
})
|
|
504
554
|
|
|
505
|
-
child.on('close', () => {
|
|
555
|
+
child.on('close', (code, signal) => {
|
|
556
|
+
cleanup()
|
|
557
|
+
const elapsedMs = elapsed()
|
|
558
|
+
debug(`[elasticdash dashboard] workflow subprocess stage=closed pid=${pid} code=${code} signal=${signal ?? 'none'} elapsedMs=${elapsedMs} stderrBytes=${stderr.length} fd3Bytes=${fd3Data.length}`)
|
|
559
|
+
|
|
506
560
|
// Flush any remaining buffered stdout line (e.g. result with no trailing newline)
|
|
507
561
|
if (stdoutBuf.startsWith(WORKFLOW_RESULT_PREFIX)) {
|
|
508
562
|
fd3Data += stdoutBuf.slice(WORKFLOW_RESULT_PREFIX.length)
|
|
@@ -514,12 +568,25 @@ function runWorkflowInSubprocess(
|
|
|
514
568
|
try {
|
|
515
569
|
resolve(JSON.parse(fd3Data))
|
|
516
570
|
return
|
|
517
|
-
} catch {
|
|
571
|
+
} catch (parseErr) {
|
|
572
|
+
const detail = `[exit=${code} signal=${signal ?? 'none'} elapsedMs=${elapsedMs} pid=${pid}] fd3 payload failed to parse: ${(parseErr as Error).message}`
|
|
573
|
+
resolve({ ok: false, error: detail })
|
|
574
|
+
return
|
|
575
|
+
}
|
|
518
576
|
}
|
|
519
|
-
|
|
577
|
+
const stderrExcerpt = stderr.length > 1024 ? `…${stderr.slice(-1024)}` : stderr
|
|
578
|
+
const detail = `[exit=${code} signal=${signal ?? 'none'} elapsedMs=${elapsedMs} pid=${pid} stderrBytes=${stderr.length}]`
|
|
579
|
+
const baseError = timedOut
|
|
580
|
+
? `Workflow subprocess timed out after ${timeoutMs}ms`
|
|
581
|
+
: (stderr.trim() || 'Workflow subprocess produced no output.')
|
|
582
|
+
const errorMsg = stderr.trim()
|
|
583
|
+
? `${baseError} ${detail}`
|
|
584
|
+
: `${baseError} ${detail}${stderrExcerpt ? `\nLast stderr: ${stderrExcerpt}` : ''}`
|
|
585
|
+
resolve({ ok: false, error: errorMsg })
|
|
520
586
|
})
|
|
521
587
|
|
|
522
588
|
child.on('error', (err) => {
|
|
589
|
+
cleanup()
|
|
523
590
|
const hint = denoProject && (err as NodeJS.ErrnoException).code === 'ENOENT'
|
|
524
591
|
? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
|
|
525
592
|
: ''
|
|
@@ -544,6 +611,7 @@ function runWorkflowInSubprocess(
|
|
|
544
611
|
})
|
|
545
612
|
child.stdin.write(payload)
|
|
546
613
|
child.stdin.end() // Always close stdin to avoid subprocess hang
|
|
614
|
+
debug(`[elasticdash dashboard] workflow subprocess stage=payload-written pid=${pid} elapsedMs=${elapsed()} payloadBytes=${payload.length}`)
|
|
547
615
|
})
|
|
548
616
|
}
|
|
549
617
|
|
|
@@ -118,6 +118,7 @@ export function runToolInSubprocess(
|
|
|
118
118
|
return new Promise((resolve) => {
|
|
119
119
|
debugLog('[elasticdash portal] Spawning tool subprocess', { toolsModulePath, toolName, args, frozenEventsCount: frozenEvents?.length ?? 0 })
|
|
120
120
|
const startMs = Date.now()
|
|
121
|
+
const elapsed = () => Date.now() - startMs
|
|
121
122
|
const workerScript = resolveWorkerScript('../tool-runner-worker.js')
|
|
122
123
|
const projectDir = path.dirname(toolsModulePath)
|
|
123
124
|
const denoProject = isDenoProject(projectDir)
|
|
@@ -136,15 +137,50 @@ export function runToolInSubprocess(
|
|
|
136
137
|
cwd: projectDir,
|
|
137
138
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
138
139
|
})
|
|
140
|
+
const pid = child.pid ?? -1
|
|
141
|
+
debugLog(`[elasticdash portal] tool subprocess stage=spawned pid=${pid} elapsedMs=${elapsed()} tool=${toolName}`)
|
|
142
|
+
|
|
143
|
+
// Heartbeat: prove the parent is still waiting on a live child. 0 disables.
|
|
144
|
+
const heartbeatMs = Number(process.env.ELASTICDASH_HEARTBEAT_MS ?? 5000)
|
|
145
|
+
const heartbeat = heartbeatMs > 0
|
|
146
|
+
? setInterval(() => {
|
|
147
|
+
debugLog(`[elasticdash portal] tool subprocess heartbeat pid=${pid} elapsedMs=${elapsed()} tool=${toolName}`)
|
|
148
|
+
}, heartbeatMs)
|
|
149
|
+
: null
|
|
150
|
+
|
|
151
|
+
// Optional kill switch. Default unset = no timeout (preserves prior behavior).
|
|
152
|
+
let timedOut = false
|
|
153
|
+
const timeoutMs = Number(process.env.ELASTICDASH_TOOL_TIMEOUT_MS ?? 0)
|
|
154
|
+
const timeout = timeoutMs > 0
|
|
155
|
+
? setTimeout(() => {
|
|
156
|
+
timedOut = true
|
|
157
|
+
debugLog(`[elasticdash portal] tool subprocess TIMEOUT pid=${pid} after ${timeoutMs}ms — sending SIGTERM`)
|
|
158
|
+
try { child.kill('SIGTERM') } catch { /* already dead */ }
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
try { child.kill('SIGKILL') } catch { /* already dead */ }
|
|
161
|
+
}, 2000)
|
|
162
|
+
}, timeoutMs)
|
|
163
|
+
: null
|
|
164
|
+
|
|
165
|
+
const cleanup = () => {
|
|
166
|
+
if (heartbeat) clearInterval(heartbeat)
|
|
167
|
+
if (timeout) clearTimeout(timeout)
|
|
168
|
+
}
|
|
139
169
|
|
|
140
170
|
const RESULT_PREFIX = '__ELASTICDASH_RESULT__:'
|
|
141
171
|
let resultLine = ''
|
|
142
172
|
let stderr = ''
|
|
173
|
+
let sawStdout = false
|
|
174
|
+
let sawStderr = false
|
|
143
175
|
|
|
144
176
|
// Line-buffer stdout so that large result JSON lines split across multiple
|
|
145
177
|
// data events are reassembled before processing.
|
|
146
178
|
let stdoutBuf = ''
|
|
147
179
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
180
|
+
if (!sawStdout) {
|
|
181
|
+
sawStdout = true
|
|
182
|
+
debugLog(`[elasticdash portal] tool subprocess stage=first-stdout pid=${pid} elapsedMs=${elapsed()}`)
|
|
183
|
+
}
|
|
148
184
|
stdoutBuf += chunk.toString()
|
|
149
185
|
const lines = stdoutBuf.split('\n')
|
|
150
186
|
stdoutBuf = lines.pop() ?? '' // keep last (possibly incomplete) line
|
|
@@ -157,12 +193,18 @@ export function runToolInSubprocess(
|
|
|
157
193
|
}
|
|
158
194
|
})
|
|
159
195
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
196
|
+
if (!sawStderr) {
|
|
197
|
+
sawStderr = true
|
|
198
|
+
debugLog(`[elasticdash portal] tool subprocess stage=first-stderr pid=${pid} elapsedMs=${elapsed()}`)
|
|
199
|
+
}
|
|
160
200
|
stderr += chunk.toString()
|
|
161
201
|
process.stderr.write(chunk)
|
|
162
202
|
})
|
|
163
203
|
|
|
164
|
-
child.on('close', () => {
|
|
165
|
-
|
|
204
|
+
child.on('close', (code, signal) => {
|
|
205
|
+
cleanup()
|
|
206
|
+
const currentDurationMs = elapsed()
|
|
207
|
+
debugLog(`[elasticdash portal] tool subprocess stage=closed pid=${pid} code=${code} signal=${signal ?? 'none'} elapsedMs=${currentDurationMs} stderrBytes=${stderr.length}`)
|
|
166
208
|
|
|
167
209
|
// Flush any remaining buffered stdout line (e.g. result with no trailing newline)
|
|
168
210
|
if (stdoutBuf.startsWith(RESULT_PREFIX)) {
|
|
@@ -175,17 +217,31 @@ export function runToolInSubprocess(
|
|
|
175
217
|
try {
|
|
176
218
|
resolve({ ...JSON.parse(resultLine), currentDurationMs })
|
|
177
219
|
return
|
|
178
|
-
} catch {
|
|
220
|
+
} catch (parseErr) {
|
|
221
|
+
const detail = `[exit=${code} signal=${signal ?? 'none'} elapsedMs=${currentDurationMs} pid=${pid}] resultLine failed to parse: ${(parseErr as Error).message}`
|
|
222
|
+
resolve({ ok: false, error: detail, currentDurationMs })
|
|
223
|
+
return
|
|
224
|
+
}
|
|
179
225
|
}
|
|
180
|
-
|
|
226
|
+
|
|
227
|
+
const stderrExcerpt = stderr.length > 1024 ? `…${stderr.slice(-1024)}` : stderr
|
|
228
|
+
const detail = `[exit=${code} signal=${signal ?? 'none'} elapsedMs=${currentDurationMs} pid=${pid} stderrBytes=${stderr.length}]`
|
|
229
|
+
const baseError = timedOut
|
|
230
|
+
? `Tool subprocess timed out after ${timeoutMs}ms`
|
|
231
|
+
: (stderr.trim() || 'Tool subprocess produced no output.')
|
|
232
|
+
const errorMsg = stderr.trim()
|
|
233
|
+
? `${baseError} ${detail}`
|
|
234
|
+
: `${baseError} ${detail}${stderrExcerpt ? `\nLast stderr: ${stderrExcerpt}` : ''}`
|
|
235
|
+
resolve({ ok: false, error: errorMsg, currentDurationMs })
|
|
181
236
|
})
|
|
182
237
|
|
|
183
238
|
child.on('error', (err) => {
|
|
239
|
+
cleanup()
|
|
184
240
|
const hint = denoProject && (err as NodeJS.ErrnoException).code === 'ENOENT'
|
|
185
241
|
? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
|
|
186
242
|
: ''
|
|
187
243
|
debugLog(`[elasticdash portal] Failed to spawn tool subprocess: ${err.message}${hint}`)
|
|
188
|
-
resolve({ ok: false, error: `Failed to spawn tool subprocess: ${err.message}${hint}`, currentDurationMs:
|
|
244
|
+
resolve({ ok: false, error: `Failed to spawn tool subprocess: ${err.message}${hint}`, currentDurationMs: elapsed() })
|
|
189
245
|
})
|
|
190
246
|
|
|
191
247
|
const payload = JSON.stringify({
|
|
@@ -196,6 +252,7 @@ export function runToolInSubprocess(
|
|
|
196
252
|
})
|
|
197
253
|
child.stdin.write(payload)
|
|
198
254
|
child.stdin.end()
|
|
255
|
+
debugLog(`[elasticdash portal] tool subprocess stage=payload-written pid=${pid} elapsedMs=${elapsed()} payloadBytes=${payload.length}`)
|
|
199
256
|
})
|
|
200
257
|
}
|
|
201
258
|
|