@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/motel",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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(() => db.close()),
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
- const params: Array<string | number> = [cutoff]
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 s.start_time_ms >= ?
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
- const params: Array<string | number> = [input.key, cutoff]
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 s.start_time_ms >= ?
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
@@ -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
- if (key.name.length === 1 && !key.ctrl && !key.meta) {
327
- setPickerInput(s.pickerInput + key.name)
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(s.filterText.slice(0, -1))
437
+ setFilterText((current) => current.slice(0, -1))
347
438
  return
348
439
  }
349
- // Single printable character
350
- if (key.name.length === 1 && !key.ctrl && !key.meta) {
351
- setFilterText(s.filterText + key.name)
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