@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.
Files changed (60) hide show
  1. package/AGENTS.md +11 -8
  2. package/README.md +13 -2
  3. package/package.json +31 -19
  4. package/skills/motel-debug/SKILL.md +203 -0
  5. package/skills/motel-debug/references/effect.md +38 -0
  6. package/src/App.tsx +3 -5
  7. package/src/StartupGate.tsx +8 -10
  8. package/src/cli.ts +15 -16
  9. package/src/config.ts +7 -1
  10. package/src/daemon.test.ts +332 -51
  11. package/src/daemon.ts +103 -152
  12. package/src/httpApi.ts +1 -0
  13. package/src/httpListPolicy.test.ts +76 -0
  14. package/src/httpListPolicy.ts +129 -0
  15. package/src/localServer.ts +194 -323
  16. package/src/mcp.ts +2 -1
  17. package/src/opentui-jsx.d.ts +11 -0
  18. package/src/otlp.test.ts +65 -0
  19. package/src/otlp.ts +20 -0
  20. package/src/otlpProtobuf.ts +35 -0
  21. package/src/registry.ts +37 -11
  22. package/src/runtime.ts +2 -6
  23. package/src/services/AsyncIngest.ts +20 -8
  24. package/src/services/LogQueryService.ts +11 -25
  25. package/src/services/TelemetryQuery.ts +62 -0
  26. package/src/services/TelemetryStore.ts +433 -249
  27. package/src/services/TraceQueryService.ts +18 -52
  28. package/src/services/ingestRpc.ts +2 -4
  29. package/src/services/queryRpc.ts +15 -0
  30. package/src/services/telemetryQueryWorker.ts +32 -0
  31. package/src/services/telemetryWorker.ts +5 -8
  32. package/src/storybook/aiChatStory.tsx +1 -1
  33. package/src/telemetry.test.ts +307 -41
  34. package/src/ui/AiChatView.tsx +1 -1
  35. package/src/ui/AttrFilterModal.tsx +1 -1
  36. package/src/ui/ServiceLogs.tsx +10 -7
  37. package/src/ui/SpanContentView.tsx +24 -21
  38. package/src/ui/TraceDetailsPane.tsx +1 -1
  39. package/src/ui/TraceList.tsx +1 -1
  40. package/src/ui/aiState.ts +10 -22
  41. package/src/ui/app/TraceWorkspace.tsx +2 -1
  42. package/src/ui/app/useAppLayout.ts +1 -1
  43. package/src/ui/app/useTraceScreenData.ts +22 -18
  44. package/src/ui/cachedLoader.test.ts +23 -0
  45. package/src/ui/cachedLoader.ts +60 -0
  46. package/src/ui/loaders.ts +34 -53
  47. package/src/ui/primitives.tsx +1 -1
  48. package/src/ui/state.ts +2 -0
  49. package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
  50. package/src/ui/traceSortNav.repro.seed.ts +1 -1
  51. package/src/ui/traceSortNav.repro.test.ts +12 -2
  52. package/src/ui/useAttrFilterPicker.ts +10 -8
  53. package/src/ui/useKeyboardNav.ts +3 -6
  54. package/src/ui/waterfallNav.repro.seed.ts +1 -1
  55. package/src/ui/waterfallNav.repro.test.ts +16 -8
  56. package/web/dist/assets/index-B01z9BaO.css +2 -0
  57. package/web/dist/assets/index-M86tcih5.js +22 -0
  58. package/web/dist/index.html +2 -2
  59. package/web/dist/assets/index-DnyVo03x.js +0 -27
  60. package/web/dist/assets/index-DzuHNBGV.css +0 -2
@@ -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 "./state.ts"
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 entries = Object.entries(span.tags)
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}>{`${entries.length} tags`}</span>
143
+ <span fg={colors.count}>{`${blocks.length} tags`}</span>
141
144
  <span fg={colors.separator}>{SEPARATOR}</span>
142
- <span fg={colors.muted}>{`${selected + 1}/${entries.length}`}</span>
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
- {entries.length === 0 ? (
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 "./state.ts"
8
+ import type { LoadStatus } from "./atoms.ts"
9
9
  import { colors, SEPARATOR } from "./theme.ts"
10
10
 
11
11
  /**
@@ -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 "./state.ts"
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 { TraceQueryService } from "../services/TraceQueryService.ts"
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(TraceQueryService.asEffect(), (service) => service.getAiCall(spanId)))
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 aiCallDetailCache = new Map<string, AiCallDetail | null>()
47
- const aiCallDetailInflight = new Map<string, Promise<AiCallDetail | null>>()
47
+ const aiCallDetailLoader = makeCachedLoader<string, AiCallDetail | null>({
48
+ load: loadAiCallDetail,
49
+ })
48
50
 
49
51
  export const getCachedAiCallDetail = (spanId: string): AiCallDetail | null | undefined =>
50
- aiCallDetailCache.has(spanId) ? aiCallDetailCache.get(spanId) ?? null : undefined
52
+ aiCallDetailLoader.get(spanId)
51
53
 
52
- export const ensureAiCallDetail = (spanId: string): Promise<AiCallDetail | null> => {
53
- if (aiCallDetailCache.has(spanId)) return Promise.resolve(aiCallDetailCache.get(spanId) ?? null)
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
- aiCallDetailCache.clear()
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 { AiCallDetailState, DetailView, LogState, ServiceLogState, TraceDetailState } from "../state.ts"
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"
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from "react"
2
2
  import { fitCell } from "../format.ts"
3
- import type { DetailView } from "../state.ts"
3
+ import type { DetailView } from "../atoms.ts"
4
4
 
5
5
  interface UseAppLayoutInput {
6
6
  readonly width: number | undefined
@@ -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 "../state.ts"
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 { LogQueryService } from "../services/LogQueryService.ts"
5
- import { TraceQueryService } from "../services/TraceQueryService.ts"
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(TraceQueryService.asEffect(), (service) => service.listServices))
8
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listServices))
9
9
 
10
10
  export const loadRecentTraceSummaries = (serviceName: string) =>
11
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
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(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({
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(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
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(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
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 facetKeysCache = new Map<string, FacetCacheEntry>()
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 valuesKey = (service: string, key: string) => `${service}\u0000${key}`
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
- facetKeysCache.get(service) ?? null
71
+ facetKeysLoader.get(service) ?? null
68
72
 
69
73
  export const getCachedFacetValues = (service: string, key: string): FacetCacheEntry | null =>
70
- facetValuesCache.get(valuesKey(service, key)) ?? null
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 ensureTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> => {
89
- const cacheKey = valuesKey(service, key)
90
- const existing = facetValuesInflight.get(cacheKey)
91
- if (existing) return existing
92
- const request = loadTraceAttributeValues(service, key)
93
- .then((data) => {
94
- const entry = { data, fetchedAt: new Date() } satisfies FacetCacheEntry
95
- facetValuesCache.set(cacheKey, entry)
96
- return entry
97
- })
98
- .finally(() => {
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
- facetKeysCache.clear()
108
- facetValuesCache.clear()
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(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
95
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getTrace(traceId)))
115
96
 
116
97
  export const loadTraceLogs = (traceId: string) =>
117
- queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
98
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceLogs(traceId)))
118
99
 
119
100
  export const loadServiceLogs = (serviceName: string) =>
120
- queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
101
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listRecentLogs(serviceName)))
@@ -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 "./state.ts"
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
@@ -1,3 +1,5 @@
1
+ // Compatibility barrel retained for published deep imports. New modules
2
+ // should import from the focused state/cache modules directly.
1
3
  export * from "./atoms.ts"
2
4
  export * from "./persistence.ts"
3
5
  export * from "./loaders.ts"
@@ -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) await tui(["close", "--session", SESSION])
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.asEffect(), (store) => store.ingestTraces({ resourceSpans })),
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, MOTEL_OTEL_DB_PATH: dbPath, MOTEL_OTEL_ENABLED: "false" },
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) await tui(["close", "--session", SESSION])
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 "./state.ts"
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 `state.ts` mean a service-change pre-warm can fill the cache
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
- ensureTraceAttributeKeys(service)
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
- ensureTraceAttributeValues(service, selectedKey)
73
+ refreshTraceAttributeValues(service, selectedKey)
72
74
  .then((entry) => {
73
75
  if (cancelled) return
74
76
  publishReady(selectedKey, entry.data)