@kitlangton/motel 0.2.0 → 0.2.4

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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +7 -5
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +213 -6
  5. package/src/daemon.ts +174 -38
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +114 -128
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +68 -0
  15. package/src/services/TelemetryStore.ts +262 -119
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +244 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +308 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +11 -28
  32. package/src/ui/Waterfall.tsx +43 -148
  33. package/src/ui/aiChatModel.test.ts +391 -0
  34. package/src/ui/aiChatModel.ts +773 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +288 -124
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +174 -40
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
package/src/daemon.ts CHANGED
@@ -2,17 +2,27 @@ import * as fs from "node:fs"
2
2
  import { promises as fsp } from "node:fs"
3
3
  import * as path from "node:path"
4
4
  import { Effect } from "effect"
5
- import { listAliveEntries, MOTEL_SERVICE_ID, type RegistryEntry, isAlive } from "./registry.js"
5
+ import { isAlive, listAliveEntries, MOTEL_SERVICE_ID, MOTEL_VERSION, type RegistryEntry } from "./registry.js"
6
6
 
7
7
  const DEFAULT_REPO_ROOT = path.resolve(import.meta.dir, "..")
8
- const DEFAULT_RUNTIME_DIR = path.join(DEFAULT_REPO_ROOT, ".motel-data")
9
- const DEFAULT_DATABASE_PATH = path.join(DEFAULT_RUNTIME_DIR, "telemetry.sqlite")
10
8
  const DEFAULT_HOST = "127.0.0.1"
11
9
  const DEFAULT_PORT = 27686
12
- const START_TIMEOUT_MS = 15_000
10
+ const START_TIMEOUT_MS = 30_000
13
11
  const STOP_TIMEOUT_MS = 10_000
14
12
  const LOCK_TIMEOUT_MS = 10_000
15
13
  const POLL_INTERVAL_MS = 150
14
+ /** Fast probe used inside the waitForHealthy poll loop — we call it
15
+ * every POLL_INTERVAL_MS, so a generous budget would stall the loop. */
16
+ const HEALTH_FAST_TIMEOUT_MS = 750
17
+ /** Patient probe used on critical paths: the first getStatus() call
18
+ * in ensure(), and the final pre-throw check after a spawned child
19
+ * dies. A real daemon with a busy SQLite writer (FTS backfill, big
20
+ * DB) can easily take 1-2s to answer /api/health — if we declare
21
+ * the port empty at 750ms we'll spawn a duplicate and collide with
22
+ * EADDRINUSE. 3s is long enough to tolerate a slow healthy daemon
23
+ * and short enough that a truly-down daemon is still detected
24
+ * before START_TIMEOUT_MS fires. */
25
+ const HEALTH_PATIENT_TIMEOUT_MS = 3_000
16
26
 
17
27
  type HealthShape = {
18
28
  readonly ok: boolean
@@ -32,6 +42,8 @@ type LockShape = {
32
42
 
33
43
  type DaemonConfig = {
34
44
  readonly repoRoot: string
45
+ readonly serverEntry: string
46
+ readonly workdir: string
35
47
  readonly runtimeDir: string
36
48
  readonly databasePath: string
37
49
  readonly logPath: string
@@ -67,6 +79,7 @@ export type DaemonManager = {
67
79
 
68
80
  type DaemonOptions = {
69
81
  readonly repoRoot?: string
82
+ readonly workdir?: string
70
83
  readonly runtimeDir?: string
71
84
  readonly databasePath?: string
72
85
  readonly host?: string
@@ -84,12 +97,15 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
84
97
 
85
98
  const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
86
99
  const repoRoot = path.resolve(options.repoRoot ?? DEFAULT_REPO_ROOT)
87
- const runtimeDir = path.resolve(options.runtimeDir ?? DEFAULT_RUNTIME_DIR)
100
+ const workdir = path.resolve(options.workdir ?? process.cwd())
101
+ const runtimeDir = path.resolve(options.runtimeDir ?? path.join(workdir, ".motel-data"))
88
102
  const databasePath = path.resolve(options.databasePath ?? path.join(runtimeDir, "telemetry.sqlite"))
89
103
  const host = options.host ?? DEFAULT_HOST
90
104
  const port = options.port ?? DEFAULT_PORT
91
105
  return {
92
106
  repoRoot,
107
+ serverEntry: path.join(repoRoot, "src/server.ts"),
108
+ workdir,
93
109
  runtimeDir,
94
110
  databasePath,
95
111
  logPath: path.join(runtimeDir, "daemon.log"),
@@ -100,16 +116,14 @@ const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
100
116
  }
101
117
  }
102
118
 
103
- const cwdMatches = (workdir: string) => {
104
- const cwd = process.cwd()
105
- const normalizedCwd = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`
106
- const normalizedWorkdir = workdir.endsWith(path.sep) ? workdir : `${workdir}${path.sep}`
107
- return normalizedCwd === normalizedWorkdir || normalizedCwd.startsWith(normalizedWorkdir)
119
+ const workdirMatches = (targetWorkdir: string, daemonWorkdir: string) => {
120
+ const normalizedTarget = targetWorkdir.endsWith(path.sep) ? targetWorkdir : `${targetWorkdir}${path.sep}`
121
+ const normalizedDaemon = daemonWorkdir.endsWith(path.sep) ? daemonWorkdir : `${daemonWorkdir}${path.sep}`
122
+ return normalizedTarget === normalizedDaemon || normalizedTarget.startsWith(normalizedDaemon)
108
123
  }
109
124
 
110
- const pickByCwd = (entries: readonly RegistryEntry[]) => {
111
- const cwd = process.cwd()
112
- const withSep = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`
125
+ const pickByWorkdir = (entries: readonly RegistryEntry[], targetWorkdir: string) => {
126
+ const withSep = targetWorkdir.endsWith(path.sep) ? targetWorkdir : `${targetWorkdir}${path.sep}`
113
127
  return entries
114
128
  .filter((entry) => {
115
129
  const workdir = entry.workdir.endsWith(path.sep) ? entry.workdir : `${entry.workdir}${path.sep}`
@@ -118,8 +132,6 @@ const pickByCwd = (entries: readonly RegistryEntry[]) => {
118
132
  .sort((a, b) => b.workdir.length - a.workdir.length)[0] ?? null
119
133
  }
120
134
 
121
- const readRegistryEntry = () => pickByCwd(listAliveEntries())
122
-
123
135
  const expectedEnv = (config: DaemonConfig) => ({
124
136
  MOTEL_OTEL_BASE_URL: config.baseUrl,
125
137
  MOTEL_OTEL_QUERY_URL: config.baseUrl,
@@ -133,10 +145,11 @@ const expectedEnv = (config: DaemonConfig) => ({
133
145
  export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager => {
134
146
  const config = resolveConfig(options)
135
147
  const mapError = (error: unknown) => new DaemonError(error instanceof Error ? error.message : String(error))
148
+ const readRegistryEntry = () => pickByWorkdir(listAliveEntries(), config.workdir)
136
149
 
137
- const fetchHealth = async (): Promise<HealthShape | null> => {
150
+ const fetchHealth = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<HealthShape | null> => {
138
151
  try {
139
- const response = await fetch(`${config.baseUrl}/api/health`, { signal: AbortSignal.timeout(750) })
152
+ const response = await fetch(`${config.baseUrl}/api/health`, { signal: AbortSignal.timeout(timeoutMs) })
140
153
  if (!response.ok) return null
141
154
  return await response.json() as HealthShape
142
155
  } catch {
@@ -144,12 +157,39 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
144
157
  }
145
158
  }
146
159
 
160
+ const startupMarkers = [`Listening on ${config.baseUrl}`, `motel local telemetry server listening on ${config.baseUrl}`]
161
+
162
+ const readLogSince = async (offset: number) => {
163
+ try {
164
+ const raw = await fsp.readFile(config.logPath, "utf8")
165
+ return raw.slice(offset)
166
+ } catch {
167
+ return ""
168
+ }
169
+ }
170
+
171
+ const detectStartedFromLog = async (pid: number, offset: number): Promise<HealthShape | null> => {
172
+ if (!isAlive(pid)) return null
173
+ const tail = await readLogSince(offset)
174
+ if (!startupMarkers.some((marker) => tail.includes(marker))) return null
175
+ return {
176
+ ok: true,
177
+ service: MOTEL_SERVICE_ID,
178
+ databasePath: config.databasePath,
179
+ pid,
180
+ url: config.baseUrl,
181
+ workdir: config.workdir,
182
+ startedAt: new Date().toISOString(),
183
+ version: MOTEL_VERSION,
184
+ }
185
+ }
186
+
147
187
  const describeManagedMismatch = (health: HealthShape) => {
148
188
  if (health.service !== MOTEL_SERVICE_ID) {
149
189
  return `Port ${config.port} is in use by ${health.service}, not ${MOTEL_SERVICE_ID}.`
150
190
  }
151
- if (!cwdMatches(health.workdir)) {
152
- return `Port ${config.port} is serving motel for ${health.workdir}, not ${process.cwd()}.`
191
+ if (!workdirMatches(config.workdir, health.workdir)) {
192
+ return `Port ${config.port} is serving motel for ${health.workdir}, not ${config.workdir}.`
153
193
  }
154
194
  if (health.databasePath !== config.databasePath) {
155
195
  return `Port ${config.port} is serving motel with ${health.databasePath}, expected ${config.databasePath}.`
@@ -157,6 +197,62 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
157
197
  return null
158
198
  }
159
199
 
200
+ /**
201
+ * Mismatch check against a registry entry — mirrors describeManagedMismatch
202
+ * but drives off the registry file instead of an HTTP health response.
203
+ * Used on the fast path in getStatus so warm-start doesn't need to wait
204
+ * on an HTTP round-trip that may queue behind heavy OTLP ingest.
205
+ *
206
+ * The service-id check is implicit: any entry living in the motel
207
+ * registry dir is by construction a motel daemon. databasePath is
208
+ * optional for back-compat with entries written by older builds;
209
+ * when absent we skip the DB check rather than refusing to adopt.
210
+ */
211
+ const describeRegistryMismatch = (entry: RegistryEntry): string | null => {
212
+ if (!workdirMatches(config.workdir, entry.workdir)) {
213
+ return `Port ${config.port} is serving motel for ${entry.workdir}, not ${config.workdir}.`
214
+ }
215
+ if (entry.databasePath && entry.databasePath !== config.databasePath) {
216
+ return `Port ${config.port} is serving motel with ${entry.databasePath}, expected ${config.databasePath}.`
217
+ }
218
+ return null
219
+ }
220
+
221
+ /**
222
+ * Build a DaemonStatus from a live registry entry. Returns null when
223
+ * there's no entry for our cwd, the registered pid isn't running, or
224
+ * the entry is for a differently-configured daemon (different port).
225
+ * This is the fast path: no HTTP, no event-loop round-trip, just a
226
+ * directory read and a process.kill(pid, 0) liveness probe.
227
+ */
228
+ const getStatusFromRegistry = (): DaemonStatus | null => {
229
+ const entry = readRegistryEntry()
230
+ if (!entry) return null
231
+ // Port discriminator: a motel registry shared across several
232
+ // daemons (e.g., user running two instances on different
233
+ // ports from the same workdir, or a test harness on a random
234
+ // port) would otherwise have us adopt an unrelated daemon.
235
+ // URL match is a fast, unambiguous identity check.
236
+ if (entry.url !== config.baseUrl) return null
237
+ const mismatch = describeRegistryMismatch(entry)
238
+ return {
239
+ running: mismatch === null,
240
+ managed: mismatch === null,
241
+ service: MOTEL_SERVICE_ID,
242
+ pid: entry.pid,
243
+ url: entry.url,
244
+ databasePath: entry.databasePath ?? config.databasePath,
245
+ workdir: entry.workdir,
246
+ startedAt: entry.startedAt,
247
+ version: entry.version,
248
+ sameWorkdir: workdirMatches(config.workdir, entry.workdir),
249
+ reason: mismatch,
250
+ logPath: config.logPath,
251
+ lockPath: config.lockPath,
252
+ registryPid: entry.pid,
253
+ }
254
+ }
255
+
160
256
  const readLock = async (): Promise<LockShape | null> => {
161
257
  try {
162
258
  const raw = await fsp.readFile(config.lockPath, "utf8")
@@ -208,7 +304,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
208
304
  return fs.openSync(config.logPath, "a")
209
305
  }
210
306
 
211
- const waitForHealthy = async (pid: number) => {
307
+ const waitForHealthy = async (pid: number, logOffset: number) => {
212
308
  const deadline = Date.now() + START_TIMEOUT_MS
213
309
  while (Date.now() < deadline) {
214
310
  const health = await fetchHealth()
@@ -217,7 +313,20 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
217
313
  if (!mismatch) return health
218
314
  throw new Error(mismatch)
219
315
  }
316
+ const started = await detectStartedFromLog(pid, logOffset)
317
+ if (started) return started
220
318
  if (!isAlive(pid)) {
319
+ // The spawned child is gone. Before declaring failure,
320
+ // do one patient probe: the child may have died from
321
+ // EADDRINUSE because another healthy motel is alive on
322
+ // the port but was answering /api/health too slowly for
323
+ // our fast poll. If that's the case, adopt it.
324
+ const patient = await fetchHealth(HEALTH_PATIENT_TIMEOUT_MS)
325
+ if (patient) {
326
+ const mismatch = describeManagedMismatch(patient)
327
+ if (!mismatch) return patient
328
+ throw new Error(mismatch)
329
+ }
221
330
  throw new Error(`Daemon process ${pid} exited before becoming healthy. See ${config.logPath}.`)
222
331
  }
223
332
  await sleep(POLL_INTERVAL_MS)
@@ -237,16 +346,33 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
237
346
  while (Date.now() < deadline) {
238
347
  if (!isAlive(pid)) return
239
348
  const health = await fetchHealth()
240
- if (!health || health.pid !== pid) return
349
+ if (health && health.pid !== pid) return
350
+ const registry = readRegistryEntry()
351
+ if (!health && (!registry || registry.pid !== pid)) return
241
352
  await sleep(POLL_INTERVAL_MS)
242
353
  }
243
354
 
244
355
  throw new Error(`Timed out waiting for daemon ${pid} to stop.`)
245
356
  }
246
357
 
247
- const getStatus = async (): Promise<DaemonStatus> => {
358
+ const getStatus = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<DaemonStatus> => {
359
+ // Fast path: trust the local filesystem registry. When a motel
360
+ // daemon started on this machine it wrote an entry for its pid
361
+ // + cwd + databasePath; if that entry is still there and the pid
362
+ // is alive, the daemon is almost certainly the one we want to
363
+ // adopt. HTTP health is skipped because the daemon's health
364
+ // endpoint can queue behind heavy OTLP ingest traffic, making
365
+ // the probe unreliable exactly when the daemon is busy.
366
+ const registryStatus = getStatusFromRegistry()
367
+ if (registryStatus) return registryStatus
368
+
369
+ // No local evidence → fall back to HTTP. Covers the edge cases
370
+ // where: a motel daemon is running but was started before this
371
+ // registry-first path shipped; OR the port is held by something
372
+ // entirely unrelated (the mismatch check turns that into a
373
+ // human-readable reason).
248
374
  const registry = readRegistryEntry()
249
- const health = await fetchHealth()
375
+ const health = await fetchHealth(timeoutMs)
250
376
  if (!health) {
251
377
  return {
252
378
  running: false,
@@ -258,7 +384,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
258
384
  workdir: registry?.workdir ?? null,
259
385
  startedAt: registry?.startedAt ?? null,
260
386
  version: registry?.version ?? null,
261
- sameWorkdir: registry ? cwdMatches(registry.workdir) : false,
387
+ sameWorkdir: registry ? workdirMatches(config.workdir, registry.workdir) : false,
262
388
  reason: registry ? "Registry entry exists but daemon is not healthy." : null,
263
389
  logPath: config.logPath,
264
390
  lockPath: config.lockPath,
@@ -277,7 +403,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
277
403
  workdir: health.workdir,
278
404
  startedAt: health.startedAt,
279
405
  version: health.version,
280
- sameWorkdir: cwdMatches(health.workdir),
406
+ sameWorkdir: workdirMatches(config.workdir, health.workdir),
281
407
  reason: mismatch,
282
408
  logPath: config.logPath,
283
409
  lockPath: config.lockPath,
@@ -286,7 +412,11 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
286
412
  }
287
413
 
288
414
  const ensure = async (): Promise<DaemonStatus> => {
289
- const existing = await getStatus()
415
+ // Use the patient timeout for the initial probe — this is the
416
+ // critical "is there already a daemon here?" check. A false
417
+ // negative here drops us into the spawn path and collides with
418
+ // any slow-but-healthy daemon sitting on the port.
419
+ const existing = await getStatus(HEALTH_PATIENT_TIMEOUT_MS)
290
420
  if (existing.managed && existing.running) return existing
291
421
  if (existing.service !== null && existing.reason) {
292
422
  throw new Error(existing.reason)
@@ -295,17 +425,22 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
295
425
  const lock = await acquireStartupLock()
296
426
  let spawnedPid: number | null = null
297
427
  try {
298
- const rechecked = await getStatus()
428
+ // Same reasoning for the post-lock re-check: another ensure()
429
+ // may have spawned a daemon between our first probe and the
430
+ // lock grant, and its initial health response can be slow
431
+ // while the runtime warms up.
432
+ const rechecked = await getStatus(HEALTH_PATIENT_TIMEOUT_MS)
299
433
  if (rechecked.managed && rechecked.running) return rechecked
300
434
  if (rechecked.service !== null && rechecked.reason) {
301
435
  throw new Error(rechecked.reason)
302
436
  }
303
437
 
304
438
  const logFd = await openLogFile()
439
+ const logOffset = fs.fstatSync(logFd).size
305
440
  try {
306
441
  const proc = Bun.spawn({
307
- cmd: [process.execPath, "run", "src/server.ts"],
308
- cwd: config.repoRoot,
442
+ cmd: [process.execPath, "run", config.serverEntry],
443
+ cwd: config.workdir,
309
444
  detached: true,
310
445
  env: {
311
446
  ...process.env,
@@ -323,7 +458,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
323
458
  throw new Error("Daemon failed to spawn.")
324
459
  }
325
460
 
326
- const health = await waitForHealthy(spawnedPid)
461
+ const health = await waitForHealthy(spawnedPid, logOffset)
327
462
  return {
328
463
  running: true,
329
464
  managed: true,
@@ -334,7 +469,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
334
469
  workdir: health.workdir,
335
470
  startedAt: health.startedAt,
336
471
  version: health.version,
337
- sameWorkdir: cwdMatches(health.workdir),
472
+ sameWorkdir: workdirMatches(config.workdir, health.workdir),
338
473
  reason: null,
339
474
  logPath: config.logPath,
340
475
  lockPath: config.lockPath,
@@ -371,7 +506,10 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
371
506
  }),
372
507
  getStatus: Effect.fn("DaemonManager.getStatus")(() =>
373
508
  Effect.tryPromise({
374
- try: getStatus,
509
+ // Wrapped so Effect.tryPromise only sees the no-arg call
510
+ // signature — the optional timeoutMs parameter is an
511
+ // internal detail used by ensure()'s critical probes.
512
+ try: () => getStatus(),
375
513
  catch: mapError,
376
514
  }),
377
515
  )(),
@@ -390,9 +528,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
390
528
  }
391
529
  }
392
530
 
393
- const defaultManager = createDaemonManager()
394
-
395
- export const applyManagedDaemonEnv = defaultManager.applyEnv
396
- export const getManagedDaemonStatus = defaultManager.getStatus
397
- export const ensureManagedDaemon = defaultManager.ensure
398
- export const stopManagedDaemon = defaultManager.stop
531
+ export const applyManagedDaemonEnv = Effect.suspend(() => createDaemonManager().applyEnv)
532
+ export const getManagedDaemonStatus = Effect.suspend(() => createDaemonManager().getStatus)
533
+ export const ensureManagedDaemon = Effect.suspend(() => createDaemonManager().ensure)
534
+ export const stopManagedDaemon = Effect.suspend(() => createDaemonManager().stop)
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { AI_FTS_KEYS, isAiSpan } from "./domain.ts"
3
+
4
+ describe("isAiSpan", () => {
5
+ test("returns false for empty tags", () => {
6
+ expect(isAiSpan({})).toBe(false)
7
+ })
8
+
9
+ test("returns false when no AI key is present", () => {
10
+ expect(isAiSpan({
11
+ "service.name": "web",
12
+ "http.method": "GET",
13
+ "db.statement": "SELECT 1",
14
+ })).toBe(false)
15
+ })
16
+
17
+ test("detects Vercel AI SDK keys", () => {
18
+ expect(isAiSpan({ "ai.prompt.messages": "[]" })).toBe(true)
19
+ expect(isAiSpan({ "ai.response.text": "hi" })).toBe(true)
20
+ expect(isAiSpan({ "ai.toolCall.args": "{}" })).toBe(true)
21
+ })
22
+
23
+ test("detects OpenTelemetry gen_ai semconv keys", () => {
24
+ expect(isAiSpan({ "gen_ai.prompt": "foo" })).toBe(true)
25
+ expect(isAiSpan({ "gen_ai.input.messages": "[]" })).toBe(true)
26
+ expect(isAiSpan({ "gen_ai.tool.definitions": "[]" })).toBe(true)
27
+ })
28
+
29
+ test("detects OpenInference keys", () => {
30
+ expect(isAiSpan({ "input.value": "hi" })).toBe(true)
31
+ expect(isAiSpan({ "output.value": "hi" })).toBe(true)
32
+ })
33
+
34
+ test("detects a single AI key among many non-AI keys", () => {
35
+ expect(isAiSpan({
36
+ "service.name": "web",
37
+ "http.method": "POST",
38
+ "http.status_code": "200",
39
+ "ai.model.id": "ignored-not-in-fts-keys",
40
+ "ai.prompt": "tell me a joke",
41
+ })).toBe(true)
42
+ })
43
+
44
+ test("ignores AI-adjacent keys that are not in the FTS set", () => {
45
+ // `ai.model.provider`, `ai.settings.*`, `ai.telemetry.*` carry
46
+ // metadata, not content, so they intentionally aren't part of
47
+ // AI_FTS_KEYS. A span with ONLY those should not be flagged.
48
+ expect(isAiSpan({
49
+ "ai.model.provider": "openai",
50
+ "ai.model.id": "gpt-4",
51
+ "ai.settings.maxRetries": "2",
52
+ })).toBe(false)
53
+ })
54
+
55
+ test("every documented key triggers detection", () => {
56
+ // Guard against a future reshuffle of AI_FTS_KEYS that might
57
+ // drop a key silently — every declared key should round-trip.
58
+ for (const key of AI_FTS_KEYS) {
59
+ expect(isAiSpan({ [key]: "payload" })).toBe(true)
60
+ }
61
+ })
62
+ })
package/src/domain.ts CHANGED
@@ -195,6 +195,22 @@ export const AI_FTS_KEYS = [
195
195
  */
196
196
  export const AI_TEXT_SEARCH_KEYS = AI_FTS_KEYS
197
197
 
198
+ /**
199
+ * True if a span's tags contain any of the AI content keys we track.
200
+ * Used as the single source of truth for "this span has LLM payloads
201
+ * worth a specialized view" — drives the ✦ marker in the waterfall row
202
+ * and picks the chat-flavored renderer when the user drills into the
203
+ * span's detail. Scanning happens once per row during render so this
204
+ * needs to stay O(AI_FTS_KEYS.length) with cheap `in` checks rather
205
+ * than an `Object.keys(...).some(...)` allocation.
206
+ */
207
+ export const isAiSpan = (tags: Readonly<Record<string, string>>): boolean => {
208
+ for (const key of AI_FTS_KEYS) {
209
+ if (key in tags) return true
210
+ }
211
+ return false
212
+ }
213
+
198
214
  const PREVIEW_LENGTH = 200
199
215
 
200
216
  export const truncatePreview = (value: string | null | undefined): string | null => {