@kitlangton/motel 0.2.5 → 0.2.6
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 +11 -8
- package/README.md +13 -2
- package/package.json +31 -19
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +3 -5
- package/src/StartupGate.tsx +8 -10
- package/src/cli.ts +15 -16
- package/src/config.ts +7 -1
- package/src/daemon.test.ts +332 -51
- package/src/daemon.ts +103 -152
- package/src/httpApi.ts +1 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/localServer.ts +194 -323
- package/src/mcp.ts +2 -1
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +20 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/registry.ts +37 -11
- package/src/runtime.ts +2 -6
- package/src/services/AsyncIngest.ts +20 -8
- package/src/services/LogQueryService.ts +11 -25
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +433 -249
- package/src/services/TraceQueryService.ts +18 -52
- package/src/services/ingestRpc.ts +2 -4
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +5 -8
- package/src/storybook/aiChatStory.tsx +1 -1
- package/src/telemetry.test.ts +307 -41
- package/src/ui/AiChatView.tsx +1 -1
- package/src/ui/AttrFilterModal.tsx +1 -1
- package/src/ui/ServiceLogs.tsx +10 -7
- package/src/ui/SpanContentView.tsx +24 -21
- package/src/ui/TraceDetailsPane.tsx +1 -1
- package/src/ui/TraceList.tsx +1 -1
- package/src/ui/aiState.ts +10 -22
- package/src/ui/app/TraceWorkspace.tsx +2 -1
- package/src/ui/app/useAppLayout.ts +1 -1
- package/src/ui/app/useTraceScreenData.ts +22 -18
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/loaders.ts +34 -53
- package/src/ui/primitives.tsx +1 -1
- package/src/ui/state.ts +2 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
- package/src/ui/traceSortNav.repro.seed.ts +1 -1
- package/src/ui/traceSortNav.repro.test.ts +12 -2
- package/src/ui/useAttrFilterPicker.ts +10 -8
- package/src/ui/useKeyboardNav.ts +3 -6
- package/src/ui/waterfallNav.repro.seed.ts +1 -1
- package/src/ui/waterfallNav.repro.test.ts +16 -8
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-M86tcih5.js +22 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DnyVo03x.js +0 -27
- package/web/dist/assets/index-DzuHNBGV.css +0 -2
package/src/daemon.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite"
|
|
2
2
|
import { afterEach, describe, expect, test } from "bun:test"
|
|
3
3
|
import { Effect } from "effect"
|
|
4
|
+
import rootModule from "@opentelemetry/otlp-transformer/build/esm/generated/root.js"
|
|
4
5
|
import * as fs from "node:fs"
|
|
5
6
|
import * as os from "node:os"
|
|
6
7
|
import * as path from "node:path"
|
|
@@ -11,6 +12,16 @@ const repoRoot = path.resolve(import.meta.dir, "..")
|
|
|
11
12
|
|
|
12
13
|
const randomPort = () => 29000 + Math.floor(Math.random() * 2000)
|
|
13
14
|
|
|
15
|
+
const protobufRoot = rootModule as unknown as {
|
|
16
|
+
readonly opentelemetry: {
|
|
17
|
+
readonly proto: {
|
|
18
|
+
readonly collector: {
|
|
19
|
+
readonly logs: { readonly v1: { readonly ExportLogsServiceRequest: { encode: (message: unknown) => { finish: () => Uint8Array } } } }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
interface Harness {
|
|
15
26
|
readonly runtimeDir: string
|
|
16
27
|
readonly port: number
|
|
@@ -18,7 +29,7 @@ interface Harness {
|
|
|
18
29
|
readonly manager: ReturnType<typeof createDaemonManager>
|
|
19
30
|
}
|
|
20
31
|
|
|
21
|
-
const makeHarness = (): Harness => {
|
|
32
|
+
const makeHarness = (options: { readonly startTimeoutMs?: number; readonly gracefulStopTimeoutMs?: number; readonly forceStopTimeoutMs?: number } = {}): Harness => {
|
|
22
33
|
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-test-"))
|
|
23
34
|
const port = randomPort()
|
|
24
35
|
const databasePath = path.join(runtimeDir, "telemetry.sqlite")
|
|
@@ -27,6 +38,7 @@ const makeHarness = (): Harness => {
|
|
|
27
38
|
runtimeDir,
|
|
28
39
|
databasePath,
|
|
29
40
|
port,
|
|
41
|
+
...options,
|
|
30
42
|
})
|
|
31
43
|
return { runtimeDir, port, databasePath, manager }
|
|
32
44
|
}
|
|
@@ -91,13 +103,7 @@ afterEach(async () => {
|
|
|
91
103
|
})
|
|
92
104
|
|
|
93
105
|
describe("daemon manager", () => {
|
|
94
|
-
test("
|
|
95
|
-
// The failure mode we're preventing: a fully-healthy motel daemon
|
|
96
|
-
// is alive for our cwd, but its /api/health response queues
|
|
97
|
-
// behind heavy OTLP ingest traffic and takes >1s (seen on this
|
|
98
|
-
// machine: /api/health taking 4s under real load). With an
|
|
99
|
-
// HTTP-only probe the TUI would stall for seconds on every
|
|
100
|
-
// launch; the registry-based fast path should close in <100ms.
|
|
106
|
+
test("does not report a registry-only daemon as healthy", async () => {
|
|
101
107
|
const harness = makeHarness()
|
|
102
108
|
activeHarnesses.push(harness)
|
|
103
109
|
|
|
@@ -106,13 +112,11 @@ describe("daemon manager", () => {
|
|
|
106
112
|
const registryRoot = path.join(harness.runtimeDir, "state")
|
|
107
113
|
const originalXdg = process.env.XDG_STATE_HOME
|
|
108
114
|
process.env.XDG_STATE_HOME = registryRoot
|
|
109
|
-
const registryInstancesDir = path.join(
|
|
115
|
+
const registryInstancesDir = path.join(harness.runtimeDir, "instances")
|
|
110
116
|
fs.mkdirSync(registryInstancesDir, { recursive: true })
|
|
111
117
|
|
|
112
|
-
// Seed an
|
|
113
|
-
//
|
|
114
|
-
// supervisor's fast path will adopt without ever issuing an
|
|
115
|
-
// HTTP request.
|
|
118
|
+
// Seed an alive registry entry whose HTTP listener cannot answer
|
|
119
|
+
// within the health deadline. PID liveness must not imply readiness.
|
|
116
120
|
const entryPath = path.join(registryInstancesDir, `${process.pid}.json`)
|
|
117
121
|
fs.writeFileSync(entryPath, JSON.stringify({
|
|
118
122
|
pid: process.pid,
|
|
@@ -123,9 +127,6 @@ describe("daemon manager", () => {
|
|
|
123
127
|
databasePath: harness.databasePath,
|
|
124
128
|
}), "utf8")
|
|
125
129
|
|
|
126
|
-
// Park a real-but-slow listener on the port. If the supervisor
|
|
127
|
-
// ever falls back to HTTP we'd wait out the 5s delay; a passing
|
|
128
|
-
// test proves the fast path took over.
|
|
129
130
|
const fake = startFakeDaemon({
|
|
130
131
|
port: harness.port,
|
|
131
132
|
databasePath: harness.databasePath,
|
|
@@ -134,15 +135,13 @@ describe("daemon manager", () => {
|
|
|
134
135
|
|
|
135
136
|
try {
|
|
136
137
|
const start = performance.now()
|
|
137
|
-
const status = await Effect.runPromise(harness.manager.
|
|
138
|
+
const status = await Effect.runPromise(harness.manager.getStatus)
|
|
138
139
|
const elapsed = performance.now() - start
|
|
139
|
-
expect(status.running).toBe(
|
|
140
|
-
expect(status.managed).toBe(
|
|
140
|
+
expect(status.running).toBe(false)
|
|
141
|
+
expect(status.managed).toBe(false)
|
|
141
142
|
expect(status.pid).toBe(process.pid)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// on the hot path.
|
|
145
|
-
expect(elapsed).toBeLessThan(500)
|
|
143
|
+
expect(elapsed).toBeGreaterThan(500)
|
|
144
|
+
expect(elapsed).toBeLessThan(2_000)
|
|
146
145
|
} finally {
|
|
147
146
|
fake.stop()
|
|
148
147
|
fs.rmSync(entryPath, { force: true })
|
|
@@ -151,18 +150,7 @@ describe("daemon manager", () => {
|
|
|
151
150
|
}
|
|
152
151
|
})
|
|
153
152
|
|
|
154
|
-
test("
|
|
155
|
-
// Reproduces the `bun dev` EADDRINUSE flake. A real daemon is alive
|
|
156
|
-
// and holds the port, but its /api/health response takes longer
|
|
157
|
-
// than the supervisor's 750ms fetch timeout (e.g. the daemon is
|
|
158
|
-
// backfilling FTS or the SQLite writer lock is held). The buggy
|
|
159
|
-
// behaviour: supervisor thinks the port is free, spawns a fresh
|
|
160
|
-
// daemon child, the child tries to bind() → EADDRINUSE → child
|
|
161
|
-
// exits → supervisor throws "exited before becoming healthy".
|
|
162
|
-
//
|
|
163
|
-
// Correct behaviour: supervisor retries the health probe with a
|
|
164
|
-
// longer budget before declaring the port empty, finds the
|
|
165
|
-
// (slow) healthy motel on it, and adopts.
|
|
153
|
+
test("refuses to adopt a responsive but unmanaged motel server", async () => {
|
|
166
154
|
const harness = makeHarness()
|
|
167
155
|
activeHarnesses.push(harness)
|
|
168
156
|
const fake = startFakeDaemon({
|
|
@@ -171,13 +159,46 @@ describe("daemon manager", () => {
|
|
|
171
159
|
delayMs: 1_500,
|
|
172
160
|
})
|
|
173
161
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
162
|
+
await expect(Effect.runPromise(harness.manager.ensure)).rejects.toThrow("not an identity-verified managed daemon")
|
|
163
|
+
} finally {
|
|
164
|
+
fake.stop()
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test("validates legacy registry entries before adopting a shared daemon", async () => {
|
|
169
|
+
const harness = makeHarness()
|
|
170
|
+
activeHarnesses.push(harness)
|
|
171
|
+
const stateRoot = path.join(harness.runtimeDir, "legacy-state")
|
|
172
|
+
const originalXdg = process.env.XDG_STATE_HOME
|
|
173
|
+
process.env.XDG_STATE_HOME = stateRoot
|
|
174
|
+
const instancesDir = path.join(harness.runtimeDir, "instances")
|
|
175
|
+
fs.mkdirSync(instancesDir, { recursive: true })
|
|
176
|
+
const entryPath = path.join(instancesDir, `${process.pid}.json`)
|
|
177
|
+
fs.writeFileSync(entryPath, JSON.stringify({
|
|
178
|
+
pid: process.pid,
|
|
179
|
+
url: `http://127.0.0.1:${harness.port}`,
|
|
180
|
+
workdir: "/tmp/legacy-project",
|
|
181
|
+
startedAt: new Date().toISOString(),
|
|
182
|
+
version: "0.0.0-legacy",
|
|
183
|
+
}), "utf8")
|
|
184
|
+
|
|
185
|
+
const fake = startFakeDaemon({
|
|
186
|
+
port: harness.port,
|
|
187
|
+
databasePath: path.join(harness.runtimeDir, "legacy.sqlite"),
|
|
188
|
+
delayMs: 0,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const status = await Effect.runPromise(harness.manager.getStatus)
|
|
193
|
+
expect(status.running).toBe(false)
|
|
194
|
+
expect(status.managed).toBe(false)
|
|
195
|
+
expect(status.reason).toContain("expected")
|
|
196
|
+
expect(status.databasePath).toBe(path.join(harness.runtimeDir, "legacy.sqlite"))
|
|
179
197
|
} finally {
|
|
180
198
|
fake.stop()
|
|
199
|
+
fs.rmSync(entryPath, { force: true })
|
|
200
|
+
if (originalXdg === undefined) delete process.env.XDG_STATE_HOME
|
|
201
|
+
else process.env.XDG_STATE_HOME = originalXdg
|
|
181
202
|
}
|
|
182
203
|
})
|
|
183
204
|
|
|
@@ -205,7 +226,7 @@ describe("daemon manager", () => {
|
|
|
205
226
|
expect(finalStatus.running).toBe(false)
|
|
206
227
|
})
|
|
207
228
|
|
|
208
|
-
test("
|
|
229
|
+
test("health responds while ingest readiness waits for a write lock", async () => {
|
|
209
230
|
const harness = makeHarness()
|
|
210
231
|
activeHarnesses.push(harness)
|
|
211
232
|
|
|
@@ -215,29 +236,268 @@ describe("daemon manager", () => {
|
|
|
215
236
|
|
|
216
237
|
const locker = new Database(harness.databasePath)
|
|
217
238
|
locker.exec("BEGIN IMMEDIATE")
|
|
239
|
+
let settled = false
|
|
240
|
+
const restarting = Effect.runPromise(harness.manager.ensure).then((status) => {
|
|
241
|
+
settled = true
|
|
242
|
+
return status
|
|
243
|
+
})
|
|
218
244
|
try {
|
|
219
245
|
const startedAt = performance.now()
|
|
220
|
-
|
|
246
|
+
let response: Response | null = null
|
|
247
|
+
while (performance.now() - startedAt < 2_000 && !response?.ok) {
|
|
248
|
+
response = await fetch(`http://127.0.0.1:${harness.port}/api/health`, { signal: AbortSignal.timeout(250) }).catch(() => null)
|
|
249
|
+
}
|
|
221
250
|
const elapsed = performance.now() - startedAt
|
|
222
|
-
expect(
|
|
223
|
-
expect(
|
|
224
|
-
expect(
|
|
251
|
+
expect(response?.ok).toBe(true)
|
|
252
|
+
expect(elapsed).toBeLessThan(2_000)
|
|
253
|
+
expect(settled).toBe(false)
|
|
225
254
|
} finally {
|
|
226
255
|
locker.exec("ROLLBACK")
|
|
227
256
|
locker.close()
|
|
228
257
|
}
|
|
258
|
+
const restarted = await restarting
|
|
259
|
+
expect(restarted.running).toBe(true)
|
|
229
260
|
}, 20_000)
|
|
230
261
|
|
|
231
|
-
test("
|
|
262
|
+
test("force-kills an identity-verified daemon that ignores graceful shutdown", async () => {
|
|
263
|
+
const harness = makeHarness({ gracefulStopTimeoutMs: 250, forceStopTimeoutMs: 1_000 })
|
|
264
|
+
activeHarnesses.push(harness)
|
|
265
|
+
const started = await Effect.runPromise(harness.manager.ensure)
|
|
266
|
+
if (started.pid === null) throw new Error("Expected managed daemon pid")
|
|
267
|
+
process.kill(started.pid, "SIGSTOP")
|
|
268
|
+
|
|
269
|
+
const startedAt = performance.now()
|
|
270
|
+
const stopped = await Effect.runPromise(harness.manager.stop)
|
|
271
|
+
expect(stopped.running).toBe(false)
|
|
272
|
+
expect(performance.now() - startedAt).toBeLessThan(2_000)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test("does not write response logs for ingest endpoints", async () => {
|
|
276
|
+
const harness = makeHarness()
|
|
277
|
+
activeHarnesses.push(harness)
|
|
278
|
+
await Effect.runPromise(harness.manager.ensure)
|
|
279
|
+
for (let index = 0; index < 10; index++) {
|
|
280
|
+
await fetch(`http://127.0.0.1:${harness.port}/v1/logs`, {
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: { "content-type": "application/json" },
|
|
283
|
+
body: "{}",
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
await fetch(`http://127.0.0.1:${harness.port}/api/services`)
|
|
287
|
+
await Bun.sleep(100)
|
|
288
|
+
const log = fs.readFileSync(path.join(harness.runtimeDir, "daemon.log"), "utf8")
|
|
289
|
+
expect(log).not.toContain("/v1/logs")
|
|
290
|
+
expect(log).toContain("/api/services")
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test("accepts OTLP protobuf log payloads", async () => {
|
|
294
|
+
const harness = makeHarness()
|
|
295
|
+
activeHarnesses.push(harness)
|
|
296
|
+
await Effect.runPromise(harness.manager.ensure)
|
|
297
|
+
const payload = protobufRoot.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest.encode({
|
|
298
|
+
resourceLogs: [{
|
|
299
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: "protobuf-fixture" } }] },
|
|
300
|
+
scopeLogs: [{ logRecords: [{
|
|
301
|
+
timeUnixNano: String(BigInt(Date.now()) * 1_000_000n),
|
|
302
|
+
severityText: "INFO",
|
|
303
|
+
body: { stringValue: "protobuf log" },
|
|
304
|
+
}] }],
|
|
305
|
+
}],
|
|
306
|
+
}).finish()
|
|
307
|
+
const response = await fetch(`http://127.0.0.1:${harness.port}/v1/logs`, {
|
|
308
|
+
method: "POST",
|
|
309
|
+
headers: { "content-type": "application/x-protobuf" },
|
|
310
|
+
body: payload.buffer instanceof ArrayBuffer
|
|
311
|
+
? payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength)
|
|
312
|
+
: Uint8Array.from(payload).buffer,
|
|
313
|
+
})
|
|
314
|
+
expect(response.status).toBe(200)
|
|
315
|
+
await Bun.sleep(100)
|
|
316
|
+
const probe = new Database(harness.databasePath, { readonly: true })
|
|
317
|
+
try {
|
|
318
|
+
const log = probe.query(`SELECT service_name, body FROM logs WHERE body = 'protobuf log'`).get() as { service_name: string; body: string } | null
|
|
319
|
+
expect(log).toEqual({ service_name: "protobuf-fixture", body: "protobuf log" })
|
|
320
|
+
} finally {
|
|
321
|
+
probe.close()
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test("repeated ingest does not recursively create Motel telemetry", async () => {
|
|
326
|
+
const harness = makeHarness()
|
|
327
|
+
activeHarnesses.push(harness)
|
|
328
|
+
await Effect.runPromise(harness.manager.ensure)
|
|
329
|
+
const nowNanos = String(BigInt(Date.now()) * 1_000_000n)
|
|
330
|
+
const payload = JSON.stringify({
|
|
331
|
+
resourceLogs: [{
|
|
332
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: "recursion-fixture" } }] },
|
|
333
|
+
scopeLogs: [{ logRecords: [{ timeUnixNano: nowNanos, body: { stringValue: "one source log" } }] }],
|
|
334
|
+
}],
|
|
335
|
+
})
|
|
336
|
+
for (let index = 0; index < 10; index++) {
|
|
337
|
+
await fetch(`http://127.0.0.1:${harness.port}/v1/logs`, {
|
|
338
|
+
method: "POST",
|
|
339
|
+
headers: { "content-type": "application/json" },
|
|
340
|
+
body: index === 0 ? payload : "{}",
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
await Bun.sleep(250)
|
|
344
|
+
const probe = new Database(harness.databasePath, { readonly: true })
|
|
345
|
+
try {
|
|
346
|
+
const total = (probe.query(`SELECT COUNT(*) AS c FROM logs`).get() as { c: number }).c
|
|
347
|
+
const motel = (probe.query(`SELECT COUNT(*) AS c FROM logs WHERE service_name = 'motel-otel-tui'`).get() as { c: number }).c
|
|
348
|
+
expect(total).toBe(1)
|
|
349
|
+
expect(motel).toBe(0)
|
|
350
|
+
} finally {
|
|
351
|
+
probe.close()
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test("large retained schema maintenance cannot block health", async () => {
|
|
356
|
+
const harness = makeHarness()
|
|
357
|
+
activeHarnesses.push(harness)
|
|
358
|
+
await Effect.runPromise(harness.manager.ensure)
|
|
359
|
+
await Effect.runPromise(harness.manager.stop)
|
|
360
|
+
|
|
361
|
+
const fixture = new Database(harness.databasePath)
|
|
362
|
+
fixture.exec(`DROP INDEX IF EXISTS idx_spans_service_time; DROP INDEX IF EXISTS idx_spans_trace_time; DROP INDEX IF EXISTS idx_spans_span_id; DROP INDEX IF EXISTS idx_spans_status_time; BEGIN`)
|
|
363
|
+
const insert = fixture.query(`INSERT INTO spans VALUES (?, ?, NULL, 'large-fixture', NULL, 'op', NULL, ?, ?, 1, 'ok', ?, '{}', '[]')`)
|
|
364
|
+
const blob = JSON.stringify({ blob: "x".repeat(512) })
|
|
365
|
+
for (let index = 0; index < 50_000; index++) insert.run(`large-trace-${index}`, `large-span-${index}`, index, index + 1, blob)
|
|
366
|
+
fixture.exec(`COMMIT; PRAGMA wal_checkpoint(TRUNCATE)`)
|
|
367
|
+
fixture.close()
|
|
368
|
+
|
|
369
|
+
const startedAt = performance.now()
|
|
370
|
+
const restarting = Effect.runPromise(harness.manager.ensure)
|
|
371
|
+
let response: Response | null = null
|
|
372
|
+
while (performance.now() - startedAt < 1_500 && !response?.ok) {
|
|
373
|
+
response = await fetch(`http://127.0.0.1:${harness.port}/api/health`, { signal: AbortSignal.timeout(200) }).catch(() => null)
|
|
374
|
+
}
|
|
375
|
+
expect(response?.ok).toBe(true)
|
|
376
|
+
expect(performance.now() - startedAt).toBeLessThan(1_500)
|
|
377
|
+
const restarted = await restarting
|
|
378
|
+
expect(restarted.running).toBe(true)
|
|
379
|
+
}, 30_000)
|
|
380
|
+
|
|
381
|
+
test("an expensive retained-database query cannot block health", async () => {
|
|
382
|
+
const harness = makeHarness()
|
|
383
|
+
activeHarnesses.push(harness)
|
|
384
|
+
await Effect.runPromise(harness.manager.ensure)
|
|
385
|
+
await Effect.runPromise(harness.manager.stop)
|
|
386
|
+
|
|
387
|
+
const fixture = new Database(harness.databasePath)
|
|
388
|
+
const insert = fixture.query(`INSERT INTO trace_summaries VALUES (?, 'query-pressure', 'op', ?, ?, 0, ?, 1, 0)`)
|
|
389
|
+
const now = Date.now()
|
|
390
|
+
fixture.exec("BEGIN")
|
|
391
|
+
for (let index = 0; index < 500_000; index++) insert.run(`query-trace-${index}`, now - index, now - index + 1, index % 1_000)
|
|
392
|
+
fixture.exec(`COMMIT; PRAGMA wal_checkpoint(TRUNCATE)`)
|
|
393
|
+
fixture.close()
|
|
394
|
+
await Effect.runPromise(harness.manager.ensure)
|
|
395
|
+
|
|
396
|
+
const query = fetch(`http://127.0.0.1:${harness.port}/api/traces/stats?groupBy=service&agg=p95_duration&lookback=1440`)
|
|
397
|
+
await Bun.sleep(5)
|
|
398
|
+
const healthStartedAt = performance.now()
|
|
399
|
+
const health = await fetch(`http://127.0.0.1:${harness.port}/api/health`, { signal: AbortSignal.timeout(250) })
|
|
400
|
+
expect(health.ok).toBe(true)
|
|
401
|
+
expect(performance.now() - healthStartedAt).toBeLessThan(250)
|
|
402
|
+
expect((await query).ok).toBe(true)
|
|
403
|
+
}, 30_000)
|
|
404
|
+
|
|
405
|
+
test("configured size retention evicts oldest logs first", async () => {
|
|
406
|
+
const previousMax = process.env.MOTEL_OTEL_MAX_DB_SIZE_MB
|
|
407
|
+
const previousBatch = process.env.MOTEL_OTEL_RETENTION_LOG_BATCH
|
|
408
|
+
const previousInterval = process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS
|
|
409
|
+
process.env.MOTEL_OTEL_MAX_DB_SIZE_MB = "3"
|
|
410
|
+
process.env.MOTEL_OTEL_RETENTION_LOG_BATCH = "2"
|
|
411
|
+
process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS = "1"
|
|
412
|
+
const harness = makeHarness()
|
|
413
|
+
activeHarnesses.push(harness)
|
|
414
|
+
try {
|
|
415
|
+
await Effect.runPromise(harness.manager.ensure)
|
|
416
|
+
const baseNanos = BigInt(Date.now()) * 1_000_000n
|
|
417
|
+
for (let index = 0; index < 10; index++) {
|
|
418
|
+
const payload = JSON.stringify({
|
|
419
|
+
resourceLogs: [{
|
|
420
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: "size-retention" } }] },
|
|
421
|
+
scopeLogs: [{ logRecords: [{
|
|
422
|
+
timeUnixNano: String(baseNanos + BigInt(index)),
|
|
423
|
+
body: { stringValue: `retention-${index}-${String(index).repeat(300_000)}` },
|
|
424
|
+
}] }],
|
|
425
|
+
}],
|
|
426
|
+
})
|
|
427
|
+
await fetch(`http://127.0.0.1:${harness.port}/v1/logs`, { method: "POST", headers: { "content-type": "application/json" }, body: payload })
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let bodies: string[] = []
|
|
431
|
+
const deadline = Date.now() + 6_000
|
|
432
|
+
while (Date.now() < deadline) {
|
|
433
|
+
const probe = new Database(harness.databasePath, { readonly: true })
|
|
434
|
+
try {
|
|
435
|
+
bodies = (probe.query(`SELECT body FROM logs WHERE service_name = 'size-retention' ORDER BY timestamp_ms ASC, id ASC`).all() as Array<{ body: string }>).map((row) => row.body)
|
|
436
|
+
} finally {
|
|
437
|
+
probe.close()
|
|
438
|
+
}
|
|
439
|
+
if (bodies.length < 10) break
|
|
440
|
+
await Bun.sleep(200)
|
|
441
|
+
}
|
|
442
|
+
expect(bodies.length).toBeLessThan(10)
|
|
443
|
+
expect(bodies.some((body) => body.startsWith("retention-0-"))).toBe(false)
|
|
444
|
+
expect(bodies.some((body) => body.startsWith("retention-9-"))).toBe(true)
|
|
445
|
+
} finally {
|
|
446
|
+
await Effect.runPromise(harness.manager.stop).catch(() => undefined)
|
|
447
|
+
if (previousMax === undefined) delete process.env.MOTEL_OTEL_MAX_DB_SIZE_MB
|
|
448
|
+
else process.env.MOTEL_OTEL_MAX_DB_SIZE_MB = previousMax
|
|
449
|
+
if (previousBatch === undefined) delete process.env.MOTEL_OTEL_RETENTION_LOG_BATCH
|
|
450
|
+
else process.env.MOTEL_OTEL_RETENTION_LOG_BATCH = previousBatch
|
|
451
|
+
if (previousInterval === undefined) delete process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS
|
|
452
|
+
else process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS = previousInterval
|
|
453
|
+
}
|
|
454
|
+
}, 20_000)
|
|
455
|
+
|
|
456
|
+
test("cleans a stale registry pid identity without signaling it", async () => {
|
|
457
|
+
const harness = makeHarness({ gracefulStopTimeoutMs: 100, forceStopTimeoutMs: 100 })
|
|
458
|
+
activeHarnesses.push(harness)
|
|
459
|
+
const sentinel = Bun.spawn({ cmd: [process.execPath, "-e", "setInterval(() => {}, 1000)"], stdout: "ignore", stderr: "ignore" })
|
|
460
|
+
await Bun.sleep(50)
|
|
461
|
+
const instances = path.join(harness.runtimeDir, "instances")
|
|
462
|
+
fs.mkdirSync(instances, { recursive: true })
|
|
463
|
+
fs.writeFileSync(path.join(instances, `${sentinel.pid}.json`), JSON.stringify({
|
|
464
|
+
pid: sentinel.pid,
|
|
465
|
+
url: `http://127.0.0.1:${harness.port}`,
|
|
466
|
+
workdir: process.cwd(),
|
|
467
|
+
startedAt: new Date().toISOString(),
|
|
468
|
+
version: "test",
|
|
469
|
+
databasePath: harness.databasePath,
|
|
470
|
+
instanceId: "stale-instance",
|
|
471
|
+
processIdentity: "stale-process",
|
|
472
|
+
}), "utf8")
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
const stopped = await Effect.runPromise(harness.manager.stop)
|
|
476
|
+
expect(stopped.running).toBe(false)
|
|
477
|
+
expect(fs.existsSync(path.join(instances, `${sentinel.pid}.json`))).toBe(false)
|
|
478
|
+
expect(sentinel.exitCode).toBeNull()
|
|
479
|
+
} finally {
|
|
480
|
+
sentinel.kill("SIGKILL")
|
|
481
|
+
await sentinel.exited
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
test("uses the shared global state dir regardless of caller cwd", async () => {
|
|
232
486
|
const projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-project-")))
|
|
233
|
-
const
|
|
487
|
+
const otherProjectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-other-project-")))
|
|
488
|
+
const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-state-"))
|
|
489
|
+
const expectedRuntimeDir = path.join(stateRoot, "motel")
|
|
490
|
+
const expectedDatabasePath = path.join(expectedRuntimeDir, "telemetry.sqlite")
|
|
491
|
+
const originalXdg = process.env.XDG_STATE_HOME
|
|
492
|
+
process.env.XDG_STATE_HOME = stateRoot
|
|
234
493
|
let manager: ReturnType<typeof createDaemonManager> | null = null
|
|
494
|
+
const port = randomPort()
|
|
235
495
|
|
|
236
496
|
try {
|
|
237
497
|
await withCwd(projectDir, async () => {
|
|
238
498
|
manager = createDaemonManager({
|
|
239
499
|
repoRoot,
|
|
240
|
-
port
|
|
500
|
+
port,
|
|
241
501
|
})
|
|
242
502
|
|
|
243
503
|
const started = await Effect.runPromise(manager.ensure)
|
|
@@ -245,13 +505,30 @@ describe("daemon manager", () => {
|
|
|
245
505
|
expect(started.managed).toBe(true)
|
|
246
506
|
expect(started.workdir).toBe(projectDir)
|
|
247
507
|
expect(started.sameWorkdir).toBe(true)
|
|
248
|
-
expect(started.databasePath).toBe(
|
|
249
|
-
expect(started.logPath).toBe(path.join(
|
|
508
|
+
expect(started.databasePath).toBe(expectedDatabasePath)
|
|
509
|
+
expect(started.logPath).toBe(path.join(expectedRuntimeDir, "daemon.log"))
|
|
510
|
+
expect(fs.existsSync(path.join(projectDir, ".motel-data"))).toBe(false)
|
|
250
511
|
|
|
251
512
|
const reused = await Effect.runPromise(manager.ensure)
|
|
252
513
|
expect(reused.pid).toBe(started.pid)
|
|
253
514
|
|
|
254
|
-
|
|
515
|
+
await withCwd(otherProjectDir, async () => {
|
|
516
|
+
const otherManager = createDaemonManager({
|
|
517
|
+
repoRoot,
|
|
518
|
+
port,
|
|
519
|
+
})
|
|
520
|
+
const adopted = await Effect.runPromise(otherManager.ensure)
|
|
521
|
+
expect(adopted.running).toBe(true)
|
|
522
|
+
expect(adopted.managed).toBe(true)
|
|
523
|
+
expect(adopted.pid).toBe(started.pid)
|
|
524
|
+
expect(adopted.workdir).toBe(projectDir)
|
|
525
|
+
expect(adopted.sameWorkdir).toBe(false)
|
|
526
|
+
expect(adopted.reason).toBe(null)
|
|
527
|
+
const stopped = await Effect.runPromise(otherManager.stop)
|
|
528
|
+
expect(stopped.running).toBe(false)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
const stopped = await Effect.runPromise(manager.getStatus)
|
|
255
532
|
expect(stopped.running).toBe(false)
|
|
256
533
|
})
|
|
257
534
|
} finally {
|
|
@@ -261,6 +538,10 @@ describe("daemon manager", () => {
|
|
|
261
538
|
}
|
|
262
539
|
})
|
|
263
540
|
fs.rmSync(projectDir, { recursive: true, force: true })
|
|
541
|
+
fs.rmSync(otherProjectDir, { recursive: true, force: true })
|
|
542
|
+
fs.rmSync(stateRoot, { recursive: true, force: true })
|
|
543
|
+
if (originalXdg === undefined) delete process.env.XDG_STATE_HOME
|
|
544
|
+
else process.env.XDG_STATE_HOME = originalXdg
|
|
264
545
|
}
|
|
265
546
|
})
|
|
266
547
|
})
|