@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.
- package/AGENTS.md +142 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +92 -0
- package/src/App.tsx +217 -0
- package/src/cli.ts +258 -0
- package/src/config.ts +39 -0
- package/src/daemon.test.ts +59 -0
- package/src/daemon.ts +398 -0
- package/src/domain.ts +233 -0
- package/src/httpApi.ts +384 -0
- package/src/index.tsx +18 -0
- package/src/instructions.ts +72 -0
- package/src/localServer.ts +699 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +260 -0
- package/src/motel.ts +86 -0
- package/src/motelClient.ts +201 -0
- package/src/otlp.ts +142 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +86 -0
- package/src/runtime.ts +38 -0
- package/src/server.ts +10 -0
- package/src/services/LogQueryService.ts +43 -0
- package/src/services/TelemetryStore.ts +1821 -0
- package/src/services/TraceQueryService.ts +71 -0
- package/src/telemetry.test.ts +726 -0
- package/src/ui/ServiceLogs.tsx +112 -0
- package/src/ui/SpanDetail.tsx +134 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +91 -0
- package/src/ui/TraceDetailsPane.tsx +169 -0
- package/src/ui/TraceList.tsx +128 -0
- package/src/ui/Waterfall.tsx +412 -0
- package/src/ui/app/TraceListPane.tsx +34 -0
- package/src/ui/app/TraceWorkspace.tsx +254 -0
- package/src/ui/app/useAppLayout.ts +79 -0
- package/src/ui/app/useTraceScreenData.ts +411 -0
- package/src/ui/format.ts +119 -0
- package/src/ui/primitives.tsx +170 -0
- package/src/ui/state.ts +137 -0
- package/src/ui/theme.ts +153 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +220 -0
- package/src/ui/useKeyboardNav.ts +532 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +263 -0
- package/src/ui/waterfallNav.test.ts +422 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-BEKIiisE.js +27 -0
- package/web/dist/assets/index-DzuHNBGV.css +2 -0
- 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
|
+
})
|