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.
- package/dist/capture/event.d.ts +5 -1
- package/dist/capture/event.d.ts.map +1 -1
- package/dist/cli.js +100 -0
- package/dist/cli.js.map +1 -1
- package/dist/evaluators/llm-judge.js +17 -14
- package/dist/evaluators/types.d.ts +1 -0
- package/dist/execution/tool-runner.d.ts +26 -0
- package/dist/execution/tool-runner.d.ts.map +1 -0
- package/dist/execution/tool-runner.js +270 -0
- package/dist/execution/tool-runner.js.map +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +2 -0
- package/dist/http.js.map +1 -1
- package/dist/index.cjs +4310 -2672
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/interceptors/ai-interceptor.d.ts.map +1 -1
- package/dist/interceptors/ai-interceptor.js +97 -4
- package/dist/interceptors/ai-interceptor.js.map +1 -1
- package/dist/interceptors/db-auto.d.ts.map +1 -1
- package/dist/interceptors/db-auto.js +116 -24
- package/dist/interceptors/db-auto.js.map +1 -1
- package/dist/interceptors/db.d.ts +5 -0
- package/dist/interceptors/db.d.ts.map +1 -1
- package/dist/interceptors/db.js +93 -15
- package/dist/interceptors/db.js.map +1 -1
- package/dist/interceptors/http.d.ts.map +1 -1
- package/dist/interceptors/http.js +125 -93
- package/dist/interceptors/http.js.map +1 -1
- package/dist/interceptors/telemetry-push.d.ts +15 -0
- package/dist/interceptors/telemetry-push.d.ts.map +1 -1
- package/dist/interceptors/telemetry-push.js +96 -13
- package/dist/interceptors/telemetry-push.js.map +1 -1
- package/dist/interceptors/tool.d.ts.map +1 -1
- package/dist/interceptors/tool.js +42 -5
- package/dist/interceptors/tool.js.map +1 -1
- package/dist/interceptors/workflow-ai.d.ts.map +1 -1
- package/dist/interceptors/workflow-ai.js +46 -2
- package/dist/interceptors/workflow-ai.js.map +1 -1
- package/dist/observability.d.ts +69 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +242 -0
- package/dist/observability.js.map +1 -0
- package/dist/portal-executor.d.ts +30 -0
- package/dist/portal-executor.d.ts.map +1 -0
- package/dist/portal-executor.js +304 -0
- package/dist/portal-executor.js.map +1 -0
- package/dist/portal-server.d.ts +3 -0
- package/dist/portal-server.d.ts.map +1 -0
- package/dist/portal-server.js +265 -0
- package/dist/portal-server.js.map +1 -0
- package/dist/telemetry-batcher.d.ts +43 -0
- package/dist/telemetry-batcher.d.ts.map +1 -0
- package/dist/telemetry-batcher.js +111 -0
- package/dist/telemetry-batcher.js.map +1 -0
- package/dist/trigger-executor.d.ts +12 -0
- package/dist/trigger-executor.d.ts.map +1 -0
- package/dist/trigger-executor.js +83 -0
- package/dist/trigger-executor.js.map +1 -0
- package/dist/types/portal.d.ts +64 -0
- package/dist/types/portal.d.ts.map +1 -0
- package/dist/types/portal.js +2 -0
- package/dist/types/portal.js.map +1 -0
- package/dist/utils/debug.d.ts +3 -0
- package/dist/utils/debug.d.ts.map +1 -0
- package/dist/utils/debug.js +8 -0
- package/dist/utils/debug.js.map +1 -0
- package/dist/utils/redact.d.ts +7 -0
- package/dist/utils/redact.d.ts.map +1 -0
- package/dist/utils/redact.js +26 -0
- package/dist/utils/redact.js.map +1 -0
- package/package.json +9 -1
- package/src/capture/event.ts +5 -1
- package/src/cli.ts +109 -0
- package/src/execution/tool-runner.ts +304 -0
- package/src/http.ts +2 -0
- package/src/index.ts +14 -0
- package/src/interceptors/ai-interceptor.ts +110 -4
- package/src/interceptors/db-auto.ts +121 -25
- package/src/interceptors/db.ts +92 -17
- package/src/interceptors/http.ts +145 -107
- package/src/interceptors/telemetry-push.ts +113 -13
- package/src/interceptors/tool.ts +42 -5
- package/src/interceptors/workflow-ai.ts +49 -2
- package/src/observability.ts +281 -0
- package/src/portal-executor.ts +335 -0
- package/src/portal-server.ts +290 -0
- package/src/telemetry-batcher.ts +143 -0
- package/src/trigger-executor.ts +121 -0
- package/src/types/portal.ts +67 -0
- package/src/utils/debug.ts +8 -0
- package/src/utils/redact.ts +25 -0
package/src/interceptors/http.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
|
275
|
-
const
|
|
276
|
-
|
|
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
|
-
|
|
308
|
-
recorder.record(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
name: 'fetch',
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
+
debugLog(`[elasticdash] pushTelemetryEvent: response status=${r.status} for type=${event.type} name=${('name' in event ? event.name : '?')}`)
|
|
274
323
|
}).catch(e => {
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/interceptors/tool.ts
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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()
|