@aryasaatvik/motel 0.3.5 → 0.4.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.
package/AGENTS.md CHANGED
@@ -97,7 +97,7 @@ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugi
97
97
  - `src/server.ts` runs the local server without the TUI.
98
98
  - `src/instructions.ts` contains the copied setup instructions for other Effect apps.
99
99
  - `src/services/TelemetryStore.ts` persists traces and logs in SQLite and exposes indexed queries through writer and read-only service identifiers.
100
- - `src/services/TelemetryQuery.ts` proxies read-only store calls to `src/services/telemetryQueryWorker.ts`, keeping synchronous Bun SQLite queries off the HTTP event loop.
100
+ - `src/services/TelemetryQuery.ts` schedules read-only store calls through a bounded queue and a disposable subprocess containing `src/services/telemetryQueryWorker.ts`. Deadlines and cancellation terminate that process to release synchronous SQLite readers; the HTTP server and TUI share this architecture.
101
101
  - `src/config.ts` is the source of truth for ports and env-driven OTEL settings.
102
102
  - `web/` is a Vite + React SPA for the browser-based UI (Tailwind CSS, `@effect/atom-react`, `AtomHttpApi`).
103
103
  - `web/src/api.ts` creates the typed `AtomHttpApi.Service` client from `src/httpApi.ts`.
@@ -147,6 +147,9 @@ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugi
147
147
  - `MOTEL_OTEL_MAX_DB_SIZE_MB`: defaults to `1024` (size-based retention cap)
148
148
  - `MOTEL_OTEL_RETENTION_TRACE_BATCH`: defaults to `100` completed traces per cleanup pass
149
149
  - `MOTEL_OTEL_RETENTION_LOG_BATCH`: defaults to `5000` logs per cleanup pass
150
+ - `MOTEL_OTEL_QUERY_CAPACITY`: defaults to `8` outstanding queries per runtime (maximum `64`)
151
+ - `MOTEL_OTEL_QUERY_DEADLINE_MS`: defaults to `5000` including queue and startup time (maximum `30000`)
152
+ - `MOTEL_OTEL_RETENTION_ROW_BATCH`: defaults to `1000` physical rows per cleanup pass
150
153
  - `MOTEL_OTEL_RETENTION_INTERVAL_SECONDS`: defaults to `10`
151
154
 
152
155
  ## TUI Keys
package/README.md CHANGED
@@ -135,3 +135,54 @@ telemetry. Correlated logs may include secrets, tokens, or PII if your
135
135
  app logs them; AI call traces may include full prompt content and
136
136
  response text. Treat the local SQLite store as sensitive development
137
137
  data when pointing motel at real workloads.
138
+
139
+ ### Ingestion readiness
140
+
141
+ `GET /api/health` reports process identity and liveness. `GET /api/readiness` reports cached
142
+ writer diagnostics without querying SQLite or joining the ingestion queue. It returns HTTP
143
+ 200 for `ready`, and 503 for `starting`, `overloaded`, or `failed`. `motel status` includes this
144
+ snapshot when the daemon supports it; older servers remain compatible.
145
+
146
+ `overloaded` means an awaiting request is at least five seconds old or at least 64 requests
147
+ are outstanding. This is a pressure signal, not an instruction to restart a shared daemon.
148
+ Idle time does not imply failure. `failed` records worker exit/error and requires operator
149
+ inspection. Outstanding counts/bytes describe awaiting callers, not durable queued data;
150
+ client cancellation cannot undo a SQLite commit. Empty probes do not advance `lastCommitAt`.
151
+ Maintenance reports its latest operation, duration, and outcome without telemetry payloads.
152
+
153
+ Run `bun scripts/bench-shared-daemon.ts` for an isolated reduced-cap mixed workload, or add
154
+ `--1gib` for the representative capacity profile. Both create and remove their own database,
155
+ include a held reader, and report request latency percentiles, failures, and database/WAL size.
156
+ They never benchmark the shared machine-global database.
157
+
158
+ Concurrent starts publish a complete shared lock atomically. Ensure operations preserve a live
159
+ daemon when ingestion is slow; explicit operator recovery remains available through `motel restart`.
160
+ An unreadable lock or interrupted stale-lock recovery fails closed and reports its path for inspection.
161
+ When maintenance operations overlap, readiness shows the oldest active operation before a completed one.
162
+
163
+ Retention marks completed traces and expired logs before removing dependent rows. Public query
164
+ results hide the whole marked trace, including correlated logs, while bounded cleanup proceeds.
165
+ Markers persist across restarts. Arrivals for a marked trace are discarded as part of its eviction
166
+ and do not count as inserted records; once cleanup finishes, later exports may recreate that trace.
167
+ Active traces are not selected. SQLite tables are internal storage; direct SQL consumers should
168
+ use the `retained_spans`, `retained_logs`, and `retained_trace_summaries` views for this visibility rule.
169
+
170
+ `MOTEL_OTEL_RETENTION_ROW_BATCH` defaults to 1000 logical rows per cleanup batch, split across
171
+ traces and logs. It bounds row cardinality, not the size of one telemetry field. Cleanup yields
172
+ 100 ms between batches while making progress, then returns to the configured idle interval.
173
+ Routine checkpoints use PASSIVE and report busy/frame counts in readiness; physical reclamation
174
+ is deferred for held readers. The configured database target and WAL size limit are not hard caps.
175
+ Existing orphan attributes/FTS entries are repaired with bounded keyset scans; normal ingests
176
+ maintain reverse FTS mappings for indexed cleanup.
177
+
178
+ Queries use one readonly subprocess at a time, with FIFO admission capped at eight outstanding
179
+ requests by default (`MOTEL_OTEL_QUERY_CAPACITY`, maximum 64). The five-second deadline includes
180
+ queue and startup time (`MOTEL_OTEL_QUERY_DEADLINE_MS`, maximum 30000). Overload returns HTTP 503
181
+ with `code: "QUERY_OVERLOADED"`; expiry returns 504 with `code: "QUERY_DEADLINE"`; process failure
182
+ returns 503 with `code: "QUERY_UNAVAILABLE"`. Successful response shapes are unchanged.
183
+
184
+ Cancelling or expiring an executing query terminates its readonly process and waits for exit
185
+ before dispatching the next query. SQLite runs in a thread inside that process so an IPC-owner
186
+ disconnect can also terminate it if the daemon or TUI dies. Ingestion owns a separate worker.
187
+ The HTTP idle timeout exceeds the query deadline by five seconds. TUI refreshes share in-flight
188
+ loads, obsolete selections discard results, and facet values prefetch sequentially for two keys.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryasaatvik/motel",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -39,9 +39,12 @@ export const config = {
39
39
  traceLookbackMinutes: parsePositiveInt(process.env.MOTEL_OTEL_TRACE_LOOKBACK_MINUTES, 1440),
40
40
  traceFetchLimit: parsePositiveInt(process.env.MOTEL_OTEL_TRACE_LIMIT, 100),
41
41
  logFetchLimit: parsePositiveInt(process.env.MOTEL_OTEL_LOG_LIMIT, 80),
42
+ queryCapacity: Math.min(parsePositiveInt(process.env.MOTEL_OTEL_QUERY_CAPACITY, 8), 64),
43
+ queryDeadlineMs: Math.min(parsePositiveInt(process.env.MOTEL_OTEL_QUERY_DEADLINE_MS, 5_000), 30_000),
42
44
  retentionHours: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_HOURS, 168),
43
45
  maxDbSizeMb: parsePositiveInt(process.env.MOTEL_OTEL_MAX_DB_SIZE_MB, 1024),
44
46
  retentionTraceBatch: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_TRACE_BATCH, 100),
47
+ retentionRowBatch: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_ROW_BATCH, 1_000),
45
48
  retentionLogBatch: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_LOG_BATCH, 5_000),
46
49
  retentionIntervalSeconds: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS, 10),
47
50
  },
package/src/daemon.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs"
2
2
  import { promises as fsp } from "node:fs"
3
3
  import * as path from "node:path"
4
- import { Effect } from "effect"
4
+ import { Effect, Schema } from "effect"
5
+ import { IngestReadiness } from "./ingestReadiness.ts"
5
6
  import { isAlive, isManagedDaemonProcess, listAliveEntries, motelStateDir, MOTEL_SERVICE_ID, processIdentity, removeRegistryEntry, type RegistryEntry } from "./registry.js"
6
7
 
7
8
  const DEFAULT_REPO_ROOT = path.resolve(import.meta.dir, "..")
@@ -58,6 +59,7 @@ type DaemonConfig = {
58
59
  }
59
60
 
60
61
  export type DaemonStatus = {
62
+ readonly readiness?: IngestReadiness
61
63
  readonly running: boolean
62
64
  readonly managed: boolean
63
65
  readonly service: string | null
@@ -170,13 +172,13 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
170
172
  }
171
173
  }
172
174
 
173
- const fetchIngestProbe = async () => {
175
+ const fetchIngestProbe = async (timeoutMs = INGEST_PROBE_TIMEOUT_MS) => {
174
176
  try {
175
177
  const postEmpty = (path: string) => fetch(`${config.baseUrl}${path}`, {
176
178
  method: "POST",
177
179
  headers: { "content-type": "application/json" },
178
180
  body: "{}",
179
- signal: AbortSignal.timeout(INGEST_PROBE_TIMEOUT_MS),
181
+ signal: AbortSignal.timeout(timeoutMs),
180
182
  })
181
183
  const [traces, logs] = await Promise.all([postEmpty("/v1/traces"), postEmpty("/v1/logs")])
182
184
  return traces.ok && logs.ok
@@ -198,47 +200,55 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
198
200
  const readLock = async (): Promise<LockShape | null> => {
199
201
  try {
200
202
  const raw = await fsp.readFile(config.lockPath, "utf8")
201
- return JSON.parse(raw) as LockShape
203
+ const lock = JSON.parse(raw) as Partial<LockShape>
204
+ if (!Number.isInteger(lock.pid) || (lock.pid ?? 0) <= 0 || typeof lock.createdAt !== "string" ||
205
+ (lock.processIdentity !== undefined && typeof lock.processIdentity !== "string")) return null
206
+ return lock as LockShape
202
207
  } catch {
203
208
  return null
204
209
  }
205
210
  }
206
211
 
207
212
  const removeStaleLock = async () => {
208
- const current = await readLock()
209
- if (!current) {
213
+ // Serialize stale-owner cleanup so two contenders cannot unlink a fresh
214
+ // replacement after both observed the same dead owner. Unknown lock contents
215
+ // fail closed: they may belong to an older writer still publishing its lock.
216
+ const recoveryPath = `${config.lockPath}.recovery`
217
+ try { await fsp.mkdir(recoveryPath) }
218
+ catch (error) {
219
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false
220
+ throw error
221
+ }
222
+ try {
223
+ const current = await readLock()
224
+ if (!current) return false
225
+ if (current.processIdentity ? processIdentity(current.pid) === current.processIdentity : isAlive(current.pid)) return false
210
226
  await fsp.rm(config.lockPath, { force: true })
211
227
  return true
212
- }
213
- if (current.processIdentity ? processIdentity(current.pid) === current.processIdentity : isAlive(current.pid)) return false
214
- await fsp.rm(config.lockPath, { force: true })
215
- return true
228
+ } finally { await fsp.rmdir(recoveryPath) }
216
229
  }
217
230
 
218
231
  const acquireStartupLock = async () => {
219
232
  const deadline = Date.now() + LOCK_TIMEOUT_MS
220
233
  await fsp.mkdir(config.runtimeDir, { recursive: true })
221
-
222
- while (Date.now() < deadline) {
223
- try {
224
- const handle = await fsp.open(config.lockPath, "wx")
225
- const contents = JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), processIdentity: processIdentity(process.pid) ?? undefined } satisfies LockShape)
226
- await handle.writeFile(contents, "utf8")
227
- return {
228
- release: async () => {
229
- await handle.close().catch(() => undefined)
230
- await fsp.rm(config.lockPath, { force: true }).catch(() => undefined)
231
- },
234
+ const candidate = `${config.lockPath}.${process.pid}.${crypto.randomUUID()}`
235
+ const contents = JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), processIdentity: processIdentity(process.pid) ?? undefined } satisfies LockShape)
236
+ await fsp.writeFile(candidate, contents, { flag: "wx" })
237
+ try {
238
+ while (Date.now() < deadline) {
239
+ try {
240
+ // link is exclusive and publishes a complete file atomically. Unlike
241
+ // open("wx") followed by writeFile, there is no observable empty lock.
242
+ await fsp.link(candidate, config.lockPath)
243
+ return { release: () => fsp.rm(config.lockPath, { force: true }) }
244
+ } catch (error) {
245
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
246
+ if (await removeStaleLock()) continue
247
+ await sleep(POLL_INTERVAL_MS)
232
248
  }
233
- } catch (error) {
234
- const errno = error as NodeJS.ErrnoException
235
- if (errno.code !== "EEXIST") throw error
236
- if (await removeStaleLock()) continue
237
- await sleep(POLL_INTERVAL_MS)
238
249
  }
239
- }
240
-
241
- throw new Error(`Timed out waiting for daemon startup lock at ${config.lockPath}`)
250
+ throw new Error(`Timed out waiting for daemon startup lock at ${config.lockPath}. Inspect its owner and ${config.lockPath}.recovery before recovery.`)
251
+ } finally { await fsp.rm(candidate, { force: true }) }
242
252
  }
243
253
 
244
254
  const openLogFile = async () => {
@@ -321,8 +331,18 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
321
331
  }
322
332
 
323
333
  const mismatch = describeManagedMismatch(health)
334
+ let readiness: IngestReadiness | undefined
335
+ if (mismatch === null) {
336
+ try {
337
+ const response = await fetch(`${config.baseUrl}/api/readiness`, { signal: AbortSignal.timeout(timeoutMs) })
338
+ if (response.status === 200 || response.status === 503) {
339
+ readiness = Schema.decodeUnknownSync(IngestReadiness)(await response.json())
340
+ }
341
+ } catch { /* Older daemons can still answer the identity handshake. */ }
342
+ }
324
343
  const managed = mismatch === null && registry?.pid === health.pid && registry.instanceId === health.instanceId && isManagedDaemonProcess(registry)
325
344
  return {
345
+ readiness,
326
346
  running: mismatch === null,
327
347
  managed,
328
348
  service: health.service,
@@ -340,6 +360,21 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
340
360
  }
341
361
  }
342
362
 
363
+ const awaitExistingIngest = async (existing: DaemonStatus): Promise<DaemonStatus> => {
364
+ if (existing.pid === process.pid) return existing
365
+ const deadline = Date.now() + startTimeoutMs
366
+ while (Date.now() < deadline) {
367
+ if (await fetchIngestProbe(Math.max(1, Math.min(INGEST_PROBE_TIMEOUT_MS, deadline - Date.now())))) {
368
+ const current = await getStatus()
369
+ if (current.running && current.managed) return current
370
+ break
371
+ }
372
+ if (existing.readiness?.state === "failed") break
373
+ await sleep(Math.min(POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())))
374
+ }
375
+ throw new Error("Existing managed Motel daemon is not ingest-ready. Its process was preserved; inspect `motel status` and recover explicitly.")
376
+ }
377
+
343
378
  const ensure = async (): Promise<DaemonStatus> => {
344
379
  // Use the patient timeout for the initial probe — this is the
345
380
  // critical "is there already a daemon here?" check. A false
@@ -351,10 +386,11 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
351
386
  // /api/health can stay healthy after the lazy ingest worker/RPC path
352
387
  // has been poisoned by an interrupted request. Empty OTLP posts are
353
388
  // side-effect free and exercise the same path real exporters need.
354
- if (existing.pid === process.pid || await fetchIngestProbe()) return existing
355
- if (existingEntry) await stopPid(existingEntry)
389
+ return awaitExistingIngest(existing)
390
+ }
391
+ if (!existing.running && existingEntry && isManagedDaemonProcess(existingEntry)) {
392
+ throw new Error("Registered Motel process is alive but health is unavailable. Its process was preserved; inspect `motel status` before recovery.")
356
393
  }
357
- if (!existing.running && existingEntry && isManagedDaemonProcess(existingEntry)) await stopPid(existingEntry)
358
394
  if (existing.service !== null && existing.reason) {
359
395
  throw new Error(existing.reason)
360
396
  }
@@ -369,9 +405,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
369
405
  // while the runtime warms up.
370
406
  const rechecked = await getStatus(HEALTH_PATIENT_TIMEOUT_MS)
371
407
  if (rechecked.managed && rechecked.running) {
372
- if (rechecked.pid === process.pid || await fetchIngestProbe()) return rechecked
373
- const recheckedEntry = readRegistryEntry()
374
- if (recheckedEntry) await stopPid(recheckedEntry)
408
+ return awaitExistingIngest(rechecked)
375
409
  }
376
410
  if (rechecked.service !== null && rechecked.reason) {
377
411
  throw new Error(rechecked.reason)
package/src/httpApi.ts CHANGED
@@ -6,7 +6,14 @@ import {
6
6
  TraceItem, TraceSummaryItem, SpanItem, LogItem,
7
7
  } from "./domain.js"
8
8
 
9
+ import { IngestReadiness } from "./ingestReadiness.js"
10
+
9
11
  const ErrorResponse = Schema.Struct({ error: Schema.String })
12
+ const QueryFailures = [
13
+ Schema.Struct({ error: Schema.String, code: Schema.Literals(["QUERY_OVERLOADED", "QUERY_UNAVAILABLE"]) }).pipe(HttpApiSchema.status(503)),
14
+ Schema.Struct({ error: Schema.String, code: Schema.Literal("QUERY_DEADLINE") }).pipe(HttpApiSchema.status(504)),
15
+ ] as const
16
+
10
17
  const Meta = Schema.Struct({
11
18
  limit: Schema.Number,
12
19
  lookback: Schema.String,
@@ -80,6 +87,9 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
80
87
  .annotate(OpenApi.Summary, "Health check and identity handshake")
81
88
  .annotate(OpenApi.Description, "Returns liveness plus identity fields (pid, url, workdir, startedAt, version). Doubles as the MCP discovery handshake: clients compare the returned pid against a registry entry to detect stale registrations that now point at an impostor process on the same port."),
82
89
 
90
+ HttpApiEndpoint.get("readiness", "/api/readiness", { success: IngestReadiness, error: IngestReadiness.pipe(HttpApiSchema.status(503)) })
91
+ .annotate(OpenApi.Summary, "Cached writer readiness and progress; does not enqueue database work"),
92
+
83
93
  HttpApiEndpoint.post("ingestTraces", "/v1/traces", {
84
94
  payload: Schema.Unknown,
85
95
  success: IngestTraceResponse,
@@ -94,7 +104,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
94
104
  .annotate(OpenApi.Summary, "Ingest OTLP logs")
95
105
  .annotate(OpenApi.Description, "Accepts OTLP HTTP log export requests and stores them in the local SQLite telemetry store."),
96
106
 
97
- HttpApiEndpoint.get("services", "/api/services", { success: ServiceList })
107
+ HttpApiEndpoint.get("services", "/api/services", { success: ServiceList, error: QueryFailures })
98
108
  .annotate(OpenApi.Summary, "List active services")
99
109
  .annotate(OpenApi.Description, "Returns service names that have emitted spans or logs within the default lookback window. Use this to discover what services are reporting, then query their traces or logs."),
100
110
 
@@ -106,6 +116,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
106
116
  cursor: CursorParam,
107
117
  },
108
118
  success: TraceSummaryList,
119
+ error: QueryFailures,
109
120
  })
110
121
  .annotate(OpenApi.Summary, "List recent traces")
111
122
  .annotate(OpenApi.Description, "Returns compact trace summaries ordered by start time descending. Use /api/traces/{traceId} for the full span tree. Supports cursor pagination and applies default/max limit and lookback bounds."),
@@ -130,6 +141,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
130
141
  cursor: CursorParam,
131
142
  },
132
143
  success: TraceSummaryList,
144
+ error: QueryFailures,
133
145
  })
134
146
  .annotate(OpenApi.Summary, "Search traces with filters")
135
147
  .annotate(OpenApi.Description, "Search compact trace summaries with filters. Use /api/traces/{traceId} for full details. Supports cursor pagination, attr.<key> filters in the query string, and aiText for full-text search across LLM prompt/response content."),
@@ -146,7 +158,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
146
158
  limit: LimitParam,
147
159
  },
148
160
  success: StatList,
149
- error: ErrorResponse,
161
+ error: [ErrorResponse, ...QueryFailures],
150
162
  })
151
163
  .annotate(OpenApi.Summary, "Aggregate trace statistics")
152
164
  .annotate(OpenApi.Description, "Returns grouped trace aggregates such as count, average duration, p95 duration, or error rate. Supports the same core filters as trace search plus groupBy dimensions like service, operation, status, and attr.<key>."),
@@ -156,7 +168,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
156
168
  traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
157
169
  },
158
170
  success: TraceResponse,
159
- error: ErrorResponse,
171
+ error: [ErrorResponse, ...QueryFailures],
160
172
  })
161
173
  .annotate(OpenApi.Summary, "Get a single trace")
162
174
  .annotate(OpenApi.Description, "Returns the full trace with all spans ordered by parent-child hierarchy. Returns 404 if the trace ID is not found or has expired."),
@@ -166,7 +178,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
166
178
  traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
167
179
  },
168
180
  success: HtmlText,
169
- error: ErrorResponse,
181
+ error: [ErrorResponse, ...QueryFailures],
170
182
  })
171
183
  .annotate(OpenApi.Summary, "Render a browser trace page")
172
184
  .annotate(OpenApi.Description, "Renders a simple HTML waterfall/log view for one trace, suitable for opening from the TUI or browser."),
@@ -181,6 +193,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
181
193
  cursor: CursorParam,
182
194
  },
183
195
  success: LogList,
196
+ error: QueryFailures,
184
197
  })
185
198
  .annotate(OpenApi.Summary, "Get logs for a trace")
186
199
  .annotate(OpenApi.Description, "Returns log records correlated with the given trace, across all spans. Ordered by timestamp descending. Supports cursor pagination and bounded lookback/limit defaults."),
@@ -190,6 +203,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
190
203
  traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
191
204
  },
192
205
  success: SpanList,
206
+ error: QueryFailures,
193
207
  })
194
208
  .annotate(OpenApi.Summary, "List spans for a trace")
195
209
  .annotate(OpenApi.Description, "Returns the flat list of spans for one trace, preserving trace context on each row. Useful for span-level filtering and sorting without traversing the full tree shape."),
@@ -199,7 +213,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
199
213
  spanId: Schema.String.pipe(Schema.annotateKey({ description: "Full 16-character hex span ID" })),
200
214
  },
201
215
  success: SpanResponse,
202
- error: ErrorResponse,
216
+ error: [ErrorResponse, ...QueryFailures],
203
217
  })
204
218
  .annotate(OpenApi.Summary, "Get a single span")
205
219
  .annotate(OpenApi.Description, "Returns a span by its ID, including the parent trace ID and root operation name for context. Returns 404 if the span is not found."),
@@ -214,6 +228,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
214
228
  cursor: CursorParam,
215
229
  },
216
230
  success: LogList,
231
+ error: QueryFailures,
217
232
  })
218
233
  .annotate(OpenApi.Summary, "Get logs for a span")
219
234
  .annotate(OpenApi.Description, "Returns log records correlated with the given span. Ordered by timestamp descending. Supports cursor pagination and bounded lookback/limit defaults."),
@@ -231,6 +246,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
231
246
  limit: LimitParam,
232
247
  },
233
248
  success: PaginatedSpanList,
249
+ error: QueryFailures,
234
250
  })
235
251
  .annotate(OpenApi.Summary, "Search spans directly")
236
252
  .annotate(OpenApi.Description, "Search spans directly instead of root traces. Supports service, traceId, operation, parentOperation, status, lookback, limit, attr.<key>=<value> (exact match), and attrContains.<key>=<substring> (case-insensitive substring search inside attribute values) in the query string."),
@@ -255,6 +271,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
255
271
  cursor: CursorParam,
256
272
  },
257
273
  success: LogList,
274
+ error: QueryFailures,
258
275
  })
259
276
  .annotate(OpenApi.Summary, "Search logs")
260
277
  .annotate(OpenApi.Description, "Search log records by service, severity, trace/span correlation, or body text (case-insensitive). Supports attr.<key>=<value> (exact match) and attrContains.<key>=<substring> (case-insensitive substring) in the query string. Cursor pagination and bounded lookback/limit defaults. Ordered by timestamp descending."),
@@ -273,6 +290,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
273
290
  cursor: CursorParam,
274
291
  },
275
292
  success: LogList,
293
+ error: QueryFailures,
276
294
  })
277
295
  .annotate(OpenApi.Summary, "Alias for log search")
278
296
  .annotate(OpenApi.Description, "Same behavior as GET /api/logs. Exists as an explicit search endpoint for agents and scripts that distinguish list vs search routes."),
@@ -289,7 +307,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
289
307
  limit: LimitParam,
290
308
  },
291
309
  success: StatList,
292
- error: ErrorResponse,
310
+ error: [ErrorResponse, ...QueryFailures],
293
311
  })
294
312
  .annotate(OpenApi.Summary, "Aggregate log statistics")
295
313
  .annotate(OpenApi.Description, "Returns grouped log counts by fields like severity, service, scope, or attr.<key>. Useful for quickly understanding log distribution before drilling into raw entries."),
@@ -324,7 +342,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
324
342
  limit: LimitParam,
325
343
  },
326
344
  success: FacetList,
327
- error: ErrorResponse,
345
+ error: [ErrorResponse, ...QueryFailures],
328
346
  })
329
347
  .annotate(OpenApi.Summary, "Get facet value counts")
330
348
  .annotate(OpenApi.Description, "Returns distinct values and their counts for a given field, useful for discovering what data exists before querying. Examples: ?type=logs&field=severity returns log level distribution; ?type=traces&field=attribute_keys&service=opencode lists top span attribute keys; ?type=traces&field=attribute_values&key=ai.model.id lists values seen for that key."),
@@ -346,6 +364,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
346
364
  limit: LimitParam,
347
365
  },
348
366
  success: AiCallList,
367
+ error: QueryFailures,
349
368
  })
350
369
  .annotate(OpenApi.Summary, "Search AI calls")
351
370
  .annotate(OpenApi.Description, "Search AI SDK calls (streamText, generateText, etc.) with normalized fields. Returns compact summaries with previews — use /api/ai/calls/{spanId} for full prompt/response payloads. Supports filtering by service, session, model, provider, function, operation, status, duration, and free-text search across prompt/response content."),
@@ -355,7 +374,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
355
374
  spanId: Schema.String.pipe(Schema.annotateKey({ description: "The span ID of the AI call" })),
356
375
  },
357
376
  success: AiCallDetailResponse,
358
- error: ErrorResponse,
377
+ error: [ErrorResponse, ...QueryFailures],
359
378
  })
360
379
  .annotate(OpenApi.Summary, "Get AI call detail")
361
380
  .annotate(OpenApi.Description, "Returns the full detail of a single AI call including complete prompt messages, response text, tool calls with args, token usage, timing, provider metadata, and correlated logs."),
@@ -381,7 +400,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
381
400
  limit: LimitParam,
382
401
  },
383
402
  success: StatList,
384
- error: ErrorResponse,
403
+ error: [ErrorResponse, ...QueryFailures],
385
404
  })
386
405
  .annotate(OpenApi.Summary, "Aggregate AI call statistics")
387
406
  .annotate(OpenApi.Description, "Returns grouped statistics for AI calls. Supports grouping by provider, model, functionId, sessionId, or status with aggregations: count, avg_duration, p95_duration, total_input_tokens, total_output_tokens."),
@@ -0,0 +1,160 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { expect, test } from "bun:test"
3
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { Effect } from "effect"
7
+ import { createDaemonManager } from "./daemon.ts"
8
+ import { IngestProgress, type IngestReadiness } from "./ingestReadiness.ts"
9
+
10
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
11
+
12
+ test("idle readiness, overload age, empty commits, and terminal failure are distinct", () => {
13
+ const progress = new IngestProgress()
14
+ expect(progress.snapshot().state).toBe("starting")
15
+ progress.receive({ _tag: "ready" })
16
+ expect(progress.snapshot(Date.now() + 86_400_000).state).toBe("ready")
17
+ const finish = progress.begin(42, 100)
18
+ expect(progress.snapshot(5100)).toMatchObject({ state: "overloaded", outstandingBytes: 42, outstandingRequests: 1 })
19
+ progress.receive({ _tag: "commit", records: 0, at: 500 })
20
+ expect(progress.snapshot().lastCommitAt).toBeNull()
21
+ finish()
22
+ finish()
23
+ expect(progress.snapshot()).toMatchObject({ state: "ready", outstandingBytes: 0, outstandingRequests: 0 })
24
+ progress.fail()
25
+ progress.receive({ _tag: "ready" })
26
+ expect(progress.snapshot().state).toBe("failed")
27
+ })
28
+
29
+ test("cached readiness bypasses a blocked writer and acknowledgement follows commit", async () => {
30
+ const runtimeDir = mkdtempSync(join(tmpdir(), "motel-readiness-"))
31
+ const databasePath = join(runtimeDir, "telemetry.sqlite")
32
+ const port = 31000 + Math.floor(Math.random() * 2000)
33
+ const manager = createDaemonManager({ runtimeDir, databasePath, port })
34
+ let lock: Database | undefined
35
+ try {
36
+ await Effect.runPromise(manager.ensure)
37
+ const base = `http://127.0.0.1:${port}`
38
+ const readiness = async () => {
39
+ const response = await fetch(`${base}/api/readiness`, { signal: AbortSignal.timeout(1500) })
40
+ return await response.json() as IngestReadiness
41
+ }
42
+ expect((await readiness()).state).toBe("ready")
43
+ lock = new Database(databasePath)
44
+ lock.exec("BEGIN IMMEDIATE")
45
+ const body = JSON.stringify({ resourceLogs: [{ resource: { attributes: [{ key: "service.name", value: { stringValue: "readiness-test" } }] }, scopeLogs: [{ logRecords: [{ timeUnixNano: String(BigInt(Date.now()) * 1_000_000n), body: { stringValue: "commit barrier" } }] }] }] })
46
+ let acknowledged = false
47
+ const ingestion = fetch(`${base}/v1/logs`, { method: "POST", headers: { "content-type": "application/json" }, body }).then(async (response) => {
48
+ expect(response.status).toBe(200)
49
+ acknowledged = true
50
+ return response.json()
51
+ })
52
+ await sleep(150)
53
+ const snapshot = await readiness()
54
+ expect(snapshot.outstandingRequests).toBe(1)
55
+ expect(snapshot.outstandingBytes).toBe(Buffer.byteLength(body))
56
+ expect(acknowledged).toBe(false)
57
+ expect(lock.query("SELECT count(*) AS n FROM logs WHERE body = 'commit barrier'").get()).toEqual({ n: 0 })
58
+ lock.exec("COMMIT")
59
+ expect(await ingestion).toEqual({ insertedLogs: 1 })
60
+ for (let i = 0; i < 50 && (await readiness()).committedRecords === 0; i++) await sleep(10)
61
+ expect(await readiness()).toMatchObject({ state: "ready", outstandingRequests: 0, outstandingBytes: 0, committedRecords: 1 })
62
+ expect(lock.query("SELECT count(*) AS n FROM logs WHERE body = 'commit barrier'").get()).toEqual({ n: 1 })
63
+ expect((await Effect.runPromise(manager.getStatus)).readiness?.state).toBe("ready")
64
+ } finally {
65
+ try { lock?.exec("ROLLBACK") } catch { /* The successful path committed. */ }
66
+ lock?.close()
67
+ await Effect.runPromise(manager.stop)
68
+ rmSync(runtimeDir, { recursive: true, force: true })
69
+ }
70
+ }, 30000)
71
+
72
+ test("a writer bootstrap failure is reported without mistaking liveness for readiness", async () => {
73
+ const root = mkdtempSync(join(tmpdir(), "motel-writer-failure-"))
74
+ const port = 35000 + Math.floor(Math.random() * 1000)
75
+ const child = Bun.spawn([process.execPath, "src/server.ts"], {
76
+ cwd: join(import.meta.dir, ".."),
77
+ env: { ...process.env, MOTEL_OTEL_DB_PATH: root, MOTEL_RUNTIME_DIR: root, MOTEL_OTEL_HOST: "127.0.0.1", MOTEL_OTEL_PORT: String(port), MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_ENABLED: "false" },
78
+ stdout: "ignore", stderr: "ignore",
79
+ })
80
+ try {
81
+ let state: string | undefined
82
+ const until = Date.now() + 10000
83
+ while (Date.now() < until) {
84
+ try {
85
+ const response = await fetch(`http://127.0.0.1:${port}/api/readiness`, { signal: AbortSignal.timeout(500) })
86
+ state = (await response.json() as IngestReadiness).state
87
+ if (state === "failed") {
88
+ expect(response.status).toBe(503)
89
+ break
90
+ }
91
+ } catch { /* Wait for the HTTP listener independently of writer bootstrap. */ }
92
+ await sleep(25)
93
+ }
94
+ expect(state).toBe("failed")
95
+ expect((await fetch(`http://127.0.0.1:${port}/api/health`)).status).toBe(200)
96
+ } finally {
97
+ child.kill("SIGTERM")
98
+ await Promise.race([child.exited, sleep(1000)])
99
+ if (child.exitCode === null) child.kill("SIGKILL")
100
+ await child.exited
101
+ rmSync(root, { recursive: true, force: true })
102
+ }
103
+ }, 15000)
104
+
105
+
106
+ test("completed maintenance cannot hide another operation that is still running", () => {
107
+ const progress = new IngestProgress()
108
+ progress.receive({ _tag: "maintenance", value: { operation: "retention", startedAt: 10, durationMs: null, outcome: "running" } })
109
+ progress.receive({ _tag: "maintenance", value: { operation: "reclaim", startedAt: 20, durationMs: null, outcome: "running" } })
110
+ progress.receive({ _tag: "maintenance", value: { operation: "reclaim", startedAt: 20, durationMs: 5, outcome: "completed" } })
111
+ expect(progress.snapshot().maintenance).toMatchObject({ operation: "retention", outcome: "running" })
112
+ progress.receive({ _tag: "maintenance", value: { operation: "retention", startedAt: 10, durationMs: 30, outcome: "completed" } })
113
+ expect(progress.snapshot().maintenance).toMatchObject({ operation: "retention", outcome: "completed", durationMs: 30 })
114
+ })
115
+
116
+ test("simultaneous cold starts from different workdirs converge on one managed daemon", async () => {
117
+ const root = mkdtempSync(join(tmpdir(), "motel-concurrent-start-"))
118
+ const workdirs = [join(root, "a"), join(root, "b")]
119
+ for (const workdir of workdirs) mkdirSync(workdir)
120
+ const port = 36000 + Math.floor(Math.random() * 1000)
121
+ const managers = workdirs.map((workdir) => createDaemonManager({ runtimeDir: root, databasePath: join(root, "db.sqlite"), port, workdir, startTimeoutMs: 5000, gracefulStopTimeoutMs: 500, forceStopTimeoutMs: 500 }))
122
+ try {
123
+ const states = await Promise.all(managers.map((manager) => Effect.runPromise(manager.ensure)))
124
+ expect(states.every((state) => state.running && state.managed)).toBe(true)
125
+ expect(states[0]!.pid).toBe(states[1]!.pid)
126
+ } finally {
127
+ await Effect.runPromise(managers[0]!.stop)
128
+ rmSync(root, { recursive: true, force: true })
129
+ }
130
+ }, 30000)
131
+
132
+ test("ensure preserves a live daemon when its ingest readiness budget expires", async () => {
133
+ const root = mkdtempSync(join(tmpdir(), "motel-busy-ensure-"))
134
+ const databasePath = join(root, "db.sqlite")
135
+ const port = 37000 + Math.floor(Math.random() * 1000)
136
+ const manager = createDaemonManager({ runtimeDir: root, databasePath, port })
137
+ const impatient = createDaemonManager({ runtimeDir: root, databasePath, port, startTimeoutMs: 150 })
138
+ let lock: Database | undefined
139
+ try {
140
+ const started = await Effect.runPromise(manager.ensure)
141
+ lock = new Database(databasePath)
142
+ lock.exec("BEGIN IMMEDIATE")
143
+ const write = fetch(`http://127.0.0.1:${port}/v1/logs`, {
144
+ method: "POST", headers: { "content-type": "application/json" },
145
+ body: JSON.stringify({ resourceLogs: [{ scopeLogs: [{ logRecords: [{ body: { stringValue: "blocked writer" } }] }] }] }),
146
+ })
147
+ await sleep(30)
148
+ await expect(Effect.runPromise(impatient.ensure)).rejects.toThrow("process was preserved")
149
+ const health = await (await fetch(`http://127.0.0.1:${port}/api/health`)).json() as { pid: number }
150
+ expect(started.pid).toBe(health.pid)
151
+ lock.exec("ROLLBACK")
152
+ expect((await write).status).toBe(200)
153
+ expect((await Effect.runPromise(manager.ensure)).pid).toBe(started.pid)
154
+ } finally {
155
+ try { lock?.exec("ROLLBACK") } catch {}
156
+ lock?.close()
157
+ await Effect.runPromise(manager.stop)
158
+ rmSync(root, { recursive: true, force: true })
159
+ }
160
+ }, 15000)