@aryasaatvik/motel 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import type { TraceSpanItem } from "../domain.ts"
|
|
3
|
+
import { computeMatchingSpanIds, findAdjacentMatch, spanMatchesFilter } from "./waterfallFilter.ts"
|
|
4
|
+
|
|
5
|
+
const span = (spanId: string, operationName: string, tags: Record<string, string> = {}): TraceSpanItem => ({
|
|
6
|
+
spanId,
|
|
7
|
+
parentSpanId: null,
|
|
8
|
+
serviceName: "test",
|
|
9
|
+
scopeName: null,
|
|
10
|
+
kind: null,
|
|
11
|
+
operationName,
|
|
12
|
+
startTime: new Date(0),
|
|
13
|
+
isRunning: false,
|
|
14
|
+
durationMs: 1,
|
|
15
|
+
status: "ok",
|
|
16
|
+
depth: 0,
|
|
17
|
+
tags,
|
|
18
|
+
warnings: [],
|
|
19
|
+
events: [],
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe("spanMatchesFilter", () => {
|
|
23
|
+
it("matches operation name case-insensitively", () => {
|
|
24
|
+
expect(spanMatchesFilter(span("a", "ai.StreamText"), "stream")).toBe(true)
|
|
25
|
+
expect(spanMatchesFilter(span("a", "ai.StreamText"), "nope")).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("matches tag values but not keys", () => {
|
|
29
|
+
expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "claude")).toBe(true)
|
|
30
|
+
// Key-only match should not count, otherwise searching "ai" dims nothing.
|
|
31
|
+
expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "model.id")).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("returns true when the needle is empty", () => {
|
|
35
|
+
expect(spanMatchesFilter(span("a", "op"), "")).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe("computeMatchingSpanIds", () => {
|
|
40
|
+
it("returns null for empty/whitespace filter", () => {
|
|
41
|
+
expect(computeMatchingSpanIds([span("a", "op")], "")).toBeNull()
|
|
42
|
+
expect(computeMatchingSpanIds([span("a", "op")], " ")).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("returns only matching span ids", () => {
|
|
46
|
+
const spans = [span("a", "ai.streamText"), span("b", "Agent.get"), span("c", "ai.toolCall")]
|
|
47
|
+
const ids = computeMatchingSpanIds(spans, "ai")
|
|
48
|
+
expect(ids).not.toBeNull()
|
|
49
|
+
expect(Array.from(ids!)).toEqual(["a", "c"])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe("findAdjacentMatch", () => {
|
|
54
|
+
const spans = [span("a", "one"), span("b", "two"), span("c", "three"), span("d", "four")]
|
|
55
|
+
const matches = new Set(["b", "d"])
|
|
56
|
+
|
|
57
|
+
it("finds next from current selection", () => {
|
|
58
|
+
expect(findAdjacentMatch(spans, matches, 0, 1)).toBe(1) // a -> b
|
|
59
|
+
expect(findAdjacentMatch(spans, matches, 1, 1)).toBe(3) // b -> d
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("wraps forward past the end", () => {
|
|
63
|
+
expect(findAdjacentMatch(spans, matches, 3, 1)).toBe(1) // d -> b (wrap)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("finds previous from current selection", () => {
|
|
67
|
+
expect(findAdjacentMatch(spans, matches, 3, -1)).toBe(1) // d -> b
|
|
68
|
+
expect(findAdjacentMatch(spans, matches, 1, -1)).toBe(3) // b -> d (wrap)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("starts from beginning/end when nothing is selected", () => {
|
|
72
|
+
expect(findAdjacentMatch(spans, matches, null, 1)).toBe(1) // forward → first match
|
|
73
|
+
expect(findAdjacentMatch(spans, matches, null, -1)).toBe(3) // backward → last match
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("returns null when there are no matches", () => {
|
|
77
|
+
expect(findAdjacentMatch(spans, new Set(), 0, 1)).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("handles a single match by returning it regardless of direction", () => {
|
|
81
|
+
expect(findAdjacentMatch(spans, new Set(["c"]), 0, 1)).toBe(2)
|
|
82
|
+
expect(findAdjacentMatch(spans, new Set(["c"]), 0, -1)).toBe(2)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { TraceSpanItem } from "../domain.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Case-insensitive substring match against a span's operation name and
|
|
5
|
+
* any of its tag values. Keys aren't checked — they're dotted
|
|
6
|
+
* identifiers the user never types, so matching them just creates false
|
|
7
|
+
* positives on bare tokens like "ai" or "response".
|
|
8
|
+
*/
|
|
9
|
+
export const spanMatchesFilter = (span: TraceSpanItem, needle: string): boolean => {
|
|
10
|
+
if (!needle) return true
|
|
11
|
+
if (span.operationName.toLowerCase().includes(needle)) return true
|
|
12
|
+
for (const value of Object.values(span.tags)) {
|
|
13
|
+
if (typeof value === "string" && value.toLowerCase().includes(needle)) return true
|
|
14
|
+
}
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute the set of span IDs that match the given filter text. Returns
|
|
20
|
+
* null when the filter is empty so callers can skip dimming entirely
|
|
21
|
+
* (hot path during waterfall scrolling).
|
|
22
|
+
*/
|
|
23
|
+
export const computeMatchingSpanIds = (
|
|
24
|
+
spans: readonly TraceSpanItem[],
|
|
25
|
+
filterText: string,
|
|
26
|
+
): ReadonlySet<string> | null => {
|
|
27
|
+
const needle = filterText.trim().toLowerCase()
|
|
28
|
+
if (!needle) return null
|
|
29
|
+
const matches = new Set<string>()
|
|
30
|
+
for (const span of spans) {
|
|
31
|
+
if (spanMatchesFilter(span, needle)) matches.add(span.spanId)
|
|
32
|
+
}
|
|
33
|
+
return matches
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Find the next (direction=1) or previous (direction=-1) matching span
|
|
38
|
+
* index in `filteredSpans` relative to `currentIndex`. Wraps around the
|
|
39
|
+
* ends. Returns `null` when the list has no matches at all. When
|
|
40
|
+
* `currentIndex` is null (nothing selected), starts from either end
|
|
41
|
+
* based on direction.
|
|
42
|
+
*/
|
|
43
|
+
export const findAdjacentMatch = (
|
|
44
|
+
filteredSpans: readonly TraceSpanItem[],
|
|
45
|
+
matchingSpanIds: ReadonlySet<string>,
|
|
46
|
+
currentIndex: number | null,
|
|
47
|
+
direction: 1 | -1,
|
|
48
|
+
): number | null => {
|
|
49
|
+
if (filteredSpans.length === 0 || matchingSpanIds.size === 0) return null
|
|
50
|
+
const start = currentIndex ?? (direction === 1 ? -1 : filteredSpans.length)
|
|
51
|
+
const n = filteredSpans.length
|
|
52
|
+
// Walk the ring exactly once so we wrap but never loop forever.
|
|
53
|
+
for (let step = 1; step <= n; step++) {
|
|
54
|
+
const idx = ((start + direction * step) % n + n) % n
|
|
55
|
+
const span = filteredSpans[idx]
|
|
56
|
+
if (span && matchingSpanIds.has(span.spanId)) return idx
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { LogItem, TraceSpanItem } from "../domain.ts"
|
|
2
|
+
import { formatDuration } from "./format.ts"
|
|
3
|
+
|
|
4
|
+
/** Filter spans to only those visible given a set of collapsed span IDs. */
|
|
5
|
+
export const getVisibleSpans = (spans: readonly TraceSpanItem[], collapsedIds: ReadonlySet<string>): readonly TraceSpanItem[] => {
|
|
6
|
+
if (collapsedIds.size === 0) return spans
|
|
7
|
+
const result: TraceSpanItem[] = []
|
|
8
|
+
let skipDepth = -1
|
|
9
|
+
for (const span of spans) {
|
|
10
|
+
if (skipDepth >= 0 && span.depth > skipDepth) continue
|
|
11
|
+
skipDepth = -1
|
|
12
|
+
result.push(span)
|
|
13
|
+
if (collapsedIds.has(span.spanId)) {
|
|
14
|
+
skipDepth = span.depth
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Find the index of a span's parent in the visible list. */
|
|
21
|
+
export const findParentIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
22
|
+
const span = spans[index]
|
|
23
|
+
if (!span || span.depth === 0) return null
|
|
24
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
25
|
+
if (spans[i]!.depth < span.depth) return i
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Find the index of a span's first child in the visible list. */
|
|
31
|
+
export const findFirstChildIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
32
|
+
const span = spans[index]
|
|
33
|
+
const next = spans[index + 1]
|
|
34
|
+
if (span && next && next.depth > span.depth) return index + 1
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const buildTreePrefix = (spans: readonly TraceSpanItem[], index: number): string => {
|
|
39
|
+
const span = spans[index]
|
|
40
|
+
if (span.depth === 0) return ""
|
|
41
|
+
|
|
42
|
+
const parts: string[] = []
|
|
43
|
+
|
|
44
|
+
const isLastChild = (spanIndex: number, depth: number): boolean => {
|
|
45
|
+
for (let i = spanIndex + 1; i < spans.length; i++) {
|
|
46
|
+
if (spans[i].depth < depth) return true
|
|
47
|
+
if (spans[i].depth === depth) return false
|
|
48
|
+
}
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
parts.push(isLastChild(index, span.depth) ? "\u2514\u2500" : "\u251c\u2500")
|
|
53
|
+
|
|
54
|
+
for (let d = span.depth - 1; d >= 1; d--) {
|
|
55
|
+
let parentIndex = index
|
|
56
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
57
|
+
if (spans[i].depth === d) {
|
|
58
|
+
parentIndex = i
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
if (spans[i].depth < d) break
|
|
62
|
+
}
|
|
63
|
+
parts.push(isLastChild(parentIndex, d) ? " " : "\u2502 ")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return parts.reverse().join("")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const INTERESTING_TAGS = [
|
|
70
|
+
"http.method", "http.url", "http.status_code", "http.route",
|
|
71
|
+
"db.system", "db.statement", "db.name",
|
|
72
|
+
"messaging.system", "messaging.destination",
|
|
73
|
+
"error", "error.message",
|
|
74
|
+
"net.peer.name", "net.peer.port",
|
|
75
|
+
] as const
|
|
76
|
+
|
|
77
|
+
export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
|
|
78
|
+
const gapsAndSuffix = suffixWidth + 2
|
|
79
|
+
const remaining = Math.max(4, contentWidth - gapsAndSuffix)
|
|
80
|
+
const labelMaxWidth = Math.max(4, Math.min(Math.floor(remaining * 0.5), 32))
|
|
81
|
+
const barWidth = Math.max(1, contentWidth - labelMaxWidth - gapsAndSuffix)
|
|
82
|
+
return { labelMaxWidth, barWidth } as const
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type WaterfallSuffixMetrics = {
|
|
86
|
+
readonly maxDurationWidth: number
|
|
87
|
+
readonly suffixWidth: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const getWaterfallSuffixMetrics = (
|
|
91
|
+
spans: readonly { readonly durationMs: number; readonly spanId: string }[],
|
|
92
|
+
): WaterfallSuffixMetrics => {
|
|
93
|
+
let maxDurationWidth = 0
|
|
94
|
+
for (const span of spans) {
|
|
95
|
+
const d = formatDuration(Math.max(0, span.durationMs)).length
|
|
96
|
+
if (d > maxDurationWidth) maxDurationWidth = d
|
|
97
|
+
}
|
|
98
|
+
return { maxDurationWidth, suffixWidth: maxDurationWidth }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const getWaterfallColumns = (
|
|
102
|
+
contentWidth: number,
|
|
103
|
+
metrics: WaterfallSuffixMetrics,
|
|
104
|
+
) => {
|
|
105
|
+
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
|
|
106
|
+
return { labelMaxWidth, barWidth, suffixWidth: metrics.suffixWidth } as const
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
|
|
110
|
+
const entries = Object.entries(span.tags)
|
|
111
|
+
const interesting = entries.filter(([key]) =>
|
|
112
|
+
INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) || key.startsWith("error"),
|
|
113
|
+
)
|
|
114
|
+
const rest = entries.filter(([key]) =>
|
|
115
|
+
!INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) && !key.startsWith("error") && !key.startsWith("otel.") && key !== "span.kind",
|
|
116
|
+
)
|
|
117
|
+
const tagResults: Array<{ key: string; value: string; isWarning?: boolean }> = []
|
|
118
|
+
if (logs.length > 0) {
|
|
119
|
+
tagResults.push({ key: "logs", value: `${logs.length} correlated` })
|
|
120
|
+
tagResults.push({ key: "log", value: logs[0]!.body.replace(/\s+/g, " ") })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
tagResults.push(...[...interesting, ...rest]
|
|
124
|
+
.slice(0, maxEntries - span.warnings.length)
|
|
125
|
+
.map(([key, value]) => ({ key, value })))
|
|
126
|
+
for (const warning of span.warnings) {
|
|
127
|
+
tagResults.push({ key: "warning", value: warning, isWarning: true })
|
|
128
|
+
}
|
|
129
|
+
return tagResults.slice(0, maxEntries)
|
|
130
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed a deterministic trace into the SQLite store. Invoked as a child process
|
|
3
|
+
* by the reproducer test so it gets a fresh module graph (config.ts caches the
|
|
4
|
+
* DB path at module-load time).
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* bun run src/ui/waterfallNav.repro.seed.ts
|
|
8
|
+
*
|
|
9
|
+
* Reads MOTEL_OTEL_DB_PATH from the environment.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Effect } from "effect"
|
|
13
|
+
import { storeRuntime } from "../runtime.ts"
|
|
14
|
+
import { TelemetryStore } from "../services/TelemetryStore.ts"
|
|
15
|
+
|
|
16
|
+
const SERVICE_NAME = "waterfall-repro"
|
|
17
|
+
const TRACE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
18
|
+
const ROOT = "1111111111111111"
|
|
19
|
+
const SIB_BEFORE = "2222222222222222"
|
|
20
|
+
const PARENT = "3333333333333333"
|
|
21
|
+
const CHILD_A = "aaaaaaaaaaaaaaaa"
|
|
22
|
+
const CHILD_B = "bbbbbbbbbbbbbbbb"
|
|
23
|
+
const CHILD_C = "cccccccccccccccc"
|
|
24
|
+
const CHILD_D = "dddddddddddddddd"
|
|
25
|
+
const CHILD_E = "eeeeeeeeeeeeeeee"
|
|
26
|
+
const CHILD_F = "ffffffffffffffff"
|
|
27
|
+
const SIB_AFTER = "4444444444444444"
|
|
28
|
+
const TAIL = "5555555555555555"
|
|
29
|
+
const TAIL_CHILD = "6666666666666666"
|
|
30
|
+
const TAIL_GRAND = "7777777777777777"
|
|
31
|
+
|
|
32
|
+
const nowNanos = BigInt(Date.now()) * 1_000_000n
|
|
33
|
+
const ms = (n: number) => String(nowNanos + BigInt(n) * 1_000_000n)
|
|
34
|
+
|
|
35
|
+
const span = (
|
|
36
|
+
spanId: string,
|
|
37
|
+
parent: string | null,
|
|
38
|
+
name: string,
|
|
39
|
+
startMs: number,
|
|
40
|
+
endMs: number,
|
|
41
|
+
) => ({
|
|
42
|
+
traceId: TRACE_ID,
|
|
43
|
+
spanId,
|
|
44
|
+
parentSpanId: parent ?? undefined,
|
|
45
|
+
name,
|
|
46
|
+
kind: 1,
|
|
47
|
+
startTimeUnixNano: ms(startMs),
|
|
48
|
+
endTimeUnixNano: ms(endMs),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const program = Effect.flatMap(TelemetryStore, (store) =>
|
|
52
|
+
store.ingestTraces({
|
|
53
|
+
resourceSpans: [
|
|
54
|
+
{
|
|
55
|
+
resource: {
|
|
56
|
+
attributes: [
|
|
57
|
+
{ key: "service.name", value: { stringValue: SERVICE_NAME } },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
scopeSpans: [
|
|
61
|
+
{
|
|
62
|
+
scope: { name: "test-scope" },
|
|
63
|
+
spans: [
|
|
64
|
+
span(ROOT, null, "root.op", 0, 100),
|
|
65
|
+
span(SIB_BEFORE, ROOT, "siblingBefore.op", 1, 2),
|
|
66
|
+
span(PARENT, ROOT, "parent.op", 5, 60),
|
|
67
|
+
span(CHILD_A, PARENT, "childA.op", 6, 8),
|
|
68
|
+
span(CHILD_B, PARENT, "childB.op", 10, 12),
|
|
69
|
+
span(CHILD_C, PARENT, "childC.op", 14, 18),
|
|
70
|
+
span(CHILD_D, PARENT, "childD.op", 20, 25),
|
|
71
|
+
span(CHILD_E, PARENT, "childE.op", 28, 35),
|
|
72
|
+
span(CHILD_F, PARENT, "childF.op", 40, 55),
|
|
73
|
+
span(SIB_AFTER, ROOT, "siblingAfter.op", 65, 68),
|
|
74
|
+
span(TAIL, ROOT, "tail.op", 70, 95),
|
|
75
|
+
span(TAIL_CHILD, TAIL, "tailChild.op", 72, 90),
|
|
76
|
+
span(TAIL_GRAND, TAIL_CHILD, "tailGrandchild.op", 75, 85),
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
await storeRuntime.runPromise(program)
|
|
86
|
+
process.exit(0)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end reproducer for the waterfall collapse/expand bug.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. Seed a deterministic trace with a parent span and three leaf children
|
|
6
|
+
* into a fresh SQLite database.
|
|
7
|
+
* 2. Launch the motel TUI under tuistory pointing at that database.
|
|
8
|
+
* 3. Drive keys to navigate onto the parent span, capture a baseline snapshot.
|
|
9
|
+
* 4. Press `h` (collapse) then `l` (expand). Capture again.
|
|
10
|
+
* 5. Assert: the visible waterfall body before === after. Today this fails
|
|
11
|
+
* because the first child silently disappears from the visible list after
|
|
12
|
+
* a collapse/expand cycle.
|
|
13
|
+
*
|
|
14
|
+
* Skipped automatically when `tuistory` is not installed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
19
|
+
import { tmpdir } from "node:os"
|
|
20
|
+
import { join } from "node:path"
|
|
21
|
+
|
|
22
|
+
const TUISTORY_BIN = "tuistory"
|
|
23
|
+
const SESSION = `motel-repro-${Date.now()}`
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// tuistory wrappers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const hasTuistory = async () => {
|
|
30
|
+
try {
|
|
31
|
+
const proc = Bun.spawn({ cmd: ["which", TUISTORY_BIN], stdout: "pipe", stderr: "ignore" })
|
|
32
|
+
const code = await proc.exited
|
|
33
|
+
return code === 0
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const tui = async (args: readonly string[]): Promise<{ code: number; stdout: string; stderr: string }> => {
|
|
40
|
+
const proc = Bun.spawn({ cmd: [TUISTORY_BIN, ...args], stdout: "pipe", stderr: "pipe" })
|
|
41
|
+
const [stdout, stderr] = await Promise.all([
|
|
42
|
+
new Response(proc.stdout).text(),
|
|
43
|
+
new Response(proc.stderr).text(),
|
|
44
|
+
])
|
|
45
|
+
const code = await proc.exited
|
|
46
|
+
return { code, stdout, stderr }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const snapshot = async () => (await tui(["snapshot", "--session", SESSION])).stdout
|
|
50
|
+
|
|
51
|
+
const press = async (...keys: string[]) => {
|
|
52
|
+
await tui(["press", "--session", SESSION, ...keys])
|
|
53
|
+
// small settle so the next snapshot reflects the keypress
|
|
54
|
+
await Bun.sleep(120)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Slice out just the waterfall body region from a snapshot. The waterfall
|
|
58
|
+
// always sits between the last two horizontal dividers (the one just below
|
|
59
|
+
// the trace meta / pane header, and the one above the footer). Using the
|
|
60
|
+
// *last* pair makes the helper robust to layout changes that add or remove
|
|
61
|
+
// header dividers (breadcrumbs, split-divider junctions, etc.).
|
|
62
|
+
//
|
|
63
|
+
// In wide level-1 mode the waterfall is rendered in the left pane and span
|
|
64
|
+
// detail is rendered on the right. Determine the pane separator from the
|
|
65
|
+
// stable header line rather than mistaking tree branch glyphs for it.
|
|
66
|
+
const waterfallBody = (snap: string): readonly string[] => {
|
|
67
|
+
const lines = snap.split("\n")
|
|
68
|
+
const header = lines.find((line) => line.includes("TRACE DETAILS") && line.includes("SPAN"))
|
|
69
|
+
const paneSeparator = header?.indexOf("\u2502") ?? -1
|
|
70
|
+
const dividerIdxs: number[] = []
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
if (lines[i]!.startsWith("─")) dividerIdxs.push(i)
|
|
73
|
+
}
|
|
74
|
+
if (dividerIdxs.length < 2) return []
|
|
75
|
+
const start = dividerIdxs[dividerIdxs.length - 2]! + 1
|
|
76
|
+
const end = dividerIdxs[dividerIdxs.length - 1]!
|
|
77
|
+
return lines.slice(start, end).map((line) => {
|
|
78
|
+
const sliced = paneSeparator >= 0 ? line.slice(0, paneSeparator) : line
|
|
79
|
+
return sliced.replace(/\s+$/g, "")
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Test fixture: temp DB with a deterministic trace
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
// Structure (kept in sync with src/ui/waterfallNav.repro.seed.ts):
|
|
88
|
+
// root.op
|
|
89
|
+
// ├─ siblingBefore.op
|
|
90
|
+
// ├─ parent.op <- the one we collapse / expand
|
|
91
|
+
// │ ├─ childA.op (1st) <- the one that used to disappear after a cycle
|
|
92
|
+
// │ ├─ childB.op
|
|
93
|
+
// │ ├─ childC.op
|
|
94
|
+
// │ ├─ childD.op
|
|
95
|
+
// │ ├─ childE.op
|
|
96
|
+
// │ └─ childF.op
|
|
97
|
+
// ├─ siblingAfter.op
|
|
98
|
+
// └─ tail.op
|
|
99
|
+
// └─ tailChild.op
|
|
100
|
+
// └─ tailGrandchild.op
|
|
101
|
+
const SERVICE_NAME = "waterfall-repro"
|
|
102
|
+
|
|
103
|
+
describe("waterfall collapse/expand (end-to-end TUI)", () => {
|
|
104
|
+
const tempDir = mkdtempSync(join(tmpdir(), "motel-repro-"))
|
|
105
|
+
const dbPath = join(tempDir, "telemetry.sqlite")
|
|
106
|
+
const port = 40_000 + Math.floor(Math.random() * 5_000)
|
|
107
|
+
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) }
|
|
108
|
+
const lastServicePath = join(tempDir, "last-service.txt")
|
|
109
|
+
let canRun = false
|
|
110
|
+
|
|
111
|
+
beforeAll(async () => {
|
|
112
|
+
canRun = await hasTuistory()
|
|
113
|
+
if (!canRun) return
|
|
114
|
+
|
|
115
|
+
// Pin selected service so the TUI lands on our seeded trace immediately.
|
|
116
|
+
writeFileSync(lastServicePath, SERVICE_NAME)
|
|
117
|
+
|
|
118
|
+
// Seed the DB in a child process. Doing it in-process collides with
|
|
119
|
+
// other tests that imported config.ts first (databasePath is captured at
|
|
120
|
+
// module load time and ESM cache busts on the runtime entry alone do not
|
|
121
|
+
// invalidate the transitively cached config module).
|
|
122
|
+
const seed = Bun.spawn({
|
|
123
|
+
cmd: ["bun", "run", "src/ui/waterfallNav.repro.seed.ts"],
|
|
124
|
+
cwd: process.cwd(),
|
|
125
|
+
env: {
|
|
126
|
+
...process.env,
|
|
127
|
+
...daemonEnv,
|
|
128
|
+
MOTEL_OTEL_DB_PATH: dbPath,
|
|
129
|
+
MOTEL_OTEL_RETENTION_HOURS: "24",
|
|
130
|
+
MOTEL_OTEL_ENABLED: "false",
|
|
131
|
+
},
|
|
132
|
+
stdout: "pipe",
|
|
133
|
+
stderr: "pipe",
|
|
134
|
+
})
|
|
135
|
+
const seedCode = await seed.exited
|
|
136
|
+
if (seedCode !== 0) {
|
|
137
|
+
const [out, err] = await Promise.all([
|
|
138
|
+
new Response(seed.stdout).text(),
|
|
139
|
+
new Response(seed.stderr).text(),
|
|
140
|
+
])
|
|
141
|
+
throw new Error(`Seed subprocess failed (${seedCode})\nstdout: ${out}\nstderr: ${err}`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Make sure no stale session is hanging around with the same name.
|
|
145
|
+
await tui(["close", "--session", SESSION])
|
|
146
|
+
|
|
147
|
+
// Launch wide enough to exercise the waterfall + span-detail split.
|
|
148
|
+
const launch = await tui([
|
|
149
|
+
"launch",
|
|
150
|
+
"bun run src/index.tsx",
|
|
151
|
+
"--session", SESSION,
|
|
152
|
+
"--cols", "160",
|
|
153
|
+
"--rows", "40",
|
|
154
|
+
"--cwd", process.cwd(),
|
|
155
|
+
"--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
|
|
156
|
+
"--env", `MOTEL_RUNTIME_DIR=${tempDir}`,
|
|
157
|
+
"--env", `MOTEL_OTEL_BASE_URL=${daemonEnv.MOTEL_OTEL_BASE_URL}`,
|
|
158
|
+
"--env", `MOTEL_OTEL_QUERY_URL=${daemonEnv.MOTEL_OTEL_QUERY_URL}`,
|
|
159
|
+
"--env", `MOTEL_OTEL_PORT=${port}`,
|
|
160
|
+
"--env", "MOTEL_OTEL_ENABLED=false",
|
|
161
|
+
"--timeout", "15000",
|
|
162
|
+
])
|
|
163
|
+
if (launch.code !== 0) {
|
|
164
|
+
throw new Error(`tuistory launch failed: ${launch.stderr || launch.stdout}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Wait for the trace row to appear.
|
|
168
|
+
const waitResult = await tui(["wait", "root.op", "--session", SESSION, "--timeout", "10000"])
|
|
169
|
+
if (waitResult.code !== 0) {
|
|
170
|
+
const snap = (await tui(["snapshot", "--session", SESSION])).stdout
|
|
171
|
+
throw new Error(`Trace did not appear in TUI after seed.\nstderr: ${waitResult.stderr}\nstdout: ${waitResult.stdout}\nSnapshot:\n${snap}`)
|
|
172
|
+
}
|
|
173
|
+
await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
|
|
174
|
+
}, 60_000)
|
|
175
|
+
|
|
176
|
+
afterAll(async () => {
|
|
177
|
+
if (canRun) {
|
|
178
|
+
await tui(["close", "--session", SESSION])
|
|
179
|
+
const stop = Bun.spawn({ cmd: ["bun", "run", "src/motel.ts", "stop"], cwd: process.cwd(), env: { ...process.env, ...daemonEnv }, stdout: "ignore", stderr: "ignore" })
|
|
180
|
+
await stop.exited
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
184
|
+
} catch {}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("collapse → expand cycle preserves the visible span list", async () => {
|
|
188
|
+
if (!canRun) return // tuistory unavailable: skip
|
|
189
|
+
|
|
190
|
+
// Enter the span navigation pane.
|
|
191
|
+
await press("return")
|
|
192
|
+
// Visible order: root, siblingBefore, parent, childA..F, siblingAfter, tail, tailChild, tailGrandchild
|
|
193
|
+
// Move down to parent.op (index 2): two j presses.
|
|
194
|
+
await press("j")
|
|
195
|
+
await press("j")
|
|
196
|
+
|
|
197
|
+
const before = waterfallBody(await snapshot())
|
|
198
|
+
const beforeText = before.join("\n")
|
|
199
|
+
// Sanity: every operation should be visible.
|
|
200
|
+
for (const op of [
|
|
201
|
+
"parent.op", "childA.op", "childB.op", "childC.op",
|
|
202
|
+
"childD.op", "childE.op", "childF.op",
|
|
203
|
+
"siblingBefore.op", "siblingAfter.op",
|
|
204
|
+
"tail.op", "tailChild.op", "tailGrandchild.op",
|
|
205
|
+
]) {
|
|
206
|
+
expect(beforeText).toContain(op)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Collapse parent.op
|
|
210
|
+
await press("h")
|
|
211
|
+
const collapsedText = waterfallBody(await snapshot()).join("\n")
|
|
212
|
+
expect(collapsedText).toContain("parent.op")
|
|
213
|
+
for (const child of ["childA.op", "childB.op", "childC.op", "childD.op", "childE.op", "childF.op"]) {
|
|
214
|
+
expect(collapsedText).not.toContain(child)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Expand parent.op
|
|
218
|
+
await press("l")
|
|
219
|
+
const after = waterfallBody(await snapshot())
|
|
220
|
+
const afterText = after.join("\n")
|
|
221
|
+
|
|
222
|
+
// Every child must reappear after expand.
|
|
223
|
+
for (const child of ["childA.op", "childB.op", "childC.op", "childD.op", "childE.op", "childF.op"]) {
|
|
224
|
+
expect(afterText).toContain(child)
|
|
225
|
+
}
|
|
226
|
+
// Sibling structure must remain intact.
|
|
227
|
+
expect(afterText).toContain("siblingBefore.op")
|
|
228
|
+
expect(afterText).toContain("siblingAfter.op")
|
|
229
|
+
expect(afterText).toContain("tailGrandchild.op")
|
|
230
|
+
|
|
231
|
+
// And the waterfall body should be byte-identical to baseline.
|
|
232
|
+
expect(after).toEqual(before)
|
|
233
|
+
}, 60_000)
|
|
234
|
+
|
|
235
|
+
it("a SINGLE collapse/expand cycle does not drift the visible list", async () => {
|
|
236
|
+
if (!canRun) return
|
|
237
|
+
|
|
238
|
+
await press("escape")
|
|
239
|
+
await press("escape")
|
|
240
|
+
await press("return")
|
|
241
|
+
await press("j")
|
|
242
|
+
await press("j")
|
|
243
|
+
|
|
244
|
+
const baseline = waterfallBody(await snapshot())
|
|
245
|
+
await press("h")
|
|
246
|
+
await press("l")
|
|
247
|
+
const after = waterfallBody(await snapshot())
|
|
248
|
+
expect(after).toEqual(baseline)
|
|
249
|
+
}, 60_000)
|
|
250
|
+
|
|
251
|
+
it("repeated h/l cycles do not drift the visible list", async () => {
|
|
252
|
+
if (!canRun) return
|
|
253
|
+
|
|
254
|
+
// Reset by re-entering nav: esc out of any sub-mode then re-enter.
|
|
255
|
+
await press("escape")
|
|
256
|
+
await press("escape")
|
|
257
|
+
await press("return")
|
|
258
|
+
await press("j") // siblingBefore
|
|
259
|
+
await press("j") // parent
|
|
260
|
+
|
|
261
|
+
const baseline = waterfallBody(await snapshot())
|
|
262
|
+
|
|
263
|
+
for (let i = 0; i < 5; i++) {
|
|
264
|
+
await press("h")
|
|
265
|
+
await press("l")
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const after = waterfallBody(await snapshot())
|
|
269
|
+
expect(after).toEqual(baseline)
|
|
270
|
+
}, 60_000)
|
|
271
|
+
})
|