elasticdash-test 0.1.17 → 0.1.18-alpha-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.
Files changed (100) hide show
  1. package/dist/capture/event.d.ts +5 -1
  2. package/dist/capture/event.d.ts.map +1 -1
  3. package/dist/cli.js +100 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/evaluators/llm-judge.js +17 -14
  6. package/dist/evaluators/types.d.ts +1 -0
  7. package/dist/execution/tool-runner.d.ts +26 -0
  8. package/dist/execution/tool-runner.d.ts.map +1 -0
  9. package/dist/execution/tool-runner.js +270 -0
  10. package/dist/execution/tool-runner.js.map +1 -0
  11. package/dist/http.d.ts +2 -0
  12. package/dist/http.d.ts.map +1 -1
  13. package/dist/http.js +2 -0
  14. package/dist/http.js.map +1 -1
  15. package/dist/index.cjs +4400 -2665
  16. package/dist/index.d.ts +12 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +9 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/interceptors/ai-interceptor.d.ts.map +1 -1
  21. package/dist/interceptors/ai-interceptor.js +97 -4
  22. package/dist/interceptors/ai-interceptor.js.map +1 -1
  23. package/dist/interceptors/db-auto.d.ts.map +1 -1
  24. package/dist/interceptors/db-auto.js +116 -24
  25. package/dist/interceptors/db-auto.js.map +1 -1
  26. package/dist/interceptors/db.d.ts +5 -0
  27. package/dist/interceptors/db.d.ts.map +1 -1
  28. package/dist/interceptors/db.js +93 -15
  29. package/dist/interceptors/db.js.map +1 -1
  30. package/dist/interceptors/http.d.ts.map +1 -1
  31. package/dist/interceptors/http.js +125 -93
  32. package/dist/interceptors/http.js.map +1 -1
  33. package/dist/interceptors/telemetry-push.d.ts +15 -0
  34. package/dist/interceptors/telemetry-push.d.ts.map +1 -1
  35. package/dist/interceptors/telemetry-push.js +96 -13
  36. package/dist/interceptors/telemetry-push.js.map +1 -1
  37. package/dist/interceptors/tool.d.ts.map +1 -1
  38. package/dist/interceptors/tool.js +42 -5
  39. package/dist/interceptors/tool.js.map +1 -1
  40. package/dist/interceptors/workflow-ai.d.ts.map +1 -1
  41. package/dist/interceptors/workflow-ai.js +46 -2
  42. package/dist/interceptors/workflow-ai.js.map +1 -1
  43. package/dist/observability.d.ts +69 -0
  44. package/dist/observability.d.ts.map +1 -0
  45. package/dist/observability.js +246 -0
  46. package/dist/observability.js.map +1 -0
  47. package/dist/portal-executor.d.ts +30 -0
  48. package/dist/portal-executor.d.ts.map +1 -0
  49. package/dist/portal-executor.js +304 -0
  50. package/dist/portal-executor.js.map +1 -0
  51. package/dist/portal-server.d.ts +3 -0
  52. package/dist/portal-server.d.ts.map +1 -0
  53. package/dist/portal-server.js +265 -0
  54. package/dist/portal-server.js.map +1 -0
  55. package/dist/socket-connector.d.ts +23 -0
  56. package/dist/socket-connector.d.ts.map +1 -0
  57. package/dist/socket-connector.js +102 -0
  58. package/dist/socket-connector.js.map +1 -0
  59. package/dist/telemetry-batcher.d.ts +43 -0
  60. package/dist/telemetry-batcher.d.ts.map +1 -0
  61. package/dist/telemetry-batcher.js +111 -0
  62. package/dist/telemetry-batcher.js.map +1 -0
  63. package/dist/trigger-executor.d.ts +12 -0
  64. package/dist/trigger-executor.d.ts.map +1 -0
  65. package/dist/trigger-executor.js +83 -0
  66. package/dist/trigger-executor.js.map +1 -0
  67. package/dist/types/portal.d.ts +64 -0
  68. package/dist/types/portal.d.ts.map +1 -0
  69. package/dist/types/portal.js +2 -0
  70. package/dist/types/portal.js.map +1 -0
  71. package/dist/utils/debug.d.ts +3 -0
  72. package/dist/utils/debug.d.ts.map +1 -0
  73. package/dist/utils/debug.js +8 -0
  74. package/dist/utils/debug.js.map +1 -0
  75. package/dist/utils/redact.d.ts +7 -0
  76. package/dist/utils/redact.d.ts.map +1 -0
  77. package/dist/utils/redact.js +26 -0
  78. package/dist/utils/redact.js.map +1 -0
  79. package/package.json +10 -1
  80. package/src/capture/event.ts +5 -1
  81. package/src/cli.ts +109 -0
  82. package/src/execution/tool-runner.ts +304 -0
  83. package/src/http.ts +2 -0
  84. package/src/index.ts +18 -0
  85. package/src/interceptors/ai-interceptor.ts +110 -4
  86. package/src/interceptors/db-auto.ts +121 -25
  87. package/src/interceptors/db.ts +92 -17
  88. package/src/interceptors/http.ts +145 -107
  89. package/src/interceptors/telemetry-push.ts +113 -13
  90. package/src/interceptors/tool.ts +42 -5
  91. package/src/interceptors/workflow-ai.ts +49 -2
  92. package/src/observability.ts +286 -0
  93. package/src/portal-executor.ts +335 -0
  94. package/src/portal-server.ts +290 -0
  95. package/src/socket-connector.ts +115 -0
  96. package/src/telemetry-batcher.ts +143 -0
  97. package/src/trigger-executor.ts +121 -0
  98. package/src/types/portal.ts +67 -0
  99. package/src/utils/debug.ts +8 -0
  100. package/src/utils/redact.ts +25 -0
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Shared tool/AI execution helpers for portal and dashboard.
3
+ * These are extracted versions of the helpers in dashboard-server.ts
4
+ * to avoid importing the full dashboard server module.
5
+ */
6
+ import path from 'node:path'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { spawn } from 'node:child_process'
9
+ import { pathToFileURL } from 'node:url'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface ToolInfo {
16
+ name: string
17
+ isAsync: boolean
18
+ signature: string
19
+ filePath: string
20
+ lineNumber?: number
21
+ sourceCode?: string
22
+ }
23
+
24
+ export interface RerunResult {
25
+ ok: boolean
26
+ currentOutput?: unknown
27
+ currentDurationMs?: number
28
+ currentUsage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }
29
+ error?: string
30
+ }
31
+
32
+ interface ParsedExport {
33
+ name: string
34
+ isAsync: boolean
35
+ signature: string
36
+ filePath: string
37
+ lineNumber?: number
38
+ sourceCode?: string
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Runtime helpers
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export function isDenoProject(dir: string): boolean {
46
+ return existsSync(path.join(dir, 'deno.json')) || existsSync(path.join(dir, 'deno.jsonc'))
47
+ }
48
+
49
+ export function resolveRuntimeModule(cwd: string, baseName: string): string | null {
50
+ for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
51
+ const candidate = path.join(cwd, `${baseName}${ext}`)
52
+ if (existsSync(candidate)) return candidate
53
+ }
54
+ return null
55
+ }
56
+
57
+ function parseSignatureParams(signature?: string): string[] {
58
+ if (!signature) return []
59
+ const trimmed = signature.trim()
60
+ if (!trimmed.startsWith('(') || !trimmed.endsWith(')')) return []
61
+ const body = trimmed.slice(1, -1).trim()
62
+ if (!body) return []
63
+ return body
64
+ .split(',')
65
+ .map(part => part.trim())
66
+ .filter(Boolean)
67
+ .map(part => part.replace(/^\.\.\./, '').split('=')[0].split(':')[0].replace(/\?/g, '').trim())
68
+ .filter(part => /^[$A-Z_][0-9A-Z_$]*$/i.test(part))
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Argument building
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export function buildToolArgs(input: unknown, tool?: ToolInfo): unknown[] {
76
+ if (input === undefined) return []
77
+ if (Array.isArray(input)) return input
78
+ if (input && typeof input === 'object') {
79
+ const argObject = input as Record<string, unknown>
80
+ const paramNames = parseSignatureParams(tool?.signature)
81
+ if (paramNames.length > 0 && paramNames.every(name => Object.prototype.hasOwnProperty.call(argObject, name))) {
82
+ return paramNames.map(name => argObject[name])
83
+ }
84
+ return [input]
85
+ }
86
+ return [input]
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Subprocess execution
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export function runToolInSubprocess(
94
+ toolsModulePath: string,
95
+ toolName: string,
96
+ args: unknown[],
97
+ ): Promise<RerunResult> {
98
+ return new Promise((resolve) => {
99
+ const startMs = Date.now()
100
+ const workerScript = new URL('../tool-runner-worker.js', import.meta.url).pathname
101
+ const projectDir = path.dirname(toolsModulePath)
102
+ const denoProject = isDenoProject(projectDir)
103
+
104
+ const nodeOptions = process.env.NODE_OPTIONS ?? ''
105
+ const tsxFlag = '--import tsx'
106
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim()
107
+ const childEnv = { ...process.env, NODE_OPTIONS: denoProject ? nodeOptions : childNodeOptions }
108
+
109
+ const runtime = denoProject ? 'deno' : process.execPath
110
+ const runtimeArgs = denoProject ? ['run', '--allow-all', workerScript] : [workerScript]
111
+
112
+ const child = spawn(runtime, runtimeArgs, {
113
+ env: childEnv,
114
+ cwd: projectDir,
115
+ stdio: ['pipe', 'pipe', 'pipe'],
116
+ })
117
+
118
+ const RESULT_PREFIX = '__ELASTICDASH_RESULT__:'
119
+ let resultLine = ''
120
+ let stderr = ''
121
+
122
+ child.stdout.on('data', (chunk: Buffer) => {
123
+ const text = chunk.toString()
124
+ for (const line of text.split('\n')) {
125
+ if (line.startsWith(RESULT_PREFIX)) {
126
+ resultLine = line.slice(RESULT_PREFIX.length)
127
+ } else if (line) {
128
+ process.stdout.write(line + '\n')
129
+ }
130
+ }
131
+ })
132
+ child.stderr.on('data', (chunk: Buffer) => {
133
+ stderr += chunk.toString()
134
+ process.stderr.write(chunk)
135
+ })
136
+
137
+ child.on('close', () => {
138
+ const currentDurationMs = Date.now() - startMs
139
+ if (resultLine) {
140
+ try {
141
+ resolve({ ...JSON.parse(resultLine), currentDurationMs })
142
+ return
143
+ } catch { /* fall through */ }
144
+ }
145
+ resolve({ ok: false, error: stderr.trim() || 'Tool subprocess produced no output.', currentDurationMs })
146
+ })
147
+
148
+ child.on('error', (err) => {
149
+ const hint = denoProject && (err as NodeJS.ErrnoException).code === 'ENOENT'
150
+ ? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
151
+ : ''
152
+ resolve({ ok: false, error: `Failed to spawn tool subprocess: ${err.message}${hint}`, currentDurationMs: Date.now() - startMs })
153
+ })
154
+
155
+ const payload = JSON.stringify({
156
+ toolsModulePath: pathToFileURL(toolsModulePath).pathname,
157
+ toolName,
158
+ args,
159
+ })
160
+ child.stdin.write(payload)
161
+ child.stdin.end()
162
+ })
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Tool scanning (static analysis of ed_tools.ts/js)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ function resolveModulePath(fromDir: string, specifier: string): string | null {
170
+ if (!specifier.startsWith('.')) return null
171
+ const exts = ['.ts', '.tsx', '.js', '.jsx', '']
172
+ for (const ext of exts) {
173
+ const candidate = path.resolve(fromDir, specifier + ext)
174
+ if (existsSync(candidate)) return candidate
175
+ }
176
+ return null
177
+ }
178
+
179
+ function lineAt(src: string, index: number): number {
180
+ return src.slice(0, index).split('\n').length
181
+ }
182
+
183
+ function extractSource(src: string, index: number): string {
184
+ const snippet = src.slice(index, index + 2000)
185
+ return snippet.length < 2000 ? snippet : snippet + '\n// (truncated)'
186
+ }
187
+
188
+ function findFunctionInSource(src: string, name: string): { isAsync: boolean; signature: string; lineNumber?: number; sourceCode?: string } {
189
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
190
+ let m = src.match(new RegExp(`export\\s+(async\\s+)?function\\s+${escaped}\\s*(\\([^)]*\\))`))
191
+ if (m) return { isAsync: !!m[1], signature: m[2], lineNumber: lineAt(src, m.index!), sourceCode: extractSource(src, m.index!) }
192
+ m = src.match(new RegExp(`(?:^|\\n)\\s*(?:async\\s+)?function\\s+${escaped}\\s*(\\([^)]*\\))`, 'm'))
193
+ if (m) return {
194
+ isAsync: new RegExp(`async\\s+function\\s+${escaped}`).test(src),
195
+ signature: m[1],
196
+ lineNumber: lineAt(src, m.index!),
197
+ sourceCode: extractSource(src, m.index!),
198
+ }
199
+ m = src.match(new RegExp(`export\\s+const\\s+${escaped}\\s*=\\s*(async\\s*)?(\\([^)]*\\))\\s*=>`))
200
+ if (m) return { isAsync: !!m[1], signature: m[2], lineNumber: lineAt(src, m.index!) }
201
+ m = src.match(new RegExp(`(?:^|\\n)\\s*const\\s+${escaped}\\s*=\\s*(async\\s*)?(\\([^)]*\\))\\s*=>`, 'm'))
202
+ if (m) return { isAsync: !!m[1], signature: m[2], lineNumber: lineAt(src, m.index!) }
203
+ return { isAsync: false, signature: '()' }
204
+ }
205
+
206
+ function extractExportsFromSource(filePath: string): ParsedExport[] {
207
+ let src: string
208
+ try {
209
+ src = readFileSync(filePath, 'utf8')
210
+ } catch {
211
+ return []
212
+ }
213
+ const dir = path.dirname(filePath)
214
+ const results: ParsedExport[] = []
215
+
216
+ // 1. Direct: export [async] function name(params) { … }
217
+ for (const m of src.matchAll(/export\s+(async\s+)?function\s+(\w+)\s*(\([^)]*\))/g)) {
218
+ results.push({
219
+ name: m[2], isAsync: !!m[1], signature: m[3], filePath,
220
+ lineNumber: lineAt(src, m.index!), sourceCode: extractSource(src, m.index!),
221
+ })
222
+ }
223
+
224
+ // 2. Direct: export const name = [async] (params) => …
225
+ for (const m of src.matchAll(/export\s+const\s+(\w+)\s*=\s*(async\s*)?\(([^)]*)\)\s*=>/g)) {
226
+ results.push({
227
+ name: m[1], isAsync: !!m[2], signature: `(${m[3]})`, filePath,
228
+ lineNumber: lineAt(src, m.index!), sourceCode: extractSource(src, m.index!),
229
+ })
230
+ }
231
+
232
+ // 3. Named re-exports: export { X [as Y], … } from './module'
233
+ for (const m of src.matchAll(/export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) {
234
+ const modulePath = resolveModulePath(dir, m[2])
235
+ let moduleSrc = ''
236
+ try { if (modulePath) moduleSrc = readFileSync(modulePath, 'utf8') } catch { /* ignore */ }
237
+ for (const spec of m[1].split(',')) {
238
+ const parts = spec.trim().split(/\s+as\s+/)
239
+ const originalName = parts[0].trim()
240
+ const exportedName = (parts[1] ?? parts[0]).trim()
241
+ if (!exportedName || exportedName === 'default') continue
242
+ const info = moduleSrc ? findFunctionInSource(moduleSrc, originalName) : { isAsync: false, signature: '()' }
243
+ results.push({
244
+ name: exportedName, isAsync: info.isAsync, signature: info.signature,
245
+ filePath: modulePath ?? filePath, lineNumber: info.lineNumber, sourceCode: info.sourceCode,
246
+ })
247
+ }
248
+ }
249
+
250
+ // 4. Import + destructure: import { obj } from './m' + export const { a, b } = obj
251
+ for (const imp of src.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) {
252
+ const importedNames = imp[1].split(',').map(s => {
253
+ const parts = s.trim().split(/\s+as\s+/)
254
+ return { original: parts[0].trim(), local: (parts[1] ?? parts[0]).trim() }
255
+ }).filter(n => n.local)
256
+ const modulePath = resolveModulePath(dir, imp[2])
257
+ for (const { local } of importedNames) {
258
+ const destructureRe = new RegExp(`export\\s+const\\s+\\{([^}]+)\\}\\s*=\\s*${local.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
259
+ const dm = src.match(destructureRe)
260
+ if (!dm) continue
261
+ let moduleSrc = ''
262
+ try { if (modulePath) moduleSrc = readFileSync(modulePath, 'utf8') } catch { /* ignore */ }
263
+ for (const member of dm[1].split(',')) {
264
+ const name = member.trim()
265
+ if (!name) continue
266
+ const info = moduleSrc ? findFunctionInSource(moduleSrc, name) : { isAsync: false, signature: '()' }
267
+ results.push({
268
+ name, isAsync: info.isAsync, signature: info.signature,
269
+ filePath: modulePath ?? filePath, lineNumber: info.lineNumber, sourceCode: info.sourceCode,
270
+ })
271
+ }
272
+ }
273
+ }
274
+
275
+ return results
276
+ }
277
+
278
+ export function scanTools(cwd: string): ToolInfo[] {
279
+ for (const candidate of [path.join(cwd, 'ed_tools.ts'), path.join(cwd, 'ed_tools.js')]) {
280
+ if (!existsSync(candidate)) continue
281
+ const exports = extractExportsFromSource(candidate)
282
+ if (exports.length > 0) {
283
+ return exports.map(e => ({
284
+ name: e.name, isAsync: e.isAsync, signature: e.signature,
285
+ filePath: e.filePath, lineNumber: e.lineNumber, sourceCode: e.sourceCode,
286
+ }))
287
+ }
288
+ }
289
+ return []
290
+ }
291
+
292
+ export function scanWorkflows(cwd: string): ToolInfo[] {
293
+ for (const candidate of [path.join(cwd, 'ed_workflows.ts'), path.join(cwd, 'ed_workflows.js')]) {
294
+ if (!existsSync(candidate)) continue
295
+ const exports = extractExportsFromSource(candidate)
296
+ if (exports.length > 0) {
297
+ return exports.map(e => ({
298
+ name: e.name, isAsync: e.isAsync, signature: e.signature,
299
+ filePath: e.filePath, lineNumber: e.lineNumber, sourceCode: e.sourceCode,
300
+ }))
301
+ }
302
+ }
303
+ return []
304
+ }
package/src/http.ts CHANGED
@@ -6,3 +6,5 @@
6
6
  export { setHttpRunContext, initHttpRunContext, getHttpRunContext } from './interceptors/telemetry-push.js'
7
7
  export { wrapTool } from './interceptors/tool.js'
8
8
  export { wrapAI } from './interceptors/workflow-ai.js'
9
+ export { wrapDB, wrapPgClient, wrapKnex, wrapMongoCollection, wrapRedisClient } from './interceptors/db.js'
10
+ export { initObservability, shutdownObservability, startTrace, wrapWorkflow } from './observability.js'
package/src/index.ts CHANGED
@@ -71,6 +71,19 @@ export { interceptRandom, restoreRandom, interceptDateNow, restoreDateNow } from
71
71
  // AI interceptor (monkey-patch based)
72
72
  export { installAIInterceptor, uninstallAIInterceptor } from './interceptors/ai-interceptor.js'
73
73
 
74
+ // Observability
75
+ export { initObservability, shutdownObservability, startTrace, wrapWorkflow } from './observability.js'
76
+ export type { ObservabilityOptions, ObservabilityHandle } from './observability.js'
77
+ export { TelemetryBatcher } from './telemetry-batcher.js'
78
+ export type { TelemetryBatcherOptions, TriggerSignal, TriggerStep } from './telemetry-batcher.js'
79
+ export type { ObservabilityContext } from './interceptors/telemetry-push.js'
80
+ export { checkToolAvailability, checkAIAvailability } from './portal-executor.js'
81
+ export type { AvailabilityResult } from './portal-executor.js'
82
+
83
+ // Socket connector (real-time backend communication)
84
+ export { connectToBackend, disconnectFromBackend } from './socket-connector.js'
85
+ export type { SocketConnectorOptions } from './socket-connector.js'
86
+
74
87
  // LLM proxy
75
88
  export { startLLMProxy, fetchCapturedTrace } from './proxy/llm-capture.js'
76
89
 
@@ -85,3 +98,8 @@ export type { RunWorkflowOptions, WorkflowRunResult } from './workflow-runner.js
85
98
  // Agent mid-trace replay
86
99
  export { serializeAgentState, deserializeAgentState, extractTaskOutputs, resolveTaskInput } from './core/agent-state.js'
87
100
  export type { AgentTask, AgentPlan, AgentState, AgentTaskStatus, AgentPlanStatus } from './types/agent.js'
101
+
102
+ // Portal (remote rerun queue)
103
+ export { startPortalServer } from './portal-server.js'
104
+ export { executePortalTask } from './portal-executor.js'
105
+ export type { PortalTask, PortalTaskResult, PortalServerOptions, PortalServerHandle, PortalStatus } from './types/portal.js'
@@ -1,6 +1,8 @@
1
1
  import { getCurrentTrace } from '../trace-adapter/context.js'
2
2
  import { getCaptureContext } from '../capture/recorder.js'
3
3
  import { rawDateNow } from './side-effects.js'
4
+ import { getObservabilityContext, getHttpRunContext, getHttpFrozenEvent, pushTelemetryEvent, tryAutoInitHttpContext } from './telemetry-push.js'
5
+ import type { WorkflowEvent } from '../capture/event.js'
4
6
 
5
7
  type UsageInfo = { inputTokens?: number; outputTokens?: number; totalTokens?: number }
6
8
 
@@ -385,9 +387,12 @@ export function installAIInterceptor(): void {
385
387
 
386
388
  const provider = detectProvider(url)
387
389
  const traceAtCall = getCurrentTrace()
390
+ const obsCtx = getObservabilityContext()
388
391
 
389
- // No match or no active trace: pass through unchanged
390
- if (!provider || !traceAtCall) {
392
+ const httpCtx = getHttpRunContext()
393
+
394
+ // No match or no active context: pass through unchanged
395
+ if (!provider || (!traceAtCall && !obsCtx && !httpCtx)) {
391
396
  return originalFetch!(input, init)
392
397
  }
393
398
 
@@ -414,7 +419,106 @@ export function installAIInterceptor(): void {
414
419
 
415
420
  const ctx = getCaptureContext()
416
421
 
417
- if (ctx) {
422
+ // Observability-only mode: no trace handle, no capture context — record via pushTelemetryEvent
423
+ if (!traceAtCall && !ctx && obsCtx) {
424
+ const id = obsCtx.nextId()
425
+ const start = rawDateNow()
426
+ const eventInput = { url, provider, model, prompt, messages }
427
+
428
+ const response = await originalFetch!(input, init)
429
+
430
+ if (isStreaming && response.body) {
431
+ const [streamForCaller, streamForRecorder] = response.body.tee()
432
+ bufferSSEStream(provider, streamForRecorder).then((completion) => {
433
+ const durationMs = rawDateNow() - start
434
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: { streamed: true, completion }, timestamp: start, durationMs })
435
+ }).catch(() => {
436
+ const durationMs = rawDateNow() - start
437
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: null, streamed: true, streamRaw: '', timestamp: start, durationMs })
438
+ })
439
+ return new Response(streamForCaller, { status: response.status, statusText: response.statusText, headers: response.headers })
440
+ }
441
+
442
+ try {
443
+ const cloned = response.clone()
444
+ const responseBody = await cloned.json() as Record<string, unknown>
445
+ const completion = extractCompletion(provider, responseBody)
446
+ const usage = extractUsage(provider, responseBody)
447
+ const durationMs = rawDateNow() - start
448
+ const event: WorkflowEvent = {
449
+ id, type: 'ai', name: model, input: eventInput, output: { completion },
450
+ timestamp: start, durationMs,
451
+ ...(usage ? { usage } : {}),
452
+ }
453
+ pushTelemetryEvent(event)
454
+ } catch {
455
+ const durationMs = rawDateNow() - start
456
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: null, timestamp: start, durationMs })
457
+ }
458
+
459
+ return response
460
+ }
461
+
462
+ // HTTP mode (no capture context): replay frozen AI events or execute live + push telemetry
463
+ if (!ctx && httpCtx) {
464
+ const id = httpCtx.nextId()
465
+ const eventInput = { url, provider, model, prompt, messages }
466
+
467
+ // Replay frozen step
468
+ const frozen = getHttpFrozenEvent(id)
469
+ if (frozen && frozen.type === 'ai') {
470
+ pushTelemetryEvent(frozen)
471
+ const frozenOutput = frozen.output as Record<string, unknown> | null
472
+ const completion = frozenOutput ? extractCompletion(provider, frozenOutput) : '(replayed)'
473
+
474
+ if (isStreaming) {
475
+ return new Response(synthesizeSSEStream(provider, completion), {
476
+ status: 200,
477
+ headers: { 'Content-Type': provider === 'gemini' ? 'application/json' : 'text/event-stream' },
478
+ })
479
+ }
480
+
481
+ const body = frozenOutput?.streamed === true
482
+ ? synthesizeCompletionJSON(provider, completion)
483
+ : (frozenOutput ?? synthesizeCompletionJSON(provider, completion))
484
+ return new Response(JSON.stringify(body), {
485
+ status: 200,
486
+ headers: { 'Content-Type': 'application/json' },
487
+ })
488
+ }
489
+
490
+ // Not frozen → execute live, push telemetry
491
+ const start = rawDateNow()
492
+ const response = await originalFetch!(input, init)
493
+
494
+ if (isStreaming && response.body) {
495
+ const [streamForCaller, streamForRecorder] = response.body.tee()
496
+ bufferSSEStream(provider, streamForRecorder).then((completion) => {
497
+ const durationMs = rawDateNow() - start
498
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: { streamed: true, completion }, timestamp: start, durationMs })
499
+ }).catch(() => {
500
+ const durationMs = rawDateNow() - start
501
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: null, streamed: true, streamRaw: '', timestamp: start, durationMs })
502
+ })
503
+ return new Response(streamForCaller, { status: response.status, statusText: response.statusText, headers: response.headers })
504
+ }
505
+
506
+ try {
507
+ const cloned = response.clone()
508
+ const responseBody = await cloned.json() as Record<string, unknown>
509
+ const completion = extractCompletion(provider, responseBody)
510
+ const usage = extractUsage(provider, responseBody)
511
+ const durationMs = rawDateNow() - start
512
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: { completion }, timestamp: start, durationMs, ...(usage ? { usage } : {}) })
513
+ } catch {
514
+ const durationMs = rawDateNow() - start
515
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: null, timestamp: start, durationMs })
516
+ }
517
+
518
+ return response
519
+ }
520
+
521
+ if (ctx && traceAtCall) {
418
522
  const { recorder, replay } = ctx
419
523
  const id = recorder.nextId()
420
524
  const start = rawDateNow()
@@ -505,7 +609,9 @@ export function installAIInterceptor(): void {
505
609
  return response
506
610
  }
507
611
 
508
- // No capture context — original behaviour (outside of a workflow run)
612
+ // No capture context — original behaviour (trace handle only, outside of a workflow run)
613
+ if (!traceAtCall) return originalFetch!(input, init)
614
+
509
615
  const response = await originalFetch!(input, init)
510
616
 
511
617
  if (isStreaming && response.body) {
@@ -1,4 +1,7 @@
1
1
  import { getCaptureContext } from '../capture/recorder.js'
2
+ import { getCurrentTrace } from '../trace-adapter/context.js'
3
+ import { rawDateNow } from './side-effects.js'
4
+ import { getHttpRunContext, getHttpFrozenEvent, pushTelemetryEvent, tryAutoInitHttpContext, getObservabilityContext } from './telemetry-push.js'
2
5
 
3
6
  type AnyFn = (...args: unknown[]) => unknown
4
7
 
@@ -10,6 +13,14 @@ interface MethodPatch {
10
13
 
11
14
  const appliedPatches: MethodPatch[] = []
12
15
 
16
+ function toTraceArgs(input: unknown): Record<string, unknown> | undefined {
17
+ if (input && typeof input === 'object' && !Array.isArray(input)) {
18
+ return input as Record<string, unknown>
19
+ }
20
+ if (input === undefined) return undefined
21
+ return { value: input }
22
+ }
23
+
13
24
  function wrapProtoMethod(proto: object, method: string, eventName: string): void {
14
25
  const p = proto as Record<string, unknown>
15
26
  if (typeof p[method] !== 'function') return
@@ -24,58 +35,143 @@ function wrapProtoMethod(proto: object, method: string, eventName: string): void
24
35
  }
25
36
 
26
37
  const ctx = getCaptureContext()
27
- if (!ctx) return original.apply(this, args)
38
+ const httpCtx = getHttpRunContext()
39
+ const obsCtx = getObservabilityContext()
40
+ const input = args.length === 1 ? args[0] : args
41
+
42
+ if (!ctx && !httpCtx && !obsCtx) return original.apply(this, args)
43
+
44
+ // Observability-only mode: record and push, no mocks/replay
45
+ if (!ctx && !httpCtx && obsCtx) {
46
+ const id = obsCtx.nextId()
47
+ const start = rawDateNow()
48
+
49
+ let result: unknown
50
+ try {
51
+ result = original.apply(this, args)
52
+ } catch (err) {
53
+ pushTelemetryEvent({ id, type: 'db', name: eventName, input, output: { error: String(err) }, timestamp: start, durationMs: rawDateNow() - start })
54
+ throw err
55
+ }
56
+
57
+ if (result != null && typeof (result as Promise<unknown>).then === 'function') {
58
+ return (result as Promise<unknown>)
59
+ .then((output: unknown) => {
60
+ pushTelemetryEvent({ id, type: 'db', name: eventName, input, output, timestamp: start, durationMs: rawDateNow() - start })
61
+ return output
62
+ })
63
+ .catch((err: unknown) => {
64
+ pushTelemetryEvent({ id, type: 'db', name: eventName, input, output: { error: String(err) }, timestamp: start, durationMs: rawDateNow() - start })
65
+ throw err
66
+ })
67
+ }
68
+
69
+ pushTelemetryEvent({ id, type: 'db', name: eventName, input, output: result, timestamp: start, durationMs: rawDateNow() - start })
70
+ return result
71
+ }
28
72
 
29
- const { recorder, replay } = ctx
73
+ // HTTP mode (no capture context): replay frozen events or execute live
74
+ if (!ctx && httpCtx) {
75
+ const id = httpCtx.nextId()
76
+
77
+ // Replay frozen step
78
+ const frozen = getHttpFrozenEvent(id)
79
+ if (frozen && frozen.type === 'db') {
80
+ pushTelemetryEvent(frozen)
81
+ return Promise.resolve(frozen.output)
82
+ }
83
+
84
+ // Not frozen → execute live, push telemetry
85
+ const start = rawDateNow()
86
+
87
+ let result: unknown
88
+ try {
89
+ result = original.apply(this, args)
90
+ } catch (err) {
91
+ pushTelemetryEvent({ id, type: 'db', name: eventName, input, output: { error: String(err) }, timestamp: start, durationMs: rawDateNow() - start })
92
+ throw err
93
+ }
94
+
95
+ if (result != null && typeof (result as Promise<unknown>).then === 'function') {
96
+ return (result as Promise<unknown>)
97
+ .then((output: unknown) => {
98
+ pushTelemetryEvent({ id, type: 'db', name: eventName, input, output, timestamp: start, durationMs: rawDateNow() - start })
99
+ return output
100
+ })
101
+ .catch((err: unknown) => {
102
+ pushTelemetryEvent({ id, type: 'db', name: eventName, input, output: { error: String(err) }, timestamp: start, durationMs: rawDateNow() - start })
103
+ throw err
104
+ })
105
+ }
106
+
107
+ pushTelemetryEvent({ id, type: 'db', name: eventName, input, output: result, timestamp: start, durationMs: rawDateNow() - start })
108
+ return result
109
+ }
110
+
111
+ // Capture mode (enhanced with telemetry + TraceHandle)
112
+ const trace = getCurrentTrace()
113
+ const { recorder, replay } = ctx!
30
114
  const id = recorder.nextId()
31
115
 
32
116
  if (replay.shouldReplay(id)) {
33
117
  const historicalEvent = replay.getRecordedEvent(id)
34
118
  if (historicalEvent) recorder.record(historicalEvent)
35
- return Promise.resolve(replay.getRecordedResult(id))
119
+ if (httpCtx) pushTelemetryEvent(historicalEvent ?? { id, type: 'db', name: eventName, input, output: null, timestamp: rawDateNow(), durationMs: 0 })
120
+ const replayed = replay.getRecordedResult(id)
121
+ if (trace && typeof trace.recordToolCall === 'function') {
122
+ trace.recordToolCall({ name: eventName, args: toTraceArgs(input), result: replayed, workflowEventId: id })
123
+ }
124
+ return Promise.resolve(replayed)
36
125
  }
37
126
 
38
- const start = Date.now()
39
- const input = args.length === 1 ? args[0] : args
127
+ const start = rawDateNow()
40
128
 
41
129
  let result: unknown
42
130
  try {
43
131
  result = original.apply(this, args)
44
132
  } catch (err) {
45
- recorder.record({
46
- id, type: 'db', name: eventName,
47
- input, output: { error: String(err) },
48
- timestamp: start, durationMs: Date.now() - start,
49
- })
133
+ const durationMs = rawDateNow() - start
134
+ const event = { id, type: 'db' as const, name: eventName, input, output: { error: String(err) }, timestamp: start, durationMs }
135
+ recorder.record(event)
136
+ if (httpCtx) pushTelemetryEvent(event)
137
+ if (trace && typeof trace.recordToolCall === 'function') {
138
+ trace.recordToolCall({ name: eventName, args: toTraceArgs(input), result: { error: String(err) }, workflowEventId: id, durationMs })
139
+ }
50
140
  throw err
51
141
  }
52
142
 
53
143
  if (result != null && typeof (result as Promise<unknown>).then === 'function') {
54
144
  return (result as Promise<unknown>)
55
145
  .then((output: unknown) => {
56
- recorder.record({
57
- id, type: 'db', name: eventName,
58
- input, output,
59
- timestamp: start, durationMs: Date.now() - start,
60
- })
146
+ const durationMs = rawDateNow() - start
147
+ const event = { id, type: 'db' as const, name: eventName, input, output, timestamp: start, durationMs }
148
+ recorder.record(event)
149
+ if (httpCtx) pushTelemetryEvent(event)
150
+ if (trace && typeof trace.recordToolCall === 'function') {
151
+ trace.recordToolCall({ name: eventName, args: toTraceArgs(input), result: output, workflowEventId: id, durationMs })
152
+ }
61
153
  return output
62
154
  })
63
155
  .catch((err: unknown) => {
64
- recorder.record({
65
- id, type: 'db', name: eventName,
66
- input, output: { error: String(err) },
67
- timestamp: start, durationMs: Date.now() - start,
68
- })
156
+ const durationMs = rawDateNow() - start
157
+ const event = { id, type: 'db' as const, name: eventName, input, output: { error: String(err) }, timestamp: start, durationMs }
158
+ recorder.record(event)
159
+ if (httpCtx) pushTelemetryEvent(event)
160
+ if (trace && typeof trace.recordToolCall === 'function') {
161
+ trace.recordToolCall({ name: eventName, args: toTraceArgs(input), result: { error: String(err) }, workflowEventId: id, durationMs })
162
+ }
69
163
  throw err
70
164
  })
71
165
  }
72
166
 
73
167
  // Sync return (rare for DB calls)
74
- recorder.record({
75
- id, type: 'db', name: eventName,
76
- input, output: result,
77
- timestamp: start, durationMs: Date.now() - start,
78
- })
168
+ const durationMs = rawDateNow() - start
169
+ const event = { id, type: 'db' as const, name: eventName, input, output: result, timestamp: start, durationMs }
170
+ recorder.record(event)
171
+ if (httpCtx) pushTelemetryEvent(event)
172
+ if (trace && typeof trace.recordToolCall === 'function') {
173
+ trace.recordToolCall({ name: eventName, args: toTraceArgs(input), result, workflowEventId: id, durationMs })
174
+ }
79
175
  return result
80
176
  }
81
177
  }