@aryasaatvik/motel 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,131 @@
1
+ import * as Atom from "effect/unstable/reactivity/Atom"
2
+ import { config } from "../config.ts"
3
+ import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
4
+ import type { ThemeName } from "./theme.ts"
5
+ import { readLastService, readLastTheme } from "./persistence.ts"
6
+
7
+ export type LoadStatus = "loading" | "ready" | "error"
8
+ export type DetailView = "waterfall" | "span-detail" | "service-logs"
9
+
10
+ export interface TraceState {
11
+ readonly status: LoadStatus
12
+ readonly services: readonly string[]
13
+ readonly data: readonly TraceSummaryItem[]
14
+ readonly error: string | null
15
+ readonly fetchedAt: Date | null
16
+ }
17
+
18
+ export interface TraceDetailState {
19
+ readonly status: LoadStatus
20
+ readonly traceId: string | null
21
+ readonly data: TraceItem | null
22
+ readonly error: string | null
23
+ readonly fetchedAt: Date | null
24
+ }
25
+
26
+ export interface LogState {
27
+ readonly status: LoadStatus
28
+ readonly traceId: string | null
29
+ readonly data: readonly LogItem[]
30
+ readonly error: string | null
31
+ readonly fetchedAt: Date | null
32
+ }
33
+
34
+ export interface ServiceLogState {
35
+ readonly status: LoadStatus
36
+ readonly serviceName: string | null
37
+ readonly data: readonly LogItem[]
38
+ readonly error: string | null
39
+ readonly fetchedAt: Date | null
40
+ }
41
+
42
+ export const initialTraceState: TraceState = {
43
+ status: "loading",
44
+ services: [],
45
+ data: [],
46
+ error: null,
47
+ fetchedAt: null,
48
+ }
49
+
50
+ export const initialLogState: LogState = {
51
+ status: "ready",
52
+ traceId: null,
53
+ data: [],
54
+ error: null,
55
+ fetchedAt: null,
56
+ }
57
+
58
+ export const initialTraceDetailState: TraceDetailState = {
59
+ status: "ready",
60
+ traceId: null,
61
+ data: null,
62
+ error: null,
63
+ fetchedAt: null,
64
+ }
65
+
66
+ export const initialServiceLogState: ServiceLogState = {
67
+ status: "ready",
68
+ serviceName: null,
69
+ data: [],
70
+ error: null,
71
+ fetchedAt: null,
72
+ }
73
+
74
+ export const traceStateAtom = Atom.make(initialTraceState).pipe(Atom.keepAlive)
75
+ export const traceDetailStateAtom = Atom.make(initialTraceDetailState).pipe(Atom.keepAlive)
76
+ export const logStateAtom = Atom.make(initialLogState).pipe(Atom.keepAlive)
77
+ export const serviceLogStateAtom = Atom.make(initialServiceLogState).pipe(Atom.keepAlive)
78
+ export const selectedServiceLogIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
79
+ export const selectedTraceIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
80
+ export const selectedTraceServiceAtom = Atom.make<string | null>(readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
81
+ export const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
82
+ export const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
83
+ export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.keepAlive)
84
+ // Cursor inside the full-screen span content view (detailView === "span-detail").
85
+ // Tracks which span tag is currently selected for copy / drill-in. Reset to 0
86
+ // on each new span so the cursor doesn't point past a shorter tag list.
87
+ export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
88
+ export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
89
+ export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
90
+ export const autoRefreshAtom = Atom.make(true).pipe(Atom.keepAlive)
91
+ export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
92
+ export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
93
+
94
+ // Waterfall-scoped filter: the `/` key while drilled into a trace
95
+ // (viewLevel >= 1) opens this filter instead of the trace-list one.
96
+ // Purely client-side — dims spans whose operation name and attribute
97
+ // values don't contain the needle.
98
+ export const waterfallFilterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
99
+ export const waterfallFilterTextAtom = Atom.make("").pipe(Atom.keepAlive)
100
+
101
+ // Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
102
+ export type AttrPickerMode = "off" | "keys" | "values"
103
+ export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
104
+ export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
105
+ export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
106
+
107
+ export interface AttrFacetState {
108
+ readonly status: LoadStatus
109
+ readonly key: string | null
110
+ readonly data: readonly { readonly value: string; readonly count: number }[]
111
+ readonly error: string | null
112
+ }
113
+
114
+ export const initialAttrFacetState: AttrFacetState = {
115
+ status: "ready",
116
+ key: null,
117
+ data: [],
118
+ error: null,
119
+ }
120
+
121
+ export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
122
+
123
+ // Applied filter (drives trace list query)
124
+ export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
125
+ export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
126
+
127
+ export const selectedThemeAtom = Atom.make<ThemeName>(readLastTheme()).pipe(Atom.keepAlive)
128
+
129
+ export type TraceSortMode = "recent" | "slowest" | "errors"
130
+ export const traceSortAtom = Atom.make<TraceSortMode>("recent").pipe(Atom.keepAlive)
131
+ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<string>).pipe(Atom.keepAlive)
@@ -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
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { parseFilterText } from "./filterParser.ts"
3
+
4
+ describe("parseFilterText", () => {
5
+ it("returns empty state for empty input", () => {
6
+ expect(parseFilterText("")).toEqual({ aiText: null, errorOnly: false, operationNeedle: "" })
7
+ })
8
+
9
+ it("extracts a bare operation-name needle", () => {
10
+ expect(parseFilterText("streamText")).toEqual({
11
+ aiText: null, errorOnly: false, operationNeedle: "streamtext",
12
+ })
13
+ })
14
+
15
+ it("recognizes :error modifier", () => {
16
+ expect(parseFilterText(":error")).toEqual({
17
+ aiText: null, errorOnly: true, operationNeedle: "",
18
+ })
19
+ })
20
+
21
+ it("composes :error with an operation needle", () => {
22
+ expect(parseFilterText("llm :error")).toEqual({
23
+ aiText: null, errorOnly: true, operationNeedle: "llm",
24
+ })
25
+ })
26
+
27
+ it("extracts :ai query up to end of string", () => {
28
+ expect(parseFilterText(":ai rate limit")).toEqual({
29
+ aiText: "rate limit", errorOnly: false, operationNeedle: "",
30
+ })
31
+ })
32
+
33
+ it("extracts :ai query stopping at the next modifier", () => {
34
+ expect(parseFilterText(":ai tool_use :error")).toEqual({
35
+ aiText: "tool_use", errorOnly: true, operationNeedle: "",
36
+ })
37
+ })
38
+
39
+ it("composes operation needle + :ai + :error", () => {
40
+ expect(parseFilterText("stream :ai rate :error")).toEqual({
41
+ aiText: "rate", errorOnly: true, operationNeedle: "stream",
42
+ })
43
+ })
44
+
45
+ it("ignores :ai with empty query", () => {
46
+ expect(parseFilterText(":ai ")).toEqual({
47
+ aiText: null, errorOnly: false, operationNeedle: "",
48
+ })
49
+ })
50
+
51
+ it("is case-insensitive for modifiers", () => {
52
+ expect(parseFilterText(":AI foo :ERROR")).toEqual({
53
+ aiText: "foo", errorOnly: true, operationNeedle: "",
54
+ })
55
+ })
56
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Parses the `/` filter input in the trace list into its component
3
+ * modifiers. Supports composable tokens:
4
+ *
5
+ * - `:error` — restricts to traces with at least one failed span
6
+ * - `:ai <query...>` — FTS-backed search against LLM prompt/response
7
+ * content (AI_FTS_KEYS) across every span in the
8
+ * trace. The query runs up to the next `:modifier`
9
+ * or end of input, so `"/ :ai rate limit :error"`
10
+ * passes `"rate limit"` as the aiText and also
11
+ * sets errorOnly.
12
+ * - bare text — case-insensitive substring match against the
13
+ * trace's root operation name (client-side)
14
+ *
15
+ * Keep this a pure function: it's unit-tested separately and the React
16
+ * hook just calls it per render.
17
+ */
18
+ export interface ParsedFilter {
19
+ readonly aiText: string | null
20
+ readonly errorOnly: boolean
21
+ readonly operationNeedle: string
22
+ }
23
+
24
+ export const parseFilterText = (raw: string): ParsedFilter => {
25
+ const text = raw ?? ""
26
+
27
+ // `:ai <query>` — greedy up to the next `:` modifier or end. The
28
+ // leading `(^|\s)` guard prevents matching a stray `:ai` glued to
29
+ // non-space characters (shouldn't happen in practice but cheap).
30
+ const aiMatch = text.match(/(?:^|\s):ai\s+([^:]*?)(?=\s:|$)/i)
31
+ const aiText = aiMatch?.[1]?.trim() || null
32
+
33
+ const errorOnly = /(?:^|\s):error(?=\s|$)/i.test(text)
34
+
35
+ // Whatever remains after removing the recognized modifiers becomes the
36
+ // operation-name needle. Lowercased here so the call site can do a
37
+ // plain includes() without re-lowercasing.
38
+ const operationNeedle = text
39
+ .replace(/(?:^|\s):ai\s+[^:]*?(?=\s:|$)/i, " ")
40
+ .replace(/(?:^|\s):error(?=\s|$)/i, " ")
41
+ .trim()
42
+ .toLowerCase()
43
+
44
+ return { aiText, errorOnly, operationNeedle }
45
+ }
@@ -0,0 +1,128 @@
1
+ import { resolveOtelUrl } from "../config.ts"
2
+ import type { LogItem } from "../domain.ts"
3
+ import { colors } from "./theme.ts"
4
+
5
+ export const truncateText = (text: string, width: number) => {
6
+ if (width <= 0) return ""
7
+ if (text.length <= width) return text
8
+ if (width <= 3) return text.slice(0, width)
9
+ return `${text.slice(0, width - 3)}...`
10
+ }
11
+
12
+ export const fitCell = (text: string, width: number, align: "left" | "right" = "left") => {
13
+ const trimmed = truncateText(text, width)
14
+ return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
15
+ }
16
+
17
+ export const formatShortDate = (date: Date) => date.toLocaleDateString("en-US", { month: "numeric", day: "numeric" })
18
+
19
+ export const formatTimestamp = (date: Date) => date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase()
20
+
21
+ export const formatDuration = (durationMs: number) => {
22
+ const { number, unit } = splitDuration(durationMs)
23
+ return `${number}${unit}`
24
+ }
25
+
26
+ /**
27
+ * Split a duration into its numeric and unit parts so the unit can render
28
+ * dimmer than the number (easier to visually parse a column of durations).
29
+ */
30
+ export const splitDuration = (durationMs: number): { number: string; unit: "s" | "ms" } => {
31
+ const trimDecimal = (value: string) => value.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")
32
+
33
+ if (durationMs >= 10_000) return { number: `${Math.round(durationMs / 1000)}`, unit: "s" }
34
+ if (durationMs >= 1000) return { number: trimDecimal((durationMs / 1000).toFixed(1)), unit: "s" }
35
+ if (durationMs >= 100) return { number: `${Math.round(durationMs)}`, unit: "ms" }
36
+ if (durationMs >= 10) return { number: trimDecimal(durationMs.toFixed(1)), unit: "ms" }
37
+ return { number: trimDecimal(durationMs.toFixed(2)), unit: "ms" }
38
+ }
39
+
40
+ export const lifecycleLabel = (value: { readonly isRunning: boolean }) => (value.isRunning ? "open" : "closed")
41
+
42
+ export const relativeTime = (date: Date) => {
43
+ const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000))
44
+ if (seconds < 60) return `${seconds}s`
45
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
46
+ if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`
47
+ return `${Math.floor(seconds / 86_400)}d`
48
+ }
49
+
50
+ export const formatLogTimestamp = (timestamp: Date) => `${formatShortDate(timestamp)} ${formatTimestamp(timestamp)}`
51
+
52
+ export const logHeadline = (body: string) => body.split(/\r?\n/, 1)[0]?.replace(/\s+/g, " ").trim() || ""
53
+
54
+ export const wrapTextLines = (text: string, width: number, maxLines: number) => {
55
+ const normalized = text.replace(/\r/g, "")
56
+ const hardLines = normalized.split("\n")
57
+ const lines: string[] = []
58
+
59
+ for (const hardLine of hardLines) {
60
+ let remaining = hardLine
61
+ if (remaining.length === 0) {
62
+ lines.push("")
63
+ if (lines.length >= maxLines) return lines
64
+ continue
65
+ }
66
+ while (remaining.length > 0) {
67
+ lines.push(remaining.slice(0, width))
68
+ remaining = remaining.slice(width)
69
+ if (lines.length >= maxLines) {
70
+ if (remaining.length > 0) {
71
+ lines[maxLines - 1] = truncateText(lines[maxLines - 1]!, width)
72
+ }
73
+ return lines
74
+ }
75
+ }
76
+ }
77
+
78
+ return lines.slice(0, maxLines)
79
+ }
80
+
81
+ export const traceIndicator = (trace: { readonly errorCount: number }) => (trace.errorCount > 0 ? "!" : "\u00b7")
82
+ export const traceIndicatorColor = (trace: { readonly errorCount: number }) => (trace.errorCount > 0 ? colors.error : colors.passing)
83
+ export const traceRowId = (traceId: string) => `trace-row-${traceId}`
84
+
85
+ export const logSeverityColor = (severity: string) => {
86
+ if (severity.startsWith("ERROR") || severity.startsWith("FATAL")) return colors.error
87
+ if (severity.startsWith("WARN")) return colors.warning
88
+ return colors.count
89
+ }
90
+
91
+ export const relevantLogAttributes = (log: LogItem) =>
92
+ Object.entries(log.attributes).filter(([key]) =>
93
+ ![
94
+ "deployment.environment.name",
95
+ "service.instance.id",
96
+ "service.name",
97
+ "telemetry.sdk.name",
98
+ "telemetry.sdk.language",
99
+ "fiberId",
100
+ "spanId",
101
+ "traceId",
102
+ ].includes(key),
103
+ )
104
+
105
+ export const traceUiUrl = (traceId: string) => resolveOtelUrl(`/trace/${traceId}`)
106
+ export const webUiUrl = () => resolveOtelUrl(`/traces`)
107
+
108
+ export const copyToClipboard = async (value: string) => {
109
+ const proc = Bun.spawn({
110
+ cmd: ["pbcopy"],
111
+ stdin: "pipe",
112
+ stdout: "ignore",
113
+ stderr: "pipe",
114
+ })
115
+
116
+ if (!proc.stdin) {
117
+ throw new Error("Clipboard is not available")
118
+ }
119
+
120
+ proc.stdin.write(value)
121
+ proc.stdin.end()
122
+
123
+ const exitCode = await proc.exited
124
+ if (exitCode !== 0) {
125
+ const stderr = await new Response(proc.stderr).text()
126
+ throw new Error(stderr.trim() || "Could not copy OTEL server details")
127
+ }
128
+ }
@@ -0,0 +1,101 @@
1
+ import { Effect } from "effect"
2
+ import { config } from "../config.ts"
3
+ import { queryRuntime } from "../runtime.ts"
4
+ import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
5
+ import { makeCachedLoader } from "./cachedLoader.ts"
6
+
7
+ export const loadTraceServices = () =>
8
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listServices))
9
+
10
+ export const loadRecentTraceSummaries = (serviceName: string) =>
11
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceSummaries(serviceName)))
12
+
13
+ /**
14
+ * Server-side trace summary search. Accepts any combination of:
15
+ *
16
+ * - `attributeFilters` — exact-match span attributes (from the `f` picker)
17
+ * - `aiText` — FTS5-backed search across LLM prompt/response
18
+ * content (AI_FTS_KEYS), from the `:ai <query>`
19
+ * modifier in the `/` filter
20
+ *
21
+ * Both filters compose: when both are set, a trace must match both. When
22
+ * neither is set, callers should prefer `loadRecentTraceSummaries` so
23
+ * the server can skip the search path entirely.
24
+ */
25
+ export const loadFilteredTraceSummaries = (
26
+ serviceName: string,
27
+ options: {
28
+ readonly attributeFilters?: Readonly<Record<string, string>>
29
+ readonly aiText?: string | null
30
+ },
31
+ ) =>
32
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.searchTraceSummaries({
33
+ serviceName,
34
+ attributeFilters: options.attributeFilters,
35
+ aiText: options.aiText ?? null,
36
+ limit: config.otel.traceFetchLimit,
37
+ })))
38
+
39
+ export const loadTraceAttributeKeys = (serviceName: string) =>
40
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
41
+
42
+ export const loadTraceAttributeValues = (serviceName: string, key: string) =>
43
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Facet cache (drives the `f` attribute filter picker)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export interface FacetRow {
50
+ readonly value: string
51
+ readonly count: number
52
+ }
53
+
54
+ export interface FacetCacheEntry {
55
+ readonly data: readonly FacetRow[]
56
+ readonly fetchedAt: Date
57
+ }
58
+
59
+ const wrapWithTimestamp = (data: readonly FacetRow[]): FacetCacheEntry => ({ data, fetchedAt: new Date() })
60
+
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
+ })
69
+
70
+ export const getCachedFacetKeys = (service: string): FacetCacheEntry | null =>
71
+ facetKeysLoader.get(service) ?? null
72
+
73
+ export const getCachedFacetValues = (service: string, key: string): FacetCacheEntry | null =>
74
+ facetValuesLoader.get({ service, key }) ?? null
75
+
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 })
87
+
88
+ /** Called from the refreshNonce effect alongside the trace / log cache clears. */
89
+ export const invalidateFacetCaches = () => {
90
+ facetKeysLoader.invalidate()
91
+ facetValuesLoader.invalidate()
92
+ }
93
+
94
+ export const loadTraceDetail = (traceId: string) =>
95
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getTrace(traceId)))
96
+
97
+ export const loadTraceLogs = (traceId: string) =>
98
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceLogs(traceId)))
99
+
100
+ export const loadServiceLogs = (serviceName: string) =>
101
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listRecentLogs(serviceName)))
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { dirname } from "node:path"
3
+ import { config } from "../config.ts"
4
+ import { defaultThemeName, type ThemeName } from "./theme.ts"
5
+
6
+ const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
7
+
8
+ export const readLastService = (): string | null => {
9
+ try {
10
+ return readFileSync(lastServicePath, "utf-8").trim() || null
11
+ } catch {
12
+ return null
13
+ }
14
+ }
15
+
16
+ let lastPersistedService = readLastService()
17
+
18
+ export const persistSelectedService = (service: string) => {
19
+ if (service === lastPersistedService) return
20
+ lastPersistedService = service
21
+ Bun.write(lastServicePath, service).catch(() => {})
22
+ }
23
+
24
+ const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
25
+
26
+ export const readLastTheme = (): ThemeName => {
27
+ try {
28
+ const raw = readFileSync(lastThemePath, "utf-8").trim()
29
+ return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : defaultThemeName
30
+ } catch {
31
+ return defaultThemeName
32
+ }
33
+ }
34
+
35
+ let lastPersistedTheme = readLastTheme()
36
+
37
+ export const persistSelectedTheme = (theme: ThemeName) => {
38
+ if (theme === lastPersistedTheme) return
39
+ lastPersistedTheme = theme
40
+ Bun.write(lastThemePath, theme).catch(() => {})
41
+ }