@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/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
+ )