@aryasaatvik/motel 0.3.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 +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -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 +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import * as fs from "node:fs"
|
|
3
|
+
import * as os from "node:os"
|
|
4
|
+
import * as path from "node:path"
|
|
5
|
+
|
|
6
|
+
import rootPackage from "../package.json" with { type: "json" }
|
|
7
|
+
|
|
8
|
+
const expectedVersion = `${rootPackage.version}\n`
|
|
9
|
+
|
|
10
|
+
const motel = async (...args: ReadonlyArray<string>) => {
|
|
11
|
+
const child = Bun.spawn({
|
|
12
|
+
cmd: [process.execPath, "run", "src/motel.ts", ...args],
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
stdout: "pipe",
|
|
15
|
+
stderr: "pipe",
|
|
16
|
+
})
|
|
17
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
18
|
+
child.exited,
|
|
19
|
+
new Response(child.stdout).text(),
|
|
20
|
+
new Response(child.stderr).text(),
|
|
21
|
+
])
|
|
22
|
+
return { exitCode, stdout, stderr }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const assertMcpStartsCleanly = async (cmd: ReadonlyArray<string>, cwd = process.cwd()) => {
|
|
26
|
+
const process = Bun.spawn({ cmd: [...cmd], cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe" })
|
|
27
|
+
const earlyExit = await Promise.race([process.exited.then((exitCode) => ({ exitCode })), Bun.sleep(500).then(() => null)])
|
|
28
|
+
expect(earlyExit).toBeNull()
|
|
29
|
+
process.kill()
|
|
30
|
+
await process.exited
|
|
31
|
+
expect(await new Response(process.stdout).text()).toBe("")
|
|
32
|
+
expect(await new Response(process.stderr).text()).toBe("")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("motel command tree", () => {
|
|
36
|
+
test("renders native root and subcommand help", async () => {
|
|
37
|
+
const root = await motel("--help")
|
|
38
|
+
expect(root.exitCode).toBe(0)
|
|
39
|
+
expect(root.stdout).toContain("motel <subcommand>")
|
|
40
|
+
expect(root.stdout).not.toContain("bun run cli")
|
|
41
|
+
expect(root.stderr).toBe("")
|
|
42
|
+
|
|
43
|
+
const leaf = await motel("trace-stats", "--help")
|
|
44
|
+
expect(leaf.exitCode).toBe(0)
|
|
45
|
+
expect(leaf.stdout).toContain("motel trace-stats")
|
|
46
|
+
expect(leaf.stderr).toBe("")
|
|
47
|
+
|
|
48
|
+
const service = await motel("service", "install", "--help")
|
|
49
|
+
expect(service.exitCode).toBe(0)
|
|
50
|
+
expect(service.stdout).toContain("motel service install")
|
|
51
|
+
expect(service.stdout).toContain("--replace")
|
|
52
|
+
expect(service.stderr).toBe("")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("keeps both version aliases to the bare package version", async () => {
|
|
56
|
+
for (const flag of ["--version", "-v"]) {
|
|
57
|
+
const result = await motel(flag)
|
|
58
|
+
expect(result.exitCode).toBe(0)
|
|
59
|
+
expect(result.stdout).toBe(expectedVersion)
|
|
60
|
+
expect(result.stderr).toBe("")
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("renders focused usage for invalid command and enum input", async () => {
|
|
65
|
+
const unknown = await motel("not-a-command")
|
|
66
|
+
expect(unknown.exitCode).toBe(1)
|
|
67
|
+
expect(unknown.stdout).toContain("motel <subcommand>")
|
|
68
|
+
expect(unknown.stderr).toContain('Unknown subcommand "not-a-command"')
|
|
69
|
+
|
|
70
|
+
const invalidAggregation = await motel("trace-stats", "service", "nope")
|
|
71
|
+
expect(invalidAggregation.exitCode).toBe(1)
|
|
72
|
+
expect(invalidAggregation.stdout).toContain("motel trace-stats")
|
|
73
|
+
expect(invalidAggregation.stderr).toContain('Invalid value for argument <aggregation>: "nope"')
|
|
74
|
+
|
|
75
|
+
const invalidFilter = await motel("search-traces", "attr.bad")
|
|
76
|
+
expect(invalidFilter.exitCode).toBe(1)
|
|
77
|
+
expect(invalidFilter.stdout).toContain("motel search-traces")
|
|
78
|
+
expect(invalidFilter.stderr).toContain("attribute filters must use attr.<key>=<value>")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("prints JSON command output without CLI banners", async () => {
|
|
82
|
+
const result = await motel("endpoints")
|
|
83
|
+
expect(result.exitCode).toBe(0)
|
|
84
|
+
expect(result.stderr).toBe("")
|
|
85
|
+
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
86
|
+
baseUrl: "http://127.0.0.1:27686",
|
|
87
|
+
queryUrl: "http://127.0.0.1:27686",
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("keeps the MCP command path protocol-clean", async () => {
|
|
92
|
+
await assertMcpStartsCleanly([process.execPath, "run", "src/motel.ts", "mcp"])
|
|
93
|
+
await assertMcpStartsCleanly([process.execPath, "run", "src/mcp.ts"])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("keeps the foreground server alive until it receives a signal", async () => {
|
|
97
|
+
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "motel-server-cli-"))
|
|
98
|
+
const port = 30000 + Math.floor(Math.random() * 1000)
|
|
99
|
+
const server = Bun.spawn({
|
|
100
|
+
cmd: [process.execPath, "run", "src/motel.ts", "server"],
|
|
101
|
+
cwd: process.cwd(),
|
|
102
|
+
env: {
|
|
103
|
+
...process.env,
|
|
104
|
+
MOTEL_RUNTIME_DIR: runtimeDir,
|
|
105
|
+
MOTEL_OTEL_DB_PATH: path.join(runtimeDir, "telemetry.sqlite"),
|
|
106
|
+
MOTEL_OTEL_PORT: String(port),
|
|
107
|
+
MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`,
|
|
108
|
+
MOTEL_OTEL_QUERY_URL: `http://127.0.0.1:${port}`,
|
|
109
|
+
},
|
|
110
|
+
stdout: "pipe",
|
|
111
|
+
stderr: "pipe",
|
|
112
|
+
})
|
|
113
|
+
try {
|
|
114
|
+
let healthy = false
|
|
115
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
116
|
+
try {
|
|
117
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/health`)
|
|
118
|
+
healthy = response.ok
|
|
119
|
+
} catch {
|
|
120
|
+
await Bun.sleep(25)
|
|
121
|
+
}
|
|
122
|
+
if (healthy) break
|
|
123
|
+
}
|
|
124
|
+
expect(healthy).toBe(true)
|
|
125
|
+
expect(await Promise.race([server.exited, Bun.sleep(50).then(() => null)])).toBeNull()
|
|
126
|
+
} finally {
|
|
127
|
+
server.kill("SIGTERM")
|
|
128
|
+
await server.exited
|
|
129
|
+
fs.rmSync(runtimeDir, { recursive: true, force: true })
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("runs the packed motel binary from an isolated temporary prefix", async () => {
|
|
134
|
+
const temporaryPrefix = fs.mkdtempSync(path.join(os.tmpdir(), "motel-installed-cli-"))
|
|
135
|
+
try {
|
|
136
|
+
const pack = Bun.spawn({
|
|
137
|
+
cmd: ["bun", "pm", "pack", "--destination", temporaryPrefix, "--quiet"],
|
|
138
|
+
cwd: process.cwd(),
|
|
139
|
+
stdout: "pipe",
|
|
140
|
+
stderr: "pipe",
|
|
141
|
+
})
|
|
142
|
+
expect(await pack.exited, await new Response(pack.stderr).text()).toBe(0)
|
|
143
|
+
const tarball = fs.readdirSync(temporaryPrefix).find((entry) => entry.endsWith(".tgz"))
|
|
144
|
+
expect(tarball).toBeDefined()
|
|
145
|
+
|
|
146
|
+
const prefix = path.join(temporaryPrefix, "prefix")
|
|
147
|
+
fs.mkdirSync(prefix)
|
|
148
|
+
const install = Bun.spawn({
|
|
149
|
+
cmd: ["bun", "install", "--cwd", prefix, path.join(temporaryPrefix, tarball!)],
|
|
150
|
+
cwd: process.cwd(),
|
|
151
|
+
stdout: "pipe",
|
|
152
|
+
stderr: "pipe",
|
|
153
|
+
})
|
|
154
|
+
expect(await install.exited, await new Response(install.stderr).text()).toBe(0)
|
|
155
|
+
|
|
156
|
+
const installed = Bun.spawn({
|
|
157
|
+
cmd: [path.join(prefix, "node_modules/.bin/motel"), "--version"],
|
|
158
|
+
cwd: prefix,
|
|
159
|
+
stdout: "pipe",
|
|
160
|
+
stderr: "pipe",
|
|
161
|
+
})
|
|
162
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
163
|
+
installed.exited,
|
|
164
|
+
new Response(installed.stdout).text(),
|
|
165
|
+
new Response(installed.stderr).text(),
|
|
166
|
+
])
|
|
167
|
+
expect(exitCode).toBe(0)
|
|
168
|
+
expect(stdout).toBe(expectedVersion)
|
|
169
|
+
expect(stderr).toBe("")
|
|
170
|
+
await assertMcpStartsCleanly([path.join(prefix, "node_modules/.bin/motel"), "mcp"], prefix)
|
|
171
|
+
await assertMcpStartsCleanly([path.join(prefix, "node_modules/.bin/motel-mcp")], prefix)
|
|
172
|
+
} finally {
|
|
173
|
+
fs.rmSync(temporaryPrefix, { recursive: true, force: true })
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
})
|
package/src/motel.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { BunRuntime, BunServices } from "@effect/platform-bun"
|
|
4
|
+
import { Console, Effect, Layer } from "effect"
|
|
5
|
+
import { CliOutput, Command, Flag } from "effect/unstable/cli"
|
|
6
|
+
import packageJson from "../package.json" with { type: "json" }
|
|
7
|
+
import { queryCommands } from "./cli.js"
|
|
8
|
+
import { applyManagedDaemonEnv } from "./daemon.js"
|
|
9
|
+
import { createLaunchAgentManager, createMotelLifecycle } from "./launchAgent.js"
|
|
10
|
+
|
|
11
|
+
const json = (value: unknown) => Console.log(JSON.stringify(value, null, 2))
|
|
12
|
+
|
|
13
|
+
const tui = Command.make("tui", {}, () =>
|
|
14
|
+
Effect.gen(function*() {
|
|
15
|
+
yield* applyManagedDaemonEnv
|
|
16
|
+
yield* Effect.promise(() => import("./index.js"))
|
|
17
|
+
}),
|
|
18
|
+
).pipe(Command.withDescription("Launch the telemetry TUI"))
|
|
19
|
+
|
|
20
|
+
const lifecycle = createMotelLifecycle()
|
|
21
|
+
|
|
22
|
+
const daemon = Command.make("daemon", {}, () =>
|
|
23
|
+
lifecycle.start.pipe(Effect.andThen(json)),
|
|
24
|
+
).pipe(
|
|
25
|
+
Command.withAlias("start"),
|
|
26
|
+
Command.withDescription("Ensure the managed telemetry daemon is running"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const status = Command.make("status", {}, () =>
|
|
30
|
+
lifecycle.status.pipe(Effect.andThen(json)),
|
|
31
|
+
).pipe(Command.withDescription("Print managed daemon status"))
|
|
32
|
+
|
|
33
|
+
const stop = Command.make("stop", {}, () =>
|
|
34
|
+
lifecycle.stop.pipe(Effect.andThen(json)),
|
|
35
|
+
).pipe(Command.withDescription("Stop the managed telemetry daemon"))
|
|
36
|
+
|
|
37
|
+
const restart = Command.make("restart", {}, () =>
|
|
38
|
+
lifecycle.restart.pipe(Effect.andThen(json)),
|
|
39
|
+
).pipe(Command.withDescription("Restart only the managed telemetry daemon"))
|
|
40
|
+
|
|
41
|
+
const serviceManager = createLaunchAgentManager()
|
|
42
|
+
const serviceInstall = Command.make("install", { replace: Flag.boolean("replace") }, ({ replace }) =>
|
|
43
|
+
serviceManager.install(replace).pipe(Effect.andThen(json)),
|
|
44
|
+
).pipe(Command.withDescription("Install the per-user Motel LaunchAgent"))
|
|
45
|
+
const serviceStatus = Command.make("status", {}, () =>
|
|
46
|
+
serviceManager.status.pipe(Effect.andThen(json)),
|
|
47
|
+
).pipe(Command.withDescription("Print per-user LaunchAgent and daemon status"))
|
|
48
|
+
const serviceUninstall = Command.make("uninstall", {}, () =>
|
|
49
|
+
serviceManager.uninstall.pipe(Effect.andThen(json)),
|
|
50
|
+
).pipe(Command.withDescription("Unload and remove the per-user Motel LaunchAgent definition"))
|
|
51
|
+
const service = Command.make("service", {}, () =>
|
|
52
|
+
Effect.fail(new Error("Specify service install, service status, or service uninstall.")),
|
|
53
|
+
).pipe(
|
|
54
|
+
Command.withDescription("Manage the per-user Motel LaunchAgent"),
|
|
55
|
+
Command.withSubcommands([serviceInstall, serviceStatus, serviceUninstall]),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const server = Command.make("server", {}, () =>
|
|
59
|
+
Effect.gen(function*() {
|
|
60
|
+
yield* applyManagedDaemonEnv
|
|
61
|
+
yield* Effect.promise(() => import("./server.js"))
|
|
62
|
+
}),
|
|
63
|
+
).pipe(Command.withDescription("Run the telemetry server in the foreground"))
|
|
64
|
+
|
|
65
|
+
const mcp = Command.make("mcp", {}, () =>
|
|
66
|
+
Effect.promise(() => import("./mcp.js")),
|
|
67
|
+
).pipe(Command.withDescription("Run the Motel MCP server over stdio"))
|
|
68
|
+
|
|
69
|
+
const motel = Command.make("motel", {}, () =>
|
|
70
|
+
Effect.gen(function*() {
|
|
71
|
+
yield* applyManagedDaemonEnv
|
|
72
|
+
yield* Effect.promise(() => import("./index.js"))
|
|
73
|
+
}),
|
|
74
|
+
).pipe(
|
|
75
|
+
Command.withDescription("Local OpenTelemetry ingest and inspection"),
|
|
76
|
+
Command.withSubcommands([tui, daemon, status, stop, restart, service, server, mcp, ...queryCommands]),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const defaultFormatter = CliOutput.defaultFormatter()
|
|
80
|
+
const output = CliOutput.layer({
|
|
81
|
+
...defaultFormatter,
|
|
82
|
+
formatVersion: (_, version) => version,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
Command.run(motel, { version: packageJson.version }).pipe(
|
|
86
|
+
Effect.provide(Layer.mergeAll(BunServices.layer, output)),
|
|
87
|
+
BunRuntime.runMain,
|
|
88
|
+
)
|
|
@@ -0,0 +1,353 @@
|
|
|
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
|
+
type AttributeFilters = Readonly<Record<string, string>>
|
|
17
|
+
|
|
18
|
+
const appendQuery = (url: URL, query: Query | undefined) => {
|
|
19
|
+
if (!query) return url
|
|
20
|
+
for (const [key, value] of Object.entries(query)) {
|
|
21
|
+
if (value === undefined || value === null || value === "") continue
|
|
22
|
+
url.searchParams.set(key, String(value))
|
|
23
|
+
}
|
|
24
|
+
return url
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const appendAttributes = (url: URL, prefix: "attr" | "attrContains", attributes: AttributeFilters | undefined) => {
|
|
28
|
+
if (!attributes) return url
|
|
29
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
30
|
+
url.searchParams.set(`${prefix}.${key}`, value)
|
|
31
|
+
}
|
|
32
|
+
return url
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const appendAllAttributes = (
|
|
36
|
+
url: URL,
|
|
37
|
+
attributes: AttributeFilters | undefined,
|
|
38
|
+
attributeContains: AttributeFilters | undefined,
|
|
39
|
+
) => appendAttributes(appendAttributes(url, "attr", attributes), "attrContains", attributeContains)
|
|
40
|
+
|
|
41
|
+
export type SearchTracesInput = {
|
|
42
|
+
readonly service?: string
|
|
43
|
+
readonly operation?: string
|
|
44
|
+
readonly status?: "ok" | "error"
|
|
45
|
+
readonly minDurationMs?: number
|
|
46
|
+
readonly lookback?: string
|
|
47
|
+
readonly limit?: number
|
|
48
|
+
readonly cursor?: string
|
|
49
|
+
readonly attributes?: AttributeFilters
|
|
50
|
+
readonly attributeContains?: AttributeFilters
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type SearchSpansInput = {
|
|
54
|
+
readonly service?: string
|
|
55
|
+
readonly traceId?: string
|
|
56
|
+
readonly operation?: string
|
|
57
|
+
readonly parentOperation?: string
|
|
58
|
+
readonly status?: "ok" | "error"
|
|
59
|
+
readonly lookback?: string
|
|
60
|
+
readonly limit?: number
|
|
61
|
+
readonly attributes?: AttributeFilters
|
|
62
|
+
readonly attributeContains?: AttributeFilters
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SearchLogsInput = {
|
|
66
|
+
readonly service?: string
|
|
67
|
+
readonly severity?: string
|
|
68
|
+
readonly traceId?: string
|
|
69
|
+
readonly spanId?: string
|
|
70
|
+
readonly body?: string
|
|
71
|
+
readonly lookback?: string
|
|
72
|
+
readonly limit?: number
|
|
73
|
+
readonly cursor?: string
|
|
74
|
+
readonly attributes?: AttributeFilters
|
|
75
|
+
readonly attributeContains?: AttributeFilters
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type TraceStatsInput = {
|
|
79
|
+
readonly groupBy: string
|
|
80
|
+
readonly agg: "count" | "avg_duration" | "p95_duration" | "error_rate"
|
|
81
|
+
readonly service?: string
|
|
82
|
+
readonly operation?: string
|
|
83
|
+
readonly status?: "ok" | "error"
|
|
84
|
+
readonly minDurationMs?: number
|
|
85
|
+
readonly lookback?: string
|
|
86
|
+
readonly limit?: number
|
|
87
|
+
readonly attributes?: AttributeFilters
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type LogStatsInput = {
|
|
91
|
+
readonly groupBy: string
|
|
92
|
+
readonly service?: string
|
|
93
|
+
readonly traceId?: string
|
|
94
|
+
readonly spanId?: string
|
|
95
|
+
readonly body?: string
|
|
96
|
+
readonly lookback?: string
|
|
97
|
+
readonly limit?: number
|
|
98
|
+
readonly attributes?: AttributeFilters
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type FacetsInput = {
|
|
102
|
+
readonly type: "traces" | "logs"
|
|
103
|
+
readonly field: string
|
|
104
|
+
readonly key?: string
|
|
105
|
+
readonly service?: string
|
|
106
|
+
readonly lookback?: string
|
|
107
|
+
readonly limit?: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type TraceLogOptions = {
|
|
111
|
+
readonly lookback?: string
|
|
112
|
+
readonly limit?: number
|
|
113
|
+
readonly cursor?: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type AiCallSearchInput = {
|
|
117
|
+
readonly service?: string
|
|
118
|
+
readonly traceId?: string
|
|
119
|
+
readonly sessionId?: string
|
|
120
|
+
readonly functionId?: string
|
|
121
|
+
readonly provider?: string
|
|
122
|
+
readonly model?: string
|
|
123
|
+
readonly operation?: string
|
|
124
|
+
readonly status?: "ok" | "error"
|
|
125
|
+
readonly minDurationMs?: number
|
|
126
|
+
readonly text?: string
|
|
127
|
+
readonly lookback?: string
|
|
128
|
+
readonly limit?: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type AiCallStatsInput = {
|
|
132
|
+
readonly groupBy: "provider" | "model" | "functionId" | "sessionId" | "status"
|
|
133
|
+
readonly agg: "count" | "avg_duration" | "p95_duration" | "total_input_tokens" | "total_output_tokens"
|
|
134
|
+
readonly service?: string
|
|
135
|
+
readonly traceId?: string
|
|
136
|
+
readonly sessionId?: string
|
|
137
|
+
readonly functionId?: string
|
|
138
|
+
readonly provider?: string
|
|
139
|
+
readonly model?: string
|
|
140
|
+
readonly operation?: string
|
|
141
|
+
readonly status?: "ok" | "error"
|
|
142
|
+
readonly minDurationMs?: number
|
|
143
|
+
readonly lookback?: string
|
|
144
|
+
readonly limit?: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class MotelClient extends Context.Service<
|
|
148
|
+
MotelClient,
|
|
149
|
+
{
|
|
150
|
+
readonly searchTraces: (input: SearchTracesInput) => Effect.Effect<unknown, MotelHttpError>
|
|
151
|
+
readonly searchSpans: (input: SearchSpansInput) => Effect.Effect<unknown, MotelHttpError>
|
|
152
|
+
readonly getTrace: (traceId: string) => Effect.Effect<unknown, MotelHttpError>
|
|
153
|
+
readonly getTraceSpans: (traceId: string) => Effect.Effect<unknown, MotelHttpError>
|
|
154
|
+
readonly getTraceLogs: (traceId: string, options: TraceLogOptions) => Effect.Effect<unknown, MotelHttpError>
|
|
155
|
+
readonly getSpan: (spanId: string) => Effect.Effect<unknown, MotelHttpError>
|
|
156
|
+
readonly getSpanLogs: (spanId: string, options: TraceLogOptions) => Effect.Effect<unknown, MotelHttpError>
|
|
157
|
+
readonly searchLogs: (input: SearchLogsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
158
|
+
readonly searchAiCalls: (input: AiCallSearchInput) => Effect.Effect<unknown, MotelHttpError>
|
|
159
|
+
readonly getAiCall: (spanId: string) => Effect.Effect<unknown, MotelHttpError>
|
|
160
|
+
readonly aiCallStats: (input: AiCallStatsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
161
|
+
readonly traceStats: (input: TraceStatsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
162
|
+
readonly logStats: (input: LogStatsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
163
|
+
readonly facets: (input: FacetsInput) => Effect.Effect<unknown, MotelHttpError>
|
|
164
|
+
readonly services: Effect.Effect<unknown, MotelHttpError>
|
|
165
|
+
readonly health: Effect.Effect<unknown, MotelHttpError>
|
|
166
|
+
readonly docs: Effect.Effect<unknown, MotelHttpError>
|
|
167
|
+
readonly getDoc: (name: string) => Effect.Effect<string, MotelHttpError>
|
|
168
|
+
readonly openapi: Effect.Effect<unknown, MotelHttpError>
|
|
169
|
+
}
|
|
170
|
+
>()("motel/MotelClient") {}
|
|
171
|
+
|
|
172
|
+
export const MotelClientLive = Layer.effect(
|
|
173
|
+
MotelClient,
|
|
174
|
+
Effect.gen(function* () {
|
|
175
|
+
const locator = yield* Locator
|
|
176
|
+
|
|
177
|
+
const get = <A = unknown>(
|
|
178
|
+
path: string,
|
|
179
|
+
query?: Query,
|
|
180
|
+
attributes?: AttributeFilters,
|
|
181
|
+
attributeContains?: AttributeFilters,
|
|
182
|
+
) =>
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const { url } = yield* Effect.mapError(
|
|
185
|
+
locator.resolve,
|
|
186
|
+
(err) => new MotelHttpError(0, err.message),
|
|
187
|
+
)
|
|
188
|
+
const target = appendAllAttributes(appendQuery(new URL(path, url), query), attributes, attributeContains)
|
|
189
|
+
return yield* Effect.tryPromise({
|
|
190
|
+
try: async () => {
|
|
191
|
+
const res = await fetch(target, { signal: AbortSignal.timeout(5000) })
|
|
192
|
+
const body = (await res.json().catch(() => ({ error: "invalid json" }))) as A
|
|
193
|
+
if (!res.ok) throw new MotelHttpError(res.status, JSON.stringify(body))
|
|
194
|
+
return body
|
|
195
|
+
},
|
|
196
|
+
catch: (err) =>
|
|
197
|
+
err instanceof MotelHttpError ? err : new MotelHttpError(0, (err as Error).message),
|
|
198
|
+
}).pipe(
|
|
199
|
+
Effect.tapError((err) => (err.status === 0 ? locator.invalidate : Effect.void)),
|
|
200
|
+
)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
searchTraces: (input) =>
|
|
205
|
+
get("/api/traces/search", {
|
|
206
|
+
service: input.service,
|
|
207
|
+
operation: input.operation,
|
|
208
|
+
status: input.status,
|
|
209
|
+
minDurationMs: input.minDurationMs,
|
|
210
|
+
lookback: input.lookback,
|
|
211
|
+
limit: input.limit,
|
|
212
|
+
cursor: input.cursor,
|
|
213
|
+
}, input.attributes, input.attributeContains),
|
|
214
|
+
|
|
215
|
+
searchSpans: (input) =>
|
|
216
|
+
get("/api/spans/search", {
|
|
217
|
+
service: input.service,
|
|
218
|
+
traceId: input.traceId,
|
|
219
|
+
operation: input.operation,
|
|
220
|
+
parentOperation: input.parentOperation,
|
|
221
|
+
status: input.status,
|
|
222
|
+
lookback: input.lookback,
|
|
223
|
+
limit: input.limit,
|
|
224
|
+
}, input.attributes, input.attributeContains),
|
|
225
|
+
|
|
226
|
+
getTrace: (traceId) => get(`/api/traces/${encodeURIComponent(traceId)}`),
|
|
227
|
+
|
|
228
|
+
getTraceSpans: (traceId) => get(`/api/traces/${encodeURIComponent(traceId)}/spans`),
|
|
229
|
+
|
|
230
|
+
getTraceLogs: (traceId, options) =>
|
|
231
|
+
get(`/api/traces/${encodeURIComponent(traceId)}/logs`, {
|
|
232
|
+
lookback: options.lookback,
|
|
233
|
+
limit: options.limit,
|
|
234
|
+
cursor: options.cursor,
|
|
235
|
+
}),
|
|
236
|
+
|
|
237
|
+
getSpan: (spanId) => get(`/api/spans/${encodeURIComponent(spanId)}`),
|
|
238
|
+
|
|
239
|
+
getSpanLogs: (spanId, options) =>
|
|
240
|
+
get(`/api/spans/${encodeURIComponent(spanId)}/logs`, {
|
|
241
|
+
lookback: options.lookback,
|
|
242
|
+
limit: options.limit,
|
|
243
|
+
cursor: options.cursor,
|
|
244
|
+
}),
|
|
245
|
+
|
|
246
|
+
searchLogs: (input) =>
|
|
247
|
+
get("/api/logs/search", {
|
|
248
|
+
service: input.service,
|
|
249
|
+
severity: input.severity,
|
|
250
|
+
traceId: input.traceId,
|
|
251
|
+
spanId: input.spanId,
|
|
252
|
+
body: input.body,
|
|
253
|
+
lookback: input.lookback,
|
|
254
|
+
limit: input.limit,
|
|
255
|
+
cursor: input.cursor,
|
|
256
|
+
}, input.attributes, input.attributeContains),
|
|
257
|
+
|
|
258
|
+
searchAiCalls: (input) =>
|
|
259
|
+
get("/api/ai/calls", {
|
|
260
|
+
service: input.service,
|
|
261
|
+
traceId: input.traceId,
|
|
262
|
+
sessionId: input.sessionId,
|
|
263
|
+
functionId: input.functionId,
|
|
264
|
+
provider: input.provider,
|
|
265
|
+
model: input.model,
|
|
266
|
+
operation: input.operation,
|
|
267
|
+
status: input.status,
|
|
268
|
+
minDurationMs: input.minDurationMs,
|
|
269
|
+
text: input.text,
|
|
270
|
+
lookback: input.lookback,
|
|
271
|
+
limit: input.limit,
|
|
272
|
+
}),
|
|
273
|
+
|
|
274
|
+
getAiCall: (spanId) => get(`/api/ai/calls/${encodeURIComponent(spanId)}`),
|
|
275
|
+
|
|
276
|
+
aiCallStats: (input) =>
|
|
277
|
+
get("/api/ai/stats", {
|
|
278
|
+
groupBy: input.groupBy,
|
|
279
|
+
agg: input.agg,
|
|
280
|
+
service: input.service,
|
|
281
|
+
traceId: input.traceId,
|
|
282
|
+
sessionId: input.sessionId,
|
|
283
|
+
functionId: input.functionId,
|
|
284
|
+
provider: input.provider,
|
|
285
|
+
model: input.model,
|
|
286
|
+
operation: input.operation,
|
|
287
|
+
status: input.status,
|
|
288
|
+
minDurationMs: input.minDurationMs,
|
|
289
|
+
lookback: input.lookback,
|
|
290
|
+
limit: input.limit,
|
|
291
|
+
}),
|
|
292
|
+
|
|
293
|
+
traceStats: (input) =>
|
|
294
|
+
get("/api/traces/stats", {
|
|
295
|
+
groupBy: input.groupBy,
|
|
296
|
+
agg: input.agg,
|
|
297
|
+
service: input.service,
|
|
298
|
+
operation: input.operation,
|
|
299
|
+
status: input.status,
|
|
300
|
+
minDurationMs: input.minDurationMs,
|
|
301
|
+
lookback: input.lookback,
|
|
302
|
+
limit: input.limit,
|
|
303
|
+
}, input.attributes),
|
|
304
|
+
|
|
305
|
+
logStats: (input) =>
|
|
306
|
+
get("/api/logs/stats", {
|
|
307
|
+
groupBy: input.groupBy,
|
|
308
|
+
agg: "count",
|
|
309
|
+
service: input.service,
|
|
310
|
+
traceId: input.traceId,
|
|
311
|
+
spanId: input.spanId,
|
|
312
|
+
body: input.body,
|
|
313
|
+
lookback: input.lookback,
|
|
314
|
+
limit: input.limit,
|
|
315
|
+
}, input.attributes),
|
|
316
|
+
|
|
317
|
+
facets: (input) =>
|
|
318
|
+
get("/api/facets", {
|
|
319
|
+
type: input.type,
|
|
320
|
+
field: input.field,
|
|
321
|
+
key: input.key,
|
|
322
|
+
service: input.service,
|
|
323
|
+
lookback: input.lookback,
|
|
324
|
+
limit: input.limit,
|
|
325
|
+
}),
|
|
326
|
+
|
|
327
|
+
services: get("/api/services"),
|
|
328
|
+
|
|
329
|
+
health: get("/api/health"),
|
|
330
|
+
|
|
331
|
+
docs: get("/api/docs"),
|
|
332
|
+
|
|
333
|
+
getDoc: (name) =>
|
|
334
|
+
Effect.gen(function* () {
|
|
335
|
+
const { url } = yield* Effect.mapError(locator.resolve, (err) => new MotelHttpError(0, err.message))
|
|
336
|
+
return yield* Effect.tryPromise({
|
|
337
|
+
try: async () => {
|
|
338
|
+
const res = await fetch(new URL(`/api/docs/${encodeURIComponent(name)}`, url), {
|
|
339
|
+
signal: AbortSignal.timeout(5000),
|
|
340
|
+
})
|
|
341
|
+
const body = await res.text()
|
|
342
|
+
if (!res.ok) throw new MotelHttpError(res.status, body)
|
|
343
|
+
return body
|
|
344
|
+
},
|
|
345
|
+
catch: (err) =>
|
|
346
|
+
err instanceof MotelHttpError ? err : new MotelHttpError(0, (err as Error).message),
|
|
347
|
+
}).pipe(Effect.tapError((err) => (err.status === 0 ? locator.invalidate : Effect.void)))
|
|
348
|
+
}),
|
|
349
|
+
|
|
350
|
+
openapi: get("/openapi.json"),
|
|
351
|
+
}
|
|
352
|
+
}),
|
|
353
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Key } from "react"
|
|
2
|
+
|
|
3
|
+
// TypeScript 6 does not preserve React's inherited `key` attribute through
|
|
4
|
+
// OpenTUI's JSX runtime declarations for custom components.
|
|
5
|
+
declare module "@opentui/react/jsx-runtime" {
|
|
6
|
+
namespace JSX {
|
|
7
|
+
interface IntrinsicAttributes {
|
|
8
|
+
readonly key?: Key | null | undefined
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/otlp.test.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import rootModule from "@opentelemetry/otlp-transformer/build/esm/generated/root.js"
|
|
2
|
+
import { describe, expect, test } from "bun:test"
|
|
3
|
+
import { normalizeOtlpBinaryId } from "./otlp.ts"
|
|
4
|
+
import { decodeProtobufLogs, decodeProtobufTraces } from "./otlpProtobuf.ts"
|
|
5
|
+
|
|
6
|
+
describe("normalizeOtlpBinaryId", () => {
|
|
7
|
+
test("normalizes hex and canonical base64 IDs", () => {
|
|
8
|
+
expect(normalizeOtlpBinaryId("0123456789ABCDEF", 8)).toBe("0123456789abcdef")
|
|
9
|
+
expect(normalizeOtlpBinaryId(Buffer.from("0123456789abcdef", "hex").toString("base64"), 8)).toBe("0123456789abcdef")
|
|
10
|
+
expect(normalizeOtlpBinaryId(Buffer.from("0123456789abcdef0123456789abcdef", "hex").toString("base64"), 16)).toBe("0123456789abcdef0123456789abcdef")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("preserves non-standard human-readable IDs", () => {
|
|
14
|
+
expect(normalizeOtlpBinaryId("ai-stream-1", 8)).toBe("ai-stream-1")
|
|
15
|
+
expect(normalizeOtlpBinaryId("ai-stream-2", 8)).toBe("ai-stream-2")
|
|
16
|
+
expect(normalizeOtlpBinaryId("trace-ai", 16)).toBe("trace-ai")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("returns null for absent IDs", () => {
|
|
20
|
+
expect(normalizeOtlpBinaryId(null, 16)).toBeNull()
|
|
21
|
+
expect(normalizeOtlpBinaryId(undefined, 8)).toBeNull()
|
|
22
|
+
expect(normalizeOtlpBinaryId("", 16)).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const root = rootModule as unknown as {
|
|
27
|
+
readonly opentelemetry: {
|
|
28
|
+
readonly proto: {
|
|
29
|
+
readonly collector: {
|
|
30
|
+
readonly trace: { readonly v1: { readonly ExportTraceServiceRequest: { encode: (message: unknown) => { finish: () => Uint8Array } } } }
|
|
31
|
+
readonly logs: { readonly v1: { readonly ExportLogsServiceRequest: { encode: (message: unknown) => { finish: () => Uint8Array } } } }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("protobuf OTLP decoders", () => {
|
|
38
|
+
test("decodes traces into the JSON ingest shape", () => {
|
|
39
|
+
const encoded = root.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest.encode({
|
|
40
|
+
resourceSpans: [{
|
|
41
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: "proto-svc" } }] },
|
|
42
|
+
scopeSpans: [{ spans: [{
|
|
43
|
+
traceId: Buffer.from("0123456789abcdef0123456789abcdef", "hex"),
|
|
44
|
+
spanId: Buffer.from("0123456789abcdef", "hex"),
|
|
45
|
+
name: "op",
|
|
46
|
+
startTimeUnixNano: 1,
|
|
47
|
+
endTimeUnixNano: 2,
|
|
48
|
+
}] }],
|
|
49
|
+
}],
|
|
50
|
+
}).finish()
|
|
51
|
+
const span = decodeProtobufTraces(encoded).resourceSpans?.[0]?.scopeSpans?.[0]?.spans?.[0]
|
|
52
|
+
expect(span?.name).toBe("op")
|
|
53
|
+
expect(normalizeOtlpBinaryId(span?.traceId, 16)).toBe("0123456789abcdef0123456789abcdef")
|
|
54
|
+
expect(normalizeOtlpBinaryId(span?.spanId, 8)).toBe("0123456789abcdef")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("decodes logs into the JSON ingest shape", () => {
|
|
58
|
+
const encoded = root.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest.encode({
|
|
59
|
+
resourceLogs: [{ scopeLogs: [{ logRecords: [{ timeUnixNano: 5, severityText: "INFO", body: { stringValue: "hello" } }] }] }],
|
|
60
|
+
}).finish()
|
|
61
|
+
const record = decodeProtobufLogs(encoded).resourceLogs?.[0]?.scopeLogs?.[0]?.logRecords?.[0]
|
|
62
|
+
expect(record?.severityText).toBe("INFO")
|
|
63
|
+
expect(record?.body?.stringValue).toBe("hello")
|
|
64
|
+
})
|
|
65
|
+
})
|