@kitlangton/motel 0.1.3 → 0.2.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 +6 -1
- package/package.json +1 -1
- package/src/App.tsx +6 -0
- package/src/domain.ts +46 -4
- package/src/httpApi.ts +4 -1
- package/src/localServer.ts +1 -0
- package/src/services/TelemetryStore.ts +138 -5
- package/src/services/TraceQueryService.ts +1 -1
- package/src/telemetry.test.ts +33 -0
- package/src/ui/TraceDetailsPane.tsx +33 -2
- package/src/ui/Waterfall.tsx +56 -29
- package/src/ui/app/TraceWorkspace.tsx +9 -5
- package/src/ui/app/useTraceScreenData.ts +31 -9
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/primitives.tsx +20 -8
- package/src/ui/state.ts +32 -2
- package/src/ui/useKeyboardNav.ts +91 -3
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
- package/web/dist/index.html +1 -1
package/AGENTS.md
CHANGED
|
@@ -146,7 +146,12 @@
|
|
|
146
146
|
- `[` / `]`: switch services
|
|
147
147
|
- `s`: cycle sort mode (recent → slowest → errors)
|
|
148
148
|
- `t`: cycle theme (motel-default → tokyo-night → catppuccin)
|
|
149
|
-
- `/`: enter filter mode
|
|
149
|
+
- `/`: enter filter mode.
|
|
150
|
+
- **In the trace list (L0)** the input matches against the root operation name. Composable modifiers:
|
|
151
|
+
- `:error` — restrict to traces with at least one failed span (client-side)
|
|
152
|
+
- `:ai <query>` — FTS5-backed search against LLM prompt/response/tool content (`AI_FTS_KEYS`) across every span in the trace. Tokens are prefix-matched and implicitly AND'd. Debounced 250ms.
|
|
153
|
+
- Modifiers compose: `/ :ai rate limit :error`
|
|
154
|
+
- **In the waterfall (L1/L2)** the input runs a client-side substring match against each span's operation name and tag values. Non-matching spans are dimmed; the filter bar shows the live match count. `enter` commits (dim persists while you navigate); `esc` clears.
|
|
150
155
|
- `f`: open attribute filter picker (browse span-attribute keys → values for the current service; `backspace` walks back to keys; `esc` in the trace list clears the active filter)
|
|
151
156
|
- `a`: pause or resume auto-refresh
|
|
152
157
|
- `r`: refresh now
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
noticeAtom,
|
|
16
16
|
persistSelectedTheme,
|
|
17
17
|
selectedThemeAtom,
|
|
18
|
+
waterfallFilterModeAtom,
|
|
19
|
+
waterfallFilterTextAtom,
|
|
18
20
|
} from "./ui/state.ts"
|
|
19
21
|
import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
|
|
20
22
|
import { getVisibleSpans } from "./ui/Waterfall.tsx"
|
|
@@ -57,6 +59,8 @@ export const App = () => {
|
|
|
57
59
|
const [pickerInput] = useAtom(attrPickerInputAtom)
|
|
58
60
|
const [pickerIndex] = useAtom(attrPickerIndexAtom)
|
|
59
61
|
const [attrFacets] = useAtom(attrFacetStateAtom)
|
|
62
|
+
const [waterfallFilterMode] = useAtom(waterfallFilterModeAtom)
|
|
63
|
+
const [waterfallFilterText] = useAtom(waterfallFilterTextAtom)
|
|
60
64
|
useAttrFilterPicker(activeAttrKey)
|
|
61
65
|
|
|
62
66
|
const layout = useAppLayout({ width, height, notice, detailView, selectedSpanIndex })
|
|
@@ -179,6 +183,8 @@ export const App = () => {
|
|
|
179
183
|
detailView={detailView}
|
|
180
184
|
filterMode={filterMode}
|
|
181
185
|
filterText={filterText}
|
|
186
|
+
waterfallFilterMode={waterfallFilterMode}
|
|
187
|
+
waterfallFilterText={waterfallFilterText}
|
|
182
188
|
traceListProps={traceListProps}
|
|
183
189
|
selectedTraceService={selectedTraceService}
|
|
184
190
|
serviceLogState={serviceLogState}
|
package/src/domain.ts
CHANGED
|
@@ -145,14 +145,56 @@ export const AI_ATTR_MAP = {
|
|
|
145
145
|
responseTimestamp: "ai.response.timestamp",
|
|
146
146
|
} as const
|
|
147
147
|
|
|
148
|
-
/**
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Attribute keys that carry LLM prompt/response content and should be
|
|
150
|
+
* indexed in the span-attribute FTS table. These are the keys emitted by
|
|
151
|
+
* well-known LLM instrumentation conventions:
|
|
152
|
+
*
|
|
153
|
+
* - **Vercel AI SDK** (`ai.*`): rich, SDK-specific attributes captured by
|
|
154
|
+
* `experimental_telemetry` on `generateText` / `streamText` / `generateObject`.
|
|
155
|
+
* - **OpenTelemetry GenAI semantic conventions** (`gen_ai.*`): the
|
|
156
|
+
* cross-vendor standard. The singular `prompt`/`completion` attrs are
|
|
157
|
+
* deprecated in favor of event-based capture but are still emitted by
|
|
158
|
+
* most instrumentations, so we keep them.
|
|
159
|
+
* - **OpenInference** (`input.value` / `output.value`): Arize Phoenix /
|
|
160
|
+
* LangChain-style normalized input/output.
|
|
161
|
+
*
|
|
162
|
+
* Keys here trigger FTS indexing on insert via a trigger in TelemetryStore.
|
|
163
|
+
* Adding a key requires a one-time backfill; removing one leaves orphan
|
|
164
|
+
* FTS entries that get cleaned up on next retention pass.
|
|
165
|
+
*/
|
|
166
|
+
export const AI_FTS_KEYS = [
|
|
167
|
+
// Vercel AI SDK
|
|
151
168
|
"ai.prompt",
|
|
152
|
-
"ai.
|
|
169
|
+
"ai.prompt.messages",
|
|
153
170
|
"ai.prompt.tools",
|
|
171
|
+
"ai.prompt.toolChoice",
|
|
172
|
+
"ai.response.text",
|
|
173
|
+
"ai.response.toolCalls",
|
|
174
|
+
"ai.response.reasoning",
|
|
175
|
+
"ai.response.object",
|
|
176
|
+
"ai.toolCall.args",
|
|
177
|
+
"ai.toolCall.result",
|
|
178
|
+
// OpenTelemetry GenAI semantic conventions
|
|
179
|
+
"gen_ai.prompt",
|
|
180
|
+
"gen_ai.completion",
|
|
181
|
+
"gen_ai.input.messages",
|
|
182
|
+
"gen_ai.output.messages",
|
|
183
|
+
"gen_ai.system_instructions",
|
|
184
|
+
"gen_ai.tool.definitions",
|
|
185
|
+
"gen_ai.tool.message.content",
|
|
186
|
+
// OpenInference (Phoenix, LangChain, etc.)
|
|
187
|
+
"input.value",
|
|
188
|
+
"output.value",
|
|
154
189
|
] as const
|
|
155
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Back-compat alias. The `text` filter on `/api/ai/calls` historically
|
|
193
|
+
* LIKE-searched these four keys; now FTS indexes the broader AI_FTS_KEYS
|
|
194
|
+
* set so the filter transparently covers more content.
|
|
195
|
+
*/
|
|
196
|
+
export const AI_TEXT_SEARCH_KEYS = AI_FTS_KEYS
|
|
197
|
+
|
|
156
198
|
const PREVIEW_LENGTH = 200
|
|
157
199
|
|
|
158
200
|
export const truncatePreview = (value: string | null | undefined): string | null => {
|
package/src/httpApi.ts
CHANGED
|
@@ -121,6 +121,9 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
121
121
|
minDurationMs: Schema.optionalKey(Schema.Number).pipe(
|
|
122
122
|
Schema.annotateKey({ description: "Only return traces slower than this threshold (milliseconds)" }),
|
|
123
123
|
),
|
|
124
|
+
aiText: Schema.optionalKey(Schema.String).pipe(
|
|
125
|
+
Schema.annotateKey({ description: "FTS match against AI prompt/response/tool content across all spans in the trace. Tokens are prefix-matched and implicitly AND'd." }),
|
|
126
|
+
),
|
|
124
127
|
lookback: LookbackParam,
|
|
125
128
|
limit: LimitParam,
|
|
126
129
|
cursor: CursorParam,
|
|
@@ -128,7 +131,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
128
131
|
success: TraceSummaryList,
|
|
129
132
|
})
|
|
130
133
|
.annotate(OpenApi.Summary, "Search traces with filters")
|
|
131
|
-
.annotate(OpenApi.Description, "Search compact trace summaries with filters. Use /api/traces/{traceId} for full details. Supports cursor pagination
|
|
134
|
+
.annotate(OpenApi.Description, "Search compact trace summaries with filters. Use /api/traces/{traceId} for full details. Supports cursor pagination, attr.<key> filters in the query string, and aiText for full-text search across LLM prompt/response content."),
|
|
132
135
|
|
|
133
136
|
HttpApiEndpoint.get("traceStats", "/api/traces/stats", {
|
|
134
137
|
query: {
|
package/src/localServer.ts
CHANGED
|
@@ -335,6 +335,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
335
335
|
status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
|
|
336
336
|
minDurationMs: url.searchParams.get("minDurationMs") ? Number.parseFloat(url.searchParams.get("minDurationMs") ?? "") : null,
|
|
337
337
|
attributeFilters,
|
|
338
|
+
aiText: url.searchParams.get("aiText"),
|
|
338
339
|
limit: limit + 1,
|
|
339
340
|
lookbackMinutes,
|
|
340
341
|
cursorStartedAtMs: cursor?.kind === "trace" ? cursor.startedAt : undefined,
|
|
@@ -4,7 +4,7 @@ import { dirname } from "node:path"
|
|
|
4
4
|
import { Clock, Effect, Layer, Schedule, Context } from "effect"
|
|
5
5
|
import { config } from "../config.js"
|
|
6
6
|
import type { AiCallDetail, AiCallSummary, FacetItem, LogItem, SpanItem, StatsItem, TraceItem, TraceSummaryItem, TraceSpanEvent, TraceSpanItem } from "../domain.js"
|
|
7
|
-
import { AI_ATTR_MAP, AI_TEXT_SEARCH_KEYS, truncatePreview } from "../domain.js"
|
|
7
|
+
import { AI_ATTR_MAP, AI_FTS_KEYS, AI_TEXT_SEARCH_KEYS, truncatePreview } from "../domain.js"
|
|
8
8
|
import { attributeMap, nanosToMilliseconds, parseAnyValue, spanKindLabel, spanStatusLabel, stringifyValue, type OtlpLogExportRequest, type OtlpTraceExportRequest } from "../otlp.js"
|
|
9
9
|
|
|
10
10
|
interface SpanRow {
|
|
@@ -57,6 +57,13 @@ interface TraceSearch {
|
|
|
57
57
|
readonly status?: "ok" | "error" | null
|
|
58
58
|
readonly minDurationMs?: number | null
|
|
59
59
|
readonly attributeFilters?: Readonly<Record<string, string>>
|
|
60
|
+
/**
|
|
61
|
+
* Full-text match against the AI prompt/response/tool attribute values
|
|
62
|
+
* on any span in the trace (see AI_FTS_KEYS). When set, traces are
|
|
63
|
+
* filtered to those containing at least one span whose indexed LLM
|
|
64
|
+
* content matches. Powered by span_attr_fts (FTS5).
|
|
65
|
+
*/
|
|
66
|
+
readonly aiText?: string | null
|
|
60
67
|
readonly lookbackMinutes?: number
|
|
61
68
|
readonly limit?: number
|
|
62
69
|
readonly cursorStartedAtMs?: number
|
|
@@ -571,6 +578,66 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
571
578
|
// FTS is optional; queries will fall back to LIKE if unavailable.
|
|
572
579
|
}
|
|
573
580
|
|
|
581
|
+
// External-content FTS5 over the subset of span_attributes.value rows
|
|
582
|
+
// whose key is in AI_FTS_KEYS (LLM prompts, responses, tool calls,
|
|
583
|
+
// etc.). External content means the inverted index is the only
|
|
584
|
+
// FTS storage — the value text itself continues to live once in
|
|
585
|
+
// span_attributes, not duplicated into the FTS table. On a 2 GB DB
|
|
586
|
+
// with 270 MB of prompt JSON this typically adds ~50-120 MB of
|
|
587
|
+
// index, turning a 500-800ms LIKE scan into a <50ms MATCH.
|
|
588
|
+
//
|
|
589
|
+
// Keys are inlined into the trigger DDL rather than looked up in a
|
|
590
|
+
// side table so the `WHEN` guard stays constant-cost (a subquery
|
|
591
|
+
// would run on every span_attributes insert — ~60/span).
|
|
592
|
+
let hasAttrFts = hasFts
|
|
593
|
+
if (hasFts) {
|
|
594
|
+
try {
|
|
595
|
+
const keyList = AI_FTS_KEYS.map((k) => `'${k.replace(/'/g, "''")}'`).join(", ")
|
|
596
|
+
db.exec(`
|
|
597
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS span_attr_fts USING fts5(
|
|
598
|
+
value,
|
|
599
|
+
content='span_attributes',
|
|
600
|
+
content_rowid='rowid',
|
|
601
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
-- Mirror inserts into FTS when the key carries LLM content.
|
|
605
|
+
-- NOTE: triggers MUST use fully-qualified name (new.rowid,
|
|
606
|
+
-- new.value) and emit rowid so external-content FTS can
|
|
607
|
+
-- fetch the value back via span_attributes.rowid.
|
|
608
|
+
CREATE TRIGGER IF NOT EXISTS span_attr_fts_ai AFTER INSERT ON span_attributes
|
|
609
|
+
WHEN new.key IN (${keyList})
|
|
610
|
+
BEGIN
|
|
611
|
+
INSERT INTO span_attr_fts(rowid, value) VALUES (new.rowid, new.value);
|
|
612
|
+
END;
|
|
613
|
+
|
|
614
|
+
-- Delete with the same guard so retention & re-ingest stay
|
|
615
|
+
-- in sync. External-content 'delete' command needs the
|
|
616
|
+
-- original value to remove from the inverted index.
|
|
617
|
+
CREATE TRIGGER IF NOT EXISTS span_attr_fts_ad AFTER DELETE ON span_attributes
|
|
618
|
+
WHEN old.key IN (${keyList})
|
|
619
|
+
BEGIN
|
|
620
|
+
INSERT INTO span_attr_fts(span_attr_fts, rowid, value)
|
|
621
|
+
VALUES ('delete', old.rowid, old.value);
|
|
622
|
+
END;
|
|
623
|
+
|
|
624
|
+
-- Handle in-place updates (rare; re-ingest usually goes
|
|
625
|
+
-- DELETE then INSERT but belt-and-braces).
|
|
626
|
+
CREATE TRIGGER IF NOT EXISTS span_attr_fts_au AFTER UPDATE ON span_attributes
|
|
627
|
+
WHEN old.key IN (${keyList}) OR new.key IN (${keyList})
|
|
628
|
+
BEGIN
|
|
629
|
+
INSERT INTO span_attr_fts(span_attr_fts, rowid, value)
|
|
630
|
+
VALUES ('delete', old.rowid, old.value);
|
|
631
|
+
INSERT INTO span_attr_fts(rowid, value)
|
|
632
|
+
SELECT new.rowid, new.value
|
|
633
|
+
WHERE new.key IN (${keyList});
|
|
634
|
+
END;
|
|
635
|
+
`)
|
|
636
|
+
} catch {
|
|
637
|
+
hasAttrFts = false
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
574
641
|
try {
|
|
575
642
|
db.exec(`ALTER TABLE trace_summaries ADD COLUMN active_span_count INTEGER NOT NULL DEFAULT 0`)
|
|
576
643
|
} catch {
|
|
@@ -741,6 +808,37 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
741
808
|
})
|
|
742
809
|
yield* Effect.forkScoped(Effect.repeat(refreshPlannerStats, Schedule.spaced("15 minutes")))
|
|
743
810
|
|
|
811
|
+
// One-time backfill for existing DBs: if span_attr_fts is empty but
|
|
812
|
+
// span_attributes has rows with AI_FTS_KEYS, populate the index.
|
|
813
|
+
// Runs forked so server startup isn't blocked; queries hitting the
|
|
814
|
+
// FTS will just return empty until the fill lands. On a 2 GB DB with
|
|
815
|
+
// ~400 matching rows this takes ~3-8 seconds.
|
|
816
|
+
if (hasAttrFts) {
|
|
817
|
+
const backfillAttrFts = Effect.sync(() => {
|
|
818
|
+
try {
|
|
819
|
+
const ftsCount = (db.query(`SELECT COUNT(*) AS c FROM span_attr_fts`).get() as { c: number }).c
|
|
820
|
+
if (ftsCount > 0) return
|
|
821
|
+
const keyList = AI_FTS_KEYS.map((k) => `'${k.replace(/'/g, "''")}'`).join(", ")
|
|
822
|
+
const attrCount = (db.query(
|
|
823
|
+
`SELECT COUNT(*) AS c FROM span_attributes WHERE key IN (${keyList})`,
|
|
824
|
+
).get() as { c: number }).c
|
|
825
|
+
if (attrCount === 0) return
|
|
826
|
+
// Single INSERT..SELECT is atomic and fast; FTS5 batches
|
|
827
|
+
// its internal segment writes. No transaction wrapper
|
|
828
|
+
// needed — it runs as one statement.
|
|
829
|
+
db.exec(`
|
|
830
|
+
INSERT INTO span_attr_fts(rowid, value)
|
|
831
|
+
SELECT rowid, value FROM span_attributes WHERE key IN (${keyList})
|
|
832
|
+
`)
|
|
833
|
+
} catch {
|
|
834
|
+
// Backfill failure is never fatal — new ingests still
|
|
835
|
+
// populate FTS via the trigger, and queries fall back to
|
|
836
|
+
// LIKE when FTS lookups return empty.
|
|
837
|
+
}
|
|
838
|
+
})
|
|
839
|
+
yield* Effect.forkScoped(backfillAttrFts)
|
|
840
|
+
}
|
|
841
|
+
|
|
744
842
|
const ingestTraces = Effect.fn("motel/TelemetryStore.ingestTraces")(function* (payload: OtlpTraceExportRequest) {
|
|
745
843
|
return yield* Effect.sync(() => {
|
|
746
844
|
let insertedSpans = 0
|
|
@@ -965,6 +1063,25 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
965
1063
|
params.push(...exactAttrMatch.params)
|
|
966
1064
|
}
|
|
967
1065
|
|
|
1066
|
+
// `:ai <query>` — FTS match against LLM content keys. Joins
|
|
1067
|
+
// span_attr_fts back to span_attributes to collect trace_ids
|
|
1068
|
+
// whose spans carry matching prompt/response content. Falls
|
|
1069
|
+
// through to no-op when the query tokenizes empty (e.g. only
|
|
1070
|
+
// stopwords or operator-chars) so users don't get a silently
|
|
1071
|
+
// empty list.
|
|
1072
|
+
if (input.aiText) {
|
|
1073
|
+
const aiFtsQuery = toFtsMatchQuery(input.aiText)
|
|
1074
|
+
if (hasAttrFts && aiFtsQuery) {
|
|
1075
|
+
clauses.push(`trace_id IN (
|
|
1076
|
+
SELECT DISTINCT sa.trace_id
|
|
1077
|
+
FROM span_attr_fts fts
|
|
1078
|
+
JOIN span_attributes sa ON sa.rowid = fts.rowid
|
|
1079
|
+
WHERE fts.value MATCH ?
|
|
1080
|
+
)`)
|
|
1081
|
+
params.push(aiFtsQuery)
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
968
1085
|
const rows = db.query(`
|
|
969
1086
|
SELECT trace_id, service_name, root_operation_name, started_at_ms, ended_at_ms, active_span_count, duration_ms, span_count, error_count
|
|
970
1087
|
FROM trace_summaries
|
|
@@ -1624,11 +1741,27 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
1624
1741
|
params.push(key, value)
|
|
1625
1742
|
}
|
|
1626
1743
|
|
|
1627
|
-
// Text search across prompt/response/tool attribute values
|
|
1744
|
+
// Text search across prompt/response/tool attribute values via
|
|
1745
|
+
// FTS5. Prefers the external-content span_attr_fts index when
|
|
1746
|
+
// available, falls back to case-insensitive LIKE so old DBs
|
|
1747
|
+
// without FTS still work. FTS turns ~500ms full scans of 3 MB
|
|
1748
|
+
// prompt JSON into <50ms MATCH lookups.
|
|
1628
1749
|
if ("text" in input && input.text) {
|
|
1629
|
-
const
|
|
1630
|
-
|
|
1631
|
-
|
|
1750
|
+
const ftsQuery = toFtsMatchQuery(input.text)
|
|
1751
|
+
if (hasAttrFts && ftsQuery) {
|
|
1752
|
+
clauses.push(`EXISTS (
|
|
1753
|
+
SELECT 1 FROM span_attr_fts fts
|
|
1754
|
+
JOIN span_attributes sa ON sa.rowid = fts.rowid
|
|
1755
|
+
WHERE sa.trace_id = s.trace_id
|
|
1756
|
+
AND sa.span_id = s.span_id
|
|
1757
|
+
AND fts.value MATCH ?
|
|
1758
|
+
)`)
|
|
1759
|
+
params.push(ftsQuery)
|
|
1760
|
+
} else {
|
|
1761
|
+
const textKeys = AI_TEXT_SEARCH_KEYS.map(() => "?").join(", ")
|
|
1762
|
+
clauses.push(`EXISTS (SELECT 1 FROM span_attributes WHERE span_attributes.trace_id = s.trace_id AND span_attributes.span_id = s.span_id AND key IN (${textKeys}) AND value LIKE ? COLLATE NOCASE)`)
|
|
1763
|
+
params.push(...AI_TEXT_SEARCH_KEYS, `%${input.text}%`)
|
|
1764
|
+
}
|
|
1632
1765
|
}
|
|
1633
1766
|
|
|
1634
1767
|
return { clauses, params }
|
|
@@ -8,7 +8,7 @@ export class TraceQueryService extends Context.Service<
|
|
|
8
8
|
readonly listServices: Effect.Effect<readonly string[], Error>
|
|
9
9
|
readonly listRecentTraces: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly TraceItem[], Error>
|
|
10
10
|
readonly listTraceSummaries: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
11
|
-
readonly searchTraceSummaries: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string
|
|
11
|
+
readonly searchTraceSummaries: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly aiText?: string | null }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
12
12
|
readonly listFacets: (input: { readonly type: "traces" | "logs"; readonly field: string; readonly serviceName?: string | null; readonly key?: string | null; readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly { readonly value: string; readonly count: number }[], Error>
|
|
13
13
|
readonly searchTraces: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly TraceItem[], Error>
|
|
14
14
|
readonly traceStats: (input: { readonly groupBy: string; readonly agg: "count" | "avg_duration" | "p95_duration" | "error_rate"; readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
|
package/src/telemetry.test.ts
CHANGED
|
@@ -651,6 +651,39 @@ describe("motel telemetry store", () => {
|
|
|
651
651
|
expect(result[0]?.spanId).toBe("ai-stream-1")
|
|
652
652
|
})
|
|
653
653
|
|
|
654
|
+
it("matches AI calls via words in the response text", async () => {
|
|
655
|
+
// Verifies FTS indexes ai.response.text, not just ai.prompt*. The
|
|
656
|
+
// seeded ai-stream-2 has response "Error: rate limited".
|
|
657
|
+
const result = await storeRuntime.runPromise(
|
|
658
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
659
|
+
store.searchAiCalls({ text: "rate limited" }),
|
|
660
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
661
|
+
)
|
|
662
|
+
expect(result.map((r) => r.spanId)).toContain("ai-stream-2")
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it("matches AI calls case-insensitively and with partial words", async () => {
|
|
666
|
+
// unicode61 tokenizer is case-insensitive by default; prefix `*`
|
|
667
|
+
// handles partial terms like `"PROG"` matching `"programming"`.
|
|
668
|
+
const result = await storeRuntime.runPromise(
|
|
669
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
670
|
+
store.searchAiCalls({ text: "PROG" }),
|
|
671
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
672
|
+
)
|
|
673
|
+
expect(result.map((r) => r.spanId)).toContain("ai-stream-1")
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it("ignores FTS special characters without syntax errors", async () => {
|
|
677
|
+
// FTS5 treats `"`, `*`, `-`, `:` as operators; toFtsQuery must
|
|
678
|
+
// strip them so raw user input never crashes the query.
|
|
679
|
+
const result = await storeRuntime.runPromise(
|
|
680
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
681
|
+
store.searchAiCalls({ text: `"joke" - about:programming*` }),
|
|
682
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
683
|
+
)
|
|
684
|
+
expect(result.map((r) => r.spanId)).toContain("ai-stream-1")
|
|
685
|
+
})
|
|
686
|
+
|
|
654
687
|
it("filters AI calls by operation type", async () => {
|
|
655
688
|
const result = await storeRuntime.runPromise(
|
|
656
689
|
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useMemo } from "react"
|
|
2
2
|
import type { TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
3
3
|
import { formatDuration, formatShortDate, formatTimestamp } from "./format.ts"
|
|
4
|
-
import { AlignedHeaderLine, Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
4
|
+
import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./primitives.tsx"
|
|
5
5
|
import { getVisibleSpans, WaterfallTimeline } from "./Waterfall.tsx"
|
|
6
|
+
import { computeMatchingSpanIds } from "./waterfallFilter.ts"
|
|
6
7
|
import type { LoadStatus, LogState } from "./state.ts"
|
|
7
8
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
8
9
|
|
|
@@ -30,6 +31,8 @@ export const TraceDetailsPane = ({
|
|
|
30
31
|
collapsedSpanIds,
|
|
31
32
|
focused = false,
|
|
32
33
|
onSelectSpan,
|
|
34
|
+
waterfallFilterMode,
|
|
35
|
+
waterfallFilterText,
|
|
33
36
|
}: {
|
|
34
37
|
trace: TraceItem | null
|
|
35
38
|
traceSummary: TraceSummaryItem | null
|
|
@@ -43,6 +46,8 @@ export const TraceDetailsPane = ({
|
|
|
43
46
|
collapsedSpanIds: ReadonlySet<string>
|
|
44
47
|
focused?: boolean
|
|
45
48
|
onSelectSpan: (index: number) => void
|
|
49
|
+
waterfallFilterMode: boolean
|
|
50
|
+
waterfallFilterText: string
|
|
46
51
|
}) => {
|
|
47
52
|
const filteredSpans = useMemo(
|
|
48
53
|
() => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
|
|
@@ -62,6 +67,15 @@ export const TraceDetailsPane = ({
|
|
|
62
67
|
() => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
|
|
63
68
|
[selectedSpan, traceLogsState.data],
|
|
64
69
|
)
|
|
70
|
+
const matchingSpanIds = useMemo(
|
|
71
|
+
() => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
|
|
72
|
+
[trace, waterfallFilterText],
|
|
73
|
+
)
|
|
74
|
+
const matchCount = matchingSpanIds?.size ?? 0
|
|
75
|
+
// Reserve 1 row for the filter bar when it's being shown so the
|
|
76
|
+
// waterfall doesn't spill into the footer.
|
|
77
|
+
const showFilterBar = waterfallFilterMode || waterfallFilterText.length > 0
|
|
78
|
+
const waterfallBodyLines = showFilterBar ? Math.max(1, bodyLines - 1) : bodyLines
|
|
65
79
|
|
|
66
80
|
const traceMeta = trace ?? traceSummary
|
|
67
81
|
const hasTraceSelection = traceSummary !== null
|
|
@@ -112,6 +126,22 @@ export const TraceDetailsPane = ({
|
|
|
112
126
|
</TextLine>
|
|
113
127
|
</box>
|
|
114
128
|
<Divider width={paneWidth} />
|
|
129
|
+
{showFilterBar ? (
|
|
130
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
131
|
+
{waterfallFilterMode ? (
|
|
132
|
+
<FilterBar text={waterfallFilterText} width={contentWidth} />
|
|
133
|
+
) : (
|
|
134
|
+
<TextLine>
|
|
135
|
+
<span fg={colors.muted}>{"/"}</span>
|
|
136
|
+
<span fg={colors.text}>{waterfallFilterText}</span>
|
|
137
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
138
|
+
<span fg={colors.count}>{matchCount} match{matchCount === 1 ? "" : "es"}</span>
|
|
139
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
140
|
+
<span fg={colors.muted}>esc clear</span>
|
|
141
|
+
</TextLine>
|
|
142
|
+
)}
|
|
143
|
+
</box>
|
|
144
|
+
) : null}
|
|
115
145
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
116
146
|
<WaterfallTimeline
|
|
117
147
|
trace={trace}
|
|
@@ -119,9 +149,10 @@ export const TraceDetailsPane = ({
|
|
|
119
149
|
spanLogCounts={spanLogCounts}
|
|
120
150
|
selectedSpanLogs={selectedSpanLogs}
|
|
121
151
|
contentWidth={contentWidth}
|
|
122
|
-
bodyLines={
|
|
152
|
+
bodyLines={waterfallBodyLines}
|
|
123
153
|
selectedSpanIndex={selectedSpanIndex}
|
|
124
154
|
collapsedSpanIds={collapsedSpanIds}
|
|
155
|
+
matchingSpanIds={matchingSpanIds}
|
|
125
156
|
onSelectSpan={onSelectSpan}
|
|
126
157
|
/>
|
|
127
158
|
</box>
|
package/src/ui/Waterfall.tsx
CHANGED
|
@@ -251,6 +251,7 @@ const WaterfallRow = memo(({
|
|
|
251
251
|
collapsed,
|
|
252
252
|
hasChildSpans,
|
|
253
253
|
suffixMetrics,
|
|
254
|
+
dimmed,
|
|
254
255
|
onSelect,
|
|
255
256
|
}: {
|
|
256
257
|
span: TraceSpanItem
|
|
@@ -262,6 +263,7 @@ const WaterfallRow = memo(({
|
|
|
262
263
|
collapsed: boolean
|
|
263
264
|
hasChildSpans: boolean
|
|
264
265
|
suffixMetrics: WaterfallSuffixMetrics
|
|
266
|
+
dimmed: boolean
|
|
265
267
|
onSelect: () => void
|
|
266
268
|
}) => {
|
|
267
269
|
const prefix = buildTreePrefix(spans, index)
|
|
@@ -282,12 +284,23 @@ const WaterfallRow = memo(({
|
|
|
282
284
|
const rowBg = selected ? colors.selectedBg : colors.screenBg
|
|
283
285
|
const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
|
|
284
286
|
const bg = selected ? colors.selectedBg : undefined
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
const
|
|
287
|
+
// Dimmed rows (non-matching under an active waterfall filter) collapse
|
|
288
|
+
// their palette to the muted separator color so matches stand out.
|
|
289
|
+
// Selection always wins — the selected row keeps its full brightness
|
|
290
|
+
// so you can still see where the cursor is while scanning.
|
|
291
|
+
const treeColor = selected ? colors.separator : dimmed ? colors.separator : colors.treeLine
|
|
292
|
+
const indicatorColor = selected ? colors.selectedText
|
|
293
|
+
: dimmed ? colors.separator
|
|
294
|
+
: isError ? colors.error
|
|
295
|
+
: hasChildSpans ? colors.muted
|
|
296
|
+
: colors.passing
|
|
297
|
+
const opColor = selected ? colors.selectedText
|
|
298
|
+
: dimmed ? colors.separator
|
|
299
|
+
: span.isRunning ? colors.warning
|
|
300
|
+
: colors.text
|
|
301
|
+
|
|
302
|
+
const durationFg = selected ? colors.selectedText : dimmed ? colors.separator : durationColor(span.durationMs)
|
|
303
|
+
const unitFg = dimmed && !selected ? colors.separator : colors.muted
|
|
291
304
|
|
|
292
305
|
// Split the duration so the unit (s/ms) renders dimmer than the number.
|
|
293
306
|
const { number: durNumber, unit: durUnit } = splitDuration(Math.max(0, span.durationMs))
|
|
@@ -373,6 +386,7 @@ export const WaterfallTimeline = ({
|
|
|
373
386
|
bodyLines,
|
|
374
387
|
selectedSpanIndex,
|
|
375
388
|
collapsedSpanIds,
|
|
389
|
+
matchingSpanIds,
|
|
376
390
|
onSelectSpan,
|
|
377
391
|
}: {
|
|
378
392
|
trace: TraceItem
|
|
@@ -383,6 +397,11 @@ export const WaterfallTimeline = ({
|
|
|
383
397
|
bodyLines: number
|
|
384
398
|
selectedSpanIndex: number | null
|
|
385
399
|
collapsedSpanIds: ReadonlySet<string>
|
|
400
|
+
/**
|
|
401
|
+
* When set, spans whose spanId is NOT in this set are dimmed. Null
|
|
402
|
+
* means no filter active — skip the per-row lookup entirely.
|
|
403
|
+
*/
|
|
404
|
+
matchingSpanIds?: ReadonlySet<string> | null
|
|
386
405
|
onSelectSpan: (index: number) => void
|
|
387
406
|
}) => {
|
|
388
407
|
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
@@ -392,29 +411,37 @@ export const WaterfallTimeline = ({
|
|
|
392
411
|
spanIndexById.set(trace.spans[i].spanId, i)
|
|
393
412
|
}
|
|
394
413
|
|
|
395
|
-
// Virtual windowing: only render visible rows
|
|
396
|
-
// the
|
|
414
|
+
// Virtual windowing: only render visible rows. We track scroll offset
|
|
415
|
+
// as state so the mouse wheel can scroll the window INDEPENDENTLY of
|
|
416
|
+
// the selected span (mirrors TraceList behavior). Selection still
|
|
417
|
+
// follows: if the user moves selection off-screen via j/k, we nudge
|
|
418
|
+
// the window to keep it visible — but wheel-scrolling never changes
|
|
419
|
+
// selection, only clicking a row does.
|
|
397
420
|
const viewportSize = Math.max(1, bodyLines)
|
|
398
|
-
const
|
|
421
|
+
const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
|
|
422
|
+
const [scrollOffset, setScrollOffset] = useState(0)
|
|
399
423
|
const lastTraceIdRef = useRef<string | null>(null)
|
|
400
424
|
|
|
401
|
-
// Reset scroll offset when the trace changes
|
|
425
|
+
// Reset scroll offset when the trace changes.
|
|
402
426
|
if (trace.traceId !== lastTraceIdRef.current) {
|
|
403
|
-
|
|
427
|
+
setScrollOffset(0)
|
|
404
428
|
lastTraceIdRef.current = trace.traceId
|
|
405
429
|
}
|
|
406
430
|
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
431
|
+
// Auto-follow selection: only if the selected span would be hidden
|
|
432
|
+
// by the current window, shift just enough to bring it back. Runs in
|
|
433
|
+
// layout effect so the visible window is accurate on the same paint
|
|
434
|
+
// that the selection changed.
|
|
435
|
+
useLayoutEffect(() => {
|
|
436
|
+
if (selectedSpanIndex === null) return
|
|
437
|
+
setScrollOffset((current) => {
|
|
438
|
+
if (selectedSpanIndex < current) return selectedSpanIndex
|
|
439
|
+
if (selectedSpanIndex >= current + viewportSize) return selectedSpanIndex - viewportSize + 1
|
|
440
|
+
return current
|
|
441
|
+
})
|
|
442
|
+
}, [selectedSpanIndex, viewportSize])
|
|
443
|
+
|
|
444
|
+
const windowStart = Math.max(0, Math.min(scrollOffset, maxOffset))
|
|
418
445
|
const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
|
|
419
446
|
const blankCount = Math.max(0, viewportSize - windowSpans.length)
|
|
420
447
|
|
|
@@ -422,19 +449,17 @@ export const WaterfallTimeline = ({
|
|
|
422
449
|
// row's duration cell lines up on the same right-edge column.
|
|
423
450
|
const suffixMetrics = getWaterfallSuffixMetrics(windowSpans)
|
|
424
451
|
|
|
425
|
-
// Mouse wheel
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
452
|
+
// Mouse wheel scrolls the window without touching selection — matches
|
|
453
|
+
// the trace list, so the user can browse ahead of their cursor freely
|
|
454
|
+
// and click a row to commit. Delta is scaled 1:1 with opentui's wheel
|
|
455
|
+
// reporting (1 notch ≈ 3 rows on most terminals).
|
|
429
456
|
const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
|
|
430
457
|
const info = event.scroll
|
|
431
458
|
if (!info || filteredSpans.length === 0) return
|
|
432
459
|
const magnitude = Math.max(1, Math.round(info.delta))
|
|
433
460
|
const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
|
|
434
461
|
if (signed === 0) return
|
|
435
|
-
|
|
436
|
-
const next = Math.max(0, Math.min(start + signed, filteredSpans.length - 1))
|
|
437
|
-
if (next !== selectedSpanIndex) onSelectSpan(next)
|
|
462
|
+
setScrollOffset((current) => Math.max(0, Math.min(current + signed, maxOffset)))
|
|
438
463
|
event.stopPropagation?.()
|
|
439
464
|
}
|
|
440
465
|
|
|
@@ -443,6 +468,7 @@ export const WaterfallTimeline = ({
|
|
|
443
468
|
{windowSpans.map((span, index) => {
|
|
444
469
|
const actualIndex = windowStart + index
|
|
445
470
|
const fullIndex = spanIndexById.get(span.spanId) ?? -1
|
|
471
|
+
const dimmed = matchingSpanIds != null && !matchingSpanIds.has(span.spanId)
|
|
446
472
|
return (
|
|
447
473
|
<WaterfallRow
|
|
448
474
|
key={`${trace.traceId}-${span.spanId}`}
|
|
@@ -455,6 +481,7 @@ export const WaterfallTimeline = ({
|
|
|
455
481
|
collapsed={collapsedSpanIds.has(span.spanId)}
|
|
456
482
|
hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
|
|
457
483
|
suffixMetrics={suffixMetrics}
|
|
484
|
+
dimmed={dimmed}
|
|
458
485
|
onSelect={() => onSelectSpan(actualIndex)}
|
|
459
486
|
/>
|
|
460
487
|
)
|