@kitlangton/motel 0.1.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.
Files changed (53) hide show
  1. package/AGENTS.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/package.json +92 -0
  5. package/src/App.tsx +217 -0
  6. package/src/cli.ts +258 -0
  7. package/src/config.ts +39 -0
  8. package/src/daemon.test.ts +59 -0
  9. package/src/daemon.ts +398 -0
  10. package/src/domain.ts +233 -0
  11. package/src/httpApi.ts +384 -0
  12. package/src/index.tsx +18 -0
  13. package/src/instructions.ts +72 -0
  14. package/src/localServer.ts +699 -0
  15. package/src/locator.ts +138 -0
  16. package/src/mcp.ts +260 -0
  17. package/src/motel.ts +86 -0
  18. package/src/motelClient.ts +201 -0
  19. package/src/otlp.ts +142 -0
  20. package/src/queryFilters.ts +39 -0
  21. package/src/registry.ts +86 -0
  22. package/src/runtime.ts +38 -0
  23. package/src/server.ts +10 -0
  24. package/src/services/LogQueryService.ts +43 -0
  25. package/src/services/TelemetryStore.ts +1821 -0
  26. package/src/services/TraceQueryService.ts +71 -0
  27. package/src/telemetry.test.ts +726 -0
  28. package/src/ui/ServiceLogs.tsx +112 -0
  29. package/src/ui/SpanDetail.tsx +134 -0
  30. package/src/ui/SpanDetailFull.tsx +224 -0
  31. package/src/ui/SpanDetailPane.tsx +91 -0
  32. package/src/ui/TraceDetailsPane.tsx +169 -0
  33. package/src/ui/TraceList.tsx +128 -0
  34. package/src/ui/Waterfall.tsx +412 -0
  35. package/src/ui/app/TraceListPane.tsx +34 -0
  36. package/src/ui/app/TraceWorkspace.tsx +254 -0
  37. package/src/ui/app/useAppLayout.ts +79 -0
  38. package/src/ui/app/useTraceScreenData.ts +411 -0
  39. package/src/ui/format.ts +119 -0
  40. package/src/ui/primitives.tsx +170 -0
  41. package/src/ui/state.ts +137 -0
  42. package/src/ui/theme.ts +153 -0
  43. package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
  44. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  45. package/src/ui/traceSortNav.repro.test.ts +220 -0
  46. package/src/ui/useKeyboardNav.ts +532 -0
  47. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  48. package/src/ui/waterfallNav.repro.test.ts +263 -0
  49. package/src/ui/waterfallNav.test.ts +422 -0
  50. package/src/ui/waterfallNav.ts +75 -0
  51. package/web/dist/assets/index-BEKIiisE.js +27 -0
  52. package/web/dist/assets/index-DzuHNBGV.css +2 -0
  53. package/web/dist/index.html +13 -0
package/src/cli.ts ADDED
@@ -0,0 +1,258 @@
1
+ import { Effect, References } from "effect"
2
+ import { config } from "./config.js"
3
+ import { otelServerInstructions } from "./instructions.js"
4
+ import { attributeFiltersFromArgs, isAttributeFilterToken } from "./queryFilters.js"
5
+ import { queryRuntime } from "./runtime.js"
6
+ import { LogQueryService } from "./services/LogQueryService.js"
7
+ import { TraceQueryService } from "./services/TraceQueryService.js"
8
+
9
+ const [command, ...args] = process.argv.slice(2)
10
+
11
+ const runQuiet = <A, E, R extends TraceQueryService | LogQueryService | never>(effect: Effect.Effect<A, E, R>) =>
12
+ queryRuntime.runPromise(effect.pipe(Effect.provideService(References.MinimumLogLevel, "None")))
13
+
14
+ try {
15
+ switch (command) {
16
+ case "services": {
17
+ const result = await runQuiet(Effect.flatMap(TraceQueryService.asEffect(), (query) => query.listServices))
18
+ console.log(JSON.stringify(result, null, 2))
19
+ break
20
+ }
21
+
22
+ case "traces": {
23
+ const service = args[0] ?? config.otel.serviceName
24
+ const limit = args[1] ? Number.parseInt(args[1], 10) : config.otel.traceFetchLimit
25
+ const result = await runQuiet(Effect.flatMap(TraceQueryService.asEffect(), (query) => query.listRecentTraces(service, { limit })))
26
+ console.log(JSON.stringify(result, null, 2))
27
+ break
28
+ }
29
+
30
+ case "trace": {
31
+ const traceId = args[0]
32
+ if (!traceId) {
33
+ throw new Error("Usage: bun run cli trace <trace-id>")
34
+ }
35
+
36
+ const result = await runQuiet(Effect.flatMap(TraceQueryService.asEffect(), (query) => query.getTrace(traceId)))
37
+ console.log(JSON.stringify(result, null, 2))
38
+ break
39
+ }
40
+
41
+ case "span": {
42
+ const spanId = args[0]
43
+ if (!spanId) {
44
+ throw new Error("Usage: bun run cli span <span-id>")
45
+ }
46
+
47
+ const result = await fetch(`${config.otel.queryUrl}/api/spans/${encodeURIComponent(spanId)}`).then((response) => response.json())
48
+ console.log(JSON.stringify(result, null, 2))
49
+ break
50
+ }
51
+
52
+ case "trace-spans": {
53
+ const traceId = args[0]
54
+ if (!traceId) {
55
+ throw new Error("Usage: bun run cli trace-spans <trace-id>")
56
+ }
57
+
58
+ const result = await runQuiet(Effect.flatMap(TraceQueryService.asEffect(), (query) => query.listTraceSpans(traceId)))
59
+ console.log(JSON.stringify(result, null, 2))
60
+ break
61
+ }
62
+
63
+ case "search-spans": {
64
+ const service = args[0] ?? config.otel.serviceName
65
+ const operation = args[1] && !isAttributeFilterToken(args[1]) && !args[1].startsWith("parent=") ? args[1] : undefined
66
+ const parentTokenIndex = args.findIndex((value, index) => index > 0 && value.startsWith("parent="))
67
+ const parentOperation = parentTokenIndex >= 0 ? args[parentTokenIndex]?.slice("parent=".length) : undefined
68
+ const attributeStartIndex = operation ? 2 : 1
69
+ const attributeFilters = attributeFiltersFromArgs(args.slice(attributeStartIndex))
70
+ const result = await runQuiet(
71
+ Effect.flatMap(TraceQueryService.asEffect(), (query) =>
72
+ query.searchSpans({
73
+ serviceName: service,
74
+ operation,
75
+ parentOperation,
76
+ attributeFilters,
77
+ limit: config.otel.logFetchLimit,
78
+ }),
79
+ ),
80
+ )
81
+ console.log(JSON.stringify(result, null, 2))
82
+ break
83
+ }
84
+
85
+ case "search-traces": {
86
+ const service = args[0] ?? config.otel.serviceName
87
+ const operation = args[1] && !isAttributeFilterToken(args[1]) ? args[1] : undefined
88
+ const attributeFilters = attributeFiltersFromArgs(args.slice(operation ? 2 : 1))
89
+ const result = await runQuiet(
90
+ Effect.flatMap(TraceQueryService.asEffect(), (query) =>
91
+ query.searchTraces({
92
+ serviceName: service,
93
+ operation,
94
+ attributeFilters,
95
+ limit: config.otel.traceFetchLimit,
96
+ }),
97
+ ),
98
+ )
99
+ console.log(JSON.stringify(result, null, 2))
100
+ break
101
+ }
102
+
103
+ case "trace-stats": {
104
+ const groupBy = args[0]
105
+ const agg = args[1]
106
+ const service = args[2] && !isAttributeFilterToken(args[2]) ? args[2] : undefined
107
+ const attributeFilters = attributeFiltersFromArgs(args.slice(service ? 3 : 2))
108
+ if (!groupBy || (agg !== "count" && agg !== "avg_duration" && agg !== "p95_duration" && agg !== "error_rate")) {
109
+ throw new Error("Usage: bun run cli trace-stats <groupBy> <count|avg_duration|p95_duration|error_rate> [service]")
110
+ }
111
+
112
+ const result = await runQuiet(
113
+ Effect.flatMap(TraceQueryService.asEffect(), (query) =>
114
+ query.traceStats({
115
+ groupBy,
116
+ agg,
117
+ serviceName: service,
118
+ attributeFilters,
119
+ limit: 20,
120
+ }),
121
+ ),
122
+ )
123
+ console.log(JSON.stringify(result, null, 2))
124
+ break
125
+ }
126
+
127
+ case "instructions": {
128
+ console.log(otelServerInstructions())
129
+ break
130
+ }
131
+
132
+ case "logs": {
133
+ const service = args[0] ?? config.otel.serviceName
134
+ const result = await runQuiet(Effect.flatMap(LogQueryService.asEffect(), (query) => query.listRecentLogs(service)))
135
+ console.log(JSON.stringify(result, null, 2))
136
+ break
137
+ }
138
+
139
+ case "search-logs": {
140
+ const service = args[0] ?? config.otel.serviceName
141
+ const body = args[1] && !isAttributeFilterToken(args[1]) ? args[1] : undefined
142
+ const attributeFilters = attributeFiltersFromArgs(args.slice(body ? 2 : 1))
143
+ const result = await runQuiet(
144
+ Effect.flatMap(LogQueryService.asEffect(), (query) =>
145
+ query.searchLogs({
146
+ serviceName: service,
147
+ body,
148
+ attributeFilters,
149
+ limit: config.otel.logFetchLimit,
150
+ }),
151
+ ),
152
+ )
153
+ console.log(JSON.stringify(result, null, 2))
154
+ break
155
+ }
156
+
157
+ case "log-stats": {
158
+ const groupBy = args[0]
159
+ const service = args[1] && !isAttributeFilterToken(args[1]) ? args[1] : undefined
160
+ const attributeFilters = attributeFiltersFromArgs(args.slice(service ? 2 : 1))
161
+ if (!groupBy) {
162
+ throw new Error("Usage: bun run cli log-stats <groupBy> [service]")
163
+ }
164
+
165
+ const result = await runQuiet(
166
+ Effect.flatMap(LogQueryService.asEffect(), (query) =>
167
+ query.logStats({
168
+ groupBy,
169
+ agg: "count",
170
+ serviceName: service,
171
+ attributeFilters,
172
+ limit: 20,
173
+ }),
174
+ ),
175
+ )
176
+ console.log(JSON.stringify(result, null, 2))
177
+ break
178
+ }
179
+
180
+ case "trace-logs": {
181
+ const traceId = args[0]
182
+ if (!traceId) {
183
+ throw new Error("Usage: bun run cli trace-logs <trace-id>")
184
+ }
185
+
186
+ const result = await runQuiet(Effect.flatMap(LogQueryService.asEffect(), (query) => query.listTraceLogs(traceId)))
187
+ console.log(JSON.stringify(result, null, 2))
188
+ break
189
+ }
190
+
191
+ case "span-logs": {
192
+ const spanId = args[0]
193
+ if (!spanId) {
194
+ throw new Error("Usage: bun run cli span-logs <span-id>")
195
+ }
196
+
197
+ const result = await runQuiet(
198
+ Effect.flatMap(LogQueryService.asEffect(), (query) =>
199
+ query.searchLogs({
200
+ spanId,
201
+ limit: config.otel.logFetchLimit,
202
+ }),
203
+ ),
204
+ )
205
+ console.log(JSON.stringify(result, null, 2))
206
+ break
207
+ }
208
+
209
+ case "facets": {
210
+ const type = args[0]
211
+ const field = args[1]
212
+ if ((type !== "traces" && type !== "logs") || !field) {
213
+ throw new Error("Usage: bun run cli facets <traces|logs> <field>")
214
+ }
215
+
216
+ const result = await runQuiet(
217
+ Effect.flatMap(LogQueryService.asEffect(), (query) =>
218
+ query.listFacets({ type, field, limit: 20 }),
219
+ ),
220
+ )
221
+ console.log(JSON.stringify(result, null, 2))
222
+ break
223
+ }
224
+
225
+ case "endpoints": {
226
+ console.log(JSON.stringify({
227
+ baseUrl: config.otel.baseUrl,
228
+ exporterUrl: config.otel.exporterUrl,
229
+ logsExporterUrl: config.otel.logsExporterUrl,
230
+ queryUrl: config.otel.queryUrl,
231
+ databasePath: config.otel.databasePath,
232
+ }, null, 2))
233
+ break
234
+ }
235
+
236
+ default: {
237
+ console.log(`Usage:
238
+ bun run cli services
239
+ bun run cli traces [service] [limit]
240
+ bun run cli trace <trace-id>
241
+ bun run cli span <span-id>
242
+ bun run cli trace-spans <trace-id>
243
+ bun run cli search-spans [service] [operation] [parent=<operation>] [attr.key=value ...]
244
+ bun run cli search-traces [service] [operation] [attr.key=value ...]
245
+ bun run cli trace-stats <groupBy> <agg> [service] [attr.key=value ...]
246
+ bun run cli logs [service]
247
+ bun run cli search-logs [service] [body] [attr.key=value ...]
248
+ bun run cli log-stats <groupBy> [service] [attr.key=value ...]
249
+ bun run cli trace-logs <trace-id>
250
+ bun run cli span-logs <span-id>
251
+ bun run cli facets <traces|logs> <field>
252
+ bun run cli instructions
253
+ bun run cli endpoints`)
254
+ }
255
+ }
256
+ } finally {
257
+ await queryRuntime.dispose()
258
+ }
package/src/config.ts ADDED
@@ -0,0 +1,39 @@
1
+ const parseBoolean = (value: string | undefined, defaultValue: boolean) => {
2
+ const normalized = value?.trim().toLowerCase()
3
+ if (!normalized) return defaultValue
4
+ return !["0", "false", "no", "off"].includes(normalized)
5
+ }
6
+
7
+ export const parsePositiveInt = (value: string | undefined, defaultValue: number) => {
8
+ const parsed = Number.parseInt(value ?? "", 10)
9
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue
10
+ }
11
+
12
+ const baseUrl =
13
+ process.env.MOTEL_OTEL_BASE_URL?.trim() ||
14
+ process.env.MOTEL_OTEL_QUERY_URL?.trim() ||
15
+ process.env.MOTEL_OTEL_COLLECTOR_URL?.trim() ||
16
+ "http://127.0.0.1:27686"
17
+
18
+ const parsedBaseUrl = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`)
19
+ export const resolveOtelUrl = (path: string) => new URL(path.startsWith("/") ? path.slice(1) : path, parsedBaseUrl).toString()
20
+ const serverPort = parsePositiveInt(process.env.MOTEL_OTEL_PORT, Number.parseInt(parsedBaseUrl.port || "80", 10))
21
+
22
+ export const config = {
23
+ otel: {
24
+ enabled: parseBoolean(process.env.MOTEL_OTEL_ENABLED, false),
25
+ serviceName: process.env.MOTEL_OTEL_SERVICE_NAME?.trim() || "motel-otel-tui",
26
+ baseUrl,
27
+ host: process.env.MOTEL_OTEL_HOST?.trim() || parsedBaseUrl.hostname,
28
+ port: serverPort,
29
+ queryUrl: baseUrl,
30
+ exporterUrl: process.env.MOTEL_OTEL_EXPORTER_URL?.trim() || resolveOtelUrl("/v1/traces"),
31
+ logsExporterUrl: process.env.MOTEL_OTEL_LOGS_EXPORTER_URL?.trim() || resolveOtelUrl("/v1/logs"),
32
+ databasePath: process.env.MOTEL_OTEL_DB_PATH?.trim() || `${import.meta.dir}/../.motel-data/telemetry.sqlite`,
33
+ traceLookbackMinutes: parsePositiveInt(process.env.MOTEL_OTEL_TRACE_LOOKBACK_MINUTES, 1440),
34
+ traceFetchLimit: parsePositiveInt(process.env.MOTEL_OTEL_TRACE_LIMIT, 100),
35
+ logFetchLimit: parsePositiveInt(process.env.MOTEL_OTEL_LOG_LIMIT, 80),
36
+ retentionHours: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_HOURS, 168),
37
+ maxDbSizeMb: parsePositiveInt(process.env.MOTEL_OTEL_MAX_DB_SIZE_MB, 256),
38
+ },
39
+ } as const
@@ -0,0 +1,59 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import * as fs from "node:fs"
4
+ import * as os from "node:os"
5
+ import * as path from "node:path"
6
+ import { createDaemonManager } from "./daemon.js"
7
+
8
+ const repoRoot = path.resolve(import.meta.dir, "..")
9
+
10
+ const randomPort = () => 29000 + Math.floor(Math.random() * 2000)
11
+
12
+ const makeHarness = () => {
13
+ const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-test-"))
14
+ const manager = createDaemonManager({
15
+ repoRoot,
16
+ runtimeDir,
17
+ databasePath: path.join(runtimeDir, "telemetry.sqlite"),
18
+ port: randomPort(),
19
+ })
20
+ return {
21
+ runtimeDir,
22
+ manager,
23
+ }
24
+ }
25
+
26
+ const activeHarnesses: Array<ReturnType<typeof makeHarness>> = []
27
+
28
+ afterEach(async () => {
29
+ for (const harness of activeHarnesses.splice(0)) {
30
+ await Effect.runPromise(harness.manager.stop).catch(() => undefined)
31
+ fs.rmSync(harness.runtimeDir, { recursive: true, force: true })
32
+ }
33
+ })
34
+
35
+ describe("daemon manager", () => {
36
+ test("starts once, reuses the same daemon, and stops cleanly", async () => {
37
+ const harness = makeHarness()
38
+ activeHarnesses.push(harness)
39
+
40
+ const initial = await Effect.runPromise(harness.manager.getStatus)
41
+ expect(initial.running).toBe(false)
42
+
43
+ const started = await Effect.runPromise(harness.manager.ensure)
44
+ expect(started.running).toBe(true)
45
+ expect(started.managed).toBe(true)
46
+ expect(typeof started.pid).toBe("number")
47
+ expect(started.databasePath).toBe(path.join(harness.runtimeDir, "telemetry.sqlite"))
48
+
49
+ const reused = await Effect.runPromise(harness.manager.ensure)
50
+ expect(reused.running).toBe(true)
51
+ expect(reused.pid).toBe(started.pid)
52
+
53
+ const stopped = await Effect.runPromise(harness.manager.stop)
54
+ expect(stopped.running).toBe(false)
55
+
56
+ const finalStatus = await Effect.runPromise(harness.manager.getStatus)
57
+ expect(finalStatus.running).toBe(false)
58
+ })
59
+ })