@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.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. 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
+ }
@@ -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
+ })