@kitlangton/motel 0.2.1 → 0.2.5

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/src/daemon.ts CHANGED
@@ -2,16 +2,15 @@ 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 { isAlive, listAliveEntries, MOTEL_SERVICE_ID, type RegistryEntry } 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
13
+ const START_POLL_INTERVAL_MS = 25
15
14
  const POLL_INTERVAL_MS = 150
16
15
  /** Fast probe used inside the waitForHealthy poll loop — we call it
17
16
  * every POLL_INTERVAL_MS, so a generous budget would stall the loop. */
@@ -44,6 +43,8 @@ type LockShape = {
44
43
 
45
44
  type DaemonConfig = {
46
45
  readonly repoRoot: string
46
+ readonly serverEntry: string
47
+ readonly workdir: string
47
48
  readonly runtimeDir: string
48
49
  readonly databasePath: string
49
50
  readonly logPath: string
@@ -79,6 +80,7 @@ export type DaemonManager = {
79
80
 
80
81
  type DaemonOptions = {
81
82
  readonly repoRoot?: string
83
+ readonly workdir?: string
82
84
  readonly runtimeDir?: string
83
85
  readonly databasePath?: string
84
86
  readonly host?: string
@@ -96,12 +98,15 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
96
98
 
97
99
  const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
98
100
  const repoRoot = path.resolve(options.repoRoot ?? DEFAULT_REPO_ROOT)
99
- const runtimeDir = path.resolve(options.runtimeDir ?? DEFAULT_RUNTIME_DIR)
101
+ const workdir = path.resolve(options.workdir ?? process.cwd())
102
+ const runtimeDir = path.resolve(options.runtimeDir ?? path.join(workdir, ".motel-data"))
100
103
  const databasePath = path.resolve(options.databasePath ?? path.join(runtimeDir, "telemetry.sqlite"))
101
104
  const host = options.host ?? DEFAULT_HOST
102
105
  const port = options.port ?? DEFAULT_PORT
103
106
  return {
104
107
  repoRoot,
108
+ serverEntry: path.join(repoRoot, "src/server.ts"),
109
+ workdir,
105
110
  runtimeDir,
106
111
  databasePath,
107
112
  logPath: path.join(runtimeDir, "daemon.log"),
@@ -112,16 +117,14 @@ const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
112
117
  }
113
118
  }
114
119
 
115
- const cwdMatches = (workdir: string) => {
116
- const cwd = process.cwd()
117
- const normalizedCwd = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`
118
- const normalizedWorkdir = workdir.endsWith(path.sep) ? workdir : `${workdir}${path.sep}`
119
- return normalizedCwd === normalizedWorkdir || normalizedCwd.startsWith(normalizedWorkdir)
120
+ const workdirMatches = (targetWorkdir: string, daemonWorkdir: string) => {
121
+ const normalizedTarget = targetWorkdir.endsWith(path.sep) ? targetWorkdir : `${targetWorkdir}${path.sep}`
122
+ const normalizedDaemon = daemonWorkdir.endsWith(path.sep) ? daemonWorkdir : `${daemonWorkdir}${path.sep}`
123
+ return normalizedTarget === normalizedDaemon || normalizedTarget.startsWith(normalizedDaemon)
120
124
  }
121
125
 
122
- const pickByCwd = (entries: readonly RegistryEntry[]) => {
123
- const cwd = process.cwd()
124
- const withSep = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`
126
+ const pickByWorkdir = (entries: readonly RegistryEntry[], targetWorkdir: string) => {
127
+ const withSep = targetWorkdir.endsWith(path.sep) ? targetWorkdir : `${targetWorkdir}${path.sep}`
125
128
  return entries
126
129
  .filter((entry) => {
127
130
  const workdir = entry.workdir.endsWith(path.sep) ? entry.workdir : `${entry.workdir}${path.sep}`
@@ -130,8 +133,6 @@ const pickByCwd = (entries: readonly RegistryEntry[]) => {
130
133
  .sort((a, b) => b.workdir.length - a.workdir.length)[0] ?? null
131
134
  }
132
135
 
133
- const readRegistryEntry = () => pickByCwd(listAliveEntries())
134
-
135
136
  const expectedEnv = (config: DaemonConfig) => ({
136
137
  MOTEL_OTEL_BASE_URL: config.baseUrl,
137
138
  MOTEL_OTEL_QUERY_URL: config.baseUrl,
@@ -145,6 +146,7 @@ const expectedEnv = (config: DaemonConfig) => ({
145
146
  export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager => {
146
147
  const config = resolveConfig(options)
147
148
  const mapError = (error: unknown) => new DaemonError(error instanceof Error ? error.message : String(error))
149
+ const readRegistryEntry = () => pickByWorkdir(listAliveEntries(), config.workdir)
148
150
 
149
151
  const fetchHealth = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<HealthShape | null> => {
150
152
  try {
@@ -156,12 +158,39 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
156
158
  }
157
159
  }
158
160
 
161
+ const startupMarkers = [`Listening on ${config.baseUrl}`, `motel local telemetry server listening on ${config.baseUrl}`]
162
+
163
+ const readLogSince = async (offset: number) => {
164
+ try {
165
+ const raw = await fsp.readFile(config.logPath, "utf8")
166
+ return raw.slice(offset)
167
+ } catch {
168
+ return ""
169
+ }
170
+ }
171
+
172
+ const detectStartedFromLog = async (pid: number, offset: number): Promise<HealthShape | null> => {
173
+ if (!isAlive(pid)) return null
174
+ const tail = await readLogSince(offset)
175
+ if (!startupMarkers.some((marker) => tail.includes(marker))) return null
176
+ return {
177
+ ok: true,
178
+ service: MOTEL_SERVICE_ID,
179
+ databasePath: config.databasePath,
180
+ pid,
181
+ url: config.baseUrl,
182
+ workdir: config.workdir,
183
+ startedAt: new Date().toISOString(),
184
+ version: MOTEL_VERSION,
185
+ }
186
+ }
187
+
159
188
  const describeManagedMismatch = (health: HealthShape) => {
160
189
  if (health.service !== MOTEL_SERVICE_ID) {
161
190
  return `Port ${config.port} is in use by ${health.service}, not ${MOTEL_SERVICE_ID}.`
162
191
  }
163
- if (!cwdMatches(health.workdir)) {
164
- return `Port ${config.port} is serving motel for ${health.workdir}, not ${process.cwd()}.`
192
+ if (!workdirMatches(config.workdir, health.workdir)) {
193
+ return `Port ${config.port} is serving motel for ${health.workdir}, not ${config.workdir}.`
165
194
  }
166
195
  if (health.databasePath !== config.databasePath) {
167
196
  return `Port ${config.port} is serving motel with ${health.databasePath}, expected ${config.databasePath}.`
@@ -181,8 +210,8 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
181
210
  * when absent we skip the DB check rather than refusing to adopt.
182
211
  */
183
212
  const describeRegistryMismatch = (entry: RegistryEntry): string | null => {
184
- if (!cwdMatches(entry.workdir)) {
185
- return `Port ${config.port} is serving motel for ${entry.workdir}, not ${process.cwd()}.`
213
+ if (!workdirMatches(config.workdir, entry.workdir)) {
214
+ return `Port ${config.port} is serving motel for ${entry.workdir}, not ${config.workdir}.`
186
215
  }
187
216
  if (entry.databasePath && entry.databasePath !== config.databasePath) {
188
217
  return `Port ${config.port} is serving motel with ${entry.databasePath}, expected ${config.databasePath}.`
@@ -217,7 +246,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
217
246
  workdir: entry.workdir,
218
247
  startedAt: entry.startedAt,
219
248
  version: entry.version,
220
- sameWorkdir: cwdMatches(entry.workdir),
249
+ sameWorkdir: workdirMatches(config.workdir, entry.workdir),
221
250
  reason: mismatch,
222
251
  logPath: config.logPath,
223
252
  lockPath: config.lockPath,
@@ -276,7 +305,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
276
305
  return fs.openSync(config.logPath, "a")
277
306
  }
278
307
 
279
- const waitForHealthy = async (pid: number) => {
308
+ const waitForHealthy = async (pid: number, logOffset: number) => {
280
309
  const deadline = Date.now() + START_TIMEOUT_MS
281
310
  while (Date.now() < deadline) {
282
311
  const health = await fetchHealth()
@@ -285,6 +314,8 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
285
314
  if (!mismatch) return health
286
315
  throw new Error(mismatch)
287
316
  }
317
+ const started = await detectStartedFromLog(pid, logOffset)
318
+ if (started) return started
288
319
  if (!isAlive(pid)) {
289
320
  // The spawned child is gone. Before declaring failure,
290
321
  // do one patient probe: the child may have died from
@@ -299,7 +330,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
299
330
  }
300
331
  throw new Error(`Daemon process ${pid} exited before becoming healthy. See ${config.logPath}.`)
301
332
  }
302
- await sleep(POLL_INTERVAL_MS)
333
+ await sleep(START_POLL_INTERVAL_MS)
303
334
  }
304
335
  throw new Error(`Timed out waiting for daemon health at ${config.baseUrl}/api/health. See ${config.logPath}.`)
305
336
  }
@@ -316,7 +347,9 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
316
347
  while (Date.now() < deadline) {
317
348
  if (!isAlive(pid)) return
318
349
  const health = await fetchHealth()
319
- if (!health || health.pid !== pid) return
350
+ if (health && health.pid !== pid) return
351
+ const registry = readRegistryEntry()
352
+ if (!health && (!registry || registry.pid !== pid)) return
320
353
  await sleep(POLL_INTERVAL_MS)
321
354
  }
322
355
 
@@ -352,7 +385,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
352
385
  workdir: registry?.workdir ?? null,
353
386
  startedAt: registry?.startedAt ?? null,
354
387
  version: registry?.version ?? null,
355
- sameWorkdir: registry ? cwdMatches(registry.workdir) : false,
388
+ sameWorkdir: registry ? workdirMatches(config.workdir, registry.workdir) : false,
356
389
  reason: registry ? "Registry entry exists but daemon is not healthy." : null,
357
390
  logPath: config.logPath,
358
391
  lockPath: config.lockPath,
@@ -371,7 +404,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
371
404
  workdir: health.workdir,
372
405
  startedAt: health.startedAt,
373
406
  version: health.version,
374
- sameWorkdir: cwdMatches(health.workdir),
407
+ sameWorkdir: workdirMatches(config.workdir, health.workdir),
375
408
  reason: mismatch,
376
409
  logPath: config.logPath,
377
410
  lockPath: config.lockPath,
@@ -404,10 +437,11 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
404
437
  }
405
438
 
406
439
  const logFd = await openLogFile()
440
+ const logOffset = fs.fstatSync(logFd).size
407
441
  try {
408
442
  const proc = Bun.spawn({
409
- cmd: [process.execPath, "run", "src/server.ts"],
410
- cwd: config.repoRoot,
443
+ cmd: [process.execPath, "run", config.serverEntry],
444
+ cwd: config.workdir,
411
445
  detached: true,
412
446
  env: {
413
447
  ...process.env,
@@ -425,7 +459,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
425
459
  throw new Error("Daemon failed to spawn.")
426
460
  }
427
461
 
428
- const health = await waitForHealthy(spawnedPid)
462
+ const health = await waitForHealthy(spawnedPid, logOffset)
429
463
  return {
430
464
  running: true,
431
465
  managed: true,
@@ -436,7 +470,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
436
470
  workdir: health.workdir,
437
471
  startedAt: health.startedAt,
438
472
  version: health.version,
439
- sameWorkdir: cwdMatches(health.workdir),
473
+ sameWorkdir: workdirMatches(config.workdir, health.workdir),
440
474
  reason: null,
441
475
  logPath: config.logPath,
442
476
  lockPath: config.lockPath,
@@ -495,9 +529,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
495
529
  }
496
530
  }
497
531
 
498
- const defaultManager = createDaemonManager()
499
-
500
- export const applyManagedDaemonEnv = defaultManager.applyEnv
501
- export const getManagedDaemonStatus = defaultManager.getStatus
502
- export const ensureManagedDaemon = defaultManager.ensure
503
- export const stopManagedDaemon = defaultManager.stop
532
+ export const applyManagedDaemonEnv = Effect.suspend(() => createDaemonManager().applyEnv)
533
+ export const getManagedDaemonStatus = Effect.suspend(() => createDaemonManager().getStatus)
534
+ export const ensureManagedDaemon = Effect.suspend(() => createDaemonManager().ensure)
535
+ export const stopManagedDaemon = Effect.suspend(() => createDaemonManager().stop)
package/src/index.tsx CHANGED
@@ -1,7 +1,10 @@
1
1
  import { RegistryProvider } from "@effect/atom-react"
2
2
  import { createCliRenderer } from "@opentui/core"
3
3
  import { createRoot } from "@opentui/react"
4
- import { App } from "./App.js"
4
+ import { startupBenchMark } from "./startupBench.js"
5
+ import { StartupGate } from "./StartupGate.js"
6
+
7
+ startupBenchMark("index_module_loaded")
5
8
 
6
9
  const renderer = await createCliRenderer({
7
10
  exitOnCtrlC: false,
@@ -11,8 +14,12 @@ const renderer = await createCliRenderer({
11
14
  },
12
15
  })
13
16
 
17
+ startupBenchMark("renderer_ready")
18
+
14
19
  createRoot(renderer).render(
15
20
  <RegistryProvider>
16
- <App />
21
+ <StartupGate />
17
22
  </RegistryProvider>,
18
23
  )
24
+
25
+ startupBenchMark("root_render_called")
@@ -9,10 +9,12 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
9
9
  import * as HttpStaticServer from "effect/unstable/http/HttpStaticServer"
10
10
  import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"
11
11
  import { MotelHttpApi } from "./httpApi.js"
12
- import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries, ATTRIBUTE_FILTER_PREFIX, ATTRIBUTE_CONTAINS_PREFIX } from "./queryFilters.js"
12
+ import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries } from "./queryFilters.js"
13
13
  import { MOTEL_SERVICE_ID, MOTEL_VERSION, removeRegistryEntry, writeRegistryEntry } from "./registry.js"
14
14
  import { AsyncIngest, AsyncIngestLive } from "./services/AsyncIngest.js"
15
- import { TelemetryStore, TelemetryStoreLive } from "./services/TelemetryStore.js"
15
+ import { LogQueryService, LogQueryServiceLive } from "./services/LogQueryService.js"
16
+ import { TelemetryStore, TelemetryStoreLive, TelemetryStoreReadonlyLive } from "./services/TelemetryStore.js"
17
+ import { TraceQueryService, TraceQueryServiceLive } from "./services/TraceQueryService.js"
16
18
  import type { LogItem, TraceItem, TraceSummaryItem } from "./domain.js"
17
19
  import { lifecycleLabel } from "./ui/format.js"
18
20
 
@@ -39,6 +41,8 @@ const htmlResponse = (value: string) => HttpServerResponse.html(value)
39
41
  const notFoundResponse = (message = "Not found") => jsonResponse({ error: message }, 404)
40
42
  const requestUrl = (request: { readonly url: string }) => new URL(request.url, config.otel.baseUrl)
41
43
  const withStore = <A>(f: (store: TelemetryStore["Service"]) => Effect.Effect<A, Error>) => Effect.flatMap(TelemetryStore.asEffect(), f)
44
+ const withTraceQuery = <A>(f: (query: TraceQueryService["Service"]) => Effect.Effect<A, Error>) => Effect.flatMap(TraceQueryService.asEffect(), f)
45
+ const withLogQuery = <A>(f: (query: LogQueryService["Service"]) => Effect.Effect<A, Error>) => Effect.flatMap(LogQueryService.asEffect(), f)
42
46
  // Response-building helpers are generic in R so a handler can depend
43
47
  // on TelemetryStore (query path) or AsyncIngest (worker-RPC path)
44
48
  // without forcing every handler onto the same service surface.
@@ -72,14 +76,10 @@ const parseLookbackMinutes = (value: string | null, fallback: number) => {
72
76
  const parseBoundedLookbackMinutes = (value: string | null, fallback: number, max: number) => clamp(parseLookbackMinutes(value, fallback), 1, max)
73
77
 
74
78
  const attributeFiltersFromQuery = (url: URL) =>
75
- attributeFiltersFromEntries(
76
- [...url.searchParams.entries()].filter(([key]) => key.startsWith(ATTRIBUTE_FILTER_PREFIX) && !key.startsWith(ATTRIBUTE_CONTAINS_PREFIX)),
77
- )
79
+ attributeFiltersFromEntries(url.searchParams.entries())
78
80
 
79
81
  const attributeContainsFiltersFromQuery = (url: URL) =>
80
- attributeContainsFiltersFromEntries(
81
- [...url.searchParams.entries()].filter(([key]) => key.startsWith(ATTRIBUTE_CONTAINS_PREFIX)),
82
- )
82
+ attributeContainsFiltersFromEntries(url.searchParams.entries())
83
83
 
84
84
  type CursorShape =
85
85
  | { readonly kind: "trace"; readonly startedAt: number; readonly id: string }
@@ -153,7 +153,7 @@ const loadLogsPage = (input: {
153
153
  readonly lookbackMinutes: number
154
154
  readonly cursor: CursorShape | null
155
155
  }) =>
156
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
156
+ Effect.flatMap(LogQueryService.asEffect(), (store) =>
157
157
  Effect.map(
158
158
  store.searchLogs({
159
159
  serviceName: input.serviceName,
@@ -316,7 +316,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
316
316
  ),
317
317
  ),
318
318
  )
319
- .handleRaw("services", () => respondJson(Effect.map(withStore((store) => store.listServices), (data) => ({ data }))))
319
+ .handleRaw("services", () => respondJson(Effect.map(withTraceQuery((store) => store.listServices), (data) => ({ data }))))
320
320
  .handleRaw("traces", ({ request }) =>
321
321
  respondRaw(Effect.gen(function*() {
322
322
  const url = requestUrl(request)
@@ -324,7 +324,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
324
324
  const limit = parseBoundedLimit(url.searchParams.get("limit"), TRACE_DEFAULT_LIMIT, TRACE_MAX_LIMIT)
325
325
  const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
326
326
  const cursor = decodeCursor(url.searchParams.get("cursor"))
327
- const data = yield* withStore((store) => store.listTraceSummaries(service, {
327
+ const data = yield* withTraceQuery((store) => store.listTraceSummaries(service, {
328
328
  limit: limit + 1,
329
329
  lookbackMinutes,
330
330
  cursorStartedAtMs: cursor?.kind === "trace" ? cursor.startedAt : undefined,
@@ -340,7 +340,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
340
340
  const limit = parseBoundedLimit(url.searchParams.get("limit"), TRACE_DEFAULT_LIMIT, TRACE_MAX_LIMIT)
341
341
  const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
342
342
  const cursor = decodeCursor(url.searchParams.get("cursor"))
343
- const data = yield* withStore((store) =>
343
+ const data = yield* withTraceQuery((store) =>
344
344
  store.searchTraceSummaries({
345
345
  serviceName: url.searchParams.get("service"),
346
346
  operation: url.searchParams.get("operation"),
@@ -367,7 +367,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
367
367
  if (!groupBy || (agg !== "count" && agg !== "avg_duration" && agg !== "p95_duration" && agg !== "error_rate")) {
368
368
  return jsonResponse({ error: "Expected groupBy and agg=count|avg_duration|p95_duration|error_rate" }, 400)
369
369
  }
370
- const data = yield* withStore((store) =>
370
+ const data = yield* withTraceQuery((store) =>
371
371
  store.traceStats({
372
372
  groupBy,
373
373
  agg,
@@ -390,7 +390,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
390
390
  const attributeContainsFilters = attributeContainsFiltersFromQuery(url)
391
391
  const limit = parseBoundedLimit(url.searchParams.get("limit"), SPAN_DEFAULT_LIMIT, SPAN_MAX_LIMIT)
392
392
  const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
393
- const data = yield* withStore((store) =>
393
+ const data = yield* withTraceQuery((store) =>
394
394
  store.searchSpans({
395
395
  serviceName: url.searchParams.get("service"),
396
396
  traceId: url.searchParams.get("traceId"),
@@ -427,7 +427,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
427
427
  })),
428
428
  )
429
429
  .handleRaw("traceSpans", ({ params }) =>
430
- respondJson(Effect.map(withStore((store) => store.listTraceSpans(params.traceId)), (data) => ({ data }))),
430
+ respondJson(Effect.map(withTraceQuery((store) => store.listTraceSpans(params.traceId)), (data) => ({ data }))),
431
431
  )
432
432
  .handleRaw("spanLogs", ({ params, request }) =>
433
433
  respondRaw(Effect.gen(function*() {
@@ -440,14 +440,14 @@ const TelemetryGroupLive = HttpApiBuilder.group(
440
440
  )
441
441
  .handleRaw("span", ({ params }) =>
442
442
  respondRaw(
443
- Effect.flatMap(withStore((store) => store.getSpan(params.spanId)), (data) =>
443
+ Effect.flatMap(withTraceQuery((store) => store.getSpan(params.spanId)), (data) =>
444
444
  Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Span not found")),
445
445
  ),
446
446
  ),
447
447
  )
448
448
  .handleRaw("trace", ({ params }) =>
449
449
  respondRaw(
450
- Effect.flatMap(withStore((store) => store.getTrace(params.traceId)), (data) =>
450
+ Effect.flatMap(withTraceQuery((store) => store.getTrace(params.traceId)), (data) =>
451
451
  Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Trace not found")),
452
452
  ),
453
453
  ),
@@ -464,7 +464,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
464
464
  if (!groupBy || agg !== "count") {
465
465
  return jsonResponse({ error: "Expected groupBy and agg=count" }, 400)
466
466
  }
467
- const data = yield* withStore((store) =>
467
+ const data = yield* withLogQuery((store) =>
468
468
  store.logStats({
469
469
  groupBy,
470
470
  agg: "count",
@@ -512,7 +512,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
512
512
  if ((type !== "traces" && type !== "logs") || !field) {
513
513
  return jsonResponse({ error: "Expected type=traces|logs and field=<name>" }, 400)
514
514
  }
515
- const data = yield* withStore((store) =>
515
+ const data = yield* withTraceQuery((store) =>
516
516
  store.listFacets({
517
517
  type,
518
518
  field,
@@ -590,9 +590,9 @@ const TelemetryGroupLive = HttpApiBuilder.group(
590
590
  )
591
591
  .handleRaw("tracePage", ({ params }) =>
592
592
  respondRaw(
593
- Effect.flatMap(withStore((store) => store.getTrace(params.traceId)), (trace) =>
593
+ Effect.flatMap(withTraceQuery((store) => store.getTrace(params.traceId)), (trace) =>
594
594
  trace
595
- ? Effect.map(withStore((store) => store.listTraceLogs(params.traceId)), (logs) => htmlResponse(renderTracePage(trace, logs)))
595
+ ? Effect.map(withLogQuery((store) => store.listTraceLogs(params.traceId)), (logs) => htmlResponse(renderTracePage(trace, logs)))
596
596
  : Effect.succeed(notFoundResponse("Trace not found")),
597
597
  ),
598
598
  ),
@@ -610,6 +610,10 @@ const ApiLayer = HttpApiBuilder.layer(MotelHttpApi, { openapiPath: "/openapi.jso
610
610
  Layer.provide(HttpApiScalar.layer(MotelHttpApi, { scalar: { forceDarkModeState: "dark", showOperationId: true } })),
611
611
  )
612
612
 
613
+ const QueryServicesLive = Layer.mergeAll(TraceQueryServiceLive, LogQueryServiceLive).pipe(
614
+ Layer.provideMerge(TelemetryStoreReadonlyLive),
615
+ )
616
+
613
617
  // Web UI: Vite-built SPA served from web/dist. HttpStaticServer.layer
614
618
  // handles GET /*, filesystem lookup under `root`, and SPA fallback to
615
619
  // index.html for unknown paths — replacing the hand-rolled serveWebUi
@@ -679,9 +683,11 @@ export const ServerLive = HttpRouter.serve(
679
683
  Layer.provide(HttpMiddleware.layerTracerDisabledForUrls(["/v1/traces", "/v1/logs"])),
680
684
  // AsyncIngest spawns the telemetry worker — keeps the main-thread
681
685
  // event loop free during heavy SQLite writes. Provided alongside
682
- // the direct TelemetryStore so query handlers can still resolve
683
- // their dependency directly.
686
+ // the writer TelemetryStore for ingest / maintenance. Query endpoints
687
+ // resolve through readonly TraceQueryService / LogQueryService so
688
+ // reads do not contend with the writer connection.
684
689
  Layer.provideMerge(AsyncIngestLive),
690
+ Layer.provideMerge(QueryServicesLive),
685
691
  Layer.provideMerge(TelemetryStoreLive),
686
692
  Layer.provideMerge(BunHttpServer.layer({
687
693
  port: config.otel.port,
package/src/motel.ts CHANGED
@@ -12,7 +12,6 @@ case undefined:
12
12
  case "tui":
13
13
  case "ui": {
14
14
  await run(applyManagedDaemonEnv)
15
- await run(ensureManagedDaemon)
16
15
  await import("./index.js")
17
16
  break
18
17
  }
@@ -42,7 +41,6 @@ case "restart": {
42
41
  // and want the TUI to reconnect to the new binary in one command.
43
42
  await run(stopManagedDaemon)
44
43
  await run(applyManagedDaemonEnv)
45
- await run(ensureManagedDaemon)
46
44
  await import("./index.js")
47
45
  break
48
46
  }
@@ -20,10 +20,11 @@
20
20
  */
21
21
 
22
22
  import * as BunWorker from "@effect/platform-bun/BunWorker"
23
- import { Context, Layer } from "effect"
23
+ import { Context, Effect, Layer, Scope } from "effect"
24
24
  import * as RpcClient from "effect/unstable/rpc/RpcClient"
25
25
  import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"
26
26
  import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
27
+ import type { WorkerError } from "effect/unstable/workers/WorkerError"
27
28
  import { IngestRpcs } from "./ingestRpc.ts"
28
29
 
29
30
  // RpcClient.make always surfaces RpcClientError in addition to the
@@ -33,7 +34,7 @@ import { IngestRpcs } from "./ingestRpc.ts"
33
34
  // unrelated structural mismatches.
34
35
  export class AsyncIngest extends Context.Service<
35
36
  AsyncIngest,
36
- RpcClient.FromGroup<typeof IngestRpcs, RpcClientError>
37
+ RpcClient.FromGroup<typeof IngestRpcs, RpcClientError | WorkerError>
37
38
  >()("@motel/AsyncIngest") {}
38
39
 
39
40
  // Protocol: RpcClient.layerProtocolWorker manages a worker pool and
@@ -48,5 +49,22 @@ const WorkerProtocol = RpcClient.layerProtocolWorker({ size: 1 }).pipe(
48
49
 
49
50
  export const AsyncIngestLive = Layer.effect(
50
51
  AsyncIngest,
51
- RpcClient.make(IngestRpcs),
52
- ).pipe(Layer.provide(WorkerProtocol))
52
+ Effect.gen(function*() {
53
+ const scope = yield* Scope.Scope
54
+ // Keep daemon startup cheap: creating the RPC client here would eagerly
55
+ // spawn the worker and make /api/health wait on the worker's SQLite
56
+ // bootstrap. Cache a lazy initializer instead so the worker only starts
57
+ // on the first ingest request, but is still shared thereafter.
58
+ const getClient = yield* Effect.gen(function*() {
59
+ const protocolContext = yield* Layer.buildWithScope(WorkerProtocol, scope)
60
+ return yield* RpcClient.make(IngestRpcs).pipe(
61
+ Effect.provide(protocolContext),
62
+ Effect.provideService(Scope.Scope, scope),
63
+ )
64
+ }).pipe(Effect.cached)
65
+ return {
66
+ ingestTraces: (input, options) => Effect.flatMap(getClient, (client) => client.ingestTraces(input, options)),
67
+ ingestLogs: (input, options) => Effect.flatMap(getClient, (client) => client.ingestLogs(input, options)),
68
+ }
69
+ }),
70
+ )
@@ -7,8 +7,8 @@ export class LogQueryService extends Context.Service<
7
7
  {
8
8
  readonly listRecentLogs: (serviceName: string) => Effect.Effect<readonly LogItem[], Error>
9
9
  readonly listTraceLogs: (traceId: string) => Effect.Effect<readonly LogItem[], Error>
10
- readonly searchLogs: (input: { readonly serviceName?: string; readonly traceId?: string; readonly spanId?: string; readonly body?: string; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly LogItem[], Error>
11
- readonly logStats: (input: { readonly groupBy: string; readonly agg: "count"; readonly serviceName?: string | null; readonly traceId?: string | null; readonly spanId?: string | null; readonly body?: string | null; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
10
+ readonly searchLogs: (input: { readonly serviceName?: string | null; readonly severity?: string | null; readonly traceId?: string | null; readonly spanId?: string | null; readonly body?: string | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorTimestampMs?: number; readonly cursorId?: string; readonly attributeFilters?: Readonly<Record<string, string>>; readonly attributeContainsFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly LogItem[], Error>
11
+ readonly logStats: (input: { readonly groupBy: string; readonly agg: "count"; readonly serviceName?: string | null; readonly traceId?: string | null; readonly spanId?: string | null; readonly body?: string | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly attributeContainsFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
12
12
  readonly listFacets: (input: { readonly type: "traces" | "logs"; readonly field: string; readonly serviceName?: string | null; readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly { readonly value: string; readonly count: number }[], Error>
13
13
  }
14
14
  >()("motel/LogQueryService") {}