@kitlangton/motel 0.2.5 → 0.2.6
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 +11 -8
- package/README.md +13 -2
- package/package.json +31 -19
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +3 -5
- package/src/StartupGate.tsx +8 -10
- package/src/cli.ts +15 -16
- package/src/config.ts +7 -1
- package/src/daemon.test.ts +332 -51
- package/src/daemon.ts +103 -152
- package/src/httpApi.ts +1 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/localServer.ts +194 -323
- package/src/mcp.ts +2 -1
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +20 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/registry.ts +37 -11
- package/src/runtime.ts +2 -6
- package/src/services/AsyncIngest.ts +20 -8
- package/src/services/LogQueryService.ts +11 -25
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +433 -249
- package/src/services/TraceQueryService.ts +18 -52
- package/src/services/ingestRpc.ts +2 -4
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +5 -8
- package/src/storybook/aiChatStory.tsx +1 -1
- package/src/telemetry.test.ts +307 -41
- package/src/ui/AiChatView.tsx +1 -1
- package/src/ui/AttrFilterModal.tsx +1 -1
- package/src/ui/ServiceLogs.tsx +10 -7
- package/src/ui/SpanContentView.tsx +24 -21
- package/src/ui/TraceDetailsPane.tsx +1 -1
- package/src/ui/TraceList.tsx +1 -1
- package/src/ui/aiState.ts +10 -22
- package/src/ui/app/TraceWorkspace.tsx +2 -1
- package/src/ui/app/useAppLayout.ts +1 -1
- package/src/ui/app/useTraceScreenData.ts +22 -18
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/loaders.ts +34 -53
- package/src/ui/primitives.tsx +1 -1
- package/src/ui/state.ts +2 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
- package/src/ui/traceSortNav.repro.seed.ts +1 -1
- package/src/ui/traceSortNav.repro.test.ts +12 -2
- package/src/ui/useAttrFilterPicker.ts +10 -8
- package/src/ui/useKeyboardNav.ts +3 -6
- package/src/ui/waterfallNav.repro.seed.ts +1 -1
- package/src/ui/waterfallNav.repro.test.ts +16 -8
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-M86tcih5.js +22 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DnyVo03x.js +0 -27
- package/web/dist/assets/index-DzuHNBGV.css +0 -2
package/src/ui/ServiceLogs.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { useLayoutEffect, useRef } from "react"
|
|
|
3
3
|
import type { LogItem } from "../domain.ts"
|
|
4
4
|
import { fitCell, formatLogTimestamp, formatTimestamp, logHeadline, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
|
|
5
5
|
import { Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
6
|
-
import type { ServiceLogState } from "./
|
|
6
|
+
import type { ServiceLogState } from "./atoms.ts"
|
|
7
7
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
8
8
|
|
|
9
9
|
export const ServiceLogsView = ({
|
|
@@ -26,6 +26,15 @@ export const ServiceLogsView = ({
|
|
|
26
26
|
const traceWidth = 8
|
|
27
27
|
const messageWidth = Math.max(16, contentWidth - timeWidth - levelWidth - traceWidth - 3)
|
|
28
28
|
|
|
29
|
+
// Hooks must be called unconditionally — keep them above the early
|
|
30
|
+
// returns. The data-dependent values below all tolerate a 0-length
|
|
31
|
+
// data array, so this stays correct in the empty/loading/error cases.
|
|
32
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
33
|
+
const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, logsState.data.length - 1))
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
scrollRef.current?.scrollChildIntoView(`svc-log-${safeSelectedIndex}`)
|
|
36
|
+
}, [safeSelectedIndex])
|
|
37
|
+
|
|
29
38
|
if (logsState.status === "loading" && logsState.data.length === 0) {
|
|
30
39
|
return <PlainLine text="Loading recent service logs..." fg={colors.count} />
|
|
31
40
|
}
|
|
@@ -38,8 +47,6 @@ export const ServiceLogsView = ({
|
|
|
38
47
|
return <PlainLine text={`No logs captured yet for service ${serviceName ?? "unknown"}.`} fg={colors.muted} />
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
42
|
-
const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, logsState.data.length - 1))
|
|
43
50
|
const selectedLog = logsState.data[safeSelectedIndex] ?? null
|
|
44
51
|
const detailWidth = Math.max(16, contentWidth - 2)
|
|
45
52
|
const detailBodyLines = selectedLog ? wrapTextLines(selectedLog.body, detailWidth, 6) : []
|
|
@@ -47,10 +54,6 @@ export const ServiceLogsView = ({
|
|
|
47
54
|
const detailHeight = selectedLog ? 3 + detailBodyLines.length + detailAttributeLines.length : 0
|
|
48
55
|
const listHeight = Math.max(4, bodyLines - detailHeight - 1)
|
|
49
56
|
|
|
50
|
-
useLayoutEffect(() => {
|
|
51
|
-
scrollRef.current?.scrollChildIntoView(`svc-log-${safeSelectedIndex}`)
|
|
52
|
-
}, [safeSelectedIndex])
|
|
53
|
-
|
|
54
57
|
return (
|
|
55
58
|
<box flexDirection="column">
|
|
56
59
|
{selectedLog ? (
|
|
@@ -47,6 +47,25 @@ export const SpanContentView = ({
|
|
|
47
47
|
readonly paneWidth: number
|
|
48
48
|
readonly selectedAttrIndex: number
|
|
49
49
|
}) => {
|
|
50
|
+
const valueWrapWidth = Math.max(16, contentWidth - VALUE_INDENT.length - CURSOR_WIDTH)
|
|
51
|
+
const tags = span?.tags
|
|
52
|
+
|
|
53
|
+
// Block layout is memoised on tags identity — otherwise every j/k press
|
|
54
|
+
// would rewrap every attribute's value even though only the highlight
|
|
55
|
+
// moved. Hooks must be called unconditionally, so this lives above the
|
|
56
|
+
// `!span` early return and the memo handles the empty case itself.
|
|
57
|
+
const blocks = useMemo<readonly AttrBlock[]>(() => {
|
|
58
|
+
if (!tags) return []
|
|
59
|
+
return Object.entries(tags).map(([key, value]) => {
|
|
60
|
+
// Word-wrap with a generous line cap — the view is scrollable so
|
|
61
|
+
// we can afford to show the whole value rather than forcing an
|
|
62
|
+
// ellipsis. 200 lines covers enormous LLM prompts without
|
|
63
|
+
// blowing up the render tree.
|
|
64
|
+
const valueLines = wrapTextLines(value, valueWrapWidth, 200)
|
|
65
|
+
return { key, valueLines, rowCount: 1 + Math.max(1, valueLines.length) }
|
|
66
|
+
})
|
|
67
|
+
}, [tags, valueWrapWidth])
|
|
68
|
+
|
|
50
69
|
if (!span) {
|
|
51
70
|
return (
|
|
52
71
|
<box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_CONTENT_HEADER_ROWS} overflow="hidden">
|
|
@@ -61,25 +80,9 @@ export const SpanContentView = ({
|
|
|
61
80
|
)
|
|
62
81
|
}
|
|
63
82
|
|
|
64
|
-
const
|
|
65
|
-
const selected = Math.max(0, Math.min(selectedAttrIndex, entries.length - 1))
|
|
66
|
-
const valueWrapWidth = Math.max(16, contentWidth - VALUE_INDENT.length - CURSOR_WIDTH)
|
|
83
|
+
const selected = Math.max(0, Math.min(selectedAttrIndex, blocks.length - 1))
|
|
67
84
|
const aiFlag = isAiSpan(span.tags)
|
|
68
85
|
|
|
69
|
-
// Block layout is memoised on entries identity — otherwise every j/k
|
|
70
|
-
// press would rewrap every attribute's value even though only the
|
|
71
|
-
// highlight moved.
|
|
72
|
-
const blocks = useMemo<readonly AttrBlock[]>(() => {
|
|
73
|
-
return entries.map(([key, value]) => {
|
|
74
|
-
// Word-wrap with a generous line cap — the view is scrollable so
|
|
75
|
-
// we can afford to show the whole value rather than forcing an
|
|
76
|
-
// ellipsis. 200 lines covers enormous LLM prompts without
|
|
77
|
-
// blowing up the render tree.
|
|
78
|
-
const valueLines = wrapTextLines(value, valueWrapWidth, 200)
|
|
79
|
-
return { key, valueLines, rowCount: 1 + Math.max(1, valueLines.length) }
|
|
80
|
-
})
|
|
81
|
-
}, [entries, valueWrapWidth])
|
|
82
|
-
|
|
83
86
|
// Viewport: pick the contiguous window of blocks that (a) fits inside
|
|
84
87
|
// bodyLines and (b) contains the selected block. We find the first
|
|
85
88
|
// start index whose accumulated row count from there through `selected`
|
|
@@ -137,14 +140,14 @@ export const SpanContentView = ({
|
|
|
137
140
|
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
138
141
|
<span fg={colors.muted}>{span.spanId.slice(0, 16)}</span>
|
|
139
142
|
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
140
|
-
<span fg={colors.count}>{`${
|
|
143
|
+
<span fg={colors.count}>{`${blocks.length} tags`}</span>
|
|
141
144
|
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
142
|
-
<span fg={colors.muted}>{`${selected + 1}/${
|
|
145
|
+
<span fg={colors.muted}>{`${selected + 1}/${blocks.length}`}</span>
|
|
143
146
|
</TextLine>
|
|
144
147
|
</box>
|
|
145
148
|
<Divider width={paneWidth} />
|
|
146
149
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
147
|
-
{
|
|
150
|
+
{blocks.length === 0 ? (
|
|
148
151
|
<PlainLine text="No tags on this span." fg={colors.muted} />
|
|
149
152
|
) : (
|
|
150
153
|
visible.map((block, offset) => {
|
|
@@ -165,7 +168,7 @@ export const SpanContentView = ({
|
|
|
165
168
|
</TextLine>
|
|
166
169
|
) : (
|
|
167
170
|
block.valueLines.map((line, i) => (
|
|
168
|
-
<TextLine key={i}>
|
|
171
|
+
<TextLine key={`${block.key}-${i}`}>
|
|
169
172
|
<span fg={colors.separator}>{VALUE_INDENT}</span>
|
|
170
173
|
<span fg={isSelected ? colors.text : colors.muted}>{line}</span>
|
|
171
174
|
</TextLine>
|
|
@@ -5,7 +5,7 @@ import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./pr
|
|
|
5
5
|
import { WaterfallTimeline } from "./Waterfall.tsx"
|
|
6
6
|
import { computeMatchingSpanIds } from "./waterfallFilter.ts"
|
|
7
7
|
import { getVisibleSpans } from "./waterfallModel.ts"
|
|
8
|
-
import type { LoadStatus } from "./
|
|
8
|
+
import type { LoadStatus } from "./atoms.ts"
|
|
9
9
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
10
10
|
|
|
11
11
|
/**
|
package/src/ui/TraceList.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { useLayoutEffect, useRef, useState } from "react"
|
|
|
3
3
|
import type { TraceSummaryItem } from "../domain.ts"
|
|
4
4
|
import { fitCell, formatDuration, lifecycleLabel, relativeTime, traceIndicator, traceIndicatorColor, traceRowId } from "./format.ts"
|
|
5
5
|
import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
|
|
6
|
-
import type { LoadStatus } from "./
|
|
6
|
+
import type { LoadStatus } from "./atoms.ts"
|
|
7
7
|
import { colors } from "./theme.ts"
|
|
8
8
|
|
|
9
9
|
const getTraceRowLayout = (contentWidth: number) => {
|
package/src/ui/aiState.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { Effect } from "effect"
|
|
|
2
2
|
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
3
3
|
import type { AiCallDetail } from "../domain.ts"
|
|
4
4
|
import { queryRuntime } from "../runtime.ts"
|
|
5
|
-
import {
|
|
5
|
+
import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
|
|
6
6
|
import type { LoadStatus } from "./atoms.ts"
|
|
7
|
+
import { makeCachedLoader } from "./cachedLoader.ts"
|
|
7
8
|
|
|
8
9
|
// AI chat view (full-screen when drilled into an `isAiSpan` span).
|
|
9
10
|
// ---------------------------------------------------------------------
|
|
@@ -37,35 +38,22 @@ export const initialAiCallDetailState: AiCallDetailState = {
|
|
|
37
38
|
export const aiCallDetailStateAtom = Atom.make(initialAiCallDetailState).pipe(Atom.keepAlive)
|
|
38
39
|
|
|
39
40
|
export const loadAiCallDetail = (spanId: string) =>
|
|
40
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
41
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getAiCall(spanId)))
|
|
41
42
|
|
|
42
43
|
// AI call detail cache: the `ai.prompt` payload can easily be 50KB+ and
|
|
43
44
|
// we don't want to re-hit SQLite every time j/k moves the selection
|
|
44
45
|
// between adjacent AI spans. Cleared alongside the other per-refresh
|
|
45
46
|
// caches in `useTraceScreenData`.
|
|
46
|
-
const
|
|
47
|
-
|
|
47
|
+
const aiCallDetailLoader = makeCachedLoader<string, AiCallDetail | null>({
|
|
48
|
+
load: loadAiCallDetail,
|
|
49
|
+
})
|
|
48
50
|
|
|
49
51
|
export const getCachedAiCallDetail = (spanId: string): AiCallDetail | null | undefined =>
|
|
50
|
-
|
|
52
|
+
aiCallDetailLoader.get(spanId)
|
|
51
53
|
|
|
52
|
-
export const ensureAiCallDetail = (spanId: string): Promise<AiCallDetail | null> =>
|
|
53
|
-
|
|
54
|
-
const existing = aiCallDetailInflight.get(spanId)
|
|
55
|
-
if (existing) return existing
|
|
56
|
-
const request = loadAiCallDetail(spanId)
|
|
57
|
-
.then((data) => {
|
|
58
|
-
aiCallDetailCache.set(spanId, data)
|
|
59
|
-
return data
|
|
60
|
-
})
|
|
61
|
-
.finally(() => {
|
|
62
|
-
aiCallDetailInflight.delete(spanId)
|
|
63
|
-
})
|
|
64
|
-
aiCallDetailInflight.set(spanId, request)
|
|
65
|
-
return request
|
|
66
|
-
}
|
|
54
|
+
export const ensureAiCallDetail = (spanId: string): Promise<AiCallDetail | null> =>
|
|
55
|
+
aiCallDetailLoader.ensure(spanId)
|
|
67
56
|
|
|
68
57
|
export const invalidateAiCallDetailCache = () => {
|
|
69
|
-
|
|
70
|
-
aiCallDetailInflight.clear()
|
|
58
|
+
aiCallDetailLoader.invalidate()
|
|
71
59
|
}
|
|
@@ -6,7 +6,8 @@ import { AlignedHeaderLine, BlankRow, Divider, SeparatorColumn, TextLine } from
|
|
|
6
6
|
import { ServiceLogsView } from "../ServiceLogs.tsx"
|
|
7
7
|
import { SpanContentView } from "../SpanContentView.tsx"
|
|
8
8
|
import { SpanDetailPane } from "../SpanDetailPane.tsx"
|
|
9
|
-
import type {
|
|
9
|
+
import type { DetailView, LogState, ServiceLogState, TraceDetailState } from "../atoms.ts"
|
|
10
|
+
import type { AiCallDetailState } from "../aiState.ts"
|
|
10
11
|
import { colors, SEPARATOR } from "../theme.ts"
|
|
11
12
|
import { TraceDetailsPane } from "../TraceDetailsPane.tsx"
|
|
12
13
|
import type { TraceListProps } from "../TraceList.tsx"
|
|
@@ -5,34 +5,17 @@ import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
|
|
|
5
5
|
import {
|
|
6
6
|
activeAttrKeyAtom,
|
|
7
7
|
activeAttrValueAtom,
|
|
8
|
-
aiCallDetailStateAtom,
|
|
9
8
|
autoRefreshAtom,
|
|
10
|
-
chatDetailChunkIdAtom,
|
|
11
|
-
chatDetailScrollOffsetAtom,
|
|
12
9
|
collapsedSpanIdsAtom,
|
|
13
10
|
detailViewAtom,
|
|
14
|
-
ensureAiCallDetail,
|
|
15
|
-
ensureTraceAttributeKeys,
|
|
16
|
-
ensureTraceAttributeValues,
|
|
17
11
|
filterModeAtom,
|
|
18
12
|
filterTextAtom,
|
|
19
|
-
getCachedAiCallDetail,
|
|
20
|
-
initialAiCallDetailState,
|
|
21
13
|
initialLogState,
|
|
22
14
|
initialServiceLogState,
|
|
23
15
|
initialTraceDetailState,
|
|
24
|
-
invalidateAiCallDetailCache,
|
|
25
|
-
loadFilteredTraceSummaries,
|
|
26
|
-
loadRecentTraceSummaries,
|
|
27
|
-
loadServiceLogs,
|
|
28
|
-
loadTraceDetail,
|
|
29
|
-
loadTraceLogs,
|
|
30
|
-
loadTraceServices,
|
|
31
16
|
logStateAtom,
|
|
32
|
-
persistSelectedService,
|
|
33
17
|
refreshNonceAtom,
|
|
34
18
|
selectedAttrIndexAtom,
|
|
35
|
-
selectedChatChunkIdAtom,
|
|
36
19
|
selectedServiceLogIndexAtom,
|
|
37
20
|
selectedSpanIndexAtom,
|
|
38
21
|
selectedTraceIndexAtom,
|
|
@@ -43,7 +26,28 @@ import {
|
|
|
43
26
|
type TraceSortMode,
|
|
44
27
|
traceSortAtom,
|
|
45
28
|
traceStateAtom,
|
|
46
|
-
} from "../
|
|
29
|
+
} from "../atoms.ts"
|
|
30
|
+
import {
|
|
31
|
+
aiCallDetailStateAtom,
|
|
32
|
+
chatDetailChunkIdAtom,
|
|
33
|
+
chatDetailScrollOffsetAtom,
|
|
34
|
+
ensureAiCallDetail,
|
|
35
|
+
getCachedAiCallDetail,
|
|
36
|
+
initialAiCallDetailState,
|
|
37
|
+
invalidateAiCallDetailCache,
|
|
38
|
+
selectedChatChunkIdAtom,
|
|
39
|
+
} from "../aiState.ts"
|
|
40
|
+
import {
|
|
41
|
+
ensureTraceAttributeKeys,
|
|
42
|
+
ensureTraceAttributeValues,
|
|
43
|
+
loadFilteredTraceSummaries,
|
|
44
|
+
loadRecentTraceSummaries,
|
|
45
|
+
loadServiceLogs,
|
|
46
|
+
loadTraceDetail,
|
|
47
|
+
loadTraceLogs,
|
|
48
|
+
loadTraceServices,
|
|
49
|
+
} from "../loaders.ts"
|
|
50
|
+
import { persistSelectedService } from "../persistence.ts"
|
|
47
51
|
import { isAiSpan } from "../../domain.ts"
|
|
48
52
|
import { buildChunks, type Chunk } from "../aiChatModel.ts"
|
|
49
53
|
import { parseFilterText } from "../filterParser.ts"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { makeCachedLoader } from "./cachedLoader.ts"
|
|
3
|
+
|
|
4
|
+
describe("makeCachedLoader", () => {
|
|
5
|
+
it("ensures cached values without loading twice", async () => {
|
|
6
|
+
let loads = 0
|
|
7
|
+
const loader = makeCachedLoader<string, number>({ load: async () => ++loads })
|
|
8
|
+
|
|
9
|
+
expect(await loader.ensure("key")).toBe(1)
|
|
10
|
+
expect(await loader.ensure("key")).toBe(1)
|
|
11
|
+
expect(loads).toBe(1)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("refreshes stale values while publishing the updated cache", async () => {
|
|
15
|
+
let value = 1
|
|
16
|
+
const loader = makeCachedLoader<string, number>({ load: async () => value })
|
|
17
|
+
|
|
18
|
+
expect(await loader.ensure("key")).toBe(1)
|
|
19
|
+
value = 2
|
|
20
|
+
expect(await loader.refresh("key")).toBe(2)
|
|
21
|
+
expect(loader.get("key")).toBe(2)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Generic cache with inflight-promise dedup, used by the facet and AI
|
|
2
|
+
// call detail caches. Both previously duplicated this pattern by hand:
|
|
3
|
+
// a Map for the cached value, a parallel Map for in-flight promises,
|
|
4
|
+
// and three small exports (get / ensure / invalidate). This factory is
|
|
5
|
+
// the seam; callers only need to supply a loader and (optionally) a
|
|
6
|
+
// hash function for compound keys.
|
|
7
|
+
|
|
8
|
+
export interface CachedLoader<K, V> {
|
|
9
|
+
/** Synchronous cache lookup. Returns `undefined` if the key has never been loaded. */
|
|
10
|
+
readonly get: (key: K) => V | undefined
|
|
11
|
+
/** Resolves with the cached value if present, otherwise loads it (deduplicating concurrent calls for the same key). */
|
|
12
|
+
readonly ensure: (key: K) => Promise<V>
|
|
13
|
+
/** Loads the latest value while deduplicating any load already running for this key. */
|
|
14
|
+
readonly refresh: (key: K) => Promise<V>
|
|
15
|
+
/** Drops every cached value and aborts dedup tracking for in-flight loads. */
|
|
16
|
+
readonly invalidate: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CachedLoaderOptions<K, V> {
|
|
20
|
+
readonly load: (key: K) => Promise<V>
|
|
21
|
+
/** Required when `K` is a compound type that can't be used as a Map key directly. */
|
|
22
|
+
readonly hash?: (key: K) => string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const makeCachedLoader = <K, V>(opts: CachedLoaderOptions<K, V>): CachedLoader<K, V> => {
|
|
26
|
+
const hash = opts.hash ?? ((k) => k as unknown as string)
|
|
27
|
+
const cache = new Map<string, V>()
|
|
28
|
+
const inflight = new Map<string, Promise<V>>()
|
|
29
|
+
|
|
30
|
+
const get = (key: K) => cache.get(hash(key))
|
|
31
|
+
|
|
32
|
+
const refresh = (key: K): Promise<V> => {
|
|
33
|
+
const h = hash(key)
|
|
34
|
+
const existing = inflight.get(h)
|
|
35
|
+
if (existing) return existing
|
|
36
|
+
const request = opts.load(key)
|
|
37
|
+
.then((data) => {
|
|
38
|
+
cache.set(h, data)
|
|
39
|
+
return data
|
|
40
|
+
})
|
|
41
|
+
.finally(() => {
|
|
42
|
+
inflight.delete(h)
|
|
43
|
+
})
|
|
44
|
+
inflight.set(h, request)
|
|
45
|
+
return request
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ensure = (key: K): Promise<V> => {
|
|
49
|
+
const h = hash(key)
|
|
50
|
+
if (cache.has(h)) return Promise.resolve(cache.get(h) as V)
|
|
51
|
+
return refresh(key)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const invalidate = () => {
|
|
55
|
+
cache.clear()
|
|
56
|
+
inflight.clear()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { get, ensure, refresh, invalidate }
|
|
60
|
+
}
|
package/src/ui/loaders.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { config } from "../config.ts"
|
|
3
3
|
import { queryRuntime } from "../runtime.ts"
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
|
|
5
|
+
import { makeCachedLoader } from "./cachedLoader.ts"
|
|
6
6
|
|
|
7
7
|
export const loadTraceServices = () =>
|
|
8
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
8
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listServices))
|
|
9
9
|
|
|
10
10
|
export const loadRecentTraceSummaries = (serviceName: string) =>
|
|
11
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
11
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceSummaries(serviceName)))
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Server-side trace summary search. Accepts any combination of:
|
|
@@ -29,7 +29,7 @@ export const loadFilteredTraceSummaries = (
|
|
|
29
29
|
readonly aiText?: string | null
|
|
30
30
|
},
|
|
31
31
|
) =>
|
|
32
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
32
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.searchTraceSummaries({
|
|
33
33
|
serviceName,
|
|
34
34
|
attributeFilters: options.attributeFilters,
|
|
35
35
|
aiText: options.aiText ?? null,
|
|
@@ -37,10 +37,10 @@ export const loadFilteredTraceSummaries = (
|
|
|
37
37
|
})))
|
|
38
38
|
|
|
39
39
|
export const loadTraceAttributeKeys = (serviceName: string) =>
|
|
40
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
40
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
|
|
41
41
|
|
|
42
42
|
export const loadTraceAttributeValues = (serviceName: string, key: string) =>
|
|
43
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
43
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
|
|
44
44
|
|
|
45
45
|
// ---------------------------------------------------------------------------
|
|
46
46
|
// Facet cache (drives the `f` attribute filter picker)
|
|
@@ -56,65 +56,46 @@ export interface FacetCacheEntry {
|
|
|
56
56
|
readonly fetchedAt: Date
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const
|
|
60
|
-
const facetValuesCache = new Map<string, FacetCacheEntry>()
|
|
61
|
-
const facetKeysInflight = new Map<string, Promise<FacetCacheEntry>>()
|
|
62
|
-
const facetValuesInflight = new Map<string, Promise<FacetCacheEntry>>()
|
|
59
|
+
const wrapWithTimestamp = (data: readonly FacetRow[]): FacetCacheEntry => ({ data, fetchedAt: new Date() })
|
|
63
60
|
|
|
64
|
-
const
|
|
61
|
+
const facetKeysLoader = makeCachedLoader<string, FacetCacheEntry>({
|
|
62
|
+
load: (service) => loadTraceAttributeKeys(service).then(wrapWithTimestamp),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const facetValuesLoader = makeCachedLoader<{ readonly service: string; readonly key: string }, FacetCacheEntry>({
|
|
66
|
+
hash: ({ service, key }) => `${service}\u0000${key}`,
|
|
67
|
+
load: ({ service, key }) => loadTraceAttributeValues(service, key).then(wrapWithTimestamp),
|
|
68
|
+
})
|
|
65
69
|
|
|
66
70
|
export const getCachedFacetKeys = (service: string): FacetCacheEntry | null =>
|
|
67
|
-
|
|
71
|
+
facetKeysLoader.get(service) ?? null
|
|
68
72
|
|
|
69
73
|
export const getCachedFacetValues = (service: string, key: string): FacetCacheEntry | null =>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
export const ensureTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> => {
|
|
73
|
-
const existing = facetKeysInflight.get(service)
|
|
74
|
-
if (existing) return existing
|
|
75
|
-
const request = loadTraceAttributeKeys(service)
|
|
76
|
-
.then((data) => {
|
|
77
|
-
const entry = { data, fetchedAt: new Date() } satisfies FacetCacheEntry
|
|
78
|
-
facetKeysCache.set(service, entry)
|
|
79
|
-
return entry
|
|
80
|
-
})
|
|
81
|
-
.finally(() => {
|
|
82
|
-
facetKeysInflight.delete(service)
|
|
83
|
-
})
|
|
84
|
-
facetKeysInflight.set(service, request)
|
|
85
|
-
return request
|
|
86
|
-
}
|
|
74
|
+
facetValuesLoader.get({ service, key }) ?? null
|
|
87
75
|
|
|
88
|
-
export const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
facetValuesInflight.delete(cacheKey)
|
|
100
|
-
})
|
|
101
|
-
facetValuesInflight.set(cacheKey, request)
|
|
102
|
-
return request
|
|
103
|
-
}
|
|
76
|
+
export const ensureTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> =>
|
|
77
|
+
facetKeysLoader.ensure(service)
|
|
78
|
+
|
|
79
|
+
export const refreshTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> =>
|
|
80
|
+
facetKeysLoader.refresh(service)
|
|
81
|
+
|
|
82
|
+
export const ensureTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> =>
|
|
83
|
+
facetValuesLoader.ensure({ service, key })
|
|
84
|
+
|
|
85
|
+
export const refreshTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> =>
|
|
86
|
+
facetValuesLoader.refresh({ service, key })
|
|
104
87
|
|
|
105
88
|
/** Called from the refreshNonce effect alongside the trace / log cache clears. */
|
|
106
89
|
export const invalidateFacetCaches = () => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
facetKeysInflight.clear()
|
|
110
|
-
facetValuesInflight.clear()
|
|
90
|
+
facetKeysLoader.invalidate()
|
|
91
|
+
facetValuesLoader.invalidate()
|
|
111
92
|
}
|
|
112
93
|
|
|
113
94
|
export const loadTraceDetail = (traceId: string) =>
|
|
114
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
95
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getTrace(traceId)))
|
|
115
96
|
|
|
116
97
|
export const loadTraceLogs = (traceId: string) =>
|
|
117
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
98
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceLogs(traceId)))
|
|
118
99
|
|
|
119
100
|
export const loadServiceLogs = (serviceName: string) =>
|
|
120
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
101
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listRecentLogs(serviceName)))
|
package/src/ui/primitives.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
|
|
|
2
2
|
import { Children } from "react"
|
|
3
3
|
import { colors } from "./theme.ts"
|
|
4
4
|
import { fitCell, truncateText } from "./format.ts"
|
|
5
|
-
import type { DetailView } from "./
|
|
5
|
+
import type { DetailView } from "./atoms.ts"
|
|
6
6
|
|
|
7
7
|
export const BlankRow = () => <box height={1} />
|
|
8
8
|
|
package/src/ui/state.ts
CHANGED
|
@@ -52,6 +52,8 @@ const rootWaterfallRow = (snap: string) =>
|
|
|
52
52
|
describe("trace details waterfall width (end-to-end TUI)", () => {
|
|
53
53
|
const tempDir = mkdtempSync(join(tmpdir(), "motel-trace-width-"))
|
|
54
54
|
const dbPath = join(tempDir, "telemetry.sqlite")
|
|
55
|
+
const port = 45_000 + Math.floor(Math.random() * 5_000)
|
|
56
|
+
const daemonEnv = { MOTEL_RUNTIME_DIR: tempDir, MOTEL_OTEL_DB_PATH: dbPath, MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_QUERY_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_PORT: String(port) }
|
|
55
57
|
const lastServicePath = join(tempDir, "last-service.txt")
|
|
56
58
|
let canRun = false
|
|
57
59
|
|
|
@@ -66,6 +68,7 @@ describe("trace details waterfall width (end-to-end TUI)", () => {
|
|
|
66
68
|
cwd: process.cwd(),
|
|
67
69
|
env: {
|
|
68
70
|
...process.env,
|
|
71
|
+
...daemonEnv,
|
|
69
72
|
MOTEL_OTEL_DB_PATH: dbPath,
|
|
70
73
|
MOTEL_OTEL_ENABLED: "false",
|
|
71
74
|
},
|
|
@@ -87,6 +90,10 @@ describe("trace details waterfall width (end-to-end TUI)", () => {
|
|
|
87
90
|
"--rows", "40",
|
|
88
91
|
"--cwd", process.cwd(),
|
|
89
92
|
"--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
|
|
93
|
+
"--env", `MOTEL_RUNTIME_DIR=${tempDir}`,
|
|
94
|
+
"--env", `MOTEL_OTEL_BASE_URL=${daemonEnv.MOTEL_OTEL_BASE_URL}`,
|
|
95
|
+
"--env", `MOTEL_OTEL_QUERY_URL=${daemonEnv.MOTEL_OTEL_QUERY_URL}`,
|
|
96
|
+
"--env", `MOTEL_OTEL_PORT=${port}`,
|
|
90
97
|
"--env", "MOTEL_OTEL_ENABLED=false",
|
|
91
98
|
"--timeout", "15000",
|
|
92
99
|
])
|
|
@@ -96,7 +103,11 @@ describe("trace details waterfall width (end-to-end TUI)", () => {
|
|
|
96
103
|
}, 60_000)
|
|
97
104
|
|
|
98
105
|
afterAll(async () => {
|
|
99
|
-
if (canRun)
|
|
106
|
+
if (canRun) {
|
|
107
|
+
await tui(["close", "--session", SESSION])
|
|
108
|
+
const stop = Bun.spawn({ cmd: ["bun", "run", "src/motel.ts", "stop"], cwd: process.cwd(), env: { ...process.env, ...daemonEnv }, stdout: "ignore", stderr: "ignore" })
|
|
109
|
+
await stop.exited
|
|
110
|
+
}
|
|
100
111
|
try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
101
112
|
})
|
|
102
113
|
|
|
@@ -57,6 +57,6 @@ const resourceSpans = specs.map((spec) => ({
|
|
|
57
57
|
}))
|
|
58
58
|
|
|
59
59
|
await storeRuntime.runPromise(
|
|
60
|
-
Effect.flatMap(TelemetryStore
|
|
60
|
+
Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({ resourceSpans })),
|
|
61
61
|
)
|
|
62
62
|
process.exit(0)
|
|
@@ -93,6 +93,8 @@ const SERVICE_NAME = "sort-nav-repro"
|
|
|
93
93
|
describe("trace navigation after changing sort", () => {
|
|
94
94
|
const tempDir = mkdtempSync(join(tmpdir(), "motel-sort-repro-"))
|
|
95
95
|
const dbPath = join(tempDir, "telemetry.sqlite")
|
|
96
|
+
const port = 50_000 + Math.floor(Math.random() * 5_000)
|
|
97
|
+
const daemonEnv = { MOTEL_RUNTIME_DIR: tempDir, MOTEL_OTEL_DB_PATH: dbPath, MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_QUERY_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_PORT: String(port) }
|
|
96
98
|
const lastServicePath = join(tempDir, "last-service.txt")
|
|
97
99
|
let canRun = false
|
|
98
100
|
|
|
@@ -106,7 +108,7 @@ describe("trace navigation after changing sort", () => {
|
|
|
106
108
|
const seed = Bun.spawn({
|
|
107
109
|
cmd: ["bun", "run", "src/ui/traceSortNav.repro.seed.ts"],
|
|
108
110
|
cwd: process.cwd(),
|
|
109
|
-
env: { ...process.env,
|
|
111
|
+
env: { ...process.env, ...daemonEnv, MOTEL_OTEL_ENABLED: "false" },
|
|
110
112
|
stdout: "pipe",
|
|
111
113
|
stderr: "pipe",
|
|
112
114
|
})
|
|
@@ -128,6 +130,10 @@ describe("trace navigation after changing sort", () => {
|
|
|
128
130
|
"--rows", "20",
|
|
129
131
|
"--cwd", process.cwd(),
|
|
130
132
|
"--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
|
|
133
|
+
"--env", `MOTEL_RUNTIME_DIR=${tempDir}`,
|
|
134
|
+
"--env", `MOTEL_OTEL_BASE_URL=${daemonEnv.MOTEL_OTEL_BASE_URL}`,
|
|
135
|
+
"--env", `MOTEL_OTEL_QUERY_URL=${daemonEnv.MOTEL_OTEL_QUERY_URL}`,
|
|
136
|
+
"--env", `MOTEL_OTEL_PORT=${port}`,
|
|
131
137
|
"--env", "MOTEL_OTEL_ENABLED=false",
|
|
132
138
|
"--timeout", "15000",
|
|
133
139
|
])
|
|
@@ -137,7 +143,11 @@ describe("trace navigation after changing sort", () => {
|
|
|
137
143
|
}, 60_000)
|
|
138
144
|
|
|
139
145
|
afterAll(async () => {
|
|
140
|
-
if (canRun)
|
|
146
|
+
if (canRun) {
|
|
147
|
+
await tui(["close", "--session", SESSION])
|
|
148
|
+
const stop = Bun.spawn({ cmd: ["bun", "run", "src/motel.ts", "stop"], cwd: process.cwd(), env: { ...process.env, ...daemonEnv }, stdout: "ignore", stderr: "ignore" })
|
|
149
|
+
await stop.exited
|
|
150
|
+
}
|
|
141
151
|
try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
142
152
|
})
|
|
143
153
|
|
|
@@ -3,13 +3,15 @@ import { useEffect } from "react"
|
|
|
3
3
|
import {
|
|
4
4
|
attrFacetStateAtom,
|
|
5
5
|
attrPickerModeAtom,
|
|
6
|
-
ensureTraceAttributeKeys,
|
|
7
|
-
ensureTraceAttributeValues,
|
|
8
|
-
getCachedFacetKeys,
|
|
9
|
-
getCachedFacetValues,
|
|
10
6
|
initialAttrFacetState,
|
|
11
7
|
selectedTraceServiceAtom,
|
|
12
|
-
} from "./
|
|
8
|
+
} from "./atoms.ts"
|
|
9
|
+
import {
|
|
10
|
+
getCachedFacetKeys,
|
|
11
|
+
getCachedFacetValues,
|
|
12
|
+
refreshTraceAttributeKeys,
|
|
13
|
+
refreshTraceAttributeValues,
|
|
14
|
+
} from "./loaders.ts"
|
|
13
15
|
|
|
14
16
|
// Drive the picker's data state from (pickerMode, service, selectedKey).
|
|
15
17
|
//
|
|
@@ -17,7 +19,7 @@ import {
|
|
|
17
19
|
// module-level cache has instantly (no "loading…" flash), then kick off a
|
|
18
20
|
// background revalidation. The first time we see a (service, key) tuple
|
|
19
21
|
// we still show `loading` so the UI has something to say. The module-level
|
|
20
|
-
// caches in `
|
|
22
|
+
// caches in `loaders.ts` mean a service-change pre-warm can fill the cache
|
|
21
23
|
// before the user ever presses `f`.
|
|
22
24
|
export const useAttrFilterPicker = (selectedKey: string | null) => {
|
|
23
25
|
const [pickerMode] = useAtom(attrPickerModeAtom)
|
|
@@ -52,7 +54,7 @@ export const useAttrFilterPicker = (selectedKey: string | null) => {
|
|
|
52
54
|
} else {
|
|
53
55
|
publishLoading(null)
|
|
54
56
|
}
|
|
55
|
-
|
|
57
|
+
refreshTraceAttributeKeys(service)
|
|
56
58
|
.then((entry) => {
|
|
57
59
|
if (cancelled) return
|
|
58
60
|
publishReady(null, entry.data)
|
|
@@ -68,7 +70,7 @@ export const useAttrFilterPicker = (selectedKey: string | null) => {
|
|
|
68
70
|
} else {
|
|
69
71
|
publishLoading(selectedKey)
|
|
70
72
|
}
|
|
71
|
-
|
|
73
|
+
refreshTraceAttributeValues(service, selectedKey)
|
|
72
74
|
.then((entry) => {
|
|
73
75
|
if (cancelled) return
|
|
74
76
|
publishReady(selectedKey, entry.data)
|