@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/AGENTS.md +12 -0
- package/package.json +7 -3
- package/src/App.tsx +10 -1
- package/src/StartupGate.tsx +291 -0
- package/src/daemon.test.ts +70 -0
- package/src/daemon.ts +67 -35
- package/src/index.tsx +9 -2
- package/src/localServer.ts +29 -23
- package/src/motel.ts +0 -2
- package/src/services/AsyncIngest.ts +22 -4
- package/src/services/LogQueryService.ts +2 -2
- package/src/services/TelemetryStore.ts +311 -162
- package/src/services/TraceQueryService.ts +6 -6
- package/src/startupBench.ts +19 -0
- package/src/storybook/aiChatStory.tsx +1 -0
- package/src/ui/AiChatView.tsx +25 -9
- package/src/ui/TraceDetailsPane.tsx +9 -27
- package/src/ui/Waterfall.tsx +5 -10
- package/src/ui/aiChatModel.test.ts +44 -0
- package/src/ui/aiChatModel.ts +38 -1
- package/src/ui/app/TraceWorkspace.tsx +4 -11
- package/src/ui/app/useTraceScreenData.ts +15 -7
- package/src/ui/atoms.ts +1 -1
- package/src/ui/persistence.ts +3 -3
- package/src/ui/theme.ts +7 -5
- package/src/ui/useKeyboardNav.ts +28 -2
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 =
|
|
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
|
|
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
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
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
|
|
123
|
-
const
|
|
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 (!
|
|
164
|
-
return `Port ${config.port} is serving motel for ${health.workdir}, not ${
|
|
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 (!
|
|
185
|
-
return `Port ${config.port} is serving motel for ${entry.workdir}, not ${
|
|
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:
|
|
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(
|
|
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 (
|
|
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 ?
|
|
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:
|
|
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",
|
|
410
|
-
cwd: config.
|
|
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:
|
|
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
|
|
499
|
-
|
|
500
|
-
export const
|
|
501
|
-
export const
|
|
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 {
|
|
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
|
-
<
|
|
21
|
+
<StartupGate />
|
|
17
22
|
</RegistryProvider>,
|
|
18
23
|
)
|
|
24
|
+
|
|
25
|
+
startupBenchMark("root_render_called")
|
package/src/localServer.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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(
|
|
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(
|
|
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*
|
|
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*
|
|
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*
|
|
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*
|
|
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(
|
|
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(
|
|
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(
|
|
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*
|
|
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*
|
|
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(
|
|
593
|
+
Effect.flatMap(withTraceQuery((store) => store.getTrace(params.traceId)), (trace) =>
|
|
594
594
|
trace
|
|
595
|
-
? Effect.map(
|
|
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
|
|
683
|
-
//
|
|
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
|
-
|
|
52
|
-
|
|
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") {}
|