Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/event-patterns-column-selector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---

feat: Allow selecting the column or SQL expression used for event pattern grouping (with shareable URL state)
18 changes: 18 additions & 0 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,17 @@ export function DBSearchPage() {
]).withDefault('results'),
);

const [patternColumn, setPatternColumn] = useQueryState(
'patternColumn',
parseAsString,
);
const [draftPatternColumn, setDraftPatternColumn] = useState(
patternColumn ?? '',
);
useEffect(() => {
setDraftPatternColumn(patternColumn ?? '');
}, [patternColumn]);

const [isLive, setIsLive] = useQueryState(
'isLive',
parseAsBoolean.withDefault(true),
Expand Down Expand Up @@ -1051,6 +1062,7 @@ export function DBSearchPage() {
});
},
)();
setPatternColumn(draftPatternColumn || null);
// clear query errors
setQueryErrors({});
}, [
Expand All @@ -1059,6 +1071,8 @@ export function DBSearchPage() {
displayedTimeInputValue,
onSearch,
setQueryErrors,
draftPatternColumn,
setPatternColumn,
]);

const debouncedSubmit = useDebouncedCallback(onSubmit, 1000);
Expand Down Expand Up @@ -2181,6 +2195,10 @@ export function DBSearchPage() {
? (getEventBody(searchedSource) ?? '')
: (chartConfig.implicitColumnExpression ?? '')
}
patternColumn={patternColumn}
draftPatternColumn={draftPatternColumn}
onDraftPatternColumnChange={setDraftPatternColumn}
onSubmit={onSubmit}
totalCountConfig={histogramTimeChartConfig}
totalCountQueryKeyPrefix={QUERY_KEY_PREFIX}
/>
Expand Down
35 changes: 33 additions & 2 deletions packages/app/src/components/PatternTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { RawLogTable } from '@/components/DBRowTable';
import { useSearchTotalCount } from '@/components/SearchTotalCountChart';
import { Pattern, useGroupedPatterns } from '@/hooks/usePatterns';

import {
buildPatternColumnExpression,
PatternColumnSelector,
} from './Patterns/PatternColumnSelector';
import PatternSidePanel from './PatternSidePanel';

const emptyMap = new Map();
Expand All @@ -21,18 +25,31 @@ export default function PatternTable({
totalCountConfig,
totalCountQueryKeyPrefix,
bodyValueExpression,
patternColumn,
draftPatternColumn,
onDraftPatternColumnChange,
onSubmit,
source,
}: {
config: BuilderChartConfigWithDateRange;
totalCountConfig: BuilderChartConfigWithDateRange;
bodyValueExpression: string;
patternColumn?: string | null;
draftPatternColumn?: string;
onDraftPatternColumnChange?: (value: string) => void;
onSubmit?: () => void;
totalCountQueryKeyPrefix: string;
source?: TSource;
}) {
const SAMPLES = 10_000;

const [selectedPattern, setSelectedPattern] = useState<Pattern | null>(null);

const effectiveBodyValueExpression = buildPatternColumnExpression({
patternColumn,
fallback: bodyValueExpression,
});

const {
error: totalCountError,
isLoading: isTotalCountLoading,
Expand All @@ -48,7 +65,7 @@ export default function PatternTable({
} = useGroupedPatterns({
config,
samples: SAMPLES,
bodyValueExpression,
bodyValueExpression: effectiveBodyValueExpression,
severityTextExpression:
(source?.kind === SourceKind.Log && source.severityTextExpression) || '',
statusCodeExpression:
Expand All @@ -69,6 +86,13 @@ export default function PatternTable({

return error ? (
<Container style={{ overflow: 'auto' }}>
<PatternColumnSelector
sourceId={source?.id}
value={draftPatternColumn ?? ''}
onChange={onDraftPatternColumnChange}
onSubmit={onSubmit}
dateRange={config.dateRange}
/>
<Box mt="lg">
Comment thread
knudtty marked this conversation as resolved.
<Text my="sm" size="sm">
Error Message:
Expand Down Expand Up @@ -100,6 +124,13 @@ export default function PatternTable({
</Container>
) : (
<>
<PatternColumnSelector
sourceId={source?.id}
value={draftPatternColumn ?? ''}
onChange={onDraftPatternColumnChange}
onSubmit={onSubmit}
dateRange={config.dateRange}
/>
<RawLogTable
isLive={false}
wrapLines={true}
Expand Down Expand Up @@ -131,7 +162,7 @@ export default function PatternTable({
isOpen
source={source}
pattern={selectedPattern}
bodyValueExpression={bodyValueExpression}
bodyValueExpression={effectiveBodyValueExpression}
onClose={() => setSelectedPattern(null)}
/>
)}
Expand Down
53 changes: 53 additions & 0 deletions packages/app/src/components/Patterns/PatternColumnSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import { Box } from '@mantine/core';

import SQLInlineEditor from '@/components/SQLEditor/SQLInlineEditor';
import { useSource } from '@/source';

export function buildPatternColumnExpression({
patternColumn,
fallback,
}: {
patternColumn: string | null | undefined;
fallback: string;
}): string {
if (!patternColumn) return fallback;
return `toString(${patternColumn})`;
Comment on lines +14 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 buildPatternColumnExpression unconditionally wraps the user input in toString(…). If a user enters an expression that already returns a string (e.g. toString(Body)), the generated SQL becomes toString(toString(Body)). ClickHouse handles this gracefully for actual strings, but a simple guard prevents the redundant wrapping.

Suggested change
if (!patternColumn) return fallback;
return `toString(${patternColumn})`;
if (!patternColumn) return fallback;
const trimmed = patternColumn.trim();
if (/^toString\s*\(/i.test(trimmed)) return trimmed;
return `toString(${trimmed})`;

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

}

export function PatternColumnSelector({
sourceId,
value,
onChange,
onSubmit,
dateRange,
}: {
sourceId: string | undefined;
value: string;
onChange?: (value: string) => void;
onSubmit?: () => void;
dateRange?: [Date, Date];
}) {
const { data: source } = useSource({ id: sourceId });
const tableConnection = tcFromSource(source);

if (!onChange) return null;

return (
<Box py="xs" maw={600}>
<SQLInlineEditor
tableConnection={tableConnection}
value={value}
onChange={onChange}
onSubmit={onSubmit}
enableHotkey
label="Pattern Expression"
placeholder="Default (body) — column name or expression"
size="xs"
allowMultiline={false}
sourceId={sourceId}
dateRange={dateRange}
/>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { buildPatternColumnExpression } from '../PatternColumnSelector';

describe('buildPatternColumnExpression', () => {
const fallback = 'Body';

it('returns the fallback when no expression is provided', () => {
expect(
buildPatternColumnExpression({ patternColumn: null, fallback }),
).toBe(fallback);
expect(
buildPatternColumnExpression({ patternColumn: undefined, fallback }),
).toBe(fallback);
expect(buildPatternColumnExpression({ patternColumn: '', fallback })).toBe(
fallback,
);
});

it('wraps a plain column reference in toString()', () => {
expect(
buildPatternColumnExpression({
patternColumn: 'ResourceAttributes',
fallback,
}),
).toBe('toString(ResourceAttributes)');
});

it('wraps an arbitrary SQL expression in toString()', () => {
expect(
buildPatternColumnExpression({
patternColumn: "concatWithSeparator(' ', Body, LogAttributes)",
fallback,
}),
).toBe("toString(concatWithSeparator(' ', Body, LogAttributes))");

expect(
buildPatternColumnExpression({
patternColumn: "JSONExtractString(Body, 'message')",
fallback,
}),
).toBe("toString(JSONExtractString(Body, 'message'))");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { reconstructTemplate } from '../reconstructTemplate';

describe('reconstructTemplate', () => {
it('returns the original log when the template is empty', () => {
expect(reconstructTemplate('hello world', '')).toBe('hello world');
});

it('restores JSON separators around stable and variable tokens', () => {
expect(
reconstructTemplate(
`{"hostname":"foo","pid":12345,"time":1700000000}`,
'hostname foo pid <*> time <*>',
),
).toBe(`{"hostname":"foo","pid":<*>,"time":<*>}`);
});

it('restores ClickHouse Map (single-quoted) separators', () => {
expect(
reconstructTemplate(
`{'hostname':'Aarons-MacBook-Pro.local','pid':12345,'time':1700000000}`,
'hostname Aarons MacBook Pro local pid <*> time <*>',
),
).toBe(`{'hostname':'Aarons-MacBook-Pro.local','pid':<*>,'time':<*>}`);
});

it('restores key=value separators', () => {
expect(
reconstructTemplate(
'level=info msg=hello user_id=42',
'level info msg hello user id <*>',
),
).toBe('level=info msg=hello user_id=<*>');
});

it('keeps the original token when the template runs short', () => {
expect(reconstructTemplate('alpha beta gamma delta', 'alpha beta')).toBe(
'alpha beta gamma delta',
);
});

it('preserves leading and trailing separators', () => {
expect(reconstructTemplate('[INFO] hello world', 'INFO hello world')).toBe(
'[INFO] hello world',
);
});

it('collapses newlines and tabs in the original log to single spaces', () => {
expect(
reconstructTemplate(
'Error:\n message: "failed"\n code: 500',
'Error message failed code <*>',
),
).toBe('Error: message: "failed" code: <*>');

expect(reconstructTemplate('foo\n\n\nbar', 'foo bar')).toBe('foo bar');

expect(reconstructTemplate('foo\tbar', 'foo bar')).toBe('foo bar');
});
});
22 changes: 22 additions & 0 deletions packages/app/src/components/Patterns/reconstructTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function reconstructTemplate(
originalLog: string,
templateMined: string,
): string {
const normalized = originalLog.replace(/\s+/g, ' ');
const tokens = templateMined.split(' ').filter(t => t.length > 0);
if (tokens.length === 0) return normalized;

const tokenOrSeparator = /([A-Za-z0-9]+)|([^A-Za-z0-9]+)/g;
let result = '';
let tokenIdx = 0;
let match: RegExpExecArray | null;
while ((match = tokenOrSeparator.exec(normalized)) !== null) {
Comment on lines +11 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 TOKEN_OR_SEPARATOR is used but never defined anywhere in this file or the codebase. The file has no imports and the grep confirms there is no definition anywhere in src/. TypeScript will refuse to compile this (Cannot find name 'TOKEN_OR_SEPARATOR') and calling .lastIndex on undefined will throw a TypeError at runtime, breaking all pattern matching. The regex /([A-Za-z0-9]+)|([^A-Za-z0-9]+)/g needs to be declared — either as a module-level constant or as a local variable inside the function before the loop.

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

if (match[1] !== undefined) {
result += tokens[tokenIdx] ?? match[1];
tokenIdx++;
} else {
result += match[2];
}
}
return result;
}
17 changes: 14 additions & 3 deletions packages/app/src/hooks/usePatterns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useQuery } from '@tanstack/react-query';

import { timeBucketByGranularity, toStartOfInterval } from '@/ChartUtils';
import { useConfigWithAdditionalSelect } from '@/components/DBRowTable';
import { reconstructTemplate } from '@/components/Patterns/reconstructTemplate';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { getFirstTimestampValueExpression } from '@/source';

Expand Down Expand Up @@ -54,10 +55,14 @@ class Miner {
await this.pyodide.runPythonAsync(`
import js
import json
import string
from drain3 import TemplateMiner
from drain3.template_miner_config import TemplateMinerConfig

${this.minerVariableName} = TemplateMiner(None, TemplateMinerConfig())
_config = TemplateMinerConfig()
_config.drain_extra_delimiters = list(string.punctuation)

${this.minerVariableName} = TemplateMiner(None, _config)
`);
}

Expand Down Expand Up @@ -168,7 +173,7 @@ function usePatterns({
} = usePyodide({ enabled });

const query = useQuery({
queryKey: ['patterns', config],
queryKey: ['patterns', config, bodyValueExpression],
queryFn: () => {
if (configWithPrimaryAndPartitionKey == null) {
throw new Error('Unexpected configWithPrimaryAndPartitionKey is null');
Expand Down Expand Up @@ -290,10 +295,16 @@ export function useGroupedPatterns({
// return at least 1
const count = Math.max(Math.round(rows.length * sampleMultiplier), 1);
const lastRow = rows.at(-1);
const reconstructedPattern = lastRow
? reconstructTemplate(
stripAnsi((lastRow[PATTERN_COLUMN_ALIAS] ?? '') as string),
(lastRow.__hdx_pattern ?? '') as string,
)
: undefined;

fullPatternGroups[patternId] = {
id: patternId,
pattern: lastRow?.__hdx_pattern, // last pattern is usually the most up to date templated pattern
pattern: reconstructedPattern, // last pattern is usually the most up to date templated pattern
count,
countStr: `~${count}`,
severityText: lastRow?.[SEVERITY_TEXT_COLUMN_ALIAS], // last severitytext is usually representative of the entire pattern set
Expand Down
Loading