@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.
- package/AGENTS.md +5 -0
- package/package.json +7 -5
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +213 -6
- package/src/daemon.ts +174 -38
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +114 -128
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +68 -0
- package/src/services/TelemetryStore.ts +262 -119
- package/src/services/TraceQueryService.ts +3 -1
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +28 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +11 -28
- package/src/ui/Waterfall.tsx +43 -148
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +288 -124
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +174 -40
- package/src/ui/atoms.ts +131 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +27 -13
- package/src/ui/state.ts +4 -199
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +552 -364
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
package/src/localServer.ts
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
|
-
import { Effect, Layer
|
|
4
|
-
import { config, parsePositiveInt
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
285
|
+
url: config.otel.baseUrl,
|
|
288
286
|
workdir: process.cwd(),
|
|
289
|
-
startedAt:
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
599
|
+
// App layer: HTTP router + static SPA + telemetry store
|
|
601
600
|
// ---------------------------------------------------------------------------
|
|
602
601
|
|
|
603
|
-
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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
|
)
|