@kitlangton/motel 0.1.1 → 0.1.2
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 +22 -8
- package/package.json +2 -1
- package/src/App.tsx +38 -28
- package/src/config.ts +1 -1
- package/src/httpApi.ts +5 -2
- package/src/localServer.ts +1 -0
- package/src/motel.ts +12 -0
- package/src/services/TelemetryStore.ts +99 -23
- package/src/services/TraceQueryService.ts +4 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/SpanDetailPane.tsx +1 -2
- package/src/ui/TraceDetailsPane.tsx +14 -22
- package/src/ui/TraceList.tsx +166 -40
- package/src/ui/Waterfall.tsx +104 -46
- package/src/ui/app/TraceListPane.tsx +19 -14
- package/src/ui/app/TraceWorkspace.tsx +60 -31
- package/src/ui/app/useAppLayout.ts +22 -3
- package/src/ui/app/useTraceScreenData.ts +13 -2
- package/src/ui/format.ts +14 -5
- package/src/ui/primitives.tsx +3 -1
- package/src/ui/state.ts +32 -0
- package/src/ui/theme.ts +24 -19
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/useAttrFilterPicker.ts +47 -0
- package/src/ui/useKeyboardNav.ts +114 -15
- package/src/ui/waterfallNav.test.ts +22 -7
- package/web/dist/assets/{index-BEKIiisE.js → index-DKinj-OE.js} +1 -1
- package/web/dist/index.html +1 -1
package/AGENTS.md
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Commands
|
|
4
4
|
- Install deps: `bun install`
|
|
5
|
-
- Run the TUI: `bun run dev` or `bun run start`
|
|
6
|
-
|
|
5
|
+
- Run the TUI: `bun run dev` or `bun run start` (auto-ensures a managed
|
|
6
|
+
OTLP daemon is running in the background so traces ingest while the TUI
|
|
7
|
+
is up)
|
|
8
|
+
- Start the background daemon only: `bun run daemon` (same as `motel start`)
|
|
9
|
+
- Stop the managed daemon: `bun run stop`
|
|
10
|
+
- Daemon status JSON: `bun run status`
|
|
11
|
+
- Restart daemon + relaunch TUI: `bun run restart`
|
|
12
|
+
- Run the local server in the foreground (no daemon, no TUI): `bun run server`
|
|
7
13
|
- Run tests: `bun run test`
|
|
8
14
|
- Query services via CLI: `bun run cli services`
|
|
9
15
|
- Query traces via CLI: `bun run cli traces <service> [limit]`
|
|
@@ -36,6 +42,7 @@
|
|
|
36
42
|
- `/api/ai/calls` searches AI SDK calls (streamText, generateText, etc.) with first-class filters for `model`, `provider`, `sessionId`, `functionId`, `operation`, `status`, `text` (cross-field search), and returns compact summaries with previews and token usage.
|
|
37
43
|
- `/api/ai/calls/<span-id>` returns the full detail of a single AI call including complete prompt messages, response text, tool calls, timing, and correlated logs.
|
|
38
44
|
- `/api/ai/stats` aggregates AI call statistics by `provider`, `model`, `functionId`, `sessionId`, or `status` with aggregations: `count`, `avg_duration`, `p95_duration`, `total_input_tokens`, `total_output_tokens`.
|
|
45
|
+
- `/api/facets?type=traces&field=attribute_keys&service=<svc>` lists span-attribute keys for a service, ranked by discriminating power (keys with many distinct values first). Pair with `field=attribute_values&key=<key>` to list values for a specific key. Used by the TUI `f` attribute filter.
|
|
39
46
|
- `/api/docs` lists available documentation; `/api/docs/debug` and `/api/docs/effect` return the full skill content.
|
|
40
47
|
|
|
41
48
|
## Architecture
|
|
@@ -48,11 +55,16 @@
|
|
|
48
55
|
(pane widths, body lines, viewport rows, drill-in level).
|
|
49
56
|
- `src/ui/app/TraceWorkspace.tsx` renders the drill-in state machine:
|
|
50
57
|
L0 (trace list), L1 (waterfall), L2 (span detail), plus the service
|
|
51
|
-
logs side mode.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
logs side mode. When drilled in the list is hidden entirely and the
|
|
59
|
+
detail pane(s) expand to fill.
|
|
60
|
+
- `src/ui/app/TraceListPane.tsx` hosts the trace list: header + optional
|
|
61
|
+
filter bar + virtual-windowed body (no opentui scrollbox — that had a
|
|
62
|
+
race with Yoga layout timing).
|
|
63
|
+
- `src/ui/TraceList.tsx` exports `TraceListHeader` (the `TRACES 100 · ...`
|
|
64
|
+
strip) and `TraceListBody` (virtual-windowed rows with mouse-wheel
|
|
65
|
+
scrolling). The body owns its own scrollOffset state, preserves the
|
|
66
|
+
selected row's visual position across auto-refresh shifts, and snaps
|
|
67
|
+
the window to follow selection that moves off-screen.
|
|
56
68
|
- `src/ui/Waterfall.tsx` renders the waterfall timeline with a
|
|
57
69
|
virtualised scroll viewport; `src/ui/waterfallNav.ts` is the pure
|
|
58
70
|
collapse/expand/walk resolver (unit-tested).
|
|
@@ -116,7 +128,7 @@
|
|
|
116
128
|
- `MOTEL_OTEL_TRACE_LIMIT`: defaults to `100`
|
|
117
129
|
- `MOTEL_OTEL_LOG_LIMIT`: defaults to `80`
|
|
118
130
|
- `MOTEL_OTEL_RETENTION_HOURS`: defaults to `168` (7d)
|
|
119
|
-
- `MOTEL_OTEL_MAX_DB_SIZE_MB`: defaults to `
|
|
131
|
+
- `MOTEL_OTEL_MAX_DB_SIZE_MB`: defaults to `1024` (size-based retention cap)
|
|
120
132
|
|
|
121
133
|
## TUI Keys
|
|
122
134
|
- `?`: toggle shortcut help
|
|
@@ -133,7 +145,9 @@
|
|
|
133
145
|
- `tab`: toggle service logs view
|
|
134
146
|
- `[` / `]`: switch services
|
|
135
147
|
- `s`: cycle sort mode (recent → slowest → errors)
|
|
148
|
+
- `t`: cycle theme (motel-default → tokyo-night → catppuccin)
|
|
136
149
|
- `/`: enter filter mode (type to match on root operation name; `:error` restricts to failing traces)
|
|
150
|
+
- `f`: open attribute filter picker (browse span-attribute keys → values for the current service; `backspace` walks back to keys; `esc` in the trace list clears the active filter)
|
|
137
151
|
- `a`: pause or resume auto-refresh
|
|
138
152
|
- `r`: refresh now
|
|
139
153
|
- `c`: copy setup instructions for another Effect app
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitlangton/motel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"daemon": "bun run src/motel.ts daemon",
|
|
51
51
|
"status": "bun run src/motel.ts status",
|
|
52
52
|
"stop": "bun run src/motel.ts stop",
|
|
53
|
+
"restart": "bun run src/motel.ts restart",
|
|
53
54
|
"server": "bun run src/motel.ts server",
|
|
54
55
|
"mcp": "bun run src/mcp.ts",
|
|
55
56
|
"test": "bun test",
|
package/src/App.tsx
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
|
-
import { RGBA, TextAttributes
|
|
1
|
+
import { RGBA, TextAttributes } from "@opentui/core"
|
|
2
2
|
import { useAtom } from "@effect/atom-react"
|
|
3
3
|
import { useTerminalDimensions } from "@opentui/react"
|
|
4
|
-
import { useCallback, useEffect,
|
|
5
|
-
import { formatTimestamp
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef } from "react"
|
|
5
|
+
import { formatTimestamp } from "./ui/format.ts"
|
|
6
6
|
import { Divider, FooterHints, HelpModal, PlainLine, SplitDivider, TextLine } from "./ui/primitives.tsx"
|
|
7
7
|
import { useAppLayout } from "./ui/app/useAppLayout.ts"
|
|
8
8
|
import { useTraceScreenData } from "./ui/app/useTraceScreenData.ts"
|
|
9
9
|
import { TraceWorkspace } from "./ui/app/TraceWorkspace.tsx"
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
attrPickerIndexAtom,
|
|
12
|
+
attrPickerInputAtom,
|
|
13
|
+
attrPickerModeAtom,
|
|
14
|
+
attrFacetStateAtom,
|
|
15
|
+
noticeAtom,
|
|
16
|
+
persistSelectedTheme,
|
|
17
|
+
selectedThemeAtom,
|
|
18
|
+
} from "./ui/state.ts"
|
|
11
19
|
import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
|
|
12
20
|
import { getVisibleSpans } from "./ui/Waterfall.tsx"
|
|
13
21
|
import { useKeyboardNav } from "./ui/useKeyboardNav.ts"
|
|
22
|
+
import { AttrFilterModal } from "./ui/AttrFilterModal.tsx"
|
|
23
|
+
import { useAttrFilterPicker } from "./ui/useAttrFilterPicker.ts"
|
|
14
24
|
|
|
15
25
|
export const App = () => {
|
|
16
26
|
const { width, height } = useTerminalDimensions()
|
|
@@ -36,11 +46,18 @@ export const App = () => {
|
|
|
36
46
|
autoRefresh,
|
|
37
47
|
filterMode,
|
|
38
48
|
filterText,
|
|
49
|
+
activeAttrKey,
|
|
50
|
+
activeAttrValue,
|
|
39
51
|
traceSort,
|
|
40
52
|
selectedTraceSummary,
|
|
41
53
|
selectedTrace,
|
|
42
54
|
filteredTraces,
|
|
43
55
|
} = useTraceScreenData()
|
|
56
|
+
const [pickerMode] = useAtom(attrPickerModeAtom)
|
|
57
|
+
const [pickerInput] = useAtom(attrPickerInputAtom)
|
|
58
|
+
const [pickerIndex] = useAtom(attrPickerIndexAtom)
|
|
59
|
+
const [attrFacets] = useAtom(attrFacetStateAtom)
|
|
60
|
+
useAttrFilterPicker(activeAttrKey)
|
|
44
61
|
|
|
45
62
|
const layout = useAppLayout({ width, height, notice, detailView, selectedSpanIndex })
|
|
46
63
|
const {
|
|
@@ -61,7 +78,6 @@ export const App = () => {
|
|
|
61
78
|
} = layout
|
|
62
79
|
|
|
63
80
|
const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
64
|
-
const traceListScrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
65
81
|
|
|
66
82
|
const flashNotice = (message: string) => {
|
|
67
83
|
if (noticeTimeoutRef.current !== null) {
|
|
@@ -83,27 +99,6 @@ export const App = () => {
|
|
|
83
99
|
persistSelectedTheme(selectedTheme)
|
|
84
100
|
}, [selectedTheme])
|
|
85
101
|
|
|
86
|
-
useLayoutEffect(() => {
|
|
87
|
-
const box = traceListScrollRef.current
|
|
88
|
-
const traceId = selectedTraceSummary?.traceId
|
|
89
|
-
if (!box || !traceId) return
|
|
90
|
-
const indexInList = filteredTraces.findIndex((trace) => trace.traceId === traceId)
|
|
91
|
-
if (indexInList < 0) return
|
|
92
|
-
const currentTop = box.scrollTop
|
|
93
|
-
const viewportRows = Math.max(1, traceViewportRows)
|
|
94
|
-
let nextTop = currentTop
|
|
95
|
-
if (indexInList < currentTop) {
|
|
96
|
-
nextTop = indexInList
|
|
97
|
-
} else if (indexInList >= currentTop + viewportRows) {
|
|
98
|
-
nextTop = indexInList - viewportRows + 1
|
|
99
|
-
}
|
|
100
|
-
const maxTop = Math.max(0, filteredTraces.length - viewportRows)
|
|
101
|
-
nextTop = Math.max(0, Math.min(nextTop, maxTop))
|
|
102
|
-
if (nextTop !== currentTop) {
|
|
103
|
-
box.scrollTop = nextTop
|
|
104
|
-
}
|
|
105
|
-
}, [filteredTraces, selectedTraceIndex, selectedTraceSummary?.traceId, traceSort, traceViewportRows])
|
|
106
|
-
|
|
107
102
|
const { spanNavActive } = useKeyboardNav({
|
|
108
103
|
selectedTrace,
|
|
109
104
|
filteredTraces,
|
|
@@ -117,12 +112,15 @@ export const App = () => {
|
|
|
117
112
|
|
|
118
113
|
const headerServiceLabel = selectedTraceService ?? "none"
|
|
119
114
|
const autoLabel = autoRefresh ? "● live" : "○ paused"
|
|
115
|
+
const attrFilterLabel = activeAttrKey && activeAttrValue
|
|
116
|
+
? ` [${activeAttrKey}=${activeAttrValue.length > 20 ? `${activeAttrValue.slice(0, 19)}…` : activeAttrValue}]`
|
|
117
|
+
: ""
|
|
120
118
|
const headerRight = traceState.fetchedAt
|
|
121
119
|
? `${autoLabel} ${formatTimestamp(traceState.fetchedAt)}`
|
|
122
120
|
: traceState.status === "loading"
|
|
123
121
|
? "loading traces..."
|
|
124
122
|
: ""
|
|
125
|
-
const headerLeftLen = "MOTEL".length + SEPARATOR.length + headerServiceLabel.length
|
|
123
|
+
const headerLeftLen = "MOTEL".length + SEPARATOR.length + headerServiceLabel.length + attrFilterLabel.length
|
|
126
124
|
const headerGap = Math.max(2, headerFooterWidth - headerLeftLen - headerRight.length)
|
|
127
125
|
const visibleFooterNotice = footerNotice
|
|
128
126
|
|
|
@@ -168,6 +166,7 @@ export const App = () => {
|
|
|
168
166
|
<span fg={colors.muted} attributes={TextAttributes.BOLD}>MOTEL</span>
|
|
169
167
|
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
170
168
|
<span fg={colors.muted}>{headerServiceLabel}</span>
|
|
169
|
+
{attrFilterLabel ? <span fg={colors.accent} attributes={TextAttributes.BOLD}>{attrFilterLabel}</span> : null}
|
|
171
170
|
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
172
171
|
<span fg={colors.muted} attributes={TextAttributes.BOLD}>{headerRight}</span>
|
|
173
172
|
</TextLine>
|
|
@@ -181,7 +180,6 @@ export const App = () => {
|
|
|
181
180
|
filterMode={filterMode}
|
|
182
181
|
filterText={filterText}
|
|
183
182
|
traceListProps={traceListProps}
|
|
184
|
-
traceListScrollRef={traceListScrollRef}
|
|
185
183
|
selectedTraceService={selectedTraceService}
|
|
186
184
|
serviceLogState={serviceLogState}
|
|
187
185
|
selectedServiceLogIndex={selectedServiceLogIndex}
|
|
@@ -212,6 +210,18 @@ export const App = () => {
|
|
|
212
210
|
</>
|
|
213
211
|
) : null}
|
|
214
212
|
{showHelp ? <HelpModal width={width ?? 100} height={height ?? 24} autoRefresh={autoRefresh} themeLabel={themeLabel(selectedTheme)} onClose={() => setShowHelp(false)} /> : null}
|
|
213
|
+
{pickerMode !== "off" ? (
|
|
214
|
+
<AttrFilterModal
|
|
215
|
+
width={width ?? 100}
|
|
216
|
+
height={height ?? 24}
|
|
217
|
+
mode={pickerMode}
|
|
218
|
+
input={pickerInput}
|
|
219
|
+
selectedIndex={pickerIndex}
|
|
220
|
+
selectedKey={activeAttrKey}
|
|
221
|
+
state={attrFacets}
|
|
222
|
+
onClose={() => { /* handled via keyboard */ }}
|
|
223
|
+
/>
|
|
224
|
+
) : null}
|
|
215
225
|
</box>
|
|
216
226
|
)
|
|
217
227
|
}
|
package/src/config.ts
CHANGED
|
@@ -34,6 +34,6 @@ export const config = {
|
|
|
34
34
|
traceFetchLimit: parsePositiveInt(process.env.MOTEL_OTEL_TRACE_LIMIT, 100),
|
|
35
35
|
logFetchLimit: parsePositiveInt(process.env.MOTEL_OTEL_LOG_LIMIT, 80),
|
|
36
36
|
retentionHours: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_HOURS, 168),
|
|
37
|
-
maxDbSizeMb: parsePositiveInt(process.env.MOTEL_OTEL_MAX_DB_SIZE_MB,
|
|
37
|
+
maxDbSizeMb: parsePositiveInt(process.env.MOTEL_OTEL_MAX_DB_SIZE_MB, 1024),
|
|
38
38
|
},
|
|
39
39
|
} as const
|
package/src/httpApi.ts
CHANGED
|
@@ -310,7 +310,10 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
310
310
|
Schema.annotateKey({ description: "Data source to facet: 'traces' facets span columns, 'logs' facets log columns" }),
|
|
311
311
|
),
|
|
312
312
|
field: Schema.String.pipe(
|
|
313
|
-
Schema.annotateKey({ description: "Column to facet. Traces: service, operation, status. Logs: service, severity, scope" }),
|
|
313
|
+
Schema.annotateKey({ description: "Column to facet. Traces: service, operation, status, attribute_keys, attribute_values. Logs: service, severity, scope. For attribute_values, also pass key=<attribute-name>." }),
|
|
314
|
+
),
|
|
315
|
+
key: Schema.optionalKey(Schema.String).pipe(
|
|
316
|
+
Schema.annotateKey({ description: "Attribute key to get values for (required when field=attribute_values)." }),
|
|
314
317
|
),
|
|
315
318
|
service: ServiceParam,
|
|
316
319
|
lookback: LookbackParam,
|
|
@@ -320,7 +323,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
320
323
|
error: ErrorResponse,
|
|
321
324
|
})
|
|
322
325
|
.annotate(OpenApi.Summary, "Get facet value counts")
|
|
323
|
-
.annotate(OpenApi.Description, "Returns distinct values and their counts for a given field, useful for discovering what data exists before querying.
|
|
326
|
+
.annotate(OpenApi.Description, "Returns distinct values and their counts for a given field, useful for discovering what data exists before querying. Examples: ?type=logs&field=severity returns log level distribution; ?type=traces&field=attribute_keys&service=opencode lists top span attribute keys; ?type=traces&field=attribute_values&key=ai.model.id lists values seen for that key."),
|
|
324
327
|
|
|
325
328
|
// AI Call endpoints
|
|
326
329
|
HttpApiEndpoint.get("aiCalls", "/api/ai/calls", {
|
package/src/localServer.ts
CHANGED
|
@@ -504,6 +504,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
504
504
|
type,
|
|
505
505
|
field,
|
|
506
506
|
serviceName: url.searchParams.get("service"),
|
|
507
|
+
key: url.searchParams.get("key"),
|
|
507
508
|
lookbackMinutes: parseLookbackMinutes(url.searchParams.get("lookback"), config.otel.traceLookbackMinutes),
|
|
508
509
|
limit: parseLimit(url.searchParams.get("limit"), 20),
|
|
509
510
|
}),
|
package/src/motel.ts
CHANGED
|
@@ -36,6 +36,17 @@ case "stop": {
|
|
|
36
36
|
break
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
case "restart": {
|
|
40
|
+
// Stop any running managed daemon, then start a fresh one + launch the
|
|
41
|
+
// TUI. Handy during local development when you've rebuilt the server
|
|
42
|
+
// and want the TUI to reconnect to the new binary in one command.
|
|
43
|
+
await run(stopManagedDaemon)
|
|
44
|
+
await run(applyManagedDaemonEnv)
|
|
45
|
+
await run(ensureManagedDaemon)
|
|
46
|
+
await import("./index.js")
|
|
47
|
+
break
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
case "server": {
|
|
40
51
|
await run(applyManagedDaemonEnv)
|
|
41
52
|
await import("./server.js")
|
|
@@ -56,6 +67,7 @@ case "-h": {
|
|
|
56
67
|
motel daemon
|
|
57
68
|
motel status
|
|
58
69
|
motel stop
|
|
70
|
+
motel restart
|
|
59
71
|
motel server
|
|
60
72
|
motel mcp
|
|
61
73
|
motel services
|
|
@@ -94,6 +94,7 @@ interface FacetSearch {
|
|
|
94
94
|
readonly type: "traces" | "logs"
|
|
95
95
|
readonly field: string
|
|
96
96
|
readonly serviceName?: string | null
|
|
97
|
+
readonly key?: string | null
|
|
97
98
|
readonly lookbackMinutes?: number
|
|
98
99
|
readonly limit?: number
|
|
99
100
|
}
|
|
@@ -612,41 +613,73 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
612
613
|
const now = yield* Clock.currentTimeMillis
|
|
613
614
|
|
|
614
615
|
yield* Effect.sync(() => {
|
|
615
|
-
let deletedData = false
|
|
616
|
-
// Time-based retention
|
|
617
616
|
const cutoff = now - config.otel.retentionHours * 60 * 60 * 1000
|
|
618
|
-
const deletedSpans = db.query(`DELETE FROM spans WHERE start_time_ms < ?`).run(cutoff) as { changes?: number }
|
|
619
|
-
const deletedLogs = db.query(`DELETE FROM logs WHERE timestamp_ms < ?`).run(cutoff) as { changes?: number }
|
|
620
|
-
deletedData = (deletedSpans.changes ?? 0) > 0 || (deletedLogs.changes ?? 0) > 0
|
|
621
617
|
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
618
|
+
// Evict at TRACE granularity so we never leave a trace half-gutted
|
|
619
|
+
// (previous logic deleted oldest 20% of spans, which happily sliced
|
|
620
|
+
// across traces and corrupted the summary rebuild). Running traces
|
|
621
|
+
// are protected — only `active_span_count = 0` summaries are in
|
|
622
|
+
// scope for eviction.
|
|
623
|
+
const toEvict = new Set<string>()
|
|
624
|
+
|
|
625
|
+
// Time-based: completed traces whose last span ended before cutoff.
|
|
626
|
+
const timeExpired = db.query(
|
|
627
|
+
`SELECT trace_id FROM trace_summaries WHERE active_span_count = 0 AND ended_at_ms > 0 AND ended_at_ms < ?`,
|
|
628
|
+
).all(cutoff) as readonly { trace_id: string }[]
|
|
629
|
+
for (const row of timeExpired) toEvict.add(row.trace_id)
|
|
630
|
+
|
|
631
|
+
// Size-based: if actual data exceeds cap, drop oldest 20% of the
|
|
632
|
+
// remaining completed traces. `(page_count - freelist_count)`
|
|
633
|
+
// ignores freed-but-not-vacuumed pages so a large freelist doesn't
|
|
634
|
+
// trigger a deletion death spiral.
|
|
625
635
|
const pageCount = (db.query(`PRAGMA page_count`).get() as { page_count: number }).page_count
|
|
626
636
|
const freePages = (db.query(`PRAGMA freelist_count`).get() as { freelist_count: number }).freelist_count
|
|
627
637
|
const pageSize = (db.query(`PRAGMA page_size`).get() as { page_size: number }).page_size
|
|
628
638
|
const dbSize = (pageCount - freePages) * pageSize
|
|
629
639
|
if (dbSize > maxDbSizeBytes) {
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
640
|
+
const completedCount = (db.query(
|
|
641
|
+
`SELECT COUNT(*) AS c FROM trace_summaries WHERE active_span_count = 0`,
|
|
642
|
+
).get() as { c: number }).c
|
|
643
|
+
const traceCutCount = Math.max(1, Math.floor(completedCount * 0.2))
|
|
644
|
+
const oldest = db.query(
|
|
645
|
+
`SELECT trace_id FROM trace_summaries WHERE active_span_count = 0 ORDER BY started_at_ms ASC LIMIT ?`,
|
|
646
|
+
).all(traceCutCount) as readonly { trace_id: string }[]
|
|
647
|
+
// Set.add dedupes overlap with the time-expired batch above.
|
|
648
|
+
for (const row of oldest) toEvict.add(row.trace_id)
|
|
637
649
|
}
|
|
638
650
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
651
|
+
// Always prune orphan logs (no trace_id) by timestamp — they're
|
|
652
|
+
// not covered by trace eviction.
|
|
653
|
+
db.query(`DELETE FROM logs WHERE trace_id IS NULL AND timestamp_ms < ?`).run(cutoff)
|
|
654
|
+
|
|
655
|
+
if (toEvict.size === 0) return
|
|
656
|
+
|
|
657
|
+
// Batch the trace-id list so the IN placeholders stay under
|
|
658
|
+
// SQLite's default limit (~999). Each batch wipes every row
|
|
659
|
+
// reachable from those trace_ids across the cascade tables.
|
|
660
|
+
const traceIds = Array.from(toEvict)
|
|
661
|
+
const BATCH_SIZE = 500
|
|
662
|
+
for (let offset = 0; offset < traceIds.length; offset += BATCH_SIZE) {
|
|
663
|
+
const batch = traceIds.slice(offset, offset + BATCH_SIZE)
|
|
664
|
+
const placeholders = batch.map(() => "?").join(",")
|
|
665
|
+
db.query(`DELETE FROM span_attributes WHERE trace_id IN (${placeholders})`).run(...batch)
|
|
642
666
|
try {
|
|
643
|
-
db.query(`DELETE FROM span_operation_fts WHERE
|
|
644
|
-
db.query(`DELETE FROM log_body_fts WHERE NOT EXISTS (SELECT 1 FROM logs WHERE logs.id = CAST(log_body_fts.log_id AS INTEGER))`).run()
|
|
667
|
+
db.query(`DELETE FROM span_operation_fts WHERE trace_id IN (${placeholders})`).run(...batch)
|
|
645
668
|
} catch {
|
|
646
|
-
// FTS
|
|
669
|
+
// FTS table may not exist on old DBs.
|
|
647
670
|
}
|
|
648
|
-
db.query(`DELETE FROM
|
|
649
|
-
|
|
671
|
+
db.query(`DELETE FROM spans WHERE trace_id IN (${placeholders})`).run(...batch)
|
|
672
|
+
db.query(`DELETE FROM logs WHERE trace_id IN (${placeholders})`).run(...batch)
|
|
673
|
+
db.query(`DELETE FROM trace_summaries WHERE trace_id IN (${placeholders})`).run(...batch)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Log-side orphans (log_attributes + FTS) are keyed by log.id,
|
|
677
|
+
// so prune what no longer has a parent log row.
|
|
678
|
+
db.query(`DELETE FROM log_attributes WHERE NOT EXISTS (SELECT 1 FROM logs WHERE logs.id = log_attributes.log_id)`).run()
|
|
679
|
+
try {
|
|
680
|
+
db.query(`DELETE FROM log_body_fts WHERE NOT EXISTS (SELECT 1 FROM logs WHERE logs.id = CAST(log_body_fts.log_id AS INTEGER))`).run()
|
|
681
|
+
} catch {
|
|
682
|
+
// FTS table may not exist on old DBs.
|
|
650
683
|
}
|
|
651
684
|
})
|
|
652
685
|
})
|
|
@@ -1424,6 +1457,49 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
1424
1457
|
`).all(...(input.serviceName ? [cutoff, input.serviceName, limit] : [cutoff, limit])) as Array<{ value: string; count: number }>
|
|
1425
1458
|
return rows
|
|
1426
1459
|
}
|
|
1460
|
+
if (input.field === "attribute_keys") {
|
|
1461
|
+
// Count distinct traces each attribute key appears on, optionally
|
|
1462
|
+
// scoped to a service. Keys with many distinct values (e.g. sessionId,
|
|
1463
|
+
// user id, model) rank higher than keys that are constant across every
|
|
1464
|
+
// trace (service.name, telemetry.sdk.*) — the latter can't discriminate
|
|
1465
|
+
// between traces so they're useless as filters.
|
|
1466
|
+
const params: Array<string | number> = [cutoff]
|
|
1467
|
+
if (input.serviceName) params.push(input.serviceName)
|
|
1468
|
+
params.push(limit)
|
|
1469
|
+
const rows = db.query(`
|
|
1470
|
+
SELECT sa.key AS value,
|
|
1471
|
+
COUNT(DISTINCT sa.trace_id) AS count,
|
|
1472
|
+
COUNT(DISTINCT sa.value) AS distinct_values
|
|
1473
|
+
FROM span_attributes sa
|
|
1474
|
+
JOIN spans s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
|
|
1475
|
+
WHERE s.start_time_ms >= ?
|
|
1476
|
+
${input.serviceName ? "AND s.service_name = ?" : ""}
|
|
1477
|
+
GROUP BY sa.key
|
|
1478
|
+
ORDER BY (CASE WHEN distinct_values = 1 THEN 1 ELSE 0 END) ASC,
|
|
1479
|
+
distinct_values DESC,
|
|
1480
|
+
count DESC,
|
|
1481
|
+
value ASC
|
|
1482
|
+
LIMIT ?
|
|
1483
|
+
`).all(...params) as Array<{ value: string; count: number; distinct_values: number }>
|
|
1484
|
+
return rows.map((row) => ({ value: row.value, count: row.count }))
|
|
1485
|
+
}
|
|
1486
|
+
if (input.field === "attribute_values") {
|
|
1487
|
+
if (!input.key) return [] as FacetItem[]
|
|
1488
|
+
const params: Array<string | number> = [input.key, cutoff]
|
|
1489
|
+
if (input.serviceName) params.push(input.serviceName)
|
|
1490
|
+
params.push(limit)
|
|
1491
|
+
const rows = db.query(`
|
|
1492
|
+
SELECT sa.value AS value, COUNT(DISTINCT sa.trace_id) AS count
|
|
1493
|
+
FROM span_attributes sa
|
|
1494
|
+
JOIN spans s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
|
|
1495
|
+
WHERE sa.key = ? AND s.start_time_ms >= ?
|
|
1496
|
+
${input.serviceName ? "AND s.service_name = ?" : ""}
|
|
1497
|
+
GROUP BY sa.value
|
|
1498
|
+
ORDER BY count DESC, value ASC
|
|
1499
|
+
LIMIT ?
|
|
1500
|
+
`).all(...params) as Array<{ value: string; count: number }>
|
|
1501
|
+
return rows
|
|
1502
|
+
}
|
|
1427
1503
|
}
|
|
1428
1504
|
|
|
1429
1505
|
return [] as FacetItem[]
|
|
@@ -8,6 +8,8 @@ export class TraceQueryService extends Context.Service<
|
|
|
8
8
|
readonly listServices: Effect.Effect<readonly string[], Error>
|
|
9
9
|
readonly listRecentTraces: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly TraceItem[], Error>
|
|
10
10
|
readonly listTraceSummaries: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
11
|
+
readonly searchTraceSummaries: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
12
|
+
readonly listFacets: (input: { readonly type: "traces" | "logs"; readonly field: string; readonly serviceName?: string | null; readonly key?: string | null; readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly { readonly value: string; readonly count: number }[], Error>
|
|
11
13
|
readonly searchTraces: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly TraceItem[], Error>
|
|
12
14
|
readonly traceStats: (input: { readonly groupBy: string; readonly agg: "count" | "avg_duration" | "p95_duration" | "error_rate"; readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
|
|
13
15
|
readonly getTrace: (traceId: string) => Effect.Effect<TraceItem | null, Error>
|
|
@@ -60,6 +62,8 @@ export const TraceQueryServiceLive = Layer.effect(
|
|
|
60
62
|
listServices,
|
|
61
63
|
listRecentTraces,
|
|
62
64
|
listTraceSummaries,
|
|
65
|
+
searchTraceSummaries: store.searchTraceSummaries,
|
|
66
|
+
listFacets: store.listFacets,
|
|
63
67
|
searchTraces: store.searchTraces,
|
|
64
68
|
traceStats: store.traceStats,
|
|
65
69
|
getTrace,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { RGBA, TextAttributes } from "@opentui/core"
|
|
2
|
+
import { BlankRow, TextLine } from "./primitives.tsx"
|
|
3
|
+
import { colors } from "./theme.ts"
|
|
4
|
+
import { fitCell, truncateText } from "./format.ts"
|
|
5
|
+
import type { AttrFacetState, AttrPickerMode } from "./state.ts"
|
|
6
|
+
|
|
7
|
+
export interface AttrFilterModalProps {
|
|
8
|
+
readonly width: number
|
|
9
|
+
readonly height: number
|
|
10
|
+
readonly mode: Exclude<AttrPickerMode, "off">
|
|
11
|
+
readonly input: string
|
|
12
|
+
readonly selectedIndex: number
|
|
13
|
+
readonly selectedKey: string | null
|
|
14
|
+
readonly state: AttrFacetState
|
|
15
|
+
readonly onClose: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Filter + rank facet rows by the user's current input so typing is
|
|
19
|
+
// responsive even on large key sets. Values list skips this — attribute
|
|
20
|
+
// values are usually opaque ids that users paste in whole.
|
|
21
|
+
export const filterFacets = (
|
|
22
|
+
rows: readonly { readonly value: string; readonly count: number }[],
|
|
23
|
+
input: string,
|
|
24
|
+
): readonly { readonly value: string; readonly count: number }[] => {
|
|
25
|
+
const needle = input.trim().toLowerCase()
|
|
26
|
+
if (!needle) return rows
|
|
27
|
+
return rows.filter((row) => row.value.toLowerCase().includes(needle))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const AttrFilterModal = ({
|
|
31
|
+
width,
|
|
32
|
+
height,
|
|
33
|
+
mode,
|
|
34
|
+
input,
|
|
35
|
+
selectedIndex,
|
|
36
|
+
selectedKey,
|
|
37
|
+
state,
|
|
38
|
+
onClose,
|
|
39
|
+
}: AttrFilterModalProps) => {
|
|
40
|
+
const panelWidth = Math.min(92, Math.max(60, width - 10))
|
|
41
|
+
const left = Math.max(2, Math.floor((width - panelWidth) / 2))
|
|
42
|
+
const top = Math.max(1, Math.floor(height / 6))
|
|
43
|
+
const innerWidth = panelWidth - 4
|
|
44
|
+
const rows = filterFacets(state.data, input)
|
|
45
|
+
const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(selectedIndex, rows.length - 1))
|
|
46
|
+
const visibleRowCount = Math.max(5, Math.min(18, height - top - 8))
|
|
47
|
+
const windowStart = Math.max(0, clampedIndex - Math.floor(visibleRowCount / 2))
|
|
48
|
+
const windowEnd = Math.min(rows.length, windowStart + visibleRowCount)
|
|
49
|
+
const windowed = rows.slice(windowStart, windowEnd)
|
|
50
|
+
|
|
51
|
+
const title = mode === "keys"
|
|
52
|
+
? "Filter traces by attribute key"
|
|
53
|
+
: `Filter · ${truncateText(selectedKey ?? "", innerWidth - 14)}`
|
|
54
|
+
|
|
55
|
+
const hint = mode === "keys"
|
|
56
|
+
? "type to narrow · ↑↓ move · enter select · esc cancel"
|
|
57
|
+
: "type to narrow · ↑↓ move · enter apply · backspace keys · esc cancel"
|
|
58
|
+
|
|
59
|
+
const countWidth = 7
|
|
60
|
+
const valueWidth = Math.max(10, innerWidth - countWidth - 1)
|
|
61
|
+
|
|
62
|
+
const renderRow = (row: { readonly value: string; readonly count: number }, isSelected: boolean) => {
|
|
63
|
+
const label = fitCell(row.value, valueWidth)
|
|
64
|
+
const count = String(row.count).padStart(countWidth - 1) + " "
|
|
65
|
+
if (isSelected) {
|
|
66
|
+
return (
|
|
67
|
+
<TextLine fg={colors.text} bg={colors.selectedBg}>
|
|
68
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{label}</span>
|
|
69
|
+
<span fg={colors.muted}>{" "}</span>
|
|
70
|
+
<span fg={colors.muted}>{count}</span>
|
|
71
|
+
</TextLine>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<TextLine>
|
|
76
|
+
<span fg={colors.text}>{label}</span>
|
|
77
|
+
<span fg={colors.muted}>{" "}</span>
|
|
78
|
+
<span fg={colors.count}>{count}</span>
|
|
79
|
+
</TextLine>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<box position="absolute" zIndex={3000} left={0} top={0} width={width} height={height} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
|
|
85
|
+
<box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column" backgroundColor={RGBA.fromInts(20, 20, 28, 255)}>
|
|
86
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexDirection="column">
|
|
87
|
+
<TextLine>
|
|
88
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>{truncateText(title, innerWidth)}</span>
|
|
89
|
+
</TextLine>
|
|
90
|
+
<TextLine>
|
|
91
|
+
<span fg={colors.muted}>{truncateText(hint, innerWidth)}</span>
|
|
92
|
+
</TextLine>
|
|
93
|
+
<BlankRow />
|
|
94
|
+
<TextLine fg={colors.accent}>
|
|
95
|
+
<span fg={colors.muted}>{"\u203a "}</span>
|
|
96
|
+
<span fg={colors.text}>{truncateText(input, Math.max(1, innerWidth - 4))}</span>
|
|
97
|
+
<span fg={colors.accent}>{"\u2588"}</span>
|
|
98
|
+
</TextLine>
|
|
99
|
+
<BlankRow />
|
|
100
|
+
{state.status === "loading" && rows.length === 0 ? (
|
|
101
|
+
<TextLine><span fg={colors.muted}>loading…</span></TextLine>
|
|
102
|
+
) : state.error ? (
|
|
103
|
+
<TextLine><span fg={colors.error}>{truncateText(state.error, innerWidth)}</span></TextLine>
|
|
104
|
+
) : rows.length === 0 ? (
|
|
105
|
+
<TextLine><span fg={colors.muted}>no matches</span></TextLine>
|
|
106
|
+
) : (
|
|
107
|
+
windowed.map((row, i) => (
|
|
108
|
+
<box key={row.value} height={1}>
|
|
109
|
+
{renderRow(row, windowStart + i === clampedIndex)}
|
|
110
|
+
</box>
|
|
111
|
+
))
|
|
112
|
+
)}
|
|
113
|
+
{rows.length > windowEnd ? (
|
|
114
|
+
<TextLine><span fg={colors.muted}>{`+${rows.length - windowEnd} more…`}</span></TextLine>
|
|
115
|
+
) : null}
|
|
116
|
+
</box>
|
|
117
|
+
</box>
|
|
118
|
+
</box>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -30,8 +30,7 @@ export const SpanDetailPane = ({
|
|
|
30
30
|
paneWidth: number
|
|
31
31
|
focused?: boolean
|
|
32
32
|
}) => {
|
|
33
|
-
const
|
|
34
|
-
const headerTitle = `${focusIndicator}SPAN`
|
|
33
|
+
const headerTitle = "SPAN"
|
|
35
34
|
const headerRight = span
|
|
36
35
|
? `${span.status} \u00b7 ${formatDuration(span.durationMs)}${logs.length > 0 ? ` \u00b7 ${logs.length} lg` : ""}`
|
|
37
36
|
: "no span selected"
|