@kitlangton/motel 0.1.2 → 0.1.3
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/package.json +1 -1
- package/src/services/TelemetryStore.ts +71 -5
- package/src/ui/useKeyboardNav.ts +100 -7
package/package.json
CHANGED
|
@@ -163,6 +163,15 @@ const parseSummaryRow = (row: TraceSummaryRow): TraceSummaryItem => ({
|
|
|
163
163
|
warnings: [],
|
|
164
164
|
})
|
|
165
165
|
|
|
166
|
+
// Skip attribute facet rows whose value blob is longer than this. Prevents
|
|
167
|
+
// multi-MB text attrs (ai.prompt, ai.prompt.messages, etc.) from dominating
|
|
168
|
+
// picker-open time — SQLite skips reading those pages from disk when the
|
|
169
|
+
// length predicate is evaluated against the page header, taking queries over
|
|
170
|
+
// a 2GB database from ~1.2s down to ~370ms. Keys whose values are ALL fat
|
|
171
|
+
// simply don't appear in the picker, which is the desired behaviour: you'd
|
|
172
|
+
// never want to filter traces by exact-match on a 1MB prompt blob anyway.
|
|
173
|
+
const FACET_VALUE_MAX_LEN = 512
|
|
174
|
+
|
|
166
175
|
const TRACE_SUMMARY_SELECT_SQL = `
|
|
167
176
|
SELECT
|
|
168
177
|
trace_id,
|
|
@@ -437,13 +446,30 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
437
446
|
mkdirSync(dirname(config.otel.databasePath), { recursive: true })
|
|
438
447
|
const db = yield* Effect.acquireRelease(
|
|
439
448
|
Effect.sync(() => new Database(config.otel.databasePath, { create: true })),
|
|
440
|
-
(db) => Effect.sync(() =>
|
|
449
|
+
(db) => Effect.sync(() => {
|
|
450
|
+
// `PRAGMA optimize` at close persists any stats SQLite gathered
|
|
451
|
+
// during the session, so the next process start gets an accurate
|
|
452
|
+
// query planner on the first query instead of a 3-second cold
|
|
453
|
+
// run. Cheap: it skips work unless stats have drifted.
|
|
454
|
+
try { db.exec(`PRAGMA optimize;`) } catch { /* nothing */ }
|
|
455
|
+
db.close()
|
|
456
|
+
}),
|
|
441
457
|
)
|
|
442
458
|
db.exec(`
|
|
443
459
|
PRAGMA journal_mode = WAL;
|
|
444
460
|
PRAGMA synchronous = NORMAL;
|
|
445
461
|
PRAGMA temp_store = MEMORY;
|
|
446
462
|
PRAGMA busy_timeout = 5000;
|
|
463
|
+
-- Bump cache above the 2MB default. 64MB fits most hot index pages
|
|
464
|
+
-- (trace_summaries, spans, span_attributes indexes) in RAM even on
|
|
465
|
+
-- multi-GB databases, cutting cold-read latency meaningfully on
|
|
466
|
+
-- picker / search queries that sweep the index.
|
|
467
|
+
PRAGMA cache_size = -65536;
|
|
468
|
+
-- Let SQLite memory-map the first 256MB of the file. This is a
|
|
469
|
+
-- cheap way to avoid read() syscalls on hot pages and lets the OS
|
|
470
|
+
-- page cache serve index lookups directly. Safe on macOS and Linux;
|
|
471
|
+
-- SQLite silently caps at actual file size for smaller DBs.
|
|
472
|
+
PRAGMA mmap_size = 268435456;
|
|
447
473
|
|
|
448
474
|
CREATE TABLE IF NOT EXISTS spans (
|
|
449
475
|
trace_id TEXT NOT NULL,
|
|
@@ -551,6 +577,24 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
551
577
|
// Existing databases may already have the column.
|
|
552
578
|
}
|
|
553
579
|
|
|
580
|
+
// Prime the query planner. `PRAGMA optimize` is SQLite's modern,
|
|
581
|
+
// lightweight stats refresh: it only re-ANALYZEs indexes whose row
|
|
582
|
+
// counts have drifted significantly since the last run, capped at
|
|
583
|
+
// `analysis_limit` iterations per index so it finishes in a
|
|
584
|
+
// bounded time even on large databases. Without this, queries like
|
|
585
|
+
// the attribute picker facet run with guessed row estimates and
|
|
586
|
+
// pay 3-4s on cold open instead of 400ms.
|
|
587
|
+
try {
|
|
588
|
+
db.exec(`PRAGMA analysis_limit = 1000; PRAGMA optimize;`)
|
|
589
|
+
// First-time databases won't have sqlite_stat1 until we run a
|
|
590
|
+
// real ANALYZE. Force it once if stats haven't been collected.
|
|
591
|
+
const hasStats = db.query(`SELECT 1 FROM sqlite_master WHERE name = 'sqlite_stat1' LIMIT 1`).get() !== null
|
|
592
|
+
if (!hasStats) db.exec(`ANALYZE;`)
|
|
593
|
+
} catch {
|
|
594
|
+
// ANALYZE / optimize failures are never fatal — queries still work,
|
|
595
|
+
// they just run with default row estimates.
|
|
596
|
+
}
|
|
597
|
+
|
|
554
598
|
const insertSpan = db.query(`
|
|
555
599
|
INSERT INTO spans (
|
|
556
600
|
trace_id, span_id, parent_span_id, service_name, scope_name, operation_name, kind,
|
|
@@ -687,6 +731,16 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
687
731
|
// Run cleanup every 60 seconds in the background, tied to the layer's scope
|
|
688
732
|
yield* Effect.forkScoped(Effect.repeat(cleanupExpired(), Schedule.spaced("60 seconds")))
|
|
689
733
|
|
|
734
|
+
// Periodically refresh query planner stats. `PRAGMA optimize` is a
|
|
735
|
+
// no-op when nothing has changed, so this is essentially free on idle
|
|
736
|
+
// servers and keeps facet/search planner estimates accurate as data
|
|
737
|
+
// grows. 15 minutes is slower than ingestion rates we care about but
|
|
738
|
+
// frequent enough that the attribute picker stays snappy.
|
|
739
|
+
const refreshPlannerStats = Effect.sync(() => {
|
|
740
|
+
try { db.exec(`PRAGMA optimize;`) } catch { /* ignore */ }
|
|
741
|
+
})
|
|
742
|
+
yield* Effect.forkScoped(Effect.repeat(refreshPlannerStats, Schedule.spaced("15 minutes")))
|
|
743
|
+
|
|
690
744
|
const ingestTraces = Effect.fn("motel/TelemetryStore.ingestTraces")(function* (payload: OtlpTraceExportRequest) {
|
|
691
745
|
return yield* Effect.sync(() => {
|
|
692
746
|
let insertedSpans = 0
|
|
@@ -1463,7 +1517,14 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
1463
1517
|
// user id, model) rank higher than keys that are constant across every
|
|
1464
1518
|
// trace (service.name, telemetry.sdk.*) — the latter can't discriminate
|
|
1465
1519
|
// between traces so they're useless as filters.
|
|
1466
|
-
|
|
1520
|
+
//
|
|
1521
|
+
// Performance note: we skip rows whose value blob is larger than
|
|
1522
|
+
// FACET_VALUE_MAX_LEN. For opencode this hides `ai.prompt`,
|
|
1523
|
+
// `ai.prompt.messages`, and `ai.prompt.tools` — which are 1-6MB text
|
|
1524
|
+
// blobs that you'd never want to filter by exact match anyway. The
|
|
1525
|
+
// WHERE clause lets SQLite skip reading those pages from disk, taking
|
|
1526
|
+
// the picker open time from ~1.2s to ~370ms on a 2GB database.
|
|
1527
|
+
const params: Array<string | number> = [FACET_VALUE_MAX_LEN, cutoff]
|
|
1467
1528
|
if (input.serviceName) params.push(input.serviceName)
|
|
1468
1529
|
params.push(limit)
|
|
1469
1530
|
const rows = db.query(`
|
|
@@ -1472,7 +1533,8 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
1472
1533
|
COUNT(DISTINCT sa.value) AS distinct_values
|
|
1473
1534
|
FROM span_attributes sa
|
|
1474
1535
|
JOIN spans s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
|
|
1475
|
-
WHERE
|
|
1536
|
+
WHERE LENGTH(sa.value) < ?
|
|
1537
|
+
AND s.start_time_ms >= ?
|
|
1476
1538
|
${input.serviceName ? "AND s.service_name = ?" : ""}
|
|
1477
1539
|
GROUP BY sa.key
|
|
1478
1540
|
ORDER BY (CASE WHEN distinct_values = 1 THEN 1 ELSE 0 END) ASC,
|
|
@@ -1485,14 +1547,18 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
1485
1547
|
}
|
|
1486
1548
|
if (input.field === "attribute_values") {
|
|
1487
1549
|
if (!input.key) return [] as FacetItem[]
|
|
1488
|
-
|
|
1550
|
+
// Skip multi-KB values here too — they blow up GROUP BY on big text.
|
|
1551
|
+
// Matches the attribute_keys pre-filter so the picker stays responsive
|
|
1552
|
+
// if someone hand-crafts a URL that targets a fat key.
|
|
1553
|
+
const params: Array<string | number> = [input.key, FACET_VALUE_MAX_LEN, cutoff]
|
|
1489
1554
|
if (input.serviceName) params.push(input.serviceName)
|
|
1490
1555
|
params.push(limit)
|
|
1491
1556
|
const rows = db.query(`
|
|
1492
1557
|
SELECT sa.value AS value, COUNT(DISTINCT sa.trace_id) AS count
|
|
1493
1558
|
FROM span_attributes sa
|
|
1494
1559
|
JOIN spans s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
|
|
1495
|
-
WHERE sa.key = ? AND
|
|
1560
|
+
WHERE sa.key = ? AND LENGTH(sa.value) < ?
|
|
1561
|
+
AND s.start_time_ms >= ?
|
|
1496
1562
|
${input.serviceName ? "AND s.service_name = ?" : ""}
|
|
1497
1563
|
GROUP BY sa.value
|
|
1498
1564
|
ORDER BY count DESC, value ASC
|
package/src/ui/useKeyboardNav.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useAtom } from "@effect/atom-react"
|
|
2
2
|
import { useKeyboard, useRenderer } from "@opentui/react"
|
|
3
|
-
import { useLayoutEffect, useRef } from "react"
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef } from "react"
|
|
4
4
|
import type { TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
5
5
|
import { otelServerInstructions } from "../instructions.ts"
|
|
6
6
|
import { copyToClipboard, traceUiUrl, webUiUrl } from "./format.ts"
|
|
@@ -34,6 +34,33 @@ import { cycleThemeName, themeLabel } from "./theme.ts"
|
|
|
34
34
|
import { getVisibleSpans } from "./Waterfall.tsx"
|
|
35
35
|
import { resolveCollapseStep } from "./waterfallNav.ts"
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Pull a printable string out of a key event. Handles two cases:
|
|
39
|
+
*
|
|
40
|
+
* 1. A plain printable key (1 char) — returns the char.
|
|
41
|
+
* 2. A multi-char sequence that arrived as one event (common when the
|
|
42
|
+
* terminal has bracketed paste disabled but the user pasted quickly and
|
|
43
|
+
* opentui's parser returned the whole buffer as one key). Returns the
|
|
44
|
+
* sanitised sequence with control bytes stripped.
|
|
45
|
+
*
|
|
46
|
+
* Returns `null` for non-printable events (function keys, modifiers, etc.)
|
|
47
|
+
* so callers can skip them.
|
|
48
|
+
*/
|
|
49
|
+
const extractPrintable = (key: {
|
|
50
|
+
readonly name: string
|
|
51
|
+
readonly sequence?: string
|
|
52
|
+
readonly ctrl: boolean
|
|
53
|
+
readonly meta: boolean
|
|
54
|
+
}): string | null => {
|
|
55
|
+
if (key.ctrl || key.meta) return null
|
|
56
|
+
if (key.name.length === 1) return key.name
|
|
57
|
+
const seq = key.sequence ?? ""
|
|
58
|
+
// Only accept sequences that are pure printable text. Any escape or
|
|
59
|
+
// control byte means this was a function / navigation key.
|
|
60
|
+
if (seq.length > 1 && !/[\x00-\x1f\x7f]/.test(seq)) return seq
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
37
64
|
interface KeyboardNavParams {
|
|
38
65
|
selectedTrace: TraceItem | null
|
|
39
66
|
filteredTraces: readonly TraceSummaryItem[]
|
|
@@ -86,6 +113,44 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
86
113
|
const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
|
|
87
114
|
const serviceLogNavActive = detailView === "service-logs"
|
|
88
115
|
|
|
116
|
+
// Bracketed paste: when the terminal has bracketed paste enabled, opentui
|
|
117
|
+
// surfaces the full pasted text as a single "paste" event on keyInput.
|
|
118
|
+
// Route it into whichever input is currently open. We also enable the
|
|
119
|
+
// mode ourselves (`\x1b[?2004h`) in case the host terminal didn't — it's
|
|
120
|
+
// a no-op on terminals that already had it on.
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const keyInput = (renderer as unknown as { keyInput?: { on: (event: string, handler: (e: unknown) => void) => void; off: (event: string, handler: (e: unknown) => void) => void } }).keyInput
|
|
123
|
+
if (!keyInput) return
|
|
124
|
+
try {
|
|
125
|
+
process.stdout.write("\x1b[?2004h")
|
|
126
|
+
} catch {
|
|
127
|
+
// Best effort — some test environments don't have a real TTY.
|
|
128
|
+
}
|
|
129
|
+
const handler = (event: unknown) => {
|
|
130
|
+
const bytes = (event as { bytes?: Uint8Array }).bytes
|
|
131
|
+
if (!bytes || bytes.length === 0) return
|
|
132
|
+
const text = Buffer.from(bytes).toString("utf8").replace(/[\x00-\x1f\x7f]+/g, (match) => match === "\n" ? " " : "")
|
|
133
|
+
if (!text) return
|
|
134
|
+
const s = stateRef.current
|
|
135
|
+
if (s.pickerMode !== "off") {
|
|
136
|
+
setPickerInput((current) => current + text)
|
|
137
|
+
setPickerIndex(0)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (s.filterMode) {
|
|
141
|
+
setFilterText((current) => current + text)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
keyInput.on("paste", handler)
|
|
146
|
+
return () => {
|
|
147
|
+
keyInput.off("paste", handler)
|
|
148
|
+
try {
|
|
149
|
+
process.stdout.write("\x1b[?2004l")
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
}, [renderer, setFilterText, setPickerInput, setPickerIndex])
|
|
153
|
+
|
|
89
154
|
const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, ...params })
|
|
90
155
|
// Keep the keyboard handler's state mirror in sync before the next paint.
|
|
91
156
|
// OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
|
|
@@ -285,6 +350,17 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
285
350
|
setPickerIndex(0)
|
|
286
351
|
return
|
|
287
352
|
}
|
|
353
|
+
// Ctrl-C: clear input, or close the picker if already empty.
|
|
354
|
+
if (key.ctrl && key.name === "c") {
|
|
355
|
+
if (s.pickerInput.length > 0) {
|
|
356
|
+
setPickerInput("")
|
|
357
|
+
setPickerIndex(0)
|
|
358
|
+
} else {
|
|
359
|
+
setPickerMode("off")
|
|
360
|
+
setPickerIndex(0)
|
|
361
|
+
}
|
|
362
|
+
return
|
|
363
|
+
}
|
|
288
364
|
if (key.name === "up" || (key.ctrl && key.name === "p")) { move(-1); return }
|
|
289
365
|
if (key.name === "down" || (key.ctrl && key.name === "n")) { move(1); return }
|
|
290
366
|
if (key.name === "pageup") { move(-10); return }
|
|
@@ -323,8 +399,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
323
399
|
}
|
|
324
400
|
return
|
|
325
401
|
}
|
|
326
|
-
|
|
327
|
-
|
|
402
|
+
// Prefer key.sequence over key.name so multi-char paste events that
|
|
403
|
+
// slip through as a single raw sequence still get inserted in full.
|
|
404
|
+
const printable = extractPrintable(key)
|
|
405
|
+
if (printable) {
|
|
406
|
+
// Functional setState — multiple key events in the same tick would
|
|
407
|
+
// otherwise all read a stale stateRef.current.pickerInput and
|
|
408
|
+
// clobber each other, losing all but the last char of a paste.
|
|
409
|
+
setPickerInput((current) => current + printable)
|
|
328
410
|
setPickerIndex(0)
|
|
329
411
|
return
|
|
330
412
|
}
|
|
@@ -338,17 +420,28 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
338
420
|
setFilterText("")
|
|
339
421
|
return
|
|
340
422
|
}
|
|
423
|
+
// Ctrl-C: clear the input, or exit filter mode if already empty.
|
|
424
|
+
if (key.ctrl && key.name === "c") {
|
|
425
|
+
if (s.filterText.length > 0) {
|
|
426
|
+
setFilterText("")
|
|
427
|
+
} else {
|
|
428
|
+
setFilterMode(false)
|
|
429
|
+
}
|
|
430
|
+
return
|
|
431
|
+
}
|
|
341
432
|
if (key.name === "return" || key.name === "enter") {
|
|
342
433
|
setFilterMode(false)
|
|
343
434
|
return
|
|
344
435
|
}
|
|
345
436
|
if (key.name === "backspace") {
|
|
346
|
-
setFilterText(
|
|
437
|
+
setFilterText((current) => current.slice(0, -1))
|
|
347
438
|
return
|
|
348
439
|
}
|
|
349
|
-
|
|
350
|
-
if (
|
|
351
|
-
|
|
440
|
+
const printable = extractPrintable(key)
|
|
441
|
+
if (printable) {
|
|
442
|
+
// Functional setState so rapid keystrokes / pastes don't clobber
|
|
443
|
+
// each other via a stale stateRef.current.filterText closure.
|
|
444
|
+
setFilterText((current) => current + printable)
|
|
352
445
|
return
|
|
353
446
|
}
|
|
354
447
|
return
|