@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,263 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { RGBA, TextAttributes } from "@opentui/core"
|
|
3
|
+
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
4
|
+
import { useEffect, useMemo, useState } from "react"
|
|
5
|
+
import { App } from "./App.js"
|
|
6
|
+
import { getManagedDaemonStatus, type DaemonStatus } from "./daemon.js"
|
|
7
|
+
import { MOTEL_SERVICE_ID } from "./registry.js"
|
|
8
|
+
import { startDaemon, stopConflictingDaemon, type ConflictStatus } from "./startupLifecycle.js"
|
|
9
|
+
import { Divider, PlainLine, TextLine } from "./ui/primitives.tsx"
|
|
10
|
+
import { colors } from "./ui/theme.ts"
|
|
11
|
+
|
|
12
|
+
type ConflictScreenState = {
|
|
13
|
+
kind: "conflict"
|
|
14
|
+
message: string
|
|
15
|
+
status: ConflictStatus
|
|
16
|
+
busy: boolean
|
|
17
|
+
notice: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ErrorScreenState = {
|
|
21
|
+
kind: "error"
|
|
22
|
+
message: string
|
|
23
|
+
busy: boolean
|
|
24
|
+
notice: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type StartupState =
|
|
28
|
+
| { kind: "loading"; message: string }
|
|
29
|
+
| { kind: "ready" }
|
|
30
|
+
| ConflictScreenState
|
|
31
|
+
| ErrorScreenState
|
|
32
|
+
|
|
33
|
+
type RecoveryAction = {
|
|
34
|
+
readonly key: string
|
|
35
|
+
readonly label: string
|
|
36
|
+
readonly run: () => Promise<void>
|
|
37
|
+
readonly disabled?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const readStatus = () => Effect.runPromise(getManagedDaemonStatus)
|
|
41
|
+
|
|
42
|
+
const isRecoverableConflict = (status: DaemonStatus | null): status is ConflictStatus =>
|
|
43
|
+
status !== null &&
|
|
44
|
+
status.service === MOTEL_SERVICE_ID &&
|
|
45
|
+
status.pid !== null &&
|
|
46
|
+
status.workdir !== null &&
|
|
47
|
+
status.reason !== null
|
|
48
|
+
|
|
49
|
+
const LoadingScreen = ({ width, height, message }: { width: number; height: number; message: string }) => {
|
|
50
|
+
const panelWidth = Math.min(76, Math.max(50, width - 8))
|
|
51
|
+
const left = Math.max(0, Math.floor((width - panelWidth) / 2))
|
|
52
|
+
const top = Math.max(0, Math.floor((height - 5) / 2))
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<box width={width} height={height} backgroundColor={RGBA.fromHex(colors.screenBg)}>
|
|
56
|
+
<box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column">
|
|
57
|
+
<TextLine>
|
|
58
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>MOTEL</span>
|
|
59
|
+
<span fg={colors.separator}>{" · "}</span>
|
|
60
|
+
<span fg={colors.muted}>starting up...</span>
|
|
61
|
+
</TextLine>
|
|
62
|
+
<Divider width={panelWidth} />
|
|
63
|
+
<box paddingTop={1}>
|
|
64
|
+
<PlainLine text={message} fg={colors.count} />
|
|
65
|
+
</box>
|
|
66
|
+
</box>
|
|
67
|
+
</box>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const RecoveryScreen = ({
|
|
72
|
+
title,
|
|
73
|
+
message,
|
|
74
|
+
width,
|
|
75
|
+
height,
|
|
76
|
+
detailLines,
|
|
77
|
+
actions,
|
|
78
|
+
selectedIndex,
|
|
79
|
+
notice,
|
|
80
|
+
busy,
|
|
81
|
+
}: {
|
|
82
|
+
readonly title: string
|
|
83
|
+
readonly message: string
|
|
84
|
+
readonly width: number
|
|
85
|
+
readonly height: number
|
|
86
|
+
readonly detailLines: readonly string[]
|
|
87
|
+
readonly actions: readonly RecoveryAction[]
|
|
88
|
+
readonly selectedIndex: number
|
|
89
|
+
readonly notice: string | null
|
|
90
|
+
readonly busy: boolean
|
|
91
|
+
}) => {
|
|
92
|
+
const panelWidth = Math.min(96, Math.max(64, width - 8))
|
|
93
|
+
const left = Math.max(0, Math.floor((width - panelWidth) / 2))
|
|
94
|
+
const bodyHeight = 9 + detailLines.length + actions.length + (notice ? 2 : 0)
|
|
95
|
+
const top = Math.max(0, Math.floor((height - bodyHeight) / 2))
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<box width={width} height={height} backgroundColor={RGBA.fromHex(colors.screenBg)}>
|
|
99
|
+
<box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column">
|
|
100
|
+
<TextLine>
|
|
101
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>MOTEL</span>
|
|
102
|
+
<span fg={colors.separator}>{" · "}</span>
|
|
103
|
+
<span fg={colors.error} attributes={TextAttributes.BOLD}>{title}</span>
|
|
104
|
+
</TextLine>
|
|
105
|
+
<Divider width={panelWidth} />
|
|
106
|
+
<box paddingTop={1} paddingBottom={1} flexDirection="column">
|
|
107
|
+
<PlainLine text={message} fg={colors.text} />
|
|
108
|
+
</box>
|
|
109
|
+
{detailLines.map((line, index) => (
|
|
110
|
+
<PlainLine key={`${index}:${line}`} text={line} fg={colors.muted} />
|
|
111
|
+
))}
|
|
112
|
+
<box paddingTop={1} flexDirection="column">
|
|
113
|
+
{actions.map((action, index) => {
|
|
114
|
+
const selected = index === selectedIndex
|
|
115
|
+
const prefix = selected ? ">" : " "
|
|
116
|
+
const text = `${prefix} [${action.key}] ${action.label}${action.disabled ? " (unavailable)" : ""}`
|
|
117
|
+
return (
|
|
118
|
+
<TextLine key={action.key} bg={selected ? colors.selectedBg : undefined}>
|
|
119
|
+
<span fg={selected ? colors.selectedText : colors.text}>{text}</span>
|
|
120
|
+
</TextLine>
|
|
121
|
+
)
|
|
122
|
+
})}
|
|
123
|
+
</box>
|
|
124
|
+
<box paddingTop={1} flexDirection="column">
|
|
125
|
+
{notice ? <PlainLine text={notice} fg={busy ? colors.warning : colors.count} /> : null}
|
|
126
|
+
<PlainLine text={busy ? "Working..." : "j/k or ↑↓ select · enter run · r retry · k stop incompatible daemon · q quit"} fg={colors.count} />
|
|
127
|
+
</box>
|
|
128
|
+
</box>
|
|
129
|
+
</box>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const StartupGate = () => {
|
|
134
|
+
const renderer = useRenderer()
|
|
135
|
+
const { width = 100, height = 24 } = useTerminalDimensions()
|
|
136
|
+
const [startupState, setStartupState] = useState<StartupState>({ kind: "loading", message: "Checking managed daemon..." })
|
|
137
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
138
|
+
|
|
139
|
+
const attemptStart = async () => {
|
|
140
|
+
setStartupState({ kind: "loading", message: "Checking managed daemon..." })
|
|
141
|
+
try {
|
|
142
|
+
await startDaemon()
|
|
143
|
+
setStartupState({ kind: "ready" })
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
146
|
+
const status = await readStatus().catch(() => null)
|
|
147
|
+
if (isRecoverableConflict(status)) {
|
|
148
|
+
setSelectedIndex(0)
|
|
149
|
+
setStartupState({ kind: "conflict", message, status, busy: false, notice: null })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
setSelectedIndex(0)
|
|
153
|
+
setStartupState({ kind: "error", message, busy: false, notice: null })
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
void attemptStart()
|
|
159
|
+
}, [])
|
|
160
|
+
|
|
161
|
+
const actions = useMemo<readonly RecoveryAction[]>(() => {
|
|
162
|
+
if (startupState.kind === "conflict") {
|
|
163
|
+
return [
|
|
164
|
+
{ key: "r", label: "Retry startup", run: attemptStart },
|
|
165
|
+
{
|
|
166
|
+
key: "k",
|
|
167
|
+
label: `Stop incompatible daemon (${startupState.status.pid})`,
|
|
168
|
+
run: async () => {
|
|
169
|
+
setStartupState((current) => current.kind === "conflict"
|
|
170
|
+
? { ...current, busy: true, notice: `Stopping daemon ${current.status.pid}...` }
|
|
171
|
+
: current)
|
|
172
|
+
try {
|
|
173
|
+
await stopConflictingDaemon(startupState.status)
|
|
174
|
+
await attemptStart()
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
177
|
+
setStartupState((current) => current.kind === "conflict"
|
|
178
|
+
? { ...current, busy: false, notice: message }
|
|
179
|
+
: current)
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{ key: "q", label: "Quit", run: async () => { renderer.destroy() } },
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
if (startupState.kind === "error") {
|
|
187
|
+
return [
|
|
188
|
+
{ key: "r", label: "Retry startup", run: attemptStart },
|
|
189
|
+
{ key: "q", label: "Quit", run: async () => { renderer.destroy() } },
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
return []
|
|
193
|
+
}, [renderer, startupState])
|
|
194
|
+
|
|
195
|
+
useKeyboard((key) => {
|
|
196
|
+
if (startupState.kind === "ready" || startupState.kind === "loading" || key.repeated) return
|
|
197
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
198
|
+
renderer.destroy()
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
if (key.name === "up" || key.name === "k") {
|
|
202
|
+
setSelectedIndex((current) => (current + actions.length - 1) % actions.length)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
if (key.name === "down" || key.name === "j") {
|
|
206
|
+
setSelectedIndex((current) => (current + 1) % actions.length)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
if (key.name === "r") {
|
|
210
|
+
void actions.find((action) => action.key === "r")?.run()
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
if (key.name === "k") {
|
|
214
|
+
void actions.find((action) => action.key === "k")?.run()
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
if (key.name === "return" || key.name === "enter") {
|
|
218
|
+
void actions[selectedIndex]?.run()
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
if (startupState.kind === "ready") return <App />
|
|
223
|
+
if (startupState.kind === "loading") return <LoadingScreen width={width} height={height} message={startupState.message} />
|
|
224
|
+
if (startupState.kind === "conflict") {
|
|
225
|
+
const status = startupState.status
|
|
226
|
+
const detailLines = [
|
|
227
|
+
`Port: ${status.url}`,
|
|
228
|
+
`Daemon workdir: ${status.workdir}`,
|
|
229
|
+
`Daemon pid: ${status.pid}`,
|
|
230
|
+
`Incompatible database: ${status.databasePath}`,
|
|
231
|
+
status.workdir.startsWith("/tmp") || status.workdir.startsWith("/private/tmp")
|
|
232
|
+
? "This looks like a temp/test daemon."
|
|
233
|
+
: "This daemon is using a different database configuration.",
|
|
234
|
+
]
|
|
235
|
+
return (
|
|
236
|
+
<RecoveryScreen
|
|
237
|
+
title="Daemon Configuration Conflict"
|
|
238
|
+
message={startupState.message}
|
|
239
|
+
width={width}
|
|
240
|
+
height={height}
|
|
241
|
+
detailLines={detailLines}
|
|
242
|
+
actions={actions}
|
|
243
|
+
selectedIndex={selectedIndex}
|
|
244
|
+
notice={startupState.notice}
|
|
245
|
+
busy={startupState.busy}
|
|
246
|
+
/>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<RecoveryScreen
|
|
252
|
+
title="Startup Error"
|
|
253
|
+
message={startupState.message}
|
|
254
|
+
width={width}
|
|
255
|
+
height={height}
|
|
256
|
+
detailLines={["Retry startup or quit the TUI."]}
|
|
257
|
+
actions={actions}
|
|
258
|
+
selectedIndex={selectedIndex}
|
|
259
|
+
notice={startupState.notice}
|
|
260
|
+
busy={startupState.busy}
|
|
261
|
+
/>
|
|
262
|
+
)
|
|
263
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { Console, Effect, Option, References } from "effect"
|
|
2
|
+
import { Argument, Command } from "effect/unstable/cli"
|
|
3
|
+
import { config } from "./config.js"
|
|
4
|
+
import { otelServerInstructions } from "./instructions.js"
|
|
5
|
+
import { attributeFiltersFromArgs, isAttributeFilterToken } from "./queryFilters.js"
|
|
6
|
+
import { queryRuntime } from "./runtime.js"
|
|
7
|
+
import { TelemetryStoreReadonly } from "./services/TelemetryStore.js"
|
|
8
|
+
|
|
9
|
+
const json = (value: unknown) => Console.log(JSON.stringify(value, null, 2))
|
|
10
|
+
|
|
11
|
+
const query = <A>(effect: Effect.Effect<A, unknown, TelemetryStoreReadonly>) =>
|
|
12
|
+
Effect.promise(() => queryRuntime.runPromise(effect.pipe(Effect.provideService(References.MinimumLogLevel, "None")))).pipe(
|
|
13
|
+
Effect.andThen(json),
|
|
14
|
+
Effect.ensuring(Effect.promise(() => queryRuntime.dispose())),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const optional = (value: Option.Option<string>, fallback?: string) => Option.getOrElse(value, () => fallback)
|
|
18
|
+
|
|
19
|
+
const searchOperand = Argument.string("service-or-filter").pipe(
|
|
20
|
+
Argument.mapTryCatch(
|
|
21
|
+
(value) => {
|
|
22
|
+
if (value.startsWith("attr.") && !isAttributeFilterToken(value)) {
|
|
23
|
+
throw new Error("attribute filters must use attr.<key>=<value>")
|
|
24
|
+
}
|
|
25
|
+
return value
|
|
26
|
+
},
|
|
27
|
+
(error) => error instanceof Error ? error.message : String(error),
|
|
28
|
+
),
|
|
29
|
+
Argument.variadic,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const services = Command.make("services", {}, () =>
|
|
33
|
+
query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.listServices)),
|
|
34
|
+
).pipe(Command.withDescription("List observed telemetry services"))
|
|
35
|
+
|
|
36
|
+
const traces = Command.make("traces", {
|
|
37
|
+
service: Argument.string("service").pipe(Argument.optional),
|
|
38
|
+
limit: Argument.integer("limit").pipe(Argument.optional),
|
|
39
|
+
}, ({ service, limit }) =>
|
|
40
|
+
query(Effect.flatMap(TelemetryStoreReadonly, (store) =>
|
|
41
|
+
store.listRecentTraces(optional(service, config.otel.serviceName)!, {
|
|
42
|
+
limit: Option.getOrElse(limit, () => config.otel.traceFetchLimit),
|
|
43
|
+
}),
|
|
44
|
+
)),
|
|
45
|
+
).pipe(Command.withDescription("List recent traces"))
|
|
46
|
+
|
|
47
|
+
const trace = Command.make("trace", {
|
|
48
|
+
traceId: Argument.string("trace-id"),
|
|
49
|
+
}, ({ traceId }) =>
|
|
50
|
+
query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.getTrace(traceId))),
|
|
51
|
+
).pipe(Command.withDescription("Get one trace"))
|
|
52
|
+
|
|
53
|
+
const span = Command.make("span", {
|
|
54
|
+
spanId: Argument.string("span-id"),
|
|
55
|
+
}, ({ spanId }) =>
|
|
56
|
+
Effect.promise(() => fetch(`${config.otel.queryUrl}/api/spans/${encodeURIComponent(spanId)}`).then((response) => response.json())).pipe(
|
|
57
|
+
Effect.andThen(json),
|
|
58
|
+
),
|
|
59
|
+
).pipe(Command.withDescription("Get one span"))
|
|
60
|
+
|
|
61
|
+
const traceSpans = Command.make("trace-spans", {
|
|
62
|
+
traceId: Argument.string("trace-id"),
|
|
63
|
+
}, ({ traceId }) =>
|
|
64
|
+
query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.listTraceSpans(traceId))),
|
|
65
|
+
).pipe(Command.withDescription("List spans in one trace"))
|
|
66
|
+
|
|
67
|
+
const parseSearchSpans = (args: ReadonlyArray<string>) => {
|
|
68
|
+
const service = args[0] ?? config.otel.serviceName
|
|
69
|
+
const operation = args[1] && !isAttributeFilterToken(args[1]) && !args[1].startsWith("parent=") ? args[1] : undefined
|
|
70
|
+
const parentTokenIndex = args.findIndex((value, index) => index > 0 && value.startsWith("parent="))
|
|
71
|
+
const parentOperation = parentTokenIndex >= 0 ? args[parentTokenIndex]?.slice("parent=".length) : undefined
|
|
72
|
+
const attributeStartIndex = operation ? 2 : 1
|
|
73
|
+
return { service, operation, parentOperation, attributeFilters: attributeFiltersFromArgs(args.slice(attributeStartIndex)) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const searchSpans = Command.make("search-spans", {
|
|
77
|
+
args: searchOperand,
|
|
78
|
+
}, ({ args }) => {
|
|
79
|
+
const input = parseSearchSpans(args as ReadonlyArray<string>)
|
|
80
|
+
return query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.searchSpans({
|
|
81
|
+
serviceName: input.service,
|
|
82
|
+
operation: input.operation,
|
|
83
|
+
parentOperation: input.parentOperation,
|
|
84
|
+
attributeFilters: input.attributeFilters,
|
|
85
|
+
limit: config.otel.logFetchLimit,
|
|
86
|
+
})))
|
|
87
|
+
}).pipe(Command.withDescription("Search spans by service, operation, parent, and attributes"))
|
|
88
|
+
|
|
89
|
+
const searchTraces = Command.make("search-traces", {
|
|
90
|
+
args: searchOperand,
|
|
91
|
+
}, ({ args }) => {
|
|
92
|
+
const values = args as ReadonlyArray<string>
|
|
93
|
+
const service = values[0] ?? config.otel.serviceName
|
|
94
|
+
const operation = values[1] && !isAttributeFilterToken(values[1]) ? values[1] : undefined
|
|
95
|
+
const attributeFilters = attributeFiltersFromArgs(values.slice(operation ? 2 : 1))
|
|
96
|
+
return query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.searchTraces({
|
|
97
|
+
serviceName: service,
|
|
98
|
+
operation,
|
|
99
|
+
attributeFilters,
|
|
100
|
+
limit: config.otel.traceFetchLimit,
|
|
101
|
+
})))
|
|
102
|
+
}).pipe(Command.withDescription("Search trace summaries by service, operation, and attributes"))
|
|
103
|
+
|
|
104
|
+
const traceStats = Command.make("trace-stats", {
|
|
105
|
+
groupBy: Argument.string("groupBy"),
|
|
106
|
+
agg: Argument.choice("aggregation", ["count", "avg_duration", "p95_duration", "error_rate"]),
|
|
107
|
+
args: searchOperand,
|
|
108
|
+
}, ({ groupBy, agg, args }) => {
|
|
109
|
+
const values = args as ReadonlyArray<string>
|
|
110
|
+
const service = values[0] && !isAttributeFilterToken(values[0]) ? values[0] : undefined
|
|
111
|
+
return query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.traceStats({
|
|
112
|
+
groupBy,
|
|
113
|
+
agg,
|
|
114
|
+
serviceName: service,
|
|
115
|
+
attributeFilters: attributeFiltersFromArgs(values.slice(service ? 1 : 0)),
|
|
116
|
+
limit: 20,
|
|
117
|
+
})))
|
|
118
|
+
}).pipe(Command.withDescription("Aggregate trace metrics"))
|
|
119
|
+
|
|
120
|
+
const instructions = Command.make("instructions", {}, () => Console.log(otelServerInstructions()))
|
|
121
|
+
.pipe(Command.withDescription("Print Effect telemetry setup instructions"))
|
|
122
|
+
|
|
123
|
+
const logs = Command.make("logs", {
|
|
124
|
+
service: Argument.string("service").pipe(Argument.optional),
|
|
125
|
+
}, ({ service }) =>
|
|
126
|
+
query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.listRecentLogs(optional(service, config.otel.serviceName)!))),
|
|
127
|
+
).pipe(Command.withDescription("List recent logs"))
|
|
128
|
+
|
|
129
|
+
const searchLogs = Command.make("search-logs", {
|
|
130
|
+
args: searchOperand,
|
|
131
|
+
}, ({ args }) => {
|
|
132
|
+
const values = args as ReadonlyArray<string>
|
|
133
|
+
const service = values[0] ?? config.otel.serviceName
|
|
134
|
+
const body = values[1] && !isAttributeFilterToken(values[1]) ? values[1] : undefined
|
|
135
|
+
return query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.searchLogs({
|
|
136
|
+
serviceName: service,
|
|
137
|
+
body,
|
|
138
|
+
attributeFilters: attributeFiltersFromArgs(values.slice(body ? 2 : 1)),
|
|
139
|
+
limit: config.otel.logFetchLimit,
|
|
140
|
+
})))
|
|
141
|
+
}).pipe(Command.withDescription("Search logs by service, body, and attributes"))
|
|
142
|
+
|
|
143
|
+
const logStats = Command.make("log-stats", {
|
|
144
|
+
groupBy: Argument.string("groupBy"),
|
|
145
|
+
args: searchOperand,
|
|
146
|
+
}, ({ groupBy, args }) => {
|
|
147
|
+
const values = args as ReadonlyArray<string>
|
|
148
|
+
const service = values[0] && !isAttributeFilterToken(values[0]) ? values[0] : undefined
|
|
149
|
+
return query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.logStats({
|
|
150
|
+
groupBy,
|
|
151
|
+
agg: "count",
|
|
152
|
+
serviceName: service,
|
|
153
|
+
attributeFilters: attributeFiltersFromArgs(values.slice(service ? 1 : 0)),
|
|
154
|
+
limit: 20,
|
|
155
|
+
})))
|
|
156
|
+
}).pipe(Command.withDescription("Aggregate log metrics"))
|
|
157
|
+
|
|
158
|
+
const traceLogs = Command.make("trace-logs", {
|
|
159
|
+
traceId: Argument.string("trace-id"),
|
|
160
|
+
}, ({ traceId }) =>
|
|
161
|
+
query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.listTraceLogs(traceId))),
|
|
162
|
+
).pipe(Command.withDescription("List logs for one trace"))
|
|
163
|
+
|
|
164
|
+
const spanLogs = Command.make("span-logs", {
|
|
165
|
+
spanId: Argument.string("span-id"),
|
|
166
|
+
}, ({ spanId }) =>
|
|
167
|
+
query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.searchLogs({
|
|
168
|
+
spanId,
|
|
169
|
+
limit: config.otel.logFetchLimit,
|
|
170
|
+
}))),
|
|
171
|
+
).pipe(Command.withDescription("List logs for one span"))
|
|
172
|
+
|
|
173
|
+
const facets = Command.make("facets", {
|
|
174
|
+
type: Argument.choice("type", ["traces", "logs"]),
|
|
175
|
+
field: Argument.string("field"),
|
|
176
|
+
}, ({ type, field }) =>
|
|
177
|
+
query(Effect.flatMap(TelemetryStoreReadonly, (store) => store.listFacets({ type, field, limit: 20 }))),
|
|
178
|
+
).pipe(Command.withDescription("List facet values"))
|
|
179
|
+
|
|
180
|
+
const endpoints = Command.make("endpoints", {}, () => json({
|
|
181
|
+
baseUrl: config.otel.baseUrl,
|
|
182
|
+
exporterUrl: config.otel.exporterUrl,
|
|
183
|
+
logsExporterUrl: config.otel.logsExporterUrl,
|
|
184
|
+
queryUrl: config.otel.queryUrl,
|
|
185
|
+
databasePath: config.otel.databasePath,
|
|
186
|
+
})).pipe(Command.withDescription("Print configured telemetry endpoints"))
|
|
187
|
+
|
|
188
|
+
export const queryCommands = [
|
|
189
|
+
services,
|
|
190
|
+
traces,
|
|
191
|
+
trace,
|
|
192
|
+
span,
|
|
193
|
+
traceSpans,
|
|
194
|
+
searchSpans,
|
|
195
|
+
searchTraces,
|
|
196
|
+
traceStats,
|
|
197
|
+
instructions,
|
|
198
|
+
logs,
|
|
199
|
+
searchLogs,
|
|
200
|
+
logStats,
|
|
201
|
+
traceLogs,
|
|
202
|
+
spanLogs,
|
|
203
|
+
facets,
|
|
204
|
+
endpoints,
|
|
205
|
+
] as const
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { motelStateDir } from "./registry.js"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
|
|
4
|
+
const parseBoolean = (value: string | undefined, defaultValue: boolean) => {
|
|
5
|
+
const normalized = value?.trim().toLowerCase()
|
|
6
|
+
if (!normalized) return defaultValue
|
|
7
|
+
return !["0", "false", "no", "off"].includes(normalized)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const parsePositiveInt = (value: string | undefined, defaultValue: number) => {
|
|
11
|
+
const parsed = Number.parseInt(value ?? "", 10)
|
|
12
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const baseUrl =
|
|
16
|
+
process.env.MOTEL_OTEL_BASE_URL?.trim() ||
|
|
17
|
+
process.env.MOTEL_OTEL_QUERY_URL?.trim() ||
|
|
18
|
+
process.env.MOTEL_OTEL_COLLECTOR_URL?.trim() ||
|
|
19
|
+
"http://127.0.0.1:27686"
|
|
20
|
+
|
|
21
|
+
const parsedBaseUrl = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`)
|
|
22
|
+
export const resolveOtelUrl = (path: string) => new URL(path.startsWith("/") ? path.slice(1) : path, parsedBaseUrl).toString()
|
|
23
|
+
const serverPort = parsePositiveInt(process.env.MOTEL_OTEL_PORT, Number.parseInt(parsedBaseUrl.port || "80", 10))
|
|
24
|
+
|
|
25
|
+
export const config = {
|
|
26
|
+
otel: {
|
|
27
|
+
enabled: parseBoolean(process.env.MOTEL_OTEL_ENABLED, false),
|
|
28
|
+
serviceName: process.env.MOTEL_OTEL_SERVICE_NAME?.trim() || "motel-otel-tui",
|
|
29
|
+
baseUrl,
|
|
30
|
+
host: process.env.MOTEL_OTEL_HOST?.trim() || parsedBaseUrl.hostname,
|
|
31
|
+
port: serverPort,
|
|
32
|
+
queryUrl: baseUrl,
|
|
33
|
+
exporterUrl: process.env.MOTEL_OTEL_EXPORTER_URL?.trim() || resolveOtelUrl("/v1/traces"),
|
|
34
|
+
logsExporterUrl: process.env.MOTEL_OTEL_LOGS_EXPORTER_URL?.trim() || resolveOtelUrl("/v1/logs"),
|
|
35
|
+
databasePath: process.env.MOTEL_OTEL_DB_PATH?.trim() || path.join(motelStateDir(), "telemetry.sqlite"),
|
|
36
|
+
traceLookbackMinutes: parsePositiveInt(process.env.MOTEL_OTEL_TRACE_LOOKBACK_MINUTES, 1440),
|
|
37
|
+
traceFetchLimit: parsePositiveInt(process.env.MOTEL_OTEL_TRACE_LIMIT, 100),
|
|
38
|
+
logFetchLimit: parsePositiveInt(process.env.MOTEL_OTEL_LOG_LIMIT, 80),
|
|
39
|
+
retentionHours: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_HOURS, 168),
|
|
40
|
+
maxDbSizeMb: parsePositiveInt(process.env.MOTEL_OTEL_MAX_DB_SIZE_MB, 1024),
|
|
41
|
+
retentionTraceBatch: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_TRACE_BATCH, 100),
|
|
42
|
+
retentionLogBatch: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_LOG_BATCH, 5_000),
|
|
43
|
+
retentionIntervalSeconds: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS, 10),
|
|
44
|
+
},
|
|
45
|
+
} as const
|