@kitlangton/motel 0.1.2 → 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 +209 -10
- 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 +191 -10
- 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
|
|
@@ -163,6 +170,15 @@ const parseSummaryRow = (row: TraceSummaryRow): TraceSummaryItem => ({
|
|
|
163
170
|
warnings: [],
|
|
164
171
|
})
|
|
165
172
|
|
|
173
|
+
// Skip attribute facet rows whose value blob is longer than this. Prevents
|
|
174
|
+
// multi-MB text attrs (ai.prompt, ai.prompt.messages, etc.) from dominating
|
|
175
|
+
// picker-open time — SQLite skips reading those pages from disk when the
|
|
176
|
+
// length predicate is evaluated against the page header, taking queries over
|
|
177
|
+
// a 2GB database from ~1.2s down to ~370ms. Keys whose values are ALL fat
|
|
178
|
+
// simply don't appear in the picker, which is the desired behaviour: you'd
|
|
179
|
+
// never want to filter traces by exact-match on a 1MB prompt blob anyway.
|
|
180
|
+
const FACET_VALUE_MAX_LEN = 512
|
|
181
|
+
|
|
166
182
|
const TRACE_SUMMARY_SELECT_SQL = `
|
|
167
183
|
SELECT
|
|
168
184
|
trace_id,
|
|
@@ -437,13 +453,30 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
437
453
|
mkdirSync(dirname(config.otel.databasePath), { recursive: true })
|
|
438
454
|
const db = yield* Effect.acquireRelease(
|
|
439
455
|
Effect.sync(() => new Database(config.otel.databasePath, { create: true })),
|
|
440
|
-
(db) => Effect.sync(() =>
|
|
456
|
+
(db) => Effect.sync(() => {
|
|
457
|
+
// `PRAGMA optimize` at close persists any stats SQLite gathered
|
|
458
|
+
// during the session, so the next process start gets an accurate
|
|
459
|
+
// query planner on the first query instead of a 3-second cold
|
|
460
|
+
// run. Cheap: it skips work unless stats have drifted.
|
|
461
|
+
try { db.exec(`PRAGMA optimize;`) } catch { /* nothing */ }
|
|
462
|
+
db.close()
|
|
463
|
+
}),
|
|
441
464
|
)
|
|
442
465
|
db.exec(`
|
|
443
466
|
PRAGMA journal_mode = WAL;
|
|
444
467
|
PRAGMA synchronous = NORMAL;
|
|
445
468
|
PRAGMA temp_store = MEMORY;
|
|
446
469
|
PRAGMA busy_timeout = 5000;
|
|
470
|
+
-- Bump cache above the 2MB default. 64MB fits most hot index pages
|
|
471
|
+
-- (trace_summaries, spans, span_attributes indexes) in RAM even on
|
|
472
|
+
-- multi-GB databases, cutting cold-read latency meaningfully on
|
|
473
|
+
-- picker / search queries that sweep the index.
|
|
474
|
+
PRAGMA cache_size = -65536;
|
|
475
|
+
-- Let SQLite memory-map the first 256MB of the file. This is a
|
|
476
|
+
-- cheap way to avoid read() syscalls on hot pages and lets the OS
|
|
477
|
+
-- page cache serve index lookups directly. Safe on macOS and Linux;
|
|
478
|
+
-- SQLite silently caps at actual file size for smaller DBs.
|
|
479
|
+
PRAGMA mmap_size = 268435456;
|
|
447
480
|
|
|
448
481
|
CREATE TABLE IF NOT EXISTS spans (
|
|
449
482
|
trace_id TEXT NOT NULL,
|
|
@@ -545,12 +578,90 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
545
578
|
// FTS is optional; queries will fall back to LIKE if unavailable.
|
|
546
579
|
}
|
|
547
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
|
+
|
|
548
641
|
try {
|
|
549
642
|
db.exec(`ALTER TABLE trace_summaries ADD COLUMN active_span_count INTEGER NOT NULL DEFAULT 0`)
|
|
550
643
|
} catch {
|
|
551
644
|
// Existing databases may already have the column.
|
|
552
645
|
}
|
|
553
646
|
|
|
647
|
+
// Prime the query planner. `PRAGMA optimize` is SQLite's modern,
|
|
648
|
+
// lightweight stats refresh: it only re-ANALYZEs indexes whose row
|
|
649
|
+
// counts have drifted significantly since the last run, capped at
|
|
650
|
+
// `analysis_limit` iterations per index so it finishes in a
|
|
651
|
+
// bounded time even on large databases. Without this, queries like
|
|
652
|
+
// the attribute picker facet run with guessed row estimates and
|
|
653
|
+
// pay 3-4s on cold open instead of 400ms.
|
|
654
|
+
try {
|
|
655
|
+
db.exec(`PRAGMA analysis_limit = 1000; PRAGMA optimize;`)
|
|
656
|
+
// First-time databases won't have sqlite_stat1 until we run a
|
|
657
|
+
// real ANALYZE. Force it once if stats haven't been collected.
|
|
658
|
+
const hasStats = db.query(`SELECT 1 FROM sqlite_master WHERE name = 'sqlite_stat1' LIMIT 1`).get() !== null
|
|
659
|
+
if (!hasStats) db.exec(`ANALYZE;`)
|
|
660
|
+
} catch {
|
|
661
|
+
// ANALYZE / optimize failures are never fatal — queries still work,
|
|
662
|
+
// they just run with default row estimates.
|
|
663
|
+
}
|
|
664
|
+
|
|
554
665
|
const insertSpan = db.query(`
|
|
555
666
|
INSERT INTO spans (
|
|
556
667
|
trace_id, span_id, parent_span_id, service_name, scope_name, operation_name, kind,
|
|
@@ -687,6 +798,47 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
687
798
|
// Run cleanup every 60 seconds in the background, tied to the layer's scope
|
|
688
799
|
yield* Effect.forkScoped(Effect.repeat(cleanupExpired(), Schedule.spaced("60 seconds")))
|
|
689
800
|
|
|
801
|
+
// Periodically refresh query planner stats. `PRAGMA optimize` is a
|
|
802
|
+
// no-op when nothing has changed, so this is essentially free on idle
|
|
803
|
+
// servers and keeps facet/search planner estimates accurate as data
|
|
804
|
+
// grows. 15 minutes is slower than ingestion rates we care about but
|
|
805
|
+
// frequent enough that the attribute picker stays snappy.
|
|
806
|
+
const refreshPlannerStats = Effect.sync(() => {
|
|
807
|
+
try { db.exec(`PRAGMA optimize;`) } catch { /* ignore */ }
|
|
808
|
+
})
|
|
809
|
+
yield* Effect.forkScoped(Effect.repeat(refreshPlannerStats, Schedule.spaced("15 minutes")))
|
|
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
|
+
|
|
690
842
|
const ingestTraces = Effect.fn("motel/TelemetryStore.ingestTraces")(function* (payload: OtlpTraceExportRequest) {
|
|
691
843
|
return yield* Effect.sync(() => {
|
|
692
844
|
let insertedSpans = 0
|
|
@@ -911,6 +1063,25 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
911
1063
|
params.push(...exactAttrMatch.params)
|
|
912
1064
|
}
|
|
913
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
|
+
|
|
914
1085
|
const rows = db.query(`
|
|
915
1086
|
SELECT trace_id, service_name, root_operation_name, started_at_ms, ended_at_ms, active_span_count, duration_ms, span_count, error_count
|
|
916
1087
|
FROM trace_summaries
|
|
@@ -1463,7 +1634,14 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
1463
1634
|
// user id, model) rank higher than keys that are constant across every
|
|
1464
1635
|
// trace (service.name, telemetry.sdk.*) — the latter can't discriminate
|
|
1465
1636
|
// between traces so they're useless as filters.
|
|
1466
|
-
|
|
1637
|
+
//
|
|
1638
|
+
// Performance note: we skip rows whose value blob is larger than
|
|
1639
|
+
// FACET_VALUE_MAX_LEN. For opencode this hides `ai.prompt`,
|
|
1640
|
+
// `ai.prompt.messages`, and `ai.prompt.tools` — which are 1-6MB text
|
|
1641
|
+
// blobs that you'd never want to filter by exact match anyway. The
|
|
1642
|
+
// WHERE clause lets SQLite skip reading those pages from disk, taking
|
|
1643
|
+
// the picker open time from ~1.2s to ~370ms on a 2GB database.
|
|
1644
|
+
const params: Array<string | number> = [FACET_VALUE_MAX_LEN, cutoff]
|
|
1467
1645
|
if (input.serviceName) params.push(input.serviceName)
|
|
1468
1646
|
params.push(limit)
|
|
1469
1647
|
const rows = db.query(`
|
|
@@ -1472,7 +1650,8 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
1472
1650
|
COUNT(DISTINCT sa.value) AS distinct_values
|
|
1473
1651
|
FROM span_attributes sa
|
|
1474
1652
|
JOIN spans s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
|
|
1475
|
-
WHERE
|
|
1653
|
+
WHERE LENGTH(sa.value) < ?
|
|
1654
|
+
AND s.start_time_ms >= ?
|
|
1476
1655
|
${input.serviceName ? "AND s.service_name = ?" : ""}
|
|
1477
1656
|
GROUP BY sa.key
|
|
1478
1657
|
ORDER BY (CASE WHEN distinct_values = 1 THEN 1 ELSE 0 END) ASC,
|
|
@@ -1485,14 +1664,18 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
1485
1664
|
}
|
|
1486
1665
|
if (input.field === "attribute_values") {
|
|
1487
1666
|
if (!input.key) return [] as FacetItem[]
|
|
1488
|
-
|
|
1667
|
+
// Skip multi-KB values here too — they blow up GROUP BY on big text.
|
|
1668
|
+
// Matches the attribute_keys pre-filter so the picker stays responsive
|
|
1669
|
+
// if someone hand-crafts a URL that targets a fat key.
|
|
1670
|
+
const params: Array<string | number> = [input.key, FACET_VALUE_MAX_LEN, cutoff]
|
|
1489
1671
|
if (input.serviceName) params.push(input.serviceName)
|
|
1490
1672
|
params.push(limit)
|
|
1491
1673
|
const rows = db.query(`
|
|
1492
1674
|
SELECT sa.value AS value, COUNT(DISTINCT sa.trace_id) AS count
|
|
1493
1675
|
FROM span_attributes sa
|
|
1494
1676
|
JOIN spans s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
|
|
1495
|
-
WHERE sa.key = ? AND
|
|
1677
|
+
WHERE sa.key = ? AND LENGTH(sa.value) < ?
|
|
1678
|
+
AND s.start_time_ms >= ?
|
|
1496
1679
|
${input.serviceName ? "AND s.service_name = ?" : ""}
|
|
1497
1680
|
GROUP BY sa.value
|
|
1498
1681
|
ORDER BY count DESC, value ASC
|
|
@@ -1558,11 +1741,27 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
1558
1741
|
params.push(key, value)
|
|
1559
1742
|
}
|
|
1560
1743
|
|
|
1561
|
-
// 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.
|
|
1562
1749
|
if ("text" in input && input.text) {
|
|
1563
|
-
const
|
|
1564
|
-
|
|
1565
|
-
|
|
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
|
+
}
|
|
1566
1765
|
}
|
|
1567
1766
|
|
|
1568
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>
|