elasticdash-test 0.1.17 → 0.1.18-alpha

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 (95) 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 +4310 -2672
  16. package/dist/index.d.ts +10 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +7 -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 +242 -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/telemetry-batcher.d.ts +43 -0
  56. package/dist/telemetry-batcher.d.ts.map +1 -0
  57. package/dist/telemetry-batcher.js +111 -0
  58. package/dist/telemetry-batcher.js.map +1 -0
  59. package/dist/trigger-executor.d.ts +12 -0
  60. package/dist/trigger-executor.d.ts.map +1 -0
  61. package/dist/trigger-executor.js +83 -0
  62. package/dist/trigger-executor.js.map +1 -0
  63. package/dist/types/portal.d.ts +64 -0
  64. package/dist/types/portal.d.ts.map +1 -0
  65. package/dist/types/portal.js +2 -0
  66. package/dist/types/portal.js.map +1 -0
  67. package/dist/utils/debug.d.ts +3 -0
  68. package/dist/utils/debug.d.ts.map +1 -0
  69. package/dist/utils/debug.js +8 -0
  70. package/dist/utils/debug.js.map +1 -0
  71. package/dist/utils/redact.d.ts +7 -0
  72. package/dist/utils/redact.d.ts.map +1 -0
  73. package/dist/utils/redact.js +26 -0
  74. package/dist/utils/redact.js.map +1 -0
  75. package/package.json +9 -1
  76. package/src/capture/event.ts +5 -1
  77. package/src/cli.ts +109 -0
  78. package/src/execution/tool-runner.ts +304 -0
  79. package/src/http.ts +2 -0
  80. package/src/index.ts +14 -0
  81. package/src/interceptors/ai-interceptor.ts +110 -4
  82. package/src/interceptors/db-auto.ts +121 -25
  83. package/src/interceptors/db.ts +92 -17
  84. package/src/interceptors/http.ts +145 -107
  85. package/src/interceptors/telemetry-push.ts +113 -13
  86. package/src/interceptors/tool.ts +42 -5
  87. package/src/interceptors/workflow-ai.ts +49 -2
  88. package/src/observability.ts +281 -0
  89. package/src/portal-executor.ts +335 -0
  90. package/src/portal-server.ts +290 -0
  91. package/src/telemetry-batcher.ts +143 -0
  92. package/src/trigger-executor.ts +121 -0
  93. package/src/types/portal.ts +67 -0
  94. package/src/utils/debug.ts +8 -0
  95. package/src/utils/redact.ts +25 -0
@@ -1,4 +1,7 @@
1
1
  import { getCaptureContext } from '../capture/recorder.js'
2
+ import { getCurrentTrace } from '../trace-adapter/context.js'
3
+ import { getHttpRunContext, getHttpFrozenEvent, pushTelemetryEvent, tryAutoInitHttpContext, getObservabilityContext } from './telemetry-push.js'
4
+ import { rawDateNow } from './side-effects.js'
2
5
 
3
6
  // AI provider URLs are already captured by ai-interceptor.ts as "llm" steps.
4
7
  // Skip them here to avoid duplicate observations.
@@ -203,13 +206,95 @@ function reconstructStream(raw: string): ReadableStream<Uint8Array> {
203
206
 
204
207
  let originalFetch: typeof globalThis.fetch | undefined
205
208
 
209
+ /** Synthesize a Response from a frozen HTTP event */
210
+ function synthesizeFrozenResponse(frozen: import('../capture/event.js').WorkflowEvent): Response {
211
+ const frozenInput = frozen.input as Record<string, unknown> | undefined
212
+ const responseMeta = (frozenInput?.__elasticdashResponse ?? {}) as Record<string, unknown>
213
+ const status = typeof responseMeta.status === 'number' ? responseMeta.status : 200
214
+ const statusText = typeof responseMeta.statusText === 'string' ? responseMeta.statusText : ''
215
+ const headers = pickReplayResponseHeaders(
216
+ responseMeta.headers && typeof responseMeta.headers === 'object'
217
+ ? (responseMeta.headers as Record<string, unknown>)
218
+ : undefined,
219
+ )
220
+
221
+ if (frozen.streamed === true) {
222
+ const raw = typeof frozen.streamRaw === 'string' ? frozen.streamRaw : ''
223
+ return new Response(reconstructStream(raw), { status, statusText, headers })
224
+ }
225
+
226
+ const body = frozen.output != null ? JSON.stringify(frozen.output) : null
227
+ return new Response(body, { status, statusText, headers })
228
+ }
229
+
230
+ /** Execute a live fetch and record the event, returning both the Response and the recorded event */
231
+ async function executeLiveAndRecord(
232
+ originalFetchFn: typeof globalThis.fetch,
233
+ input: string | URL | Request,
234
+ init: RequestInit | undefined,
235
+ id: number,
236
+ url: string,
237
+ method: string,
238
+ rawHeaders: RequestInit['headers'] | undefined,
239
+ rawBody: RequestInit['body'] | null | undefined,
240
+ ): Promise<{ response: Response; event: import('../capture/event.js').WorkflowEvent }> {
241
+ const query = parseQuery(url)
242
+ const body = parseBody(rawBody as RequestInit['body'] | null | undefined)
243
+ const headers = normalizeHeaders(rawHeaders)
244
+
245
+ const start = rawDateNow()
246
+ const res = await originalFetchFn(input, init)
247
+
248
+ const responseHeadersObj: Record<string, string> = {}
249
+ res.headers.forEach((v, k) => { responseHeadersObj[k] = v })
250
+
251
+ const elasticdashResponse = { status: res.status, statusText: res.statusText, headers: responseHeadersObj, url: res.url }
252
+ const baseInput = {
253
+ url, method,
254
+ ...(query ? { query } : {}),
255
+ ...(body !== undefined ? { body } : {}),
256
+ ...(headers && Object.keys(headers).length > 0 ? { headers } : {}),
257
+ __elasticdashResponse: elasticdashResponse,
258
+ }
259
+
260
+ if (isStreamingContentType(res.headers) && res.body) {
261
+ const [streamForCaller, streamForRecorder] = res.body.tee()
262
+ const vercelAI = isVercelAIDataStream(res.headers)
263
+ // Return immediately with a pending event; caller handles async recording
264
+ const eventPromise = bufferStream(streamForRecorder).then((rawText): import('../capture/event.js').WorkflowEvent => ({
265
+ id, type: 'http', name: 'fetch', input: baseInput,
266
+ output: vercelAI ? parseVercelAIDataStream(rawText) : null,
267
+ streamed: true, streamRaw: rawText, timestamp: start, durationMs: rawDateNow() - start,
268
+ })).catch((): import('../capture/event.js').WorkflowEvent => ({
269
+ id, type: 'http', name: 'fetch', input: baseInput,
270
+ output: null, streamed: true, streamRaw: '', timestamp: start, durationMs: rawDateNow() - start,
271
+ }))
272
+ const streamResponse = new Response(streamForCaller, { status: res.status, statusText: res.statusText, headers: res.headers })
273
+ // Attach promise as a property for async recording
274
+ ;(streamResponse as unknown as Record<string, unknown>).__eventPromise = eventPromise
275
+ return { response: streamResponse, event: null as unknown as import('../capture/event.js').WorkflowEvent }
276
+ }
277
+
278
+ let output: unknown = null
279
+ try { output = await res.clone().json() } catch { /* not JSON */ }
280
+
281
+ const event: import('../capture/event.js').WorkflowEvent = {
282
+ id, type: 'http', name: 'fetch', input: baseInput, output, timestamp: start, durationMs: rawDateNow() - start,
283
+ }
284
+ return { response: res, event }
285
+ }
286
+
206
287
  export function interceptFetch(): void {
207
288
  if (originalFetch) return // already installed
208
289
  originalFetch = globalThis.fetch
209
290
 
210
291
  globalThis.fetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
292
+ await tryAutoInitHttpContext()
211
293
  const ctx = getCaptureContext()
212
- if (!ctx) return originalFetch!(input, init)
294
+ const httpCtx = getHttpRunContext()
295
+ const obsCtx = getObservabilityContext()
296
+
297
+ if (!ctx && !httpCtx && !obsCtx) return originalFetch!(input, init)
213
298
 
214
299
  const url =
215
300
  typeof input === 'string'
@@ -226,7 +311,44 @@ export function interceptFetch(): void {
226
311
  return originalFetch!(input, init)
227
312
  }
228
313
 
229
- const { recorder, replay } = ctx
314
+ // --- Observability-only mode: record and push, no mocks/replay ---
315
+ if (!ctx && !httpCtx && obsCtx) {
316
+ const id = obsCtx.nextId()
317
+ const { response, event } = await executeLiveAndRecord(originalFetch!, input, init, id, url, method, rawHeaders, rawBody as RequestInit['body'] | null | undefined)
318
+ const eventPromise = (response as unknown as Record<string, unknown>).__eventPromise as Promise<import('../capture/event.js').WorkflowEvent> | undefined
319
+ if (eventPromise) {
320
+ eventPromise.then((ev) => pushTelemetryEvent(ev)).catch(() => {})
321
+ } else if (event) {
322
+ pushTelemetryEvent(event)
323
+ }
324
+ return response
325
+ }
326
+
327
+ // --- HTTP mode (no capture context) — replay frozen events or execute live ---
328
+ if (!ctx && httpCtx) {
329
+ const id = httpCtx.nextId()
330
+
331
+ // Replay frozen step
332
+ const frozen = getHttpFrozenEvent(id)
333
+ if (frozen && frozen.type === 'http') {
334
+ pushTelemetryEvent(frozen)
335
+ return synthesizeFrozenResponse(frozen)
336
+ }
337
+
338
+ // Not frozen → execute live, push telemetry
339
+ const { response, event } = await executeLiveAndRecord(originalFetch!, input, init, id, url, method, rawHeaders, rawBody as RequestInit['body'] | null | undefined)
340
+ const eventPromise = (response as unknown as Record<string, unknown>).__eventPromise as Promise<import('../capture/event.js').WorkflowEvent> | undefined
341
+ if (eventPromise) {
342
+ eventPromise.then((ev) => pushTelemetryEvent(ev)).catch(() => {})
343
+ } else if (event) {
344
+ pushTelemetryEvent(event)
345
+ }
346
+ return response
347
+ }
348
+
349
+ // --- Capture mode (existing behaviour, enhanced with telemetry + TraceHandle) ---
350
+ const trace = getCurrentTrace()
351
+ const { recorder, replay } = ctx!
230
352
  const id = recorder.nextId()
231
353
 
232
354
  if (replay.shouldReplay(id)) {
@@ -242,120 +364,36 @@ export function interceptFetch(): void {
242
364
 
243
365
  if (isReplayMatch && historicalEvent) {
244
366
  recorder.record(historicalEvent)
245
-
246
- const replayMeta = (historicalInput?.__elasticdashResponse ?? {}) as Record<string, unknown>
247
- const replayStatus = typeof replayMeta.status === 'number' ? replayMeta.status : 200
248
- const replayStatusText = typeof replayMeta.statusText === 'string' ? replayMeta.statusText : ''
249
- const replayHeaders = pickReplayResponseHeaders(
250
- replayMeta.headers && typeof replayMeta.headers === 'object'
251
- ? (replayMeta.headers as Record<string, unknown>)
252
- : undefined,
253
- )
254
-
255
- if (historicalEvent.streamed === true) {
256
- const raw = typeof historicalEvent.streamRaw === 'string' ? historicalEvent.streamRaw : ''
257
- return new Response(reconstructStream(raw), {
258
- status: replayStatus,
259
- statusText: replayStatusText,
260
- headers: replayHeaders,
261
- })
367
+ if (httpCtx) pushTelemetryEvent(historicalEvent)
368
+ if (trace && typeof trace.recordToolCall === 'function') {
369
+ trace.recordToolCall({ name: 'fetch', args: { url, method }, result: historicalEvent.output, workflowEventId: id })
262
370
  }
263
371
 
264
- const historicalOutput = replay.getRecordedResult(id)
265
- const body = historicalOutput != null ? JSON.stringify(historicalOutput) : null
266
- return new Response(body, {
267
- status: replayStatus,
268
- statusText: replayStatusText,
269
- headers: replayHeaders,
270
- })
372
+ return synthesizeFrozenResponse(historicalEvent)
271
373
  }
272
374
  }
273
375
 
274
- const query = parseQuery(url)
275
- const body = parseBody(rawBody as RequestInit['body'] | null | undefined)
276
- const headers = normalizeHeaders(rawHeaders)
277
-
278
- const start = Date.now()
279
- const res = await originalFetch!(input, init)
280
-
281
- const responseHeadersObj: Record<string, string> = {}
282
- res.headers.forEach((v, k) => {
283
- responseHeadersObj[k] = v
284
- })
285
-
286
- const elasticdashResponse = {
287
- status: res.status,
288
- statusText: res.statusText,
289
- headers: responseHeadersObj,
290
- url: res.url,
291
- }
292
-
293
- const baseInput = {
294
- url,
295
- method,
296
- ...(query ? { query } : {}),
297
- ...(body !== undefined ? { body } : {}),
298
- ...(headers && Object.keys(headers).length > 0 ? { headers } : {}),
299
- __elasticdashResponse: elasticdashResponse,
300
- }
301
-
302
- if (isStreamingContentType(res.headers) && res.body) {
303
- const [streamForCaller, streamForRecorder] = res.body.tee()
304
- const vercelAI = isVercelAIDataStream(res.headers)
305
-
376
+ const { response, event } = await executeLiveAndRecord(originalFetch!, input, init, id, url, method, rawHeaders, rawBody as RequestInit['body'] | null | undefined)
377
+ const eventPromise = (response as unknown as Record<string, unknown>).__eventPromise as Promise<import('../capture/event.js').WorkflowEvent> | undefined
378
+ if (eventPromise) {
306
379
  recorder.trackAsync(
307
- bufferStream(streamForRecorder).then((rawText) => {
308
- recorder.record({
309
- id,
310
- type: 'http',
311
- name: 'fetch',
312
- input: baseInput,
313
- output: vercelAI ? parseVercelAIDataStream(rawText) : null,
314
- streamed: true,
315
- streamRaw: rawText,
316
- timestamp: start,
317
- durationMs: Date.now() - start,
318
- })
319
- }).catch(() => {
320
- recorder.record({
321
- id,
322
- type: 'http',
323
- name: 'fetch',
324
- input: baseInput,
325
- output: null,
326
- streamed: true,
327
- streamRaw: '',
328
- timestamp: start,
329
- durationMs: Date.now() - start,
330
- })
331
- })
380
+ eventPromise.then((ev) => {
381
+ recorder.record(ev)
382
+ if (httpCtx) pushTelemetryEvent(ev)
383
+ if (trace && typeof trace.recordToolCall === 'function') {
384
+ trace.recordToolCall({ name: 'fetch', args: { url, method }, result: ev.output, workflowEventId: id })
385
+ }
386
+ }).catch(() => {})
332
387
  )
333
-
334
- return new Response(streamForCaller, {
335
- status: res.status,
336
- statusText: res.statusText,
337
- headers: res.headers,
338
- })
339
- }
340
-
341
- let output: unknown = null
342
- try {
343
- output = await res.clone().json()
344
- } catch {
345
- // not JSON — record null
388
+ } else if (event) {
389
+ recorder.record(event)
390
+ if (httpCtx) pushTelemetryEvent(event)
391
+ if (trace && typeof trace.recordToolCall === 'function') {
392
+ trace.recordToolCall({ name: 'fetch', args: { url, method }, result: event.output, workflowEventId: id })
393
+ }
346
394
  }
347
395
 
348
- recorder.record({
349
- id,
350
- type: 'http',
351
- name: 'fetch',
352
- input: baseInput,
353
- output,
354
- timestamp: start,
355
- durationMs: Date.now() - start,
356
- })
357
-
358
- return res
396
+ return response
359
397
  }
360
398
  }
361
399
 
@@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto'
3
3
  import type { WorkflowEvent } from '../capture/event.js'
4
4
  import { extractSystemPrompt, replaceSystemPrompt, extractUserPrompts, replaceUserPrompt, lookupMockEntry, normaliseMockResult } from '../internals/mock-resolver.js'
5
5
  import type { AIMockEntry, UserPromptMockEntry } from '../internals/mock-resolver.js'
6
+ import { debugLog } from '../utils/debug.js'
7
+ import type { TelemetryBatcher } from '../telemetry-batcher.js'
6
8
 
7
9
  interface ToolMockEntry {
8
10
  mode: 'live' | 'mock-all' | 'mock-specific'
@@ -33,14 +35,48 @@ export interface HttpRunContext {
33
35
  userPromptCallCounters: Record<string, number>
34
36
  }
35
37
 
38
+ export interface ObservabilityContext {
39
+ sessionId: string
40
+ serviceId: string
41
+ serverUrl: string
42
+ apiKey?: string
43
+ batcher: TelemetryBatcher
44
+ nextId: () => number
45
+ sampleRate: number
46
+ redactKeys: string[]
47
+ traceId: string
48
+ }
49
+
36
50
  const g = globalThis as Record<string, unknown>
37
51
  const HTTP_RUN_ALS_KEY = '__elasticdash_http_run_als__'
38
52
  const GLOBAL_CTX_KEY = '__elasticdash_global_http_ctx__'
53
+ const OBS_ALS_KEY = '__elasticdash_obs_als__'
54
+ const GLOBAL_OBS_KEY = '__elasticdash_global_obs_ctx__'
55
+
39
56
  const httpRunAls: AsyncLocalStorage<HttpRunContext | undefined> =
40
57
  (g[HTTP_RUN_ALS_KEY] as AsyncLocalStorage<HttpRunContext | undefined>) ??
41
58
  new AsyncLocalStorage<HttpRunContext | undefined>()
42
59
  if (!g[HTTP_RUN_ALS_KEY]) g[HTTP_RUN_ALS_KEY] = httpRunAls
43
60
 
61
+ const obsAls: AsyncLocalStorage<ObservabilityContext | undefined> =
62
+ (g[OBS_ALS_KEY] as AsyncLocalStorage<ObservabilityContext | undefined>) ??
63
+ new AsyncLocalStorage<ObservabilityContext | undefined>()
64
+ if (!g[OBS_ALS_KEY]) g[OBS_ALS_KEY] = obsAls
65
+
66
+ export function setObservabilityContext(ctx: ObservabilityContext): void {
67
+ obsAls.enterWith(ctx)
68
+ g[GLOBAL_OBS_KEY] = ctx
69
+ }
70
+
71
+ export function getObservabilityContext(): ObservabilityContext | undefined {
72
+ return obsAls.getStore() ?? (g[GLOBAL_OBS_KEY] as ObservabilityContext | undefined)
73
+ }
74
+
75
+ export function clearObservabilityContext(): void {
76
+ obsAls.enterWith(undefined)
77
+ g[GLOBAL_OBS_KEY] = undefined
78
+ }
79
+
44
80
  function buildContext(
45
81
  runId: string,
46
82
  dashboardUrl: string,
@@ -83,6 +119,7 @@ export function setHttpRunContext(runId: string, dashboardUrl: string): void {
83
119
  const ctx = buildContext(runId, dashboardUrl, [], {})
84
120
  httpRunAls.enterWith(ctx)
85
121
  g[GLOBAL_CTX_KEY] = ctx
122
+ ensureInterceptorsInstalled().catch(() => {})
86
123
  }
87
124
 
88
125
  /**
@@ -119,6 +156,7 @@ export async function initHttpRunContext(runId: string, dashboardUrl: string): P
119
156
  const ctx = buildContext(runId, dashboardUrl, frozenEvents, promptMocks, toolMockConfig, aiMockConfig, userPromptMocks)
120
157
  httpRunAls.enterWith(ctx)
121
158
  g[GLOBAL_CTX_KEY] = ctx
159
+ await ensureInterceptorsInstalled()
122
160
  }
123
161
 
124
162
  export function getHttpRunContext(): HttpRunContext | undefined {
@@ -137,22 +175,22 @@ export function getHttpFrozenEvent(id: number): WorkflowEvent | undefined {
137
175
  export function getHttpPromptMock(input: unknown): unknown | undefined {
138
176
  const ctx = getHttpRunContext()
139
177
  if (!ctx || ctx.promptMocks.size === 0) {
140
- console.log(`[elasticdash] getHttpPromptMock: skip — promptMocks.size=${ctx?.promptMocks.size ?? 'no ctx'}`)
178
+ debugLog(`[elasticdash] getHttpPromptMock: skip — promptMocks.size=${ctx?.promptMocks.size ?? 'no ctx'}`)
141
179
  return undefined
142
180
  }
143
181
  const systemPrompt = extractSystemPrompt(input)
144
182
  if (systemPrompt === undefined) {
145
183
  const inputKeys = (input && typeof input === 'object') ? Object.keys(input as object).join(',') : typeof input
146
- console.log(`[elasticdash] getHttpPromptMock: no system prompt found in input (keys: ${inputKeys})`)
184
+ debugLog(`[elasticdash] getHttpPromptMock: no system prompt found in input (keys: ${inputKeys})`)
147
185
  return undefined
148
186
  }
149
187
  const newSystemPrompt = ctx.promptMocks.get(systemPrompt)
150
- console.log(`[elasticdash] getHttpPromptMock: extracted system prompt (len=${systemPrompt.length}, first50=${JSON.stringify(systemPrompt.slice(0,50))}) — mock found=${newSystemPrompt !== undefined}`)
188
+ debugLog(`[elasticdash] getHttpPromptMock: extracted system prompt (len=${systemPrompt.length}, first50=${JSON.stringify(systemPrompt.slice(0,50))}) — mock found=${newSystemPrompt !== undefined}`)
151
189
  if (newSystemPrompt !== undefined) {
152
- console.log(`[elasticdash] getHttpPromptMock: available mock keys=${JSON.stringify([...ctx.promptMocks.keys()].map(k => k.slice(0,50)))}`)
190
+ debugLog(`[elasticdash] getHttpPromptMock: available mock keys=${JSON.stringify([...ctx.promptMocks.keys()].map(k => k.slice(0,50)))}`)
153
191
  }
154
192
  if (newSystemPrompt === undefined) {
155
- console.log(`[elasticdash] getHttpPromptMock: no mock for this prompt. Available mock keys (first 50 chars each): ${JSON.stringify([...ctx.promptMocks.keys()].map(k => k.slice(0,50)))}`)
193
+ debugLog(`[elasticdash] getHttpPromptMock: no mock for this prompt. Available mock keys (first 50 chars each): ${JSON.stringify([...ctx.promptMocks.keys()].map(k => k.slice(0,50)))}`)
156
194
  return undefined
157
195
  }
158
196
  return replaceSystemPrompt(input, newSystemPrompt)
@@ -258,24 +296,59 @@ export function getHttpAIMock(modelName: string): MockResult {
258
296
  }
259
297
 
260
298
  export function pushTelemetryEvent(event: WorkflowEvent, explicitCtx?: { runId: string; dashboardUrl: string }): void {
299
+ // Observability mode: route to batcher with sampling
300
+ const obsCtx = getObservabilityContext()
301
+ if (obsCtx && !explicitCtx) {
302
+ if (obsCtx.sampleRate < 1 && Math.random() >= obsCtx.sampleRate) return
303
+ if (!event.traceId && obsCtx.traceId) {
304
+ event.traceId = obsCtx.traceId
305
+ }
306
+ obsCtx.batcher.enqueue(event)
307
+ return
308
+ }
309
+
261
310
  const ctx = explicitCtx ?? getHttpRunContext()
262
311
  if (!ctx) {
263
- console.log(`[elasticdash] pushTelemetryEvent: no HTTP context, dropping event type=${event.type} name=${('name' in event ? event.name : '?')}`)
312
+ debugLog(`[elasticdash] pushTelemetryEvent: no HTTP context, dropping event type=${event.type} name=${('name' in event ? event.name : '?')}`)
264
313
  return
265
314
  }
266
315
  const { runId, dashboardUrl } = ctx
267
- console.log(`[elasticdash] pushTelemetryEvent: posting event type=${event.type} name=${('name' in event ? event.name : '?')} runId=${runId} to ${dashboardUrl}`)
316
+ debugLog(`[elasticdash] pushTelemetryEvent: posting event type=${event.type} name=${('name' in event ? event.name : '?')} runId=${runId} to ${dashboardUrl}`)
268
317
  fetch(`${dashboardUrl}/api/trace-events`, {
269
318
  method: 'POST',
270
319
  headers: { 'Content-Type': 'application/json' },
271
320
  body: JSON.stringify({ runId, event }),
272
321
  }).then(r => {
273
- console.log(`[elasticdash] pushTelemetryEvent: response status=${r.status} for type=${event.type} name=${('name' in event ? event.name : '?')}`)
322
+ debugLog(`[elasticdash] pushTelemetryEvent: response status=${r.status} for type=${event.type} name=${('name' in event ? event.name : '?')}`)
274
323
  }).catch(e => {
275
- console.log(`[elasticdash] pushTelemetryEvent: fetch failed: ${e instanceof Error ? e.message : String(e)}`)
324
+ debugLog(`[elasticdash] pushTelemetryEvent: fetch failed: ${e instanceof Error ? e.message : String(e)}`)
276
325
  })
277
326
  }
278
327
 
328
+ const INTERCEPTORS_KEY = '__elasticdash_interceptors_installed__'
329
+
330
+ /**
331
+ * Ensures fetch (HTTP + AI) and DB interceptors are installed globally.
332
+ * Uses dynamic imports to avoid circular dependencies. Safe to call multiple
333
+ * times — only the first call does actual work.
334
+ */
335
+ async function ensureInterceptorsInstalled(): Promise<void> {
336
+ if (g[INTERCEPTORS_KEY]) return
337
+ g[INTERCEPTORS_KEY] = true
338
+ try {
339
+ const [httpMod, aiMod, dbMod] = await Promise.all([
340
+ import('./http.js'),
341
+ import('./ai-interceptor.js'),
342
+ import('./db-auto.js'),
343
+ ])
344
+ httpMod.interceptFetch()
345
+ aiMod.installAIInterceptor()
346
+ await dbMod.installDBAutoInterceptor()
347
+ } catch {
348
+ // Non-fatal: interceptors may already be installed or deps missing
349
+ }
350
+ }
351
+
279
352
  const AUTO_INIT_KEY = '__elasticdash_auto_init_promise__'
280
353
 
281
354
  /**
@@ -307,6 +380,7 @@ export async function runInHttpContext<T>(
307
380
  dashboardUrl: string,
308
381
  callback: () => Promise<T>,
309
382
  ): Promise<T> {
383
+ await ensureInterceptorsInstalled()
310
384
  const ctx = buildContext(runId, dashboardUrl, [], {})
311
385
  g[GLOBAL_CTX_KEY] = ctx
312
386
  try {
@@ -357,16 +431,17 @@ export async function runWithInitializedHttpContext<T>(
357
431
  }
358
432
  const mockKeys = Object.keys(promptMocks)
359
433
  const userMockKeys = Object.keys(userPromptMocks ?? {})
360
- console.log(`[elasticdash] runWithInitializedHttpContext: fetched ${mockKeys.length} prompt mocks, ${userMockKeys.length} user prompt mocks, ${frozenEvents.length} frozen events`)
434
+ debugLog(`[elasticdash] runWithInitializedHttpContext: fetched ${mockKeys.length} prompt mocks, ${userMockKeys.length} user prompt mocks, ${frozenEvents.length} frozen events`)
361
435
  if (mockKeys.length > 0) {
362
- console.log(`[elasticdash] runWithInitializedHttpContext: mock keys (first 80 chars each): ${JSON.stringify(mockKeys.map(k => k.slice(0,80)))}`)
436
+ debugLog(`[elasticdash] runWithInitializedHttpContext: mock keys (first 80 chars each): ${JSON.stringify(mockKeys.map(k => k.slice(0,80)))}`)
363
437
  }
364
438
  } else {
365
- console.log(`[elasticdash] runWithInitializedHttpContext: run-configs fetch returned ${res.status}`)
439
+ debugLog(`[elasticdash] runWithInitializedHttpContext: run-configs fetch returned ${res.status}`)
366
440
  }
367
441
  } catch {
368
442
  // Dashboard unreachable or run config not registered — proceed with live execution
369
443
  }
444
+ await ensureInterceptorsInstalled()
370
445
  const ctx = buildContext(runId, dashboardUrl, frozenEvents, promptMocks, toolMockConfig, aiMockConfig, userPromptMocks)
371
446
  g[GLOBAL_CTX_KEY] = ctx
372
447
  try {
@@ -379,12 +454,37 @@ export async function runWithInitializedHttpContext<T>(
379
454
  export async function tryAutoInitHttpContext(): Promise<void> {
380
455
  // Fast path: already initialised in this async context
381
456
  if (getHttpRunContext()) return
457
+ if (getObservabilityContext()) return
458
+
459
+ // Check for observability mode first (ELASTICDASH_API_URL)
460
+ const apiUrl = (typeof process !== 'undefined' && process.env?.ELASTICDASH_API_URL) ?? ''
461
+ if (apiUrl) {
462
+ const obsInitKey = '__elasticdash_obs_auto_init__'
463
+ if (!g[obsInitKey]) {
464
+ g[obsInitKey] = (async () => {
465
+ try {
466
+ // Dynamic import to avoid circular dependency at module load time
467
+ const { initObservability } = await import('../observability.js')
468
+ initObservability({
469
+ serverUrl: apiUrl,
470
+ apiKey: process.env.ELASTICDASH_API_KEY,
471
+ serviceId: process.env.ELASTICDASH_SERVICE_ID,
472
+ sessionId: process.env.ELASTICDASH_SESSION_ID,
473
+ })
474
+ } catch (err) {
475
+ debugLog(`[elasticdash] Observability auto-init failed: ${err instanceof Error ? err.message : String(err)}`)
476
+ }
477
+ })()
478
+ }
479
+ await (g[obsInitKey] as Promise<void>)
480
+ return
481
+ }
382
482
 
483
+ // Fall back to HTTP run context (ELASTICDASH_SERVER — dashboard/test mode)
383
484
  const serverUrl = (typeof process !== 'undefined' && process.env?.ELASTICDASH_SERVER) ?? ''
384
485
  if (!serverUrl) return
385
486
 
386
487
  // Deduplicate concurrent first calls within the same process
387
- const g = globalThis as Record<string, unknown>
388
488
  if (!g[AUTO_INIT_KEY]) {
389
489
  g[AUTO_INIT_KEY] = (async () => {
390
490
  try {
@@ -1,7 +1,8 @@
1
1
  import { getCaptureContext } from '../capture/recorder.js'
2
2
  import { getCurrentTrace } from '../trace-adapter/context.js'
3
3
  import { rawDateNow } from './side-effects.js'
4
- import { getHttpRunContext, getHttpFrozenEvent, getHttpToolMock, pushTelemetryEvent, tryAutoInitHttpContext } from './telemetry-push.js'
4
+ import { getHttpRunContext, getHttpFrozenEvent, getHttpToolMock, pushTelemetryEvent, tryAutoInitHttpContext, getObservabilityContext } from './telemetry-push.js'
5
+ import { debugLog } from '../utils/debug.js'
5
6
 
6
7
  const TOOL_WRAPPER_ACTIVE_KEY = '__elasticdash_tool_wrapper_active__'
7
8
 
@@ -88,10 +89,46 @@ export function wrapTool<Args extends unknown[], R>(
88
89
  await tryAutoInitHttpContext()
89
90
  const ctx = getCaptureContext()
90
91
  const httpCtx = getHttpRunContext()
91
- console.log(`[elasticdash] Tool called: ${name}`, { args })
92
- console.log(`[elasticdash] Current capture context:`, ctx ? { hasRecorder: !!ctx.recorder, hasReplay: !!ctx.replay } : null)
93
- console.log(`[elasticdash] Current HTTP context:`, httpCtx ? { hasHttpContext: !!httpCtx } : null)
94
- if (!ctx && !httpCtx) return fn(...args)
92
+ const obsCtx = getObservabilityContext()
93
+ debugLog(`[elasticdash] Tool called: ${name}`, { args })
94
+ debugLog(`[elasticdash] Current capture context:`, ctx ? { hasRecorder: !!ctx.recorder, hasReplay: !!ctx.replay } : null)
95
+ debugLog(`[elasticdash] Current HTTP context:`, httpCtx ? { hasHttpContext: !!httpCtx } : null)
96
+ if (!ctx && !httpCtx && !obsCtx) return fn(...args)
97
+
98
+ // Observability-only mode: record and push, no mocks/replay
99
+ if (!ctx && !httpCtx && obsCtx) {
100
+ const id = obsCtx.nextId()
101
+ const input = args.length === 1 ? args[0] : args
102
+ const start = rawDateNow()
103
+ try {
104
+ const output = await fn(...args)
105
+ const durationMs = rawDateNow() - start
106
+ if (isReadableStream(output)) {
107
+ const [streamForCaller, streamForRecorder] = output.tee()
108
+ bufferReadableStream(streamForRecorder).then((rawText) => {
109
+ const durationMs = rawDateNow() - start
110
+ pushTelemetryEvent({ id, type: 'tool', name, input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs })
111
+ }).catch(() => {
112
+ const durationMs = rawDateNow() - start
113
+ pushTelemetryEvent({ id, type: 'tool', name, input, output: null, streamed: true, streamRaw: '', timestamp: start, durationMs })
114
+ })
115
+ return streamForCaller as unknown as R
116
+ }
117
+ if (isAsyncIterable(output)) {
118
+ return wrapAsyncIterable(output, (chunks) => {
119
+ const durationMs = rawDateNow() - start
120
+ const rawText = chunks.map((c) => (typeof c === 'string' ? c : JSON.stringify(c))).join('')
121
+ pushTelemetryEvent({ id, type: 'tool', name, input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs })
122
+ }) as unknown as R
123
+ }
124
+ pushTelemetryEvent({ id, type: 'tool', name, input, output, timestamp: start, durationMs })
125
+ return output
126
+ } catch (e) {
127
+ const durationMs = rawDateNow() - start
128
+ pushTelemetryEvent({ id, type: 'tool', name, input, output: { error: String(e) }, timestamp: start, durationMs })
129
+ throw e
130
+ }
131
+ }
95
132
 
96
133
  // HTTP mode only (no capture context) — execute, record timing, push telemetry
97
134
  if (!ctx) {
@@ -1,6 +1,6 @@
1
1
  import { getCaptureContext } from '../capture/recorder.js'
2
2
  import { rawDateNow } from './side-effects.js'
3
- import { getHttpRunContext, getHttpFrozenEvent, getHttpPromptMock, getHttpUserPromptMock, getHttpAIMock, pushTelemetryEvent, tryAutoInitHttpContext } from './telemetry-push.js'
3
+ import { getHttpRunContext, getHttpFrozenEvent, getHttpPromptMock, getHttpUserPromptMock, getHttpAIMock, pushTelemetryEvent, tryAutoInitHttpContext, getObservabilityContext } from './telemetry-push.js'
4
4
  import { resolveAIMock, resolvePromptMock, resolveUserPromptMock } from '../internals/mock-resolver.js'
5
5
  import type { WorkflowEvent } from '../capture/event.js'
6
6
 
@@ -115,11 +115,58 @@ export function wrapAI<Args extends unknown[], R>(
115
115
  await tryAutoInitHttpContext()
116
116
  const ctx = getCaptureContext()
117
117
  const httpCtx = getHttpRunContext()
118
+ const obsCtx = getObservabilityContext()
118
119
 
119
- if (!ctx && !httpCtx) return callFn(...args)
120
+ if (!ctx && !httpCtx && !obsCtx) return callFn(...args)
120
121
 
121
122
  const start = rawDateNow()
122
123
 
124
+ // Observability-only mode: record and push, no mocks/replay
125
+ if (!ctx && !httpCtx && obsCtx) {
126
+ const id = obsCtx.nextId()
127
+ const input = args.length === 1 ? args[0] : args
128
+ try {
129
+ const output = await callFn(...args)
130
+
131
+ if (isReadableStream(output)) {
132
+ const [streamForCaller, streamForRecorder] = output.tee()
133
+ bufferReadableStream(streamForRecorder).then((rawText) => {
134
+ const durationMs = rawDateNow() - start
135
+ pushTelemetryEvent({ id, type: 'ai', name: modelName, input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs })
136
+ }).catch(() => {
137
+ const durationMs = rawDateNow() - start
138
+ pushTelemetryEvent({ id, type: 'ai', name: modelName, input, output: null, streamed: true, streamRaw: '', timestamp: start, durationMs })
139
+ })
140
+ return streamForCaller as unknown as R
141
+ }
142
+
143
+ if (isAsyncIterable(output)) {
144
+ return wrapAsyncIterable(output, (chunks) => {
145
+ const durationMs = rawDateNow() - start
146
+ const rawText = chunks.map((c) => (typeof c === 'string' ? c : JSON.stringify(c))).join('')
147
+ pushTelemetryEvent({ id, type: 'ai', name: modelName, input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs })
148
+ }) as unknown as R
149
+ }
150
+
151
+ const durationMs = rawDateNow() - start
152
+ const usage = extractUsage(output)
153
+ const event: WorkflowEvent = {
154
+ id, type: 'ai', name: modelName, input, output,
155
+ timestamp: start, durationMs,
156
+ ...(usage ? { usage } : {}),
157
+ }
158
+ pushTelemetryEvent(event)
159
+ return output
160
+ } catch (e) {
161
+ const durationMs = rawDateNow() - start
162
+ pushTelemetryEvent({
163
+ id, type: 'ai', name: modelName, input,
164
+ output: { error: String(e) }, timestamp: start, durationMs,
165
+ })
166
+ throw e
167
+ }
168
+ }
169
+
123
170
  if (ctx) {
124
171
  const { recorder, replay } = ctx
125
172
  const id = recorder.nextId()