@kitlangton/motel 0.1.0

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 (53) hide show
  1. package/AGENTS.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/package.json +92 -0
  5. package/src/App.tsx +217 -0
  6. package/src/cli.ts +258 -0
  7. package/src/config.ts +39 -0
  8. package/src/daemon.test.ts +59 -0
  9. package/src/daemon.ts +398 -0
  10. package/src/domain.ts +233 -0
  11. package/src/httpApi.ts +384 -0
  12. package/src/index.tsx +18 -0
  13. package/src/instructions.ts +72 -0
  14. package/src/localServer.ts +699 -0
  15. package/src/locator.ts +138 -0
  16. package/src/mcp.ts +260 -0
  17. package/src/motel.ts +86 -0
  18. package/src/motelClient.ts +201 -0
  19. package/src/otlp.ts +142 -0
  20. package/src/queryFilters.ts +39 -0
  21. package/src/registry.ts +86 -0
  22. package/src/runtime.ts +38 -0
  23. package/src/server.ts +10 -0
  24. package/src/services/LogQueryService.ts +43 -0
  25. package/src/services/TelemetryStore.ts +1821 -0
  26. package/src/services/TraceQueryService.ts +71 -0
  27. package/src/telemetry.test.ts +726 -0
  28. package/src/ui/ServiceLogs.tsx +112 -0
  29. package/src/ui/SpanDetail.tsx +134 -0
  30. package/src/ui/SpanDetailFull.tsx +224 -0
  31. package/src/ui/SpanDetailPane.tsx +91 -0
  32. package/src/ui/TraceDetailsPane.tsx +169 -0
  33. package/src/ui/TraceList.tsx +128 -0
  34. package/src/ui/Waterfall.tsx +412 -0
  35. package/src/ui/app/TraceListPane.tsx +34 -0
  36. package/src/ui/app/TraceWorkspace.tsx +254 -0
  37. package/src/ui/app/useAppLayout.ts +79 -0
  38. package/src/ui/app/useTraceScreenData.ts +411 -0
  39. package/src/ui/format.ts +119 -0
  40. package/src/ui/primitives.tsx +170 -0
  41. package/src/ui/state.ts +137 -0
  42. package/src/ui/theme.ts +153 -0
  43. package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
  44. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  45. package/src/ui/traceSortNav.repro.test.ts +220 -0
  46. package/src/ui/useKeyboardNav.ts +532 -0
  47. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  48. package/src/ui/waterfallNav.repro.test.ts +263 -0
  49. package/src/ui/waterfallNav.test.ts +422 -0
  50. package/src/ui/waterfallNav.ts +75 -0
  51. package/web/dist/assets/index-BEKIiisE.js +27 -0
  52. package/web/dist/assets/index-DzuHNBGV.css +2 -0
  53. package/web/dist/index.html +13 -0
package/src/daemon.ts ADDED
@@ -0,0 +1,398 @@
1
+ import * as fs from "node:fs"
2
+ import { promises as fsp } from "node:fs"
3
+ import * as path from "node:path"
4
+ import { Effect } from "effect"
5
+ import { listAliveEntries, MOTEL_SERVICE_ID, type RegistryEntry, isAlive } from "./registry.js"
6
+
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
+ const DEFAULT_HOST = "127.0.0.1"
11
+ const DEFAULT_PORT = 27686
12
+ const START_TIMEOUT_MS = 15_000
13
+ const STOP_TIMEOUT_MS = 10_000
14
+ const LOCK_TIMEOUT_MS = 10_000
15
+ const POLL_INTERVAL_MS = 150
16
+
17
+ type HealthShape = {
18
+ readonly ok: boolean
19
+ readonly service: string
20
+ readonly databasePath: string
21
+ readonly pid: number
22
+ readonly url: string
23
+ readonly workdir: string
24
+ readonly startedAt: string
25
+ readonly version: string
26
+ }
27
+
28
+ type LockShape = {
29
+ readonly pid: number
30
+ readonly createdAt: string
31
+ }
32
+
33
+ type DaemonConfig = {
34
+ readonly repoRoot: string
35
+ readonly runtimeDir: string
36
+ readonly databasePath: string
37
+ readonly logPath: string
38
+ readonly lockPath: string
39
+ readonly host: string
40
+ readonly port: number
41
+ readonly baseUrl: string
42
+ }
43
+
44
+ export type DaemonStatus = {
45
+ readonly running: boolean
46
+ readonly managed: boolean
47
+ readonly service: string | null
48
+ readonly pid: number | null
49
+ readonly url: string
50
+ readonly databasePath: string
51
+ readonly workdir: string | null
52
+ readonly startedAt: string | null
53
+ readonly version: string | null
54
+ readonly sameWorkdir: boolean
55
+ readonly reason: string | null
56
+ readonly logPath: string
57
+ readonly lockPath: string
58
+ readonly registryPid: number | null
59
+ }
60
+
61
+ export type DaemonManager = {
62
+ readonly applyEnv: Effect.Effect<void>
63
+ readonly getStatus: Effect.Effect<DaemonStatus, DaemonError>
64
+ readonly ensure: Effect.Effect<DaemonStatus, DaemonError>
65
+ readonly stop: Effect.Effect<DaemonStatus, DaemonError>
66
+ }
67
+
68
+ type DaemonOptions = {
69
+ readonly repoRoot?: string
70
+ readonly runtimeDir?: string
71
+ readonly databasePath?: string
72
+ readonly host?: string
73
+ readonly port?: number
74
+ }
75
+
76
+ export class DaemonError extends Error {
77
+ readonly _tag = "DaemonError"
78
+ constructor(message: string) {
79
+ super(message)
80
+ }
81
+ }
82
+
83
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
84
+
85
+ const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
86
+ const repoRoot = path.resolve(options.repoRoot ?? DEFAULT_REPO_ROOT)
87
+ const runtimeDir = path.resolve(options.runtimeDir ?? DEFAULT_RUNTIME_DIR)
88
+ const databasePath = path.resolve(options.databasePath ?? path.join(runtimeDir, "telemetry.sqlite"))
89
+ const host = options.host ?? DEFAULT_HOST
90
+ const port = options.port ?? DEFAULT_PORT
91
+ return {
92
+ repoRoot,
93
+ runtimeDir,
94
+ databasePath,
95
+ logPath: path.join(runtimeDir, "daemon.log"),
96
+ lockPath: path.join(runtimeDir, "daemon.lock"),
97
+ host,
98
+ port,
99
+ baseUrl: `http://${host}:${port}`,
100
+ }
101
+ }
102
+
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)
108
+ }
109
+
110
+ const pickByCwd = (entries: readonly RegistryEntry[]) => {
111
+ const cwd = process.cwd()
112
+ const withSep = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`
113
+ return entries
114
+ .filter((entry) => {
115
+ const workdir = entry.workdir.endsWith(path.sep) ? entry.workdir : `${entry.workdir}${path.sep}`
116
+ return withSep === workdir || withSep.startsWith(workdir)
117
+ })
118
+ .sort((a, b) => b.workdir.length - a.workdir.length)[0] ?? null
119
+ }
120
+
121
+ const readRegistryEntry = () => pickByCwd(listAliveEntries())
122
+
123
+ const expectedEnv = (config: DaemonConfig) => ({
124
+ MOTEL_OTEL_BASE_URL: config.baseUrl,
125
+ MOTEL_OTEL_QUERY_URL: config.baseUrl,
126
+ MOTEL_OTEL_HOST: config.host,
127
+ MOTEL_OTEL_PORT: String(config.port),
128
+ MOTEL_OTEL_DB_PATH: config.databasePath,
129
+ MOTEL_OTEL_EXPORTER_URL: `${config.baseUrl}/v1/traces`,
130
+ MOTEL_OTEL_LOGS_EXPORTER_URL: `${config.baseUrl}/v1/logs`,
131
+ })
132
+
133
+ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager => {
134
+ const config = resolveConfig(options)
135
+ const mapError = (error: unknown) => new DaemonError(error instanceof Error ? error.message : String(error))
136
+
137
+ const fetchHealth = async (): Promise<HealthShape | null> => {
138
+ try {
139
+ const response = await fetch(`${config.baseUrl}/api/health`, { signal: AbortSignal.timeout(750) })
140
+ if (!response.ok) return null
141
+ return await response.json() as HealthShape
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+
147
+ const describeManagedMismatch = (health: HealthShape) => {
148
+ if (health.service !== MOTEL_SERVICE_ID) {
149
+ return `Port ${config.port} is in use by ${health.service}, not ${MOTEL_SERVICE_ID}.`
150
+ }
151
+ if (!cwdMatches(health.workdir)) {
152
+ return `Port ${config.port} is serving motel for ${health.workdir}, not ${process.cwd()}.`
153
+ }
154
+ if (health.databasePath !== config.databasePath) {
155
+ return `Port ${config.port} is serving motel with ${health.databasePath}, expected ${config.databasePath}.`
156
+ }
157
+ return null
158
+ }
159
+
160
+ const readLock = async (): Promise<LockShape | null> => {
161
+ try {
162
+ const raw = await fsp.readFile(config.lockPath, "utf8")
163
+ return JSON.parse(raw) as LockShape
164
+ } catch {
165
+ return null
166
+ }
167
+ }
168
+
169
+ const removeStaleLock = async () => {
170
+ const current = await readLock()
171
+ if (!current) {
172
+ await fsp.rm(config.lockPath, { force: true })
173
+ return true
174
+ }
175
+ if (isAlive(current.pid)) return false
176
+ await fsp.rm(config.lockPath, { force: true })
177
+ return true
178
+ }
179
+
180
+ const acquireStartupLock = async () => {
181
+ const deadline = Date.now() + LOCK_TIMEOUT_MS
182
+ await fsp.mkdir(config.runtimeDir, { recursive: true })
183
+
184
+ while (Date.now() < deadline) {
185
+ try {
186
+ const handle = await fsp.open(config.lockPath, "wx")
187
+ const contents = JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() } satisfies LockShape)
188
+ await handle.writeFile(contents, "utf8")
189
+ return {
190
+ release: async () => {
191
+ await handle.close().catch(() => undefined)
192
+ await fsp.rm(config.lockPath, { force: true }).catch(() => undefined)
193
+ },
194
+ }
195
+ } catch (error) {
196
+ const errno = error as NodeJS.ErrnoException
197
+ if (errno.code !== "EEXIST") throw error
198
+ if (await removeStaleLock()) continue
199
+ await sleep(POLL_INTERVAL_MS)
200
+ }
201
+ }
202
+
203
+ throw new Error(`Timed out waiting for daemon startup lock at ${config.lockPath}`)
204
+ }
205
+
206
+ const openLogFile = async () => {
207
+ await fsp.mkdir(config.runtimeDir, { recursive: true })
208
+ return fs.openSync(config.logPath, "a")
209
+ }
210
+
211
+ const waitForHealthy = async (pid: number) => {
212
+ const deadline = Date.now() + START_TIMEOUT_MS
213
+ while (Date.now() < deadline) {
214
+ const health = await fetchHealth()
215
+ if (health) {
216
+ const mismatch = describeManagedMismatch(health)
217
+ if (!mismatch) return health
218
+ throw new Error(mismatch)
219
+ }
220
+ if (!isAlive(pid)) {
221
+ throw new Error(`Daemon process ${pid} exited before becoming healthy. See ${config.logPath}.`)
222
+ }
223
+ await sleep(POLL_INTERVAL_MS)
224
+ }
225
+ throw new Error(`Timed out waiting for daemon health at ${config.baseUrl}/api/health. See ${config.logPath}.`)
226
+ }
227
+
228
+ const stopPid = async (pid: number) => {
229
+ try {
230
+ process.kill(pid, "SIGTERM")
231
+ } catch (error) {
232
+ const errno = error as NodeJS.ErrnoException
233
+ if (errno.code !== "ESRCH") throw error
234
+ }
235
+
236
+ const deadline = Date.now() + STOP_TIMEOUT_MS
237
+ while (Date.now() < deadline) {
238
+ if (!isAlive(pid)) return
239
+ const health = await fetchHealth()
240
+ if (!health || health.pid !== pid) return
241
+ await sleep(POLL_INTERVAL_MS)
242
+ }
243
+
244
+ throw new Error(`Timed out waiting for daemon ${pid} to stop.`)
245
+ }
246
+
247
+ const getStatus = async (): Promise<DaemonStatus> => {
248
+ const registry = readRegistryEntry()
249
+ const health = await fetchHealth()
250
+ if (!health) {
251
+ return {
252
+ running: false,
253
+ managed: false,
254
+ service: null,
255
+ pid: registry?.pid ?? null,
256
+ url: config.baseUrl,
257
+ databasePath: config.databasePath,
258
+ workdir: registry?.workdir ?? null,
259
+ startedAt: registry?.startedAt ?? null,
260
+ version: registry?.version ?? null,
261
+ sameWorkdir: registry ? cwdMatches(registry.workdir) : false,
262
+ reason: registry ? "Registry entry exists but daemon is not healthy." : null,
263
+ logPath: config.logPath,
264
+ lockPath: config.lockPath,
265
+ registryPid: registry?.pid ?? null,
266
+ }
267
+ }
268
+
269
+ const mismatch = describeManagedMismatch(health)
270
+ return {
271
+ running: mismatch === null,
272
+ managed: mismatch === null,
273
+ service: health.service,
274
+ pid: health.pid,
275
+ url: health.url,
276
+ databasePath: health.databasePath,
277
+ workdir: health.workdir,
278
+ startedAt: health.startedAt,
279
+ version: health.version,
280
+ sameWorkdir: cwdMatches(health.workdir),
281
+ reason: mismatch,
282
+ logPath: config.logPath,
283
+ lockPath: config.lockPath,
284
+ registryPid: registry?.pid ?? null,
285
+ }
286
+ }
287
+
288
+ const ensure = async (): Promise<DaemonStatus> => {
289
+ const existing = await getStatus()
290
+ if (existing.managed && existing.running) return existing
291
+ if (existing.service !== null && existing.reason) {
292
+ throw new Error(existing.reason)
293
+ }
294
+
295
+ const lock = await acquireStartupLock()
296
+ let spawnedPid: number | null = null
297
+ try {
298
+ const rechecked = await getStatus()
299
+ if (rechecked.managed && rechecked.running) return rechecked
300
+ if (rechecked.service !== null && rechecked.reason) {
301
+ throw new Error(rechecked.reason)
302
+ }
303
+
304
+ const logFd = await openLogFile()
305
+ try {
306
+ const proc = Bun.spawn({
307
+ cmd: [process.execPath, "run", "src/server.ts"],
308
+ cwd: config.repoRoot,
309
+ detached: true,
310
+ env: {
311
+ ...process.env,
312
+ ...expectedEnv(config),
313
+ },
314
+ stdio: ["ignore", logFd, logFd],
315
+ })
316
+ spawnedPid = proc.pid
317
+ proc.unref()
318
+ } finally {
319
+ fs.closeSync(logFd)
320
+ }
321
+
322
+ if (spawnedPid === null) {
323
+ throw new Error("Daemon failed to spawn.")
324
+ }
325
+
326
+ const health = await waitForHealthy(spawnedPid)
327
+ return {
328
+ running: true,
329
+ managed: true,
330
+ service: health.service,
331
+ pid: health.pid,
332
+ url: health.url,
333
+ databasePath: health.databasePath,
334
+ workdir: health.workdir,
335
+ startedAt: health.startedAt,
336
+ version: health.version,
337
+ sameWorkdir: cwdMatches(health.workdir),
338
+ reason: null,
339
+ logPath: config.logPath,
340
+ lockPath: config.lockPath,
341
+ registryPid: health.pid,
342
+ }
343
+ } catch (error) {
344
+ if (spawnedPid !== null) {
345
+ await stopPid(spawnedPid).catch(() => undefined)
346
+ }
347
+ throw error
348
+ } finally {
349
+ await lock.release()
350
+ }
351
+ }
352
+
353
+ const stop = async (): Promise<DaemonStatus> => {
354
+ const status = await getStatus()
355
+ if (status.pid === null) return status
356
+ if (!status.sameWorkdir) {
357
+ throw new Error(`Refusing to stop motel owned by ${status.workdir}.`)
358
+ }
359
+ if (status.service !== null && status.service !== MOTEL_SERVICE_ID) {
360
+ throw new Error(`Refusing to stop non-motel service ${status.service} on ${status.url}.`)
361
+ }
362
+ await stopPid(status.pid)
363
+ return await getStatus()
364
+ }
365
+
366
+ return {
367
+ applyEnv: Effect.sync(() => {
368
+ for (const [key, value] of Object.entries(expectedEnv(config))) {
369
+ process.env[key] = value
370
+ }
371
+ }),
372
+ getStatus: Effect.fn("DaemonManager.getStatus")(() =>
373
+ Effect.tryPromise({
374
+ try: getStatus,
375
+ catch: mapError,
376
+ }),
377
+ )(),
378
+ ensure: Effect.fn("DaemonManager.ensure")(() =>
379
+ Effect.tryPromise({
380
+ try: ensure,
381
+ catch: mapError,
382
+ }),
383
+ )(),
384
+ stop: Effect.fn("DaemonManager.stop")(() =>
385
+ Effect.tryPromise({
386
+ try: stop,
387
+ catch: mapError,
388
+ }),
389
+ )(),
390
+ }
391
+ }
392
+
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
package/src/domain.ts ADDED
@@ -0,0 +1,233 @@
1
+ import { Schema } from "effect"
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Reusable helpers
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const StringRecord = Schema.Record(Schema.String, Schema.String)
8
+
9
+ const DateFromString = Schema.DateFromString
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Core telemetry domain types (Schema is the single source of truth)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export const TraceSpanStatus = Schema.Literals(["ok", "error"])
16
+ export type TraceSpanStatus = typeof TraceSpanStatus.Type
17
+
18
+ export const TraceSpanEvent = Schema.Struct({
19
+ name: Schema.String.pipe(Schema.annotateKey({ description: "Event name" })),
20
+ timestamp: DateFromString.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp" })),
21
+ attributes: StringRecord.pipe(Schema.annotateKey({ description: "Key-value attributes attached to the event" })),
22
+ }).annotate({ identifier: "TraceSpanEvent" })
23
+
24
+ export type TraceSpanEvent = typeof TraceSpanEvent.Type
25
+
26
+ export const TraceSpanItem = Schema.Struct({
27
+ spanId: Schema.String,
28
+ parentSpanId: Schema.NullOr(Schema.String),
29
+ serviceName: Schema.String,
30
+ scopeName: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Instrumentation scope (e.g. module or library name)" })),
31
+ kind: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Span kind: client, server, producer, consumer, or internal" })),
32
+ operationName: Schema.String.pipe(Schema.annotateKey({ description: "The operation this span represents (e.g. HTTP handler, DB query)" })),
33
+ startTime: DateFromString.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp" })),
34
+ isRunning: Schema.Boolean.pipe(Schema.annotateKey({ description: "True when the span has not reported an end timestamp yet" })),
35
+ durationMs: Schema.Number.pipe(Schema.annotateKey({ description: "Wall-clock duration in milliseconds" })),
36
+ status: TraceSpanStatus.pipe(Schema.annotateKey({ description: "ok or error" })),
37
+ depth: Schema.Number.pipe(Schema.annotateKey({ description: "Nesting depth in the span tree (root = 0)" })),
38
+ tags: StringRecord.pipe(Schema.annotateKey({ description: "Span attributes as key-value pairs" })),
39
+ warnings: Schema.Array(Schema.String).pipe(Schema.annotateKey({ description: "Structural warnings (e.g. missing parent span)" })),
40
+ events: Schema.Array(TraceSpanEvent),
41
+ }).annotate({ identifier: "TraceSpan" })
42
+
43
+ export type TraceSpanItem = typeof TraceSpanItem.Type
44
+
45
+ export const TraceItem = Schema.Struct({
46
+ traceId: Schema.String,
47
+ serviceName: Schema.String.pipe(Schema.annotateKey({ description: "Service that owns the root span" })),
48
+ rootOperationName: Schema.String.pipe(Schema.annotateKey({ description: "Operation name of the root span" })),
49
+ startedAt: DateFromString.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp of the earliest span" })),
50
+ isRunning: Schema.Boolean.pipe(Schema.annotateKey({ description: "True when any span in the trace is still open" })),
51
+ durationMs: Schema.Number.pipe(Schema.annotateKey({ description: "End-to-end trace duration in milliseconds" })),
52
+ spanCount: Schema.Number,
53
+ errorCount: Schema.Number.pipe(Schema.annotateKey({ description: "Number of spans with status=error" })),
54
+ warnings: Schema.Array(Schema.String),
55
+ spans: Schema.Array(TraceSpanItem).pipe(Schema.annotateKey({ description: "Spans ordered by parent-child hierarchy, depth-first" })),
56
+ }).annotate({ identifier: "Trace" })
57
+
58
+ export type TraceItem = typeof TraceItem.Type
59
+
60
+ export const TraceSummaryItem = Schema.Struct({
61
+ traceId: Schema.String,
62
+ serviceName: Schema.String,
63
+ rootOperationName: Schema.String,
64
+ startedAt: DateFromString.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp" })),
65
+ isRunning: Schema.Boolean,
66
+ durationMs: Schema.Number,
67
+ spanCount: Schema.Number,
68
+ errorCount: Schema.Number,
69
+ warnings: Schema.Array(Schema.String),
70
+ }).annotate({ identifier: "TraceSummary" })
71
+
72
+ export type TraceSummaryItem = typeof TraceSummaryItem.Type
73
+
74
+ export const SpanItem = Schema.Struct({
75
+ traceId: Schema.String,
76
+ rootOperationName: Schema.String.pipe(Schema.annotateKey({ description: "Operation name of the trace's root span, for context" })),
77
+ parentOperationName: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Parent span operation name, if present" })),
78
+ span: TraceSpanItem,
79
+ }).annotate({ identifier: "SpanWithContext" })
80
+
81
+ export type SpanItem = typeof SpanItem.Type
82
+
83
+ export const LogItem = Schema.Struct({
84
+ id: Schema.String,
85
+ timestamp: DateFromString.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp" })),
86
+ serviceName: Schema.String,
87
+ severityText: Schema.String.pipe(Schema.annotateKey({ description: "Log level: TRACE, DEBUG, INFO, WARN, ERROR, FATAL" })),
88
+ body: Schema.String.pipe(Schema.annotateKey({ description: "Log message body" })),
89
+ traceId: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Associated trace ID, if the log was emitted inside a span" })),
90
+ spanId: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Associated span ID, if the log was emitted inside a span" })),
91
+ scopeName: Schema.NullOr(Schema.String),
92
+ attributes: StringRecord.pipe(Schema.annotateKey({ description: "Merged resource + log attributes as key-value pairs" })),
93
+ }).annotate({ identifier: "Log" })
94
+
95
+ export type LogItem = typeof LogItem.Type
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Shared query result types
99
+ // ---------------------------------------------------------------------------
100
+
101
+ export const FacetItem = Schema.Struct({
102
+ value: Schema.String.pipe(Schema.annotateKey({ description: "Distinct value for the faceted field" })),
103
+ count: Schema.Number.pipe(Schema.annotateKey({ description: "Number of occurrences" })),
104
+ }).annotate({ identifier: "Facet" })
105
+
106
+ export type FacetItem = typeof FacetItem.Type
107
+
108
+ export const StatsItem = Schema.Struct({
109
+ group: Schema.String.pipe(Schema.annotateKey({ description: "Grouping key" })),
110
+ value: Schema.Number.pipe(Schema.annotateKey({ description: "Aggregate value for the chosen metric" })),
111
+ count: Schema.Number.pipe(Schema.annotateKey({ description: "Number of samples in the group" })),
112
+ }).annotate({ identifier: "Stat" })
113
+
114
+ export type StatsItem = typeof StatsItem.Type
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // AI Call types
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** Maps normalized field names to the AI SDK attribute keys in span_attributes */
121
+ export const AI_ATTR_MAP = {
122
+ operationId: "ai.operationId",
123
+ functionId: "ai.telemetry.functionId",
124
+ provider: "ai.model.provider",
125
+ model: "ai.model.id",
126
+ sessionId: "ai.telemetry.metadata.sessionId",
127
+ userId: "ai.telemetry.metadata.userId",
128
+ finishReason: "ai.response.finishReason",
129
+ inputTokens: "ai.usage.inputTokens",
130
+ outputTokens: "ai.usage.outputTokens",
131
+ totalTokens: "ai.usage.totalTokens",
132
+ cachedInputTokens: "ai.usage.cachedInputTokens",
133
+ reasoningTokens: "ai.usage.reasoningTokens",
134
+ msToFirstChunk: "ai.response.msToFirstChunk",
135
+ msToFinish: "ai.response.msToFinish",
136
+ avgOutputTokensPerSecond: "ai.response.avgOutputTokensPerSecond",
137
+ promptMessages: "ai.prompt.messages",
138
+ prompt: "ai.prompt",
139
+ responseText: "ai.response.text",
140
+ tools: "ai.prompt.tools",
141
+ toolChoice: "ai.prompt.toolChoice",
142
+ providerMetadata: "ai.response.providerMetadata",
143
+ responseModel: "ai.response.model",
144
+ responseId: "ai.response.id",
145
+ responseTimestamp: "ai.response.timestamp",
146
+ } as const
147
+
148
+ /** Attribute keys to search across when using the `text` filter */
149
+ export const AI_TEXT_SEARCH_KEYS = [
150
+ "ai.prompt.messages",
151
+ "ai.prompt",
152
+ "ai.response.text",
153
+ "ai.prompt.tools",
154
+ ] as const
155
+
156
+ const PREVIEW_LENGTH = 200
157
+
158
+ export const truncatePreview = (value: string | null | undefined): string | null => {
159
+ if (!value) return null
160
+ if (value.length <= PREVIEW_LENGTH) return value
161
+ return value.slice(0, PREVIEW_LENGTH) + "..."
162
+ }
163
+
164
+ export const AiUsage = Schema.Struct({
165
+ inputTokens: Schema.NullOr(Schema.Number),
166
+ outputTokens: Schema.NullOr(Schema.Number),
167
+ totalTokens: Schema.NullOr(Schema.Number),
168
+ cachedInputTokens: Schema.NullOr(Schema.Number),
169
+ reasoningTokens: Schema.NullOr(Schema.Number),
170
+ }).annotate({ identifier: "AiUsage" })
171
+
172
+ export type AiUsage = typeof AiUsage.Type
173
+
174
+ export const AiCallSummary = Schema.Struct({
175
+ traceId: Schema.String,
176
+ spanId: Schema.String,
177
+ operation: Schema.String.pipe(Schema.annotateKey({ description: "AI operation: streamText, generateText, streamObject, etc." })),
178
+ service: Schema.String,
179
+ functionId: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "ai.telemetry.functionId" })),
180
+ provider: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "ai.model.provider (e.g. openai.responses)" })),
181
+ model: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "ai.model.id (e.g. gpt-5.4)" })),
182
+ status: TraceSpanStatus,
183
+ startedAt: Schema.String.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp" })),
184
+ durationMs: Schema.Number,
185
+ sessionId: Schema.NullOr(Schema.String),
186
+ userId: Schema.NullOr(Schema.String),
187
+ promptPreview: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "First ~200 chars of prompt content" })),
188
+ responsePreview: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "First ~200 chars of response text" })),
189
+ finishReason: Schema.NullOr(Schema.String),
190
+ toolCallCount: Schema.Number.pipe(Schema.annotateKey({ description: "Number of tool call child spans" })),
191
+ usage: Schema.NullOr(AiUsage),
192
+ }).annotate({ identifier: "AiCallSummary" })
193
+
194
+ export type AiCallSummary = typeof AiCallSummary.Type
195
+
196
+ export const AiToolCall = Schema.Struct({
197
+ name: Schema.String,
198
+ spanId: Schema.NullOr(Schema.String),
199
+ status: TraceSpanStatus,
200
+ durationMs: Schema.NullOr(Schema.Number),
201
+ }).annotate({ identifier: "AiToolCall" })
202
+
203
+ export type AiToolCall = typeof AiToolCall.Type
204
+
205
+ export const AiCallDetail = Schema.Struct({
206
+ traceId: Schema.String,
207
+ spanId: Schema.String,
208
+ operation: Schema.String,
209
+ service: Schema.String,
210
+ functionId: Schema.NullOr(Schema.String),
211
+ provider: Schema.NullOr(Schema.String),
212
+ model: Schema.NullOr(Schema.String),
213
+ status: TraceSpanStatus,
214
+ startedAt: Schema.String,
215
+ durationMs: Schema.Number,
216
+ sessionId: Schema.NullOr(Schema.String),
217
+ userId: Schema.NullOr(Schema.String),
218
+ finishReason: Schema.NullOr(Schema.String),
219
+ promptMessages: Schema.NullOr(Schema.Unknown).pipe(Schema.annotateKey({ description: "Full parsed ai.prompt.messages or ai.prompt" })),
220
+ responseText: Schema.NullOr(Schema.String).pipe(Schema.annotateKey({ description: "Full ai.response.text" })),
221
+ toolCalls: Schema.Array(AiToolCall),
222
+ toolsAvailable: Schema.NullOr(Schema.Unknown).pipe(Schema.annotateKey({ description: "Full ai.prompt.tools" })),
223
+ providerMetadata: Schema.NullOr(Schema.Unknown),
224
+ usage: Schema.NullOr(AiUsage),
225
+ timing: Schema.Struct({
226
+ msToFirstChunk: Schema.NullOr(Schema.Number),
227
+ msToFinish: Schema.NullOr(Schema.Number),
228
+ avgOutputTokensPerSecond: Schema.NullOr(Schema.Number),
229
+ }),
230
+ logs: Schema.Array(Schema.Unknown).pipe(Schema.annotateKey({ description: "Correlated log records" })),
231
+ }).annotate({ identifier: "AiCallDetail" })
232
+
233
+ export type AiCallDetail = typeof AiCallDetail.Type