@kitlangton/motel 0.2.0 → 0.2.4

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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +7 -5
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +213 -6
  5. package/src/daemon.ts +174 -38
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +114 -128
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +68 -0
  15. package/src/services/TelemetryStore.ts +262 -119
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +244 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +308 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +11 -28
  32. package/src/ui/Waterfall.tsx +43 -148
  33. package/src/ui/aiChatModel.test.ts +391 -0
  34. package/src/ui/aiChatModel.ts +773 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +288 -124
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +174 -40
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
@@ -1,18 +1,27 @@
1
1
  import { promises as fs } from "node:fs"
2
2
  import path from "node:path"
3
- import { Effect, Layer, Context } from "effect"
4
- import { config, parsePositiveInt, resolveOtelUrl } from "./config.js"
3
+ import { Effect, Layer } from "effect"
4
+ import { config, parsePositiveInt } from "./config.js"
5
5
  import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
6
+ import * as HttpMiddleware from "effect/unstable/http/HttpMiddleware"
6
7
  import * as HttpRouter from "effect/unstable/http/HttpRouter"
7
- import * as HttpServer from "effect/unstable/http/HttpServer"
8
8
  import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
9
+ import * as HttpStaticServer from "effect/unstable/http/HttpStaticServer"
10
+ import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"
9
11
  import { MotelHttpApi } from "./httpApi.js"
10
- import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries, ATTRIBUTE_FILTER_PREFIX, ATTRIBUTE_CONTAINS_PREFIX } from "./queryFilters.js"
11
- import { MOTEL_SERVICE_ID, MOTEL_VERSION, writeRegistryEntry } from "./registry.js"
12
+ import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries } from "./queryFilters.js"
13
+ import { MOTEL_SERVICE_ID, MOTEL_VERSION, removeRegistryEntry, writeRegistryEntry } from "./registry.js"
14
+ import { AsyncIngest, AsyncIngestLive } from "./services/AsyncIngest.js"
12
15
  import { TelemetryStore, TelemetryStoreLive } from "./services/TelemetryStore.js"
13
16
  import type { LogItem, TraceItem, TraceSummaryItem } from "./domain.js"
14
17
  import { lifecycleLabel } from "./ui/format.js"
15
18
 
19
+ // Set by the RegistryLayer acquisition once the Bun socket has bound.
20
+ // Both /api/health and the registry entry read from here so they agree
21
+ // on a single server-start timestamp, and the value reflects actual
22
+ // listen time rather than module-evaluation time.
23
+ let serverStartedAt: string = new Date(0).toISOString()
24
+
16
25
  const TRACE_DEFAULT_LIMIT = 20
17
26
  const TRACE_MAX_LIMIT = 100
18
27
  const TRACE_DEFAULT_LOOKBACK = 60
@@ -24,28 +33,21 @@ const LOG_MAX_LIMIT = 500
24
33
  const LOG_DEFAULT_LOOKBACK = 60
25
34
  const LOG_MAX_LOOKBACK = 24 * 60
26
35
 
27
- let server: ReturnType<typeof Bun.serve> | null = null
28
- let disposeWebHandler: (() => Promise<void>) | null = null
29
- let startedAt: string | null = null
30
-
31
- const resolveBoundUrl = () => {
32
- if (!server) return config.otel.queryUrl
33
- const host = server.hostname === "0.0.0.0" || server.hostname === "::" ? "127.0.0.1" : server.hostname
34
- return `http://${host}:${server.port}`
35
- }
36
-
37
36
  const jsonResponse = (value: unknown, status = 200) => HttpServerResponse.jsonUnsafe(value, { status })
38
37
  const textResponse = (value: string) => HttpServerResponse.text(value)
39
38
  const htmlResponse = (value: string) => HttpServerResponse.html(value)
40
39
  const notFoundResponse = (message = "Not found") => jsonResponse({ error: message }, 404)
41
40
  const requestUrl = (request: { readonly url: string }) => new URL(request.url, config.otel.baseUrl)
42
41
  const withStore = <A>(f: (store: TelemetryStore["Service"]) => Effect.Effect<A, Error>) => Effect.flatMap(TelemetryStore.asEffect(), f)
43
- const respondJson = <A>(effect: Effect.Effect<A, unknown, TelemetryStore>) =>
42
+ // Response-building helpers are generic in R so a handler can depend
43
+ // on TelemetryStore (query path) or AsyncIngest (worker-RPC path)
44
+ // without forcing every handler onto the same service surface.
45
+ const respondJson = <A, R>(effect: Effect.Effect<A, unknown, R>) =>
44
46
  Effect.match(effect, {
45
47
  onFailure: (error) => jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500),
46
48
  onSuccess: (value) => jsonResponse(value),
47
49
  })
48
- const respondRaw = (effect: Effect.Effect<ReturnType<typeof jsonResponse>, unknown, TelemetryStore>) =>
50
+ const respondRaw = <R>(effect: Effect.Effect<ReturnType<typeof jsonResponse>, unknown, R>) =>
49
51
  Effect.match(effect, {
50
52
  onFailure: (error) => jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500),
51
53
  onSuccess: (value) => value,
@@ -70,14 +72,10 @@ const parseLookbackMinutes = (value: string | null, fallback: number) => {
70
72
  const parseBoundedLookbackMinutes = (value: string | null, fallback: number, max: number) => clamp(parseLookbackMinutes(value, fallback), 1, max)
71
73
 
72
74
  const attributeFiltersFromQuery = (url: URL) =>
73
- attributeFiltersFromEntries(
74
- [...url.searchParams.entries()].filter(([key]) => key.startsWith(ATTRIBUTE_FILTER_PREFIX) && !key.startsWith(ATTRIBUTE_CONTAINS_PREFIX)),
75
- )
75
+ attributeFiltersFromEntries(url.searchParams.entries())
76
76
 
77
77
  const attributeContainsFiltersFromQuery = (url: URL) =>
78
- attributeContainsFiltersFromEntries(
79
- [...url.searchParams.entries()].filter(([key]) => key.startsWith(ATTRIBUTE_CONTAINS_PREFIX)),
80
- )
78
+ attributeContainsFiltersFromEntries(url.searchParams.entries())
81
79
 
82
80
  type CursorShape =
83
81
  | { readonly kind: "trace"; readonly startedAt: number; readonly id: string }
@@ -284,23 +282,33 @@ const TelemetryGroupLive = HttpApiBuilder.group(
284
282
  service: MOTEL_SERVICE_ID,
285
283
  databasePath: config.otel.databasePath,
286
284
  pid: process.pid,
287
- url: resolveBoundUrl(),
285
+ url: config.otel.baseUrl,
288
286
  workdir: process.cwd(),
289
- startedAt: startedAt ?? new Date(0).toISOString(),
287
+ startedAt: serverStartedAt,
290
288
  version: MOTEL_VERSION,
291
289
  }),
292
290
  )
291
+ // OTLP ingest is routed to the worker thread via AsyncIngest
292
+ // so the main event loop stays free during heavy SQLite writes.
293
+ // Everything else still uses the direct TelemetryStore — reads
294
+ // are fast enough that IPC overhead isn't worth paying.
293
295
  .handleRaw("ingestTraces", ({ request }) =>
294
296
  respondRaw(
295
297
  Effect.flatMap(request.json, (payload) =>
296
- Effect.map(withStore((store) => store.ingestTraces(payload as any)), (result) => jsonResponse(result)),
298
+ Effect.map(
299
+ Effect.flatMap(AsyncIngest.asEffect(), (ingest) => ingest.ingestTraces({ payload })),
300
+ (result) => jsonResponse(result),
301
+ ),
297
302
  ),
298
303
  ),
299
304
  )
300
305
  .handleRaw("ingestLogs", ({ request }) =>
301
306
  respondRaw(
302
307
  Effect.flatMap(request.json, (payload) =>
303
- Effect.map(withStore((store) => store.ingestLogs(payload as any)), (result) => jsonResponse(result)),
308
+ Effect.map(
309
+ Effect.flatMap(AsyncIngest.asEffect(), (ingest) => ingest.ingestLogs({ payload })),
310
+ (result) => jsonResponse(result),
311
+ ),
304
312
  ),
305
313
  ),
306
314
  )
@@ -587,115 +595,93 @@ const TelemetryGroupLive = HttpApiBuilder.group(
587
595
  ),
588
596
  )
589
597
 
590
- const ApiLive = Layer.provideMerge(
591
- HttpApiBuilder.layer(MotelHttpApi, { openapiPath: "/openapi.json" }).pipe(
592
- Layer.provide(TelemetryGroupLive),
593
- Layer.provide(HttpApiScalar.layer(MotelHttpApi, { scalar: { forceDarkModeState: "dark", showOperationId: true } })),
594
- Layer.provide(HttpServer.layerServices),
595
- ),
596
- TelemetryStoreLive,
597
- )
598
-
599
598
  // ---------------------------------------------------------------------------
600
- // Static file serving for the web UI
599
+ // App layer: HTTP router + static SPA + telemetry store
601
600
  // ---------------------------------------------------------------------------
602
601
 
603
- const WEB_DIST_DIR = path.resolve(import.meta.dir, "../web/dist")
604
- // Only cache `true` a `false` result is rechecked so a later `web:build` is picked up
605
- let webUiAvailable = false
606
-
607
- const isWebUiAvailable = async (): Promise<boolean> => {
608
- if (webUiAvailable) return true
609
- try {
610
- webUiAvailable = await Bun.file(path.join(WEB_DIST_DIR, "index.html")).exists()
611
- } catch {
612
- /* ignore */
613
- }
614
- return webUiAvailable
615
- }
616
-
617
- /** Routes that must always go through the Effect API handler */
618
- const isStrictApiRoute = (pathname: string) =>
619
- pathname.startsWith("/api/") ||
620
- pathname.startsWith("/v1/") ||
621
- pathname === "/openapi.json" ||
622
- pathname === "/docs"
623
-
624
- const serveWebUi = async (request: Request, apiHandler: (req: Request) => Promise<Response>): Promise<Response> => {
625
- const url = new URL(request.url)
626
- const pathname = url.pathname
627
-
628
- // Strict API routes always go through the Effect handler
629
- if (isStrictApiRoute(pathname)) return apiHandler(request)
630
-
631
- // Only serve web UI if built
632
- if (!(await isWebUiAvailable())) return apiHandler(request)
602
+ // API routes come from the Effect HttpApi definition. Everything under
603
+ // /api/*, /v1/*, /openapi.json, /docs is handled here.
604
+ const ApiLayer = HttpApiBuilder.layer(MotelHttpApi, { openapiPath: "/openapi.json" }).pipe(
605
+ Layer.provide(TelemetryGroupLive),
606
+ Layer.provide(HttpApiScalar.layer(MotelHttpApi, { scalar: { forceDarkModeState: "dark", showOperationId: true } })),
607
+ )
633
608
 
634
- // Try to serve a static file from web/dist/ (hashed assets, favicon, etc.)
635
- if (pathname.startsWith("/assets/") || (pathname !== "/" && pathname.includes("."))) {
636
- const resolved = path.resolve(WEB_DIST_DIR, pathname.slice(1))
637
- if (resolved.startsWith(WEB_DIST_DIR) && await Bun.file(resolved).exists()) {
638
- return new Response(Bun.file(resolved))
639
- }
640
- }
609
+ // Web UI: Vite-built SPA served from web/dist. HttpStaticServer.layer
610
+ // handles GET /*, filesystem lookup under `root`, and SPA fallback to
611
+ // index.html for unknown paths — replacing the hand-rolled serveWebUi
612
+ // wrapper that previously lived inline with Bun.serve. The API routes
613
+ // above take precedence because HttpApi registers specific paths that
614
+ // the router matches before falling through to the /* catch-all.
615
+ const WEB_DIST_DIR = path.resolve(import.meta.dir, "../web/dist")
616
+ const StaticLayer = HttpStaticServer.layer({
617
+ root: WEB_DIST_DIR,
618
+ spa: true,
619
+ })
641
620
 
642
- // SPA fallback: serve index.html for / and all client routes
643
- return new Response(Bun.file(path.join(WEB_DIST_DIR, "index.html")), {
644
- headers: { "content-type": "text/html; charset=utf-8" },
645
- })
646
- }
621
+ // Registry-entry writer as a scoped acquisition. The entry is published
622
+ // after BunHttpServer.layer binds the socket (scope acquisition order)
623
+ // and removed on scope release, so a bind failure never leaves a zombie
624
+ // entry and a graceful shutdown cleans up alongside the server stop —
625
+ // both in the same finalizer chain managed by Layer.launch.
626
+ const RegistryLayer = Layer.effectDiscard(
627
+ Effect.acquireRelease(
628
+ Effect.sync(() => {
629
+ serverStartedAt = new Date().toISOString()
630
+ try {
631
+ writeRegistryEntry({
632
+ pid: process.pid,
633
+ url: config.otel.baseUrl,
634
+ workdir: process.cwd(),
635
+ startedAt: serverStartedAt,
636
+ version: MOTEL_VERSION,
637
+ databasePath: config.otel.databasePath,
638
+ })
639
+ } catch (err) {
640
+ console.warn(`motel: failed to write registry entry: ${(err as Error).message}`)
641
+ }
642
+ }),
643
+ () => Effect.sync(() => removeRegistryEntry(process.pid)),
644
+ ),
645
+ )
647
646
 
648
647
  // ---------------------------------------------------------------------------
649
648
  // Server lifecycle
650
649
  // ---------------------------------------------------------------------------
651
650
 
652
- export const startLocalServer = async () => {
653
- if (server) return server
654
- const { handler, dispose } = HttpRouter.toWebHandler(ApiLive, { disableLogger: true })
655
- disposeWebHandler = dispose
656
- server = Bun.serve({
657
- hostname: config.otel.host,
651
+ /**
652
+ * Launchable server layer. Composes the API + static UI + store + registry,
653
+ * wraps the whole stack in HttpMiddleware.tracer (per-request OTel spans
654
+ * with http.method / url / status / user-agent attributes), and binds the
655
+ * socket via @effect/platform-bun's BunHttpServer. Use from server.ts:
656
+ *
657
+ * await Effect.runPromise(Layer.launch(ServerLive))
658
+ *
659
+ * Socket lifecycle, graceful shutdown, and error propagation are managed
660
+ * by the BunHttpServer layer's Scope — no hand-rolled start/stop plumbing.
661
+ * `reusePort: true` is retained as defense-in-depth against TIME_WAIT
662
+ * rebind conflicts (the registry-based adoption path in daemon.ts is the
663
+ * primary protection, but this covers a raw `bun src/server.ts` restart).
664
+ */
665
+ export const ServerLive = HttpRouter.serve(
666
+ Layer.mergeAll(ApiLayer, StaticLayer, RegistryLayer),
667
+ { middleware: HttpMiddleware.tracer },
668
+ ).pipe(
669
+ // OTLP ingest paths are NOT traced by the middleware, otherwise
670
+ // MOTEL_OTEL_ENABLED creates a feedback loop: every outbound span
671
+ // POSTs to /v1/traces, the tracer emits a span for that POST, which
672
+ // POSTs again on the next flush. This also shaves ~1 KB of header
673
+ // attributes off every ingest request that would have been written
674
+ // to the spans table as noise.
675
+ Layer.provide(HttpMiddleware.layerTracerDisabledForUrls(["/v1/traces", "/v1/logs"])),
676
+ // AsyncIngest spawns the telemetry worker — keeps the main-thread
677
+ // event loop free during heavy SQLite writes. Provided alongside
678
+ // the direct TelemetryStore so query handlers can still resolve
679
+ // their dependency directly.
680
+ Layer.provideMerge(AsyncIngestLive),
681
+ Layer.provideMerge(TelemetryStoreLive),
682
+ Layer.provideMerge(BunHttpServer.layer({
658
683
  port: config.otel.port,
659
- fetch(request) {
660
- return serveWebUi(request, handler)
661
- },
662
- })
663
- startedAt = new Date().toISOString()
664
- try {
665
- writeRegistryEntry({
666
- pid: process.pid,
667
- url: resolveBoundUrl(),
668
- workdir: process.cwd(),
669
- startedAt,
670
- version: MOTEL_VERSION,
671
- })
672
- } catch (err) {
673
- console.warn(`motel: failed to write registry entry: ${(err as Error).message}`)
674
- }
675
- return server
676
- }
677
-
678
- export const ensureLocalServer = async () => {
679
- if (server) return server
680
- try {
681
- const response = await fetch(resolveOtelUrl("/api/health"), { signal: AbortSignal.timeout(250) })
682
- if (response.ok) return null
683
- } catch {
684
- // Start local server below.
685
- }
686
- return await startLocalServer()
687
- }
688
-
689
- export const stopLocalServer = () => {
690
- server?.stop(true)
691
- server = null
692
- startedAt = null
693
-
694
- const dispose = disposeWebHandler
695
- disposeWebHandler = null
696
- if (dispose) {
697
- void dispose().catch((err) => {
698
- console.warn(`motel: failed to dispose web handler: ${(err as Error).message}`)
699
- })
700
- }
701
- }
684
+ hostname: config.otel.host,
685
+ reusePort: true,
686
+ })),
687
+ )
package/src/mcp.ts CHANGED
@@ -13,6 +13,13 @@ const Attributes = Schema.optional(
13
13
  }),
14
14
  )
15
15
 
16
+ const AttributeContains = Schema.optional(
17
+ Schema.Record(Schema.String, Schema.String).annotate({
18
+ description:
19
+ "Case-insensitive substring attribute filters. Key is the attribute name WITHOUT the 'attrContains.' prefix (it is added for you). Values must be strings.",
20
+ }),
21
+ )
22
+
16
23
  const Lookback = Schema.optional(
17
24
  Schema.String.annotate({
18
25
  description:
@@ -42,6 +49,12 @@ const Status = Schema.optional(
42
49
  }),
43
50
  )
44
51
 
52
+ const Severity = Schema.optional(
53
+ Schema.String.annotate({
54
+ description: "Filter by log severity, e.g. TRACE, DEBUG, INFO, WARN, ERROR, FATAL.",
55
+ }),
56
+ )
57
+
45
58
  const StatusTool = Tool.make("motel_status", {
46
59
  description:
47
60
  "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.",
@@ -123,11 +136,65 @@ const GetTraceLogsTool = Tool.make("motel_get_trace_logs", {
123
136
  success: Schema.Unknown,
124
137
  }).annotate(Tool.Readonly, true)
125
138
 
139
+ const GetTraceSpansTool = Tool.make("motel_get_trace_spans", {
140
+ description:
141
+ "Fetch the flat span list for a specific trace. Use this when you already know the traceId and want to inspect span durations, status, parents, and raw attributes without the nested trace wrapper.",
142
+ parameters: Schema.Struct({
143
+ traceId: Schema.String.annotate({ description: "Full 32-character hex trace ID." }),
144
+ }),
145
+ success: Schema.Unknown,
146
+ }).annotate(Tool.Readonly, true)
147
+
148
+ const SearchSpansTool = Tool.make("motel_search_spans", {
149
+ description:
150
+ "Search spans directly by service, traceId, operation, parentOperation, status, time window, and raw OTel attributes. Use this when traces are too coarse and you need to find the exact span or suspicious operation first.",
151
+ parameters: Schema.Struct({
152
+ service: ServiceParam,
153
+ traceId: Schema.optional(
154
+ Schema.String.annotate({ description: "Scope search to a single trace ID." }),
155
+ ),
156
+ operation: Schema.optional(
157
+ Schema.String.annotate({ description: "Substring match on span operation name." }),
158
+ ),
159
+ parentOperation: Schema.optional(
160
+ Schema.String.annotate({ description: "Substring match on parent operation name." }),
161
+ ),
162
+ status: Status,
163
+ attributes: Attributes,
164
+ attributeContains: AttributeContains,
165
+ lookback: Lookback,
166
+ limit: Limit,
167
+ }),
168
+ success: Schema.Unknown,
169
+ }).annotate(Tool.Readonly, true)
170
+
171
+ const GetSpanTool = Tool.make("motel_get_span", {
172
+ description:
173
+ "Fetch a single span by its 16-character hex spanId. Use this after motel_search_spans to inspect one span's full payload, parent trace, raw tags, and events.",
174
+ parameters: Schema.Struct({
175
+ spanId: Schema.String.annotate({ description: "Full 16-character hex span ID." }),
176
+ }),
177
+ success: Schema.Unknown,
178
+ }).annotate(Tool.Readonly, true)
179
+
180
+ const GetSpanLogsTool = Tool.make("motel_get_span_logs", {
181
+ description:
182
+ "Fetch log records correlated with a specific span. Use this after motel_get_span when you need the exact logs emitted from that one span, not the entire trace.",
183
+ parameters: Schema.Struct({
184
+ spanId: Schema.String.annotate({ description: "Full 16-character hex span ID." }),
185
+ lookback: Lookback,
186
+ limit: Limit,
187
+ cursor: Cursor,
188
+ }),
189
+ success: Schema.Unknown,
190
+ }).annotate(Tool.Readonly, true)
191
+
126
192
  const SearchLogsTool = Tool.make("motel_search_logs", {
127
193
  description:
128
194
  "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
195
  parameters: Schema.Struct({
130
196
  service: ServiceParam,
197
+ severity: Severity,
131
198
  traceId: Schema.optional(
132
199
  Schema.String.annotate({ description: "Filter by trace ID." }),
133
200
  ),
@@ -138,6 +205,7 @@ const SearchLogsTool = Tool.make("motel_search_logs", {
138
205
  Schema.String.annotate({ description: "Substring match on log body (case-sensitive)." }),
139
206
  ),
140
207
  attributes: Attributes,
208
+ attributeContains: AttributeContains,
141
209
  lookback: Lookback,
142
210
  limit: Limit,
143
211
  cursor: Cursor,
@@ -145,6 +213,79 @@ const SearchLogsTool = Tool.make("motel_search_logs", {
145
213
  success: Schema.Unknown,
146
214
  }).annotate(Tool.Readonly, true)
147
215
 
216
+ const SearchAiCallsTool = Tool.make("motel_search_ai_calls", {
217
+ description:
218
+ "Search normalized AI calls such as streamText and generateText by session, provider, model, functionId, operation, duration, status, or free-text prompt/response content. Use this for LLM-specific investigations rather than raw span search.",
219
+ parameters: Schema.Struct({
220
+ service: ServiceParam,
221
+ traceId: Schema.optional(Schema.String.annotate({ description: "Filter by trace ID." })),
222
+ sessionId: Schema.optional(Schema.String.annotate({ description: "Filter by normalized AI sessionId." })),
223
+ functionId: Schema.optional(Schema.String.annotate({ description: "Filter by AI functionId, e.g. session.llm." })),
224
+ provider: Schema.optional(Schema.String.annotate({ description: "Filter by provider, e.g. openai.responses." })),
225
+ model: Schema.optional(Schema.String.annotate({ description: "Filter by model ID." })),
226
+ operation: Schema.optional(Schema.String.annotate({ description: "Filter by normalized AI operation, e.g. streamText." })),
227
+ status: Status,
228
+ minDurationMs: Schema.optional(Schema.Number.annotate({ description: "Only return AI calls slower than this (ms)." })),
229
+ text: Schema.optional(Schema.String.annotate({ description: "Case-insensitive substring match across prompt, response, and tool content." })),
230
+ lookback: Lookback,
231
+ limit: Limit,
232
+ }),
233
+ success: Schema.Unknown,
234
+ }).annotate(Tool.Readonly, true)
235
+
236
+ const GetAiCallTool = Tool.make("motel_get_ai_call", {
237
+ description:
238
+ "Fetch the full detail for one AI call by spanId, including complete prompt messages, response payloads, tool calls, token usage, provider metadata, and correlated logs.",
239
+ parameters: Schema.Struct({
240
+ spanId: Schema.String.annotate({ description: "The span ID of the AI call." }),
241
+ }),
242
+ success: Schema.Unknown,
243
+ }).annotate(Tool.Readonly, true)
244
+
245
+ const AiStatsTool = Tool.make("motel_ai_stats", {
246
+ description:
247
+ "Aggregate AI call statistics grouped by provider, model, functionId, sessionId, or status. Use this before paging raw AI calls when you want to understand which models are slowest or which functions consume the most tokens.",
248
+ parameters: Schema.Struct({
249
+ groupBy: Schema.Literals(["provider", "model", "functionId", "sessionId", "status"]),
250
+ agg: Schema.Literals(["count", "avg_duration", "p95_duration", "total_input_tokens", "total_output_tokens"]),
251
+ service: ServiceParam,
252
+ traceId: Schema.optional(Schema.String),
253
+ sessionId: Schema.optional(Schema.String),
254
+ functionId: Schema.optional(Schema.String),
255
+ provider: Schema.optional(Schema.String),
256
+ model: Schema.optional(Schema.String),
257
+ operation: Schema.optional(Schema.String),
258
+ status: Status,
259
+ minDurationMs: Schema.optional(Schema.Number),
260
+ lookback: Lookback,
261
+ limit: Limit,
262
+ }),
263
+ success: Schema.Unknown,
264
+ }).annotate(Tool.Readonly, true)
265
+
266
+ const DocsIndexTool = Tool.make("motel_docs_index", {
267
+ description:
268
+ "List the documentation pages bundled with motel, such as the debug workflow and Effect guide. Use this before motel_get_doc if you are unsure which docs are available.",
269
+ parameters: Tool.EmptyParams,
270
+ success: Schema.Unknown,
271
+ }).annotate(Tool.Readonly, true)
272
+
273
+ const GetDocTool = Tool.make("motel_get_doc", {
274
+ description:
275
+ "Fetch a bundled motel documentation page as markdown text. Useful for giving an agent the exact debug workflow or Effect instrumentation guidance without leaving MCP.",
276
+ parameters: Schema.Struct({
277
+ name: Schema.String.annotate({ description: "Document name, e.g. 'debug' or 'effect'." }),
278
+ }),
279
+ success: Schema.Unknown,
280
+ }).annotate(Tool.Readonly, true)
281
+
282
+ const OpenApiTool = Tool.make("motel_openapi", {
283
+ description:
284
+ "Fetch motel's OpenAPI JSON document. Use this when you need the authoritative HTTP API surface or want to compare MCP coverage against the server routes.",
285
+ parameters: Tool.EmptyParams,
286
+ success: Schema.Unknown,
287
+ }).annotate(Tool.Readonly, true)
288
+
148
289
  const TraceStatsTool = Tool.make("motel_traces_stats", {
149
290
  description:
150
291
  "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'.",
@@ -189,9 +330,19 @@ const MotelToolkit = Toolkit.make(
189
330
  SearchTracesTool,
190
331
  GetTraceTool,
191
332
  GetTraceLogsTool,
333
+ GetTraceSpansTool,
334
+ SearchSpansTool,
335
+ GetSpanTool,
336
+ GetSpanLogsTool,
192
337
  SearchLogsTool,
338
+ SearchAiCallsTool,
339
+ GetAiCallTool,
340
+ AiStatsTool,
193
341
  TraceStatsTool,
194
342
  LogStatsTool,
343
+ DocsIndexTool,
344
+ GetDocTool,
345
+ OpenApiTool,
195
346
  )
196
347
 
197
348
  const asResult = <A>(effect: Effect.Effect<A, { readonly message: string }>) =>
@@ -234,11 +385,32 @@ const ToolHandlers = MotelToolkit.toLayer(
234
385
  motel_get_trace_logs: ({ traceId, lookback, limit, cursor }) =>
235
386
  asResult(client.getTraceLogs(traceId, { lookback, limit, cursor })),
236
387
 
388
+ motel_get_trace_spans: ({ traceId }) => asResult(client.getTraceSpans(traceId)),
389
+
390
+ motel_search_spans: (input) => asResult(client.searchSpans(input)),
391
+
392
+ motel_get_span: ({ spanId }) => asResult(client.getSpan(spanId)),
393
+
394
+ motel_get_span_logs: ({ spanId, lookback, limit, cursor }) =>
395
+ asResult(client.getSpanLogs(spanId, { lookback, limit, cursor })),
396
+
237
397
  motel_search_logs: (input) => asResult(client.searchLogs(input)),
238
398
 
399
+ motel_search_ai_calls: (input) => asResult(client.searchAiCalls(input)),
400
+
401
+ motel_get_ai_call: ({ spanId }) => asResult(client.getAiCall(spanId)),
402
+
403
+ motel_ai_stats: (input) => asResult(client.aiCallStats(input)),
404
+
239
405
  motel_traces_stats: (input) => asResult(client.traceStats(input)),
240
406
 
241
407
  motel_logs_stats: (input) => asResult(client.logStats(input)),
408
+
409
+ motel_docs_index: () => asResult(client.docs),
410
+
411
+ motel_get_doc: ({ name }) => asResult(Effect.map(client.getDoc(name), (data) => ({ data }))),
412
+
413
+ motel_openapi: () => asResult(client.openapi),
242
414
  }
243
415
  }),
244
416
  )