You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[HDX-3964] Add event pattern mining to CLI (Shift+P) (#2106)
## Summary
Adds a pattern mining feature to the CLI, accessible via `Shift+P`. This mirrors the web app's Pattern Table functionality but runs entirely in TypeScript — no Pyodide/Python WASM needed.
**Linear:** https://linear.app/hyperdx/issue/HDX-3964
## What changed
### 1. Drain library in common-utils (`packages/common-utils/src/drain/`)
Ported the [browser-drain](https://github.com/DeploySentinel/browser-drain) TypeScript library into `@hyperdx/common-utils`. This is a pure TypeScript implementation of the Drain3 log template mining algorithm, including:
- `TemplateMiner` / `TemplateMinerConfig` — main API
- `Drain` — core algorithm with prefix tree and LRU cluster cache
- `LogMasker` — regex-based token masking (IPs, numbers, etc.)
- `LruCache` — custom LRU cache matching Python Drain3's eviction semantics
- 11 Jest tests ported from the original `node:test` suite
### 2. CLI pattern view (`packages/cli/src/components/EventViewer/`)
**Keybinding:** `Shift+P` toggles pattern view (pauses follow mode, restores on exit)
**Data flow (mirrors web app's `useGroupedPatterns`):**
- Issues `SELECT ... ORDER BY rand() LIMIT 100000` to randomly sample up to 100K events
- Issues parallel `SELECT count()` to get true total event count
- Feeds sampled log bodies through the TypeScript `TemplateMiner`
- Estimates pattern counts via `sampleMultiplier = totalCount / sampledRowCount`
- Computes time-bucketed trend data per pattern
**UI:**
- Pattern list with columns: Est. Count (with `~` prefix), Pattern
- `l`/`Enter` expands a pattern to show its sample events (full table columns)
- `h`/`Esc` returns to pattern list
- `j/k/G/g/Ctrl+D/Ctrl+U` navigation throughout
- Loading spinner while sampling query runs
**Alias fix:** Pattern and count queries compute `WITH` clauses from the source's `defaultTableSelectExpression` so Lucene searches using aliases (e.g. `level:error` where `level` is an alias for `SeverityText`) resolve correctly.
### New files
- `packages/common-utils/src/drain/` — 7 source files + barrel index
- `packages/common-utils/src/__tests__/drain.test.ts`
- `packages/cli/src/components/EventViewer/usePatternData.ts`
- `packages/cli/src/components/EventViewer/PatternView.tsx`
- `packages/cli/src/components/EventViewer/PatternSamplesView.tsx`
### Modified files
- `packages/cli/src/api/eventQuery.ts` — added `buildPatternSampleQuery`, `buildTotalCountQuery`, `buildAliasWithClauses`
- `packages/cli/src/components/EventViewer/EventViewer.tsx` — wired in pattern state + rendering
- `packages/cli/src/components/EventViewer/useKeybindings.ts` — added P, l, h keybindings + pattern/sample navigation
- `packages/cli/src/components/EventViewer/SubComponents.tsx` — added P to help screen
### Demo
https://github.com/user-attachments/assets/50a2edfc-8891-43ae-ab86-b96fca778c66
@@ -171,11 +197,22 @@ export default function EventViewer({
171
197
focusDetailSearch,
172
198
showHelp,
173
199
showSql,
200
+
showPatterns,
174
201
expandedRow,
175
202
detailTab,
176
203
traceDetailExpanded,
177
204
selectedRow,
178
205
scrollOffset,
206
+
patternSelectedRow,
207
+
patternScrollOffset,
208
+
patternCount: patterns.length,
209
+
expandedPattern,
210
+
sampleSelectedRow,
211
+
sampleScrollOffset,
212
+
sampleCount:
213
+
expandedPattern!==null
214
+
? (patterns[expandedPattern]?.samples.length??0)
215
+
: 0,
179
216
isFollowing,
180
217
hasMore,
181
218
events,
@@ -198,7 +235,13 @@ export default function EventViewer({
198
235
setFocusDetailSearch,
199
236
setShowHelp,
200
237
setShowSql,
238
+
setShowPatterns,
201
239
setSqlScrollOffset,
240
+
setPatternSelectedRow,
241
+
setPatternScrollOffset,
242
+
setExpandedPattern,
243
+
setSampleSelectedRow,
244
+
setSampleScrollOffset,
202
245
setSelectedRow,
203
246
setScrollOffset,
204
247
setExpandedRow,
@@ -313,6 +356,27 @@ export default function EventViewer({
313
356
onTraceChSqlChange={setTraceChSql}
314
357
onTraceSelectedNodeChange={setTraceSelectedNode}
315
358
/>
359
+
) : showPatterns&&
360
+
expandedPattern!==null&&
361
+
patterns[expandedPattern] ? (
362
+
<PatternSamplesView
363
+
pattern={patterns[expandedPattern]}
364
+
columns={columns}
365
+
selectedRow={sampleSelectedRow}
366
+
scrollOffset={sampleScrollOffset}
367
+
maxRows={maxRows}
368
+
wrapLines={wrapLines}
369
+
/>
370
+
) : showPatterns ? (
371
+
<PatternView
372
+
patterns={patterns}
373
+
selectedRow={patternSelectedRow}
374
+
scrollOffset={patternScrollOffset}
375
+
maxRows={maxRows}
376
+
loading={patternsLoading}
377
+
error={patternsError}
378
+
wrapLines={wrapLines}
379
+
/>
316
380
) : (
317
381
<TableView
318
382
columns={columns}
@@ -328,13 +392,35 @@ export default function EventViewer({
328
392
)}
329
393
330
394
<Footer
331
-
rowCount={events.length}
332
-
cursorPos={scrollOffset+selectedRow+1}
395
+
rowCount={
396
+
showPatterns&&expandedPattern!==null
397
+
? (patterns[expandedPattern]?.samples.length??0)
398
+
: showPatterns
399
+
? patterns.length
400
+
: events.length
401
+
}
402
+
cursorPos={
403
+
showPatterns&&expandedPattern!==null
404
+
? sampleScrollOffset+sampleSelectedRow+1
405
+
: showPatterns
406
+
? patternScrollOffset+patternSelectedRow+1
407
+
: scrollOffset+selectedRow+1
408
+
}
333
409
wrapLines={wrapLines}
334
410
isFollowing={isFollowing}
335
411
loadingMore={loadingMore}
336
412
paginationError={paginationError}
337
-
scrollInfo={expandedRow!==null ? `Ctrl+D/U to scroll` : undefined}
413
+
scrollInfo={
414
+
expandedRow!==null
415
+
? 'Ctrl+D/U to scroll'
416
+
: showPatterns&&expandedPattern!==null
417
+
? '[SAMPLES] h to go back'
418
+
: showPatterns
419
+
? patternsLoading
420
+
? '[PATTERNS] Sampling…'
421
+
: `[PATTERNS] ${patterns.length} patterns${patternsTotalCount!=null ? ` from ~${patternsTotalCount.toLocaleString()} events` : ''} — l to expand, P/Esc to close`
0 commit comments