@aryasaatvik/motel 0.3.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 +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
3
|
+
import { Effect } from "effect"
|
|
4
|
+
import rootModule from "@opentelemetry/otlp-transformer/build/esm/generated/root.js"
|
|
5
|
+
import * as fs from "node:fs"
|
|
6
|
+
import * as os from "node:os"
|
|
7
|
+
import * as path from "node:path"
|
|
8
|
+
import { createDaemonManager } from "./daemon.js"
|
|
9
|
+
import { MOTEL_SERVICE_ID } from "./registry.js"
|
|
10
|
+
|
|
11
|
+
const repoRoot = path.resolve(import.meta.dir, "..")
|
|
12
|
+
|
|
13
|
+
const randomPort = () => 29000 + Math.floor(Math.random() * 2000)
|
|
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
|
+
|
|
25
|
+
interface Harness {
|
|
26
|
+
readonly runtimeDir: string
|
|
27
|
+
readonly port: number
|
|
28
|
+
readonly databasePath: string
|
|
29
|
+
readonly manager: ReturnType<typeof createDaemonManager>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const makeHarness = (options: { readonly startTimeoutMs?: number; readonly gracefulStopTimeoutMs?: number; readonly forceStopTimeoutMs?: number } = {}): Harness => {
|
|
33
|
+
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-test-"))
|
|
34
|
+
const port = randomPort()
|
|
35
|
+
const databasePath = path.join(runtimeDir, "telemetry.sqlite")
|
|
36
|
+
const manager = createDaemonManager({
|
|
37
|
+
repoRoot,
|
|
38
|
+
runtimeDir,
|
|
39
|
+
databasePath,
|
|
40
|
+
port,
|
|
41
|
+
...options,
|
|
42
|
+
})
|
|
43
|
+
return { runtimeDir, port, databasePath, manager }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const withCwd = async <A>(cwd: string, f: () => Promise<A>): Promise<A> => {
|
|
47
|
+
const previous = process.cwd()
|
|
48
|
+
process.chdir(cwd)
|
|
49
|
+
try {
|
|
50
|
+
return await f()
|
|
51
|
+
} finally {
|
|
52
|
+
process.chdir(previous)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Start a motel-shaped HTTP server on a test port that answers
|
|
58
|
+
* /api/health with an arbitrary delay. Used to simulate a real daemon
|
|
59
|
+
* that's alive + holding the port but currently slow — the exact
|
|
60
|
+
* scenario that makes `bun dev` fail with EADDRINUSE when the
|
|
61
|
+
* supervisor's health probe times out and it tries to spawn a
|
|
62
|
+
* duplicate. Returns a stop() that releases the port.
|
|
63
|
+
*/
|
|
64
|
+
const startFakeDaemon = (opts: {
|
|
65
|
+
readonly port: number
|
|
66
|
+
readonly databasePath: string
|
|
67
|
+
readonly delayMs: number
|
|
68
|
+
}) => {
|
|
69
|
+
const startedAt = new Date().toISOString()
|
|
70
|
+
const server = Bun.serve({
|
|
71
|
+
port: opts.port,
|
|
72
|
+
hostname: "127.0.0.1",
|
|
73
|
+
async fetch(req) {
|
|
74
|
+
const url = new URL(req.url)
|
|
75
|
+
if (url.pathname !== "/api/health") {
|
|
76
|
+
return new Response("not found", { status: 404 })
|
|
77
|
+
}
|
|
78
|
+
if (opts.delayMs > 0) {
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, opts.delayMs))
|
|
80
|
+
}
|
|
81
|
+
return Response.json({
|
|
82
|
+
ok: true,
|
|
83
|
+
service: MOTEL_SERVICE_ID,
|
|
84
|
+
databasePath: opts.databasePath,
|
|
85
|
+
pid: process.pid,
|
|
86
|
+
url: `http://127.0.0.1:${opts.port}`,
|
|
87
|
+
workdir: process.cwd(),
|
|
88
|
+
startedAt,
|
|
89
|
+
version: "0.0.0-test",
|
|
90
|
+
})
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
return { stop: () => server.stop(true) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const activeHarnesses: Array<ReturnType<typeof makeHarness>> = []
|
|
97
|
+
|
|
98
|
+
afterEach(async () => {
|
|
99
|
+
for (const harness of activeHarnesses.splice(0)) {
|
|
100
|
+
await Effect.runPromise(harness.manager.stop).catch(() => undefined)
|
|
101
|
+
fs.rmSync(harness.runtimeDir, { recursive: true, force: true })
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe("daemon manager", () => {
|
|
106
|
+
test("does not report a registry-only daemon as healthy", async () => {
|
|
107
|
+
const harness = makeHarness()
|
|
108
|
+
activeHarnesses.push(harness)
|
|
109
|
+
|
|
110
|
+
// Scope the motel registry to the harness's runtime dir so we
|
|
111
|
+
// neither read nor pollute the user's real ~/.local/state/motel.
|
|
112
|
+
const registryRoot = path.join(harness.runtimeDir, "state")
|
|
113
|
+
const originalXdg = process.env.XDG_STATE_HOME
|
|
114
|
+
process.env.XDG_STATE_HOME = registryRoot
|
|
115
|
+
const registryInstancesDir = path.join(harness.runtimeDir, "instances")
|
|
116
|
+
fs.mkdirSync(registryInstancesDir, { recursive: true })
|
|
117
|
+
|
|
118
|
+
// Seed an alive registry entry whose HTTP listener cannot answer
|
|
119
|
+
// within the health deadline. PID liveness must not imply readiness.
|
|
120
|
+
const entryPath = path.join(registryInstancesDir, `${process.pid}.json`)
|
|
121
|
+
fs.writeFileSync(entryPath, JSON.stringify({
|
|
122
|
+
pid: process.pid,
|
|
123
|
+
url: `http://127.0.0.1:${harness.port}`,
|
|
124
|
+
workdir: process.cwd(),
|
|
125
|
+
startedAt: new Date().toISOString(),
|
|
126
|
+
version: "0.0.0-test",
|
|
127
|
+
databasePath: harness.databasePath,
|
|
128
|
+
}), "utf8")
|
|
129
|
+
|
|
130
|
+
const fake = startFakeDaemon({
|
|
131
|
+
port: harness.port,
|
|
132
|
+
databasePath: harness.databasePath,
|
|
133
|
+
delayMs: 5_000,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const start = performance.now()
|
|
138
|
+
const status = await Effect.runPromise(harness.manager.getStatus)
|
|
139
|
+
const elapsed = performance.now() - start
|
|
140
|
+
expect(status.running).toBe(false)
|
|
141
|
+
expect(status.managed).toBe(false)
|
|
142
|
+
expect(status.pid).toBe(process.pid)
|
|
143
|
+
expect(elapsed).toBeGreaterThan(500)
|
|
144
|
+
expect(elapsed).toBeLessThan(2_000)
|
|
145
|
+
} finally {
|
|
146
|
+
fake.stop()
|
|
147
|
+
fs.rmSync(entryPath, { force: true })
|
|
148
|
+
if (originalXdg === undefined) delete process.env.XDG_STATE_HOME
|
|
149
|
+
else process.env.XDG_STATE_HOME = originalXdg
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test("refuses to adopt a responsive but unmanaged motel server", async () => {
|
|
154
|
+
const harness = makeHarness()
|
|
155
|
+
activeHarnesses.push(harness)
|
|
156
|
+
const fake = startFakeDaemon({
|
|
157
|
+
port: harness.port,
|
|
158
|
+
databasePath: harness.databasePath,
|
|
159
|
+
delayMs: 1_500,
|
|
160
|
+
})
|
|
161
|
+
try {
|
|
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"))
|
|
197
|
+
} finally {
|
|
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
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test("starts once, reuses the same daemon, and stops cleanly", async () => {
|
|
206
|
+
const harness = makeHarness()
|
|
207
|
+
activeHarnesses.push(harness)
|
|
208
|
+
|
|
209
|
+
const initial = await Effect.runPromise(harness.manager.getStatus)
|
|
210
|
+
expect(initial.running).toBe(false)
|
|
211
|
+
|
|
212
|
+
const started = await Effect.runPromise(harness.manager.ensure)
|
|
213
|
+
expect(started.running).toBe(true)
|
|
214
|
+
expect(started.managed).toBe(true)
|
|
215
|
+
expect(typeof started.pid).toBe("number")
|
|
216
|
+
expect(started.databasePath).toBe(path.join(harness.runtimeDir, "telemetry.sqlite"))
|
|
217
|
+
|
|
218
|
+
const reused = await Effect.runPromise(harness.manager.ensure)
|
|
219
|
+
expect(reused.running).toBe(true)
|
|
220
|
+
expect(reused.pid).toBe(started.pid)
|
|
221
|
+
|
|
222
|
+
const stopped = await Effect.runPromise(harness.manager.stop)
|
|
223
|
+
expect(stopped.running).toBe(false)
|
|
224
|
+
|
|
225
|
+
const finalStatus = await Effect.runPromise(harness.manager.getStatus)
|
|
226
|
+
expect(finalStatus.running).toBe(false)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("health responds while ingest readiness waits for a write lock", async () => {
|
|
230
|
+
const harness = makeHarness()
|
|
231
|
+
activeHarnesses.push(harness)
|
|
232
|
+
|
|
233
|
+
const firstStart = await Effect.runPromise(harness.manager.ensure)
|
|
234
|
+
expect(firstStart.running).toBe(true)
|
|
235
|
+
await Effect.runPromise(harness.manager.stop)
|
|
236
|
+
|
|
237
|
+
const locker = new Database(harness.databasePath)
|
|
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
|
+
})
|
|
244
|
+
try {
|
|
245
|
+
const startedAt = performance.now()
|
|
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
|
+
}
|
|
250
|
+
const elapsed = performance.now() - startedAt
|
|
251
|
+
expect(response?.ok).toBe(true)
|
|
252
|
+
expect(elapsed).toBeLessThan(2_000)
|
|
253
|
+
expect(settled).toBe(false)
|
|
254
|
+
} finally {
|
|
255
|
+
locker.exec("ROLLBACK")
|
|
256
|
+
locker.close()
|
|
257
|
+
}
|
|
258
|
+
const restarted = await restarting
|
|
259
|
+
expect(restarted.running).toBe(true)
|
|
260
|
+
}, 20_000)
|
|
261
|
+
|
|
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 () => {
|
|
486
|
+
const projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-project-")))
|
|
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
|
|
493
|
+
let manager: ReturnType<typeof createDaemonManager> | null = null
|
|
494
|
+
const port = randomPort()
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
await withCwd(projectDir, async () => {
|
|
498
|
+
manager = createDaemonManager({
|
|
499
|
+
repoRoot,
|
|
500
|
+
port,
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
const started = await Effect.runPromise(manager.ensure)
|
|
504
|
+
expect(started.running).toBe(true)
|
|
505
|
+
expect(started.managed).toBe(true)
|
|
506
|
+
expect(started.workdir).toBe(projectDir)
|
|
507
|
+
expect(started.sameWorkdir).toBe(true)
|
|
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)
|
|
511
|
+
|
|
512
|
+
const reused = await Effect.runPromise(manager.ensure)
|
|
513
|
+
expect(reused.pid).toBe(started.pid)
|
|
514
|
+
|
|
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)
|
|
532
|
+
expect(stopped.running).toBe(false)
|
|
533
|
+
})
|
|
534
|
+
} finally {
|
|
535
|
+
await withCwd(projectDir, async () => {
|
|
536
|
+
if (manager) {
|
|
537
|
+
await Effect.runPromise(manager.stop).catch(() => undefined)
|
|
538
|
+
}
|
|
539
|
+
})
|
|
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
|
|
545
|
+
}
|
|
546
|
+
})
|
|
547
|
+
})
|