@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/locator.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Effect, Layer, Ref, Context } from "effect"
|
|
2
|
+
import { listAliveEntries, MOTEL_SERVICE_ID, type RegistryEntry } from "./registry.js"
|
|
3
|
+
|
|
4
|
+
export class LocatorError extends Error {
|
|
5
|
+
readonly _tag = "LocatorError"
|
|
6
|
+
constructor(readonly detail: string) {
|
|
7
|
+
super(detail)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ambiguousDetail = (candidates: readonly RegistryEntry[]) =>
|
|
12
|
+
`Multiple motel instances running and none match cwd. Set MOTEL_URL to choose one:\n` +
|
|
13
|
+
candidates.map((c) => ` - ${c.url} (workdir=${c.workdir}, pid=${c.pid})`).join("\n")
|
|
14
|
+
|
|
15
|
+
type Resolved = {
|
|
16
|
+
readonly url: string
|
|
17
|
+
readonly pid: number
|
|
18
|
+
readonly workdir: string
|
|
19
|
+
readonly version: string
|
|
20
|
+
readonly cwdMatch: boolean
|
|
21
|
+
readonly instanceCount: number
|
|
22
|
+
readonly source: "env" | "registry"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type HealthShape = {
|
|
26
|
+
readonly ok: boolean
|
|
27
|
+
readonly service: string
|
|
28
|
+
readonly pid: number
|
|
29
|
+
readonly url: string
|
|
30
|
+
readonly workdir: string
|
|
31
|
+
readonly version: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const handshake = (url: string): Effect.Effect<HealthShape, LocatorError> =>
|
|
35
|
+
Effect.tryPromise({
|
|
36
|
+
try: async () => {
|
|
37
|
+
const res = await fetch(new URL("/api/health", url), {
|
|
38
|
+
signal: AbortSignal.timeout(1500),
|
|
39
|
+
})
|
|
40
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
41
|
+
const body = (await res.json()) as HealthShape
|
|
42
|
+
if (body.service !== MOTEL_SERVICE_ID) {
|
|
43
|
+
throw new Error(`service=${body.service} (expected ${MOTEL_SERVICE_ID})`)
|
|
44
|
+
}
|
|
45
|
+
return body
|
|
46
|
+
},
|
|
47
|
+
catch: (err) => new LocatorError(`Handshake with ${url} failed: ${(err as Error).message}`),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const pickByCwd = (entries: readonly RegistryEntry[], cwd: string) => {
|
|
51
|
+
const withSep = cwd.endsWith("/") ? cwd : cwd + "/"
|
|
52
|
+
const matching = entries
|
|
53
|
+
.filter((e) => {
|
|
54
|
+
const workdir = e.workdir.endsWith("/") ? e.workdir : e.workdir + "/"
|
|
55
|
+
return withSep === workdir || withSep.startsWith(workdir)
|
|
56
|
+
})
|
|
57
|
+
.sort((a, b) => b.workdir.length - a.workdir.length)
|
|
58
|
+
return matching[0] ?? null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const discover = Effect.fn("Locator.discover")(function* () {
|
|
62
|
+
const envUrl = process.env.MOTEL_URL?.trim()
|
|
63
|
+
if (envUrl) {
|
|
64
|
+
const health = yield* handshake(envUrl)
|
|
65
|
+
return {
|
|
66
|
+
url: envUrl,
|
|
67
|
+
pid: health.pid,
|
|
68
|
+
workdir: health.workdir,
|
|
69
|
+
version: health.version,
|
|
70
|
+
cwdMatch: process.cwd().startsWith(health.workdir),
|
|
71
|
+
instanceCount: 1,
|
|
72
|
+
source: "env" as const,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const all = listAliveEntries()
|
|
77
|
+
|
|
78
|
+
if (all.length === 0) {
|
|
79
|
+
return yield* Effect.fail(
|
|
80
|
+
new LocatorError(
|
|
81
|
+
"No motel instance found. Start one with `bun run server` from your project root, then retry.",
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const cwd = process.cwd()
|
|
87
|
+
const byCwd = pickByCwd(all, cwd)
|
|
88
|
+
const chosen = byCwd ?? (all.length === 1 ? all[0]! : null)
|
|
89
|
+
|
|
90
|
+
if (!chosen) {
|
|
91
|
+
return yield* Effect.fail(new LocatorError(ambiguousDetail(all)))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const health = yield* handshake(chosen.url)
|
|
95
|
+
|
|
96
|
+
if (health.pid !== chosen.pid) {
|
|
97
|
+
return yield* Effect.fail(
|
|
98
|
+
new LocatorError(
|
|
99
|
+
`Registry entry pid=${chosen.pid} but server at ${chosen.url} reports pid=${health.pid}. Stale entry — next discovery will prune it.`,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
url: chosen.url,
|
|
106
|
+
pid: health.pid,
|
|
107
|
+
workdir: health.workdir,
|
|
108
|
+
version: health.version,
|
|
109
|
+
cwdMatch: cwd.startsWith(chosen.workdir),
|
|
110
|
+
instanceCount: all.length,
|
|
111
|
+
source: "registry" as const,
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
export class Locator extends Context.Service<
|
|
116
|
+
Locator,
|
|
117
|
+
{
|
|
118
|
+
readonly resolve: Effect.Effect<Resolved, LocatorError>
|
|
119
|
+
readonly invalidate: Effect.Effect<void>
|
|
120
|
+
}
|
|
121
|
+
>()("motel/Locator") {}
|
|
122
|
+
|
|
123
|
+
export const LocatorLive = Layer.effect(
|
|
124
|
+
Locator,
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const cache = yield* Ref.make<Resolved | null>(null)
|
|
127
|
+
return {
|
|
128
|
+
resolve: Effect.gen(function* () {
|
|
129
|
+
const cached = yield* Ref.get(cache)
|
|
130
|
+
if (cached) return cached
|
|
131
|
+
const resolved = yield* discover()
|
|
132
|
+
yield* Ref.set(cache, resolved)
|
|
133
|
+
return resolved
|
|
134
|
+
}),
|
|
135
|
+
invalidate: Ref.set(cache, null),
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
)
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { BunRuntime, BunStdio } from "@effect/platform-bun"
|
|
3
|
+
import { Effect, Layer, Logger, Schema } from "effect"
|
|
4
|
+
import { McpServer, Tool, Toolkit } from "effect/unstable/ai"
|
|
5
|
+
import { TraceSpanStatus } from "./domain.js"
|
|
6
|
+
import { MotelClient, MotelClientLive } from "./motelClient.js"
|
|
7
|
+
import { Locator, LocatorLive } from "./locator.js"
|
|
8
|
+
|
|
9
|
+
const Attributes = Schema.optional(
|
|
10
|
+
Schema.Record(Schema.String, Schema.String).annotate({
|
|
11
|
+
description:
|
|
12
|
+
"Arbitrary OTel attribute filters. Key is the attribute name WITHOUT the 'attr.' prefix (it is added for you). Values must be strings.",
|
|
13
|
+
}),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const Lookback = Schema.optional(
|
|
17
|
+
Schema.String.annotate({
|
|
18
|
+
description:
|
|
19
|
+
"Time window to look back, e.g. '15m', '1h', '6h', '1d'. Max 24h. Default 60m.",
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const Limit = Schema.optional(
|
|
24
|
+
Schema.Number.annotate({ description: "Max items to return in this page. Tool defaults apply." }),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const Cursor = Schema.optional(
|
|
28
|
+
Schema.String.annotate({
|
|
29
|
+
description:
|
|
30
|
+
"Opaque pagination cursor from a previous response's meta.nextCursor. Pass it back to fetch the next page.",
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const ServiceParam = Schema.optional(
|
|
35
|
+
Schema.String.annotate({ description: "Filter by OTel service name (e.g. 'opencode', 'my-app')." }),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const Status = Schema.optional(
|
|
39
|
+
TraceSpanStatus.annotate({
|
|
40
|
+
description:
|
|
41
|
+
"Filter by trace health. 'error' = at least one span errored. 'ok' = no errors.",
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const StatusTool = Tool.make("motel_status", {
|
|
46
|
+
description:
|
|
47
|
+
"Check which motel instance this shim is connected to. Call this FIRST if any other tool errors, to confirm the connection. Returns url, version, workdir, whether the cwd matches, and how many motel instances are running on this machine.",
|
|
48
|
+
parameters: Tool.EmptyParams,
|
|
49
|
+
success: Schema.Struct({
|
|
50
|
+
connected: Schema.Boolean,
|
|
51
|
+
url: Schema.optional(Schema.String),
|
|
52
|
+
version: Schema.optional(Schema.String),
|
|
53
|
+
workdir: Schema.optional(Schema.String),
|
|
54
|
+
cwdMatch: Schema.optional(Schema.Boolean),
|
|
55
|
+
instanceCount: Schema.optional(Schema.Number),
|
|
56
|
+
source: Schema.optional(Schema.String),
|
|
57
|
+
error: Schema.optional(Schema.String),
|
|
58
|
+
}),
|
|
59
|
+
}).annotate(Tool.Readonly, true)
|
|
60
|
+
|
|
61
|
+
const ServicesTool = Tool.make("motel_services", {
|
|
62
|
+
description:
|
|
63
|
+
"List every OTel service name that has emitted traces or logs recently. Use this to discover what's being observed before narrowing down with motel_search_traces or motel_search_logs.",
|
|
64
|
+
parameters: Tool.EmptyParams,
|
|
65
|
+
success: Schema.Unknown,
|
|
66
|
+
}).annotate(Tool.Readonly, true)
|
|
67
|
+
|
|
68
|
+
const FacetsTool = Tool.make("motel_facets", {
|
|
69
|
+
description:
|
|
70
|
+
"Return distinct values and counts for a given field, so the agent can see what data exists before filtering. For traces, valid fields include 'service', 'operation', 'status'. For logs, 'service', 'severity', 'scope'. Supports attr.<key> fields too.",
|
|
71
|
+
parameters: Schema.Struct({
|
|
72
|
+
type: Schema.Literals(["traces", "logs"]).annotate({
|
|
73
|
+
description: "Which dataset to facet.",
|
|
74
|
+
}),
|
|
75
|
+
field: Schema.String.annotate({
|
|
76
|
+
description: "The column or attr.<key> to return distinct values for.",
|
|
77
|
+
}),
|
|
78
|
+
service: ServiceParam,
|
|
79
|
+
lookback: Lookback,
|
|
80
|
+
limit: Limit,
|
|
81
|
+
}),
|
|
82
|
+
success: Schema.Unknown,
|
|
83
|
+
}).annotate(Tool.Readonly, true)
|
|
84
|
+
|
|
85
|
+
const SearchTracesTool = Tool.make("motel_search_traces", {
|
|
86
|
+
description:
|
|
87
|
+
"Search distributed traces by service, operation, error status, minimum duration, time window, and arbitrary OTel attributes. Returns compact trace summaries with traceId, duration, error count, span count, and a nextCursor. Drill into a specific trace with motel_get_trace. For 'what just broke' investigations, pass status='error' with a short lookback like '15m'.",
|
|
88
|
+
parameters: Schema.Struct({
|
|
89
|
+
service: ServiceParam,
|
|
90
|
+
operation: Schema.optional(
|
|
91
|
+
Schema.String.annotate({ description: "Substring match on span operation name." }),
|
|
92
|
+
),
|
|
93
|
+
status: Status,
|
|
94
|
+
minDurationMs: Schema.optional(
|
|
95
|
+
Schema.Number.annotate({ description: "Only return traces slower than this (ms)." }),
|
|
96
|
+
),
|
|
97
|
+
attributes: Attributes,
|
|
98
|
+
lookback: Lookback,
|
|
99
|
+
limit: Limit,
|
|
100
|
+
cursor: Cursor,
|
|
101
|
+
}),
|
|
102
|
+
success: Schema.Unknown,
|
|
103
|
+
}).annotate(Tool.Readonly, true)
|
|
104
|
+
|
|
105
|
+
const GetTraceTool = Tool.make("motel_get_trace", {
|
|
106
|
+
description:
|
|
107
|
+
"Fetch a single trace by its 32-character hex traceId, including the full span tree ordered parent-first. Use this to drill into a trace found via motel_search_traces. For the logs emitted inside this trace, use motel_get_trace_logs instead.",
|
|
108
|
+
parameters: Schema.Struct({
|
|
109
|
+
traceId: Schema.String.annotate({ description: "Full 32-character hex trace ID." }),
|
|
110
|
+
}),
|
|
111
|
+
success: Schema.Unknown,
|
|
112
|
+
}).annotate(Tool.Readonly, true)
|
|
113
|
+
|
|
114
|
+
const GetTraceLogsTool = Tool.make("motel_get_trace_logs", {
|
|
115
|
+
description:
|
|
116
|
+
"Fetch log records correlated with a specific trace, across all spans. When investigating a failing trace, call this before motel_search_logs — it is the most scoped and usually the most informative log view.",
|
|
117
|
+
parameters: Schema.Struct({
|
|
118
|
+
traceId: Schema.String.annotate({ description: "Full 32-character hex trace ID." }),
|
|
119
|
+
lookback: Lookback,
|
|
120
|
+
limit: Limit,
|
|
121
|
+
cursor: Cursor,
|
|
122
|
+
}),
|
|
123
|
+
success: Schema.Unknown,
|
|
124
|
+
}).annotate(Tool.Readonly, true)
|
|
125
|
+
|
|
126
|
+
const SearchLogsTool = Tool.make("motel_search_logs", {
|
|
127
|
+
description:
|
|
128
|
+
"Search logs by service, trace/span correlation, body substring, time window, and arbitrary OTel attributes. Returns log entries with a nextCursor. For logs tied to a known traceId, prefer motel_get_trace_logs — it is more focused.",
|
|
129
|
+
parameters: Schema.Struct({
|
|
130
|
+
service: ServiceParam,
|
|
131
|
+
traceId: Schema.optional(
|
|
132
|
+
Schema.String.annotate({ description: "Filter by trace ID." }),
|
|
133
|
+
),
|
|
134
|
+
spanId: Schema.optional(
|
|
135
|
+
Schema.String.annotate({ description: "Filter by span ID." }),
|
|
136
|
+
),
|
|
137
|
+
body: Schema.optional(
|
|
138
|
+
Schema.String.annotate({ description: "Substring match on log body (case-sensitive)." }),
|
|
139
|
+
),
|
|
140
|
+
attributes: Attributes,
|
|
141
|
+
lookback: Lookback,
|
|
142
|
+
limit: Limit,
|
|
143
|
+
cursor: Cursor,
|
|
144
|
+
}),
|
|
145
|
+
success: Schema.Unknown,
|
|
146
|
+
}).annotate(Tool.Readonly, true)
|
|
147
|
+
|
|
148
|
+
const TraceStatsTool = Tool.make("motel_traces_stats", {
|
|
149
|
+
description:
|
|
150
|
+
"Aggregate statistics across traces: count, average duration, p95 duration, or error rate, grouped by a field like service, operation, status, or attr.<key>. Use this BEFORE paginating raw traces when you want to understand the shape of the data — for example 'what tools are the slowest' or 'which services are erroring'.",
|
|
151
|
+
parameters: Schema.Struct({
|
|
152
|
+
groupBy: Schema.String.annotate({
|
|
153
|
+
description: "Grouping dimension. Examples: 'service', 'operation', 'status', 'attr.tool.name'.",
|
|
154
|
+
}),
|
|
155
|
+
agg: Schema.Literals(["count", "avg_duration", "p95_duration", "error_rate"]),
|
|
156
|
+
service: ServiceParam,
|
|
157
|
+
operation: Schema.optional(Schema.String),
|
|
158
|
+
status: Status,
|
|
159
|
+
minDurationMs: Schema.optional(Schema.Number),
|
|
160
|
+
attributes: Attributes,
|
|
161
|
+
lookback: Lookback,
|
|
162
|
+
limit: Limit,
|
|
163
|
+
}),
|
|
164
|
+
success: Schema.Unknown,
|
|
165
|
+
}).annotate(Tool.Readonly, true)
|
|
166
|
+
|
|
167
|
+
const LogStatsTool = Tool.make("motel_logs_stats", {
|
|
168
|
+
description:
|
|
169
|
+
"Group and count logs by a field like 'severity', 'service', 'scope', or 'attr.<key>'. Useful for quickly understanding log-level distribution (e.g. how many ERROR logs there are in the last hour) before drilling into individual entries.",
|
|
170
|
+
parameters: Schema.Struct({
|
|
171
|
+
groupBy: Schema.String.annotate({
|
|
172
|
+
description: "Grouping dimension. Examples: 'service', 'severity', 'scope', 'attr.session.id'.",
|
|
173
|
+
}),
|
|
174
|
+
service: ServiceParam,
|
|
175
|
+
traceId: Schema.optional(Schema.String),
|
|
176
|
+
spanId: Schema.optional(Schema.String),
|
|
177
|
+
body: Schema.optional(Schema.String),
|
|
178
|
+
attributes: Attributes,
|
|
179
|
+
lookback: Lookback,
|
|
180
|
+
limit: Limit,
|
|
181
|
+
}),
|
|
182
|
+
success: Schema.Unknown,
|
|
183
|
+
}).annotate(Tool.Readonly, true)
|
|
184
|
+
|
|
185
|
+
const MotelToolkit = Toolkit.make(
|
|
186
|
+
StatusTool,
|
|
187
|
+
ServicesTool,
|
|
188
|
+
FacetsTool,
|
|
189
|
+
SearchTracesTool,
|
|
190
|
+
GetTraceTool,
|
|
191
|
+
GetTraceLogsTool,
|
|
192
|
+
SearchLogsTool,
|
|
193
|
+
TraceStatsTool,
|
|
194
|
+
LogStatsTool,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const asResult = <A>(effect: Effect.Effect<A, { readonly message: string }>) =>
|
|
198
|
+
Effect.match(effect, {
|
|
199
|
+
onFailure: (err) => ({ error: err.message }) as unknown,
|
|
200
|
+
onSuccess: (value) => value as unknown,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const ToolHandlers = MotelToolkit.toLayer(
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const client = yield* MotelClient
|
|
206
|
+
const locator = yield* Locator
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
motel_status: () =>
|
|
210
|
+
Effect.match(locator.resolve, {
|
|
211
|
+
onFailure: (err) => ({
|
|
212
|
+
connected: false as const,
|
|
213
|
+
error: err instanceof Error ? err.message : String(err),
|
|
214
|
+
}),
|
|
215
|
+
onSuccess: (r) => ({
|
|
216
|
+
connected: true as const,
|
|
217
|
+
url: r.url,
|
|
218
|
+
version: r.version,
|
|
219
|
+
workdir: r.workdir,
|
|
220
|
+
cwdMatch: r.cwdMatch,
|
|
221
|
+
instanceCount: r.instanceCount,
|
|
222
|
+
source: r.source,
|
|
223
|
+
}),
|
|
224
|
+
}),
|
|
225
|
+
|
|
226
|
+
motel_services: () => asResult(client.services),
|
|
227
|
+
|
|
228
|
+
motel_facets: (input) => asResult(client.facets(input)),
|
|
229
|
+
|
|
230
|
+
motel_search_traces: (input) => asResult(client.searchTraces(input)),
|
|
231
|
+
|
|
232
|
+
motel_get_trace: ({ traceId }) => asResult(client.getTrace(traceId)),
|
|
233
|
+
|
|
234
|
+
motel_get_trace_logs: ({ traceId, lookback, limit, cursor }) =>
|
|
235
|
+
asResult(client.getTraceLogs(traceId, { lookback, limit, cursor })),
|
|
236
|
+
|
|
237
|
+
motel_search_logs: (input) => asResult(client.searchLogs(input)),
|
|
238
|
+
|
|
239
|
+
motel_traces_stats: (input) => asResult(client.traceStats(input)),
|
|
240
|
+
|
|
241
|
+
motel_logs_stats: (input) => asResult(client.logStats(input)),
|
|
242
|
+
}
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const ServerLayer = McpServer.toolkit(MotelToolkit).pipe(
|
|
247
|
+
Layer.provideMerge(ToolHandlers),
|
|
248
|
+
Layer.provide(MotelClientLive),
|
|
249
|
+
Layer.provide(LocatorLive),
|
|
250
|
+
Layer.provide(
|
|
251
|
+
McpServer.layerStdio({
|
|
252
|
+
name: "motel",
|
|
253
|
+
version: "0.1.0",
|
|
254
|
+
}),
|
|
255
|
+
),
|
|
256
|
+
Layer.provide(BunStdio.layer),
|
|
257
|
+
Layer.provide(Logger.layer([Logger.consolePretty({ stderr: true })])),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
Layer.launch(ServerLayer).pipe(BunRuntime.runMain)
|
package/src/motel.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { Effect } from "effect"
|
|
4
|
+
import { applyManagedDaemonEnv, ensureManagedDaemon, getManagedDaemonStatus, stopManagedDaemon } from "./daemon.js"
|
|
5
|
+
|
|
6
|
+
const [command, ...args] = process.argv.slice(2)
|
|
7
|
+
|
|
8
|
+
const run = <A>(effect: Effect.Effect<A, Error>) => Effect.runPromise(effect)
|
|
9
|
+
|
|
10
|
+
switch (command) {
|
|
11
|
+
case undefined:
|
|
12
|
+
case "tui":
|
|
13
|
+
case "ui": {
|
|
14
|
+
await run(applyManagedDaemonEnv)
|
|
15
|
+
await run(ensureManagedDaemon)
|
|
16
|
+
await import("./index.js")
|
|
17
|
+
break
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
case "daemon":
|
|
21
|
+
case "start": {
|
|
22
|
+
const status = await run(ensureManagedDaemon)
|
|
23
|
+
console.log(JSON.stringify(status, null, 2))
|
|
24
|
+
break
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
case "status": {
|
|
28
|
+
const status = await run(getManagedDaemonStatus)
|
|
29
|
+
console.log(JSON.stringify(status, null, 2))
|
|
30
|
+
break
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
case "stop": {
|
|
34
|
+
const status = await run(stopManagedDaemon)
|
|
35
|
+
console.log(JSON.stringify(status, null, 2))
|
|
36
|
+
break
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case "server": {
|
|
40
|
+
await run(applyManagedDaemonEnv)
|
|
41
|
+
await import("./server.js")
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
case "mcp": {
|
|
46
|
+
await import("./mcp.js")
|
|
47
|
+
break
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
case "help":
|
|
51
|
+
case "--help":
|
|
52
|
+
case "-h": {
|
|
53
|
+
console.log(`Usage:
|
|
54
|
+
motel
|
|
55
|
+
motel tui
|
|
56
|
+
motel daemon
|
|
57
|
+
motel status
|
|
58
|
+
motel stop
|
|
59
|
+
motel server
|
|
60
|
+
motel mcp
|
|
61
|
+
motel services
|
|
62
|
+
motel traces [service] [limit]
|
|
63
|
+
motel trace <trace-id>
|
|
64
|
+
motel span <span-id>
|
|
65
|
+
motel trace-spans <trace-id>
|
|
66
|
+
motel search-spans [service] [operation] [parent=<operation>] [attr.key=value ...]
|
|
67
|
+
motel search-traces [service] [operation] [attr.key=value ...]
|
|
68
|
+
motel trace-stats <groupBy> <agg> [service] [attr.key=value ...]
|
|
69
|
+
motel logs [service]
|
|
70
|
+
motel search-logs [service] [body] [attr.key=value ...]
|
|
71
|
+
motel log-stats <groupBy> [service] [attr.key=value ...]
|
|
72
|
+
motel trace-logs <trace-id>
|
|
73
|
+
motel span-logs <span-id>
|
|
74
|
+
motel facets <traces|logs> <field>
|
|
75
|
+
motel instructions
|
|
76
|
+
motel endpoints`)
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
default: {
|
|
81
|
+
await run(applyManagedDaemonEnv)
|
|
82
|
+
process.argv = [process.argv[0]!, process.argv[1]!, command, ...args]
|
|
83
|
+
await import("./cli.js")
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Effect, Layer, Context } from "effect"
|
|
2
|
+
import { Locator } from "./locator.js"
|
|
3
|
+
|
|
4
|
+
export class MotelHttpError extends Error {
|
|
5
|
+
readonly _tag = "MotelHttpError"
|
|
6
|
+
constructor(
|
|
7
|
+
readonly status: number,
|
|
8
|
+
readonly detail: string,
|
|
9
|
+
) {
|
|
10
|
+
super(`motel returned HTTP ${status}: ${detail}`)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type QueryValue = string | number | boolean | null | undefined
|
|
15
|
+
type Query = Readonly<Record<string, QueryValue>>
|
|
16
|
+
|
|
17
|
+
const appendQuery = (url: URL, query: Query | undefined) => {
|
|
18
|
+
if (!query) return url
|
|
19
|
+
for (const [key, value] of Object.entries(query)) {
|
|
20
|
+
if (value === undefined || value === null || value === "") continue
|
|
21
|
+
url.searchParams.set(key, String(value))
|
|
22
|
+
}
|
|
23
|
+
return url
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const appendAttributes = (url: URL, attributes: Readonly<Record<string, string>> | undefined) => {
|
|
27
|
+
if (!attributes) return url
|
|
28
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
29
|
+
url.searchParams.set(`attr.${key}`, value)
|
|
30
|
+
}
|
|
31
|
+
return url
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SearchTracesInput = {
|
|
35
|
+
readonly service?: string
|
|
36
|
+
readonly operation?: string
|
|
37
|
+
readonly status?: "ok" | "error"
|
|
38
|
+
readonly minDurationMs?: number
|
|
39
|
+
readonly lookback?: string
|
|
40
|
+
readonly limit?: number
|
|
41
|
+
readonly cursor?: string
|
|
42
|
+
readonly attributes?: Readonly<Record<string, string>>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type SearchLogsInput = {
|
|
46
|
+
readonly service?: string
|
|
47
|
+
readonly traceId?: string
|
|
48
|
+
readonly spanId?: string
|
|
49
|
+
readonly body?: string
|
|
50
|
+
readonly lookback?: string
|
|
51
|
+
readonly limit?: number
|
|
52
|
+
readonly cursor?: string
|
|
53
|
+
readonly attributes?: Readonly<Record<string, string>>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type TraceStatsInput = {
|
|
57
|
+
readonly groupBy: string
|
|
58
|
+
readonly agg: "count" | "avg_duration" | "p95_duration" | "error_rate"
|
|
59
|
+
readonly service?: string
|
|
60
|
+
readonly operation?: string
|
|
61
|
+
readonly status?: "ok" | "error"
|
|
62
|
+
readonly minDurationMs?: number
|
|
63
|
+
readonly lookback?: string
|
|
64
|
+
readonly limit?: number
|
|
65
|
+
readonly attributes?: Readonly<Record<string, string>>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type LogStatsInput = {
|
|
69
|
+
readonly groupBy: string
|
|
70
|
+
readonly service?: string
|
|
71
|
+
readonly traceId?: string
|
|
72
|
+
readonly spanId?: string
|
|
73
|
+
readonly body?: string
|
|
74
|
+
readonly lookback?: string
|
|
75
|
+
readonly limit?: number
|
|
76
|
+
readonly attributes?: Readonly<Record<string, string>>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type FacetsInput = {
|
|
80
|
+
readonly type: "traces" | "logs"
|
|
81
|
+
readonly field: string
|
|
82
|
+
readonly service?: string
|
|
83
|
+
readonly lookback?: string
|
|
84
|
+
readonly limit?: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class MotelClient extends Context.Service<
|
|
88
|
+
MotelClient,
|
|
89
|
+
{
|
|
90
|
+
readonly searchTraces: (input: SearchTracesInput) => Effect.Effect<unknown, MotelHttpError>
|
|
91
|
+
readonly getTrace: (traceId: string) => Effect.Effect<unknown, MotelHttpError>
|
|
92
|
+
readonly getTraceLogs: (
|
|
93
|
+
traceId: string,
|
|
94
|
+
options: { readonly lookback?: string; readonly limit?: number; readonly cursor?: string },
|
|
95
|
+
) => Effect.Effect<unknown, MotelHttpError>
|
|
96
|
+
readonly searchLogs: (input: SearchLogsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
97
|
+
readonly traceStats: (input: TraceStatsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
98
|
+
readonly logStats: (input: LogStatsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
99
|
+
readonly facets: (input: FacetsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
100
|
+
readonly services: Effect.Effect<unknown, MotelHttpError>
|
|
101
|
+
readonly health: Effect.Effect<unknown, MotelHttpError>
|
|
102
|
+
}
|
|
103
|
+
>()("motel/MotelClient") {}
|
|
104
|
+
|
|
105
|
+
export const MotelClientLive = Layer.effect(
|
|
106
|
+
MotelClient,
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const locator = yield* Locator
|
|
109
|
+
|
|
110
|
+
const get = <A = unknown>(path: string, query?: Query, attributes?: Readonly<Record<string, string>>) =>
|
|
111
|
+
Effect.gen(function* () {
|
|
112
|
+
const { url } = yield* Effect.mapError(
|
|
113
|
+
locator.resolve,
|
|
114
|
+
(err) => new MotelHttpError(0, err.message),
|
|
115
|
+
)
|
|
116
|
+
const target = appendAttributes(appendQuery(new URL(path, url), query), attributes)
|
|
117
|
+
return yield* Effect.tryPromise({
|
|
118
|
+
try: async () => {
|
|
119
|
+
const res = await fetch(target, { signal: AbortSignal.timeout(5000) })
|
|
120
|
+
const body = (await res.json().catch(() => ({ error: "invalid json" }))) as A
|
|
121
|
+
if (!res.ok) throw new MotelHttpError(res.status, JSON.stringify(body))
|
|
122
|
+
return body
|
|
123
|
+
},
|
|
124
|
+
catch: (err) =>
|
|
125
|
+
err instanceof MotelHttpError ? err : new MotelHttpError(0, (err as Error).message),
|
|
126
|
+
}).pipe(
|
|
127
|
+
Effect.tapError((err) => (err.status === 0 ? locator.invalidate : Effect.void)),
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
searchTraces: (input) =>
|
|
133
|
+
get("/api/traces/search", {
|
|
134
|
+
service: input.service,
|
|
135
|
+
operation: input.operation,
|
|
136
|
+
status: input.status,
|
|
137
|
+
minDurationMs: input.minDurationMs,
|
|
138
|
+
lookback: input.lookback,
|
|
139
|
+
limit: input.limit,
|
|
140
|
+
cursor: input.cursor,
|
|
141
|
+
}, input.attributes),
|
|
142
|
+
|
|
143
|
+
getTrace: (traceId) => get(`/api/traces/${encodeURIComponent(traceId)}`),
|
|
144
|
+
|
|
145
|
+
getTraceLogs: (traceId, options) =>
|
|
146
|
+
get(`/api/traces/${encodeURIComponent(traceId)}/logs`, {
|
|
147
|
+
lookback: options.lookback,
|
|
148
|
+
limit: options.limit,
|
|
149
|
+
cursor: options.cursor,
|
|
150
|
+
}),
|
|
151
|
+
|
|
152
|
+
searchLogs: (input) =>
|
|
153
|
+
get("/api/logs/search", {
|
|
154
|
+
service: input.service,
|
|
155
|
+
traceId: input.traceId,
|
|
156
|
+
spanId: input.spanId,
|
|
157
|
+
body: input.body,
|
|
158
|
+
lookback: input.lookback,
|
|
159
|
+
limit: input.limit,
|
|
160
|
+
cursor: input.cursor,
|
|
161
|
+
}, input.attributes),
|
|
162
|
+
|
|
163
|
+
traceStats: (input) =>
|
|
164
|
+
get("/api/traces/stats", {
|
|
165
|
+
groupBy: input.groupBy,
|
|
166
|
+
agg: input.agg,
|
|
167
|
+
service: input.service,
|
|
168
|
+
operation: input.operation,
|
|
169
|
+
status: input.status,
|
|
170
|
+
minDurationMs: input.minDurationMs,
|
|
171
|
+
lookback: input.lookback,
|
|
172
|
+
limit: input.limit,
|
|
173
|
+
}, input.attributes),
|
|
174
|
+
|
|
175
|
+
logStats: (input) =>
|
|
176
|
+
get("/api/logs/stats", {
|
|
177
|
+
groupBy: input.groupBy,
|
|
178
|
+
agg: "count",
|
|
179
|
+
service: input.service,
|
|
180
|
+
traceId: input.traceId,
|
|
181
|
+
spanId: input.spanId,
|
|
182
|
+
body: input.body,
|
|
183
|
+
lookback: input.lookback,
|
|
184
|
+
limit: input.limit,
|
|
185
|
+
}, input.attributes),
|
|
186
|
+
|
|
187
|
+
facets: (input) =>
|
|
188
|
+
get("/api/facets", {
|
|
189
|
+
type: input.type,
|
|
190
|
+
field: input.field,
|
|
191
|
+
service: input.service,
|
|
192
|
+
lookback: input.lookback,
|
|
193
|
+
limit: input.limit,
|
|
194
|
+
}),
|
|
195
|
+
|
|
196
|
+
services: get("/api/services"),
|
|
197
|
+
|
|
198
|
+
health: get("/api/health"),
|
|
199
|
+
}
|
|
200
|
+
}),
|
|
201
|
+
)
|