From 62a605381302cec7b9e68f4c8104d6701195aebd Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Tue, 7 Jul 2026 16:43:27 -0600 Subject: [PATCH 1/3] feat: add event patterns as a first-class dashboard tile type Event patterns can now be created, edited, and saved as dashboard tiles with full parity across the UI, MCP, and External API v2. - Add EventPatterns to the DisplayType enum and all exhaustive switches (rawSqlParams, constants, external API conversions) - Dedicated Pattern Expression input in the tile edit UI with the sources default body expression as placeholder - Fix config persistence: select is now correctly saved as a string for EventPatterns (was falling through to the series array path) - Fix source switching: override from/connection/select from the live source so stale columns dont leak into queries - Add isStringSelectDisplayType guard to consolidate the repeated Search/EventPatterns display-type checks - Add MCP tile schema to both save and patch unions so agents can create pattern tiles - Add External API v2 Zod schema and bidirectional conversion so pattern tiles round-trip through GET/PUT without downgrade - Remove variant prop from PatternTable - parent containers own bg - Hide PatternColumnSelector in dashboard/preview contexts --- .../api/src/mcp/tools/dashboards/schemas.ts | 29 +++++++++ packages/api/src/mcp/tools/query/helpers.ts | 22 +++++++ .../external-api/v2/utils/dashboards.ts | 18 ++++++ packages/api/src/utils/zod.ts | 9 +++ packages/app/src/DBDashboardPage.tsx | 51 +++++++++++++++ .../src/components/ChartEditor/constants.tsx | 1 + .../app/src/components/ChartEditor/utils.ts | 52 +++++++++++----- .../ChartEditorControls.tsx | 27 ++++++++ .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 62 ++++++++++++++++++- .../DBEditTimeChartForm/EditTimeChartForm.tsx | 13 +++- .../components/DBEditTimeChartForm/utils.ts | 2 + packages/app/src/components/PatternTable.tsx | 36 +++++++---- .../tests/e2e/page-objects/DashboardPage.ts | 9 ++- packages/common-utils/src/rawSqlParams.ts | 2 + packages/common-utils/src/types.ts | 1 + 15 files changed, 303 insertions(+), 31 deletions(-) diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 54ecd6d867..640aabb243 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -716,6 +716,31 @@ const mcpSearchTileSchema = mcpTileLayoutSchema.extend({ }), }); +const mcpEventPatternsTileSchema = mcpTileLayoutSchema.extend({ + config: z.object({ + displayType: z + .literal('event_patterns') + .describe('Event pattern mining tile'), + sourceId: z.string().describe('Source ID – call clickstack_list_sources'), + where: z + .string() + .optional() + .default('') + .describe('Filter in Lucene syntax. Example: "level:error"'), + whereLanguage: + SearchConditionTrimmedLanguageSchema.optional().default('lucene'), + select: z + .string() + .optional() + .default('') + .describe( + 'Pattern expression — column or expression to mine patterns from. ' + + 'Leave empty to use the source default (Body for logs, SpanName for traces). ' + + 'Example: "Body", "SpanName", "SpanAttributes[\'http.url\']"', + ), + }), +}); + const mcpMarkdownTileSchema = mcpTileLayoutSchema.extend({ config: z.object({ displayType: z.literal('markdown').describe('Free-form Markdown text tile'), @@ -811,6 +836,7 @@ const mcpTileSchema = z.union([ mcpPieTileSchema, mcpHeatmapTileSchema, mcpSearchTileSchema, + mcpEventPatternsTileSchema, mcpMarkdownTileSchema, mcpSqlTileSchema, ]); @@ -849,6 +875,9 @@ const mcpPatchTileSchema = z.union([ mcpPatchTileLayoutSchema.extend({ config: mcpSearchTileSchema.shape.config, }), + mcpPatchTileLayoutSchema.extend({ + config: mcpEventPatternsTileSchema.shape.config, + }), mcpPatchTileLayoutSchema.extend({ config: mcpMarkdownTileSchema.shape.config, }), diff --git a/packages/api/src/mcp/tools/query/helpers.ts b/packages/api/src/mcp/tools/query/helpers.ts index 5a148a703e..aa6cf77a4a 100644 --- a/packages/api/src/mcp/tools/query/helpers.ts +++ b/packages/api/src/mcp/tools/query/helpers.ts @@ -33,6 +33,8 @@ import { trimToolResponse } from '@/utils/trimToolResponse'; import type { ExternalDashboardTileWithId } from '@/utils/zod'; import { externalDashboardTileSchemaWithId } from '@/utils/zod'; +import { runEventPatterns } from './runEventPatterns'; + // ─── Source body expression helpers ────────────────────────────────────────── export interface SourceBodyFields { @@ -404,6 +406,26 @@ export async function runConfigTile( }; } + // Event-patterns tiles need the Drain pattern-mining pipeline, not the + // generic chart-config SQL renderer. Delegate to the same function the + // standalone clickstack_event_patterns tool uses. + if (builderConfig.displayType === DisplayType.EventPatterns) { + const selectStr = + typeof builderConfig.select === 'string' ? builderConfig.select : ''; + return runEventPatterns( + teamId, + builderConfig.source, + startDate, + endDate, + { + where: builderConfig.where ?? '', + whereLanguage: + (builderConfig.whereLanguage as 'lucene' | 'sql') ?? 'lucene', + bodyExpression: selectStr || undefined, + }, + ); + } + const source = await getSource(teamId, builderConfig.source); if (!source) { return mcpUserError( diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 08b952ed2d..5f1f394067 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -247,6 +247,7 @@ const convertToExternalTileChartConfig = ( case DisplayType.Search: case DisplayType.Markdown: case DisplayType.Heatmap: + case DisplayType.EventPatterns: logger.error( { config }, 'Error converting chart config to external chart - unsupported display type for raw SQL config', @@ -420,6 +421,14 @@ const convertToExternalTileChartConfig = ( numberFormat: config.numberFormat, }; } + case DisplayType.EventPatterns: + return { + displayType: config.displayType, + sourceId, + select: stringValueOrDefault(config.select, ''), + where: config.where, + whereLanguage: config.whereLanguage ?? 'lucene', + }; case undefined: logger.error( { config }, @@ -788,6 +797,15 @@ export function convertToInternalTileConfig( whereLanguage: externalConfig.whereLanguage ?? 'lucene', } satisfies BuilderSavedChartConfig; break; + case 'event_patterns': + internalConfig = { + ...pick(externalConfig, ['select', 'where']), + displayType: DisplayType.EventPatterns, + source: externalConfig.sourceId, + name, + whereLanguage: externalConfig.whereLanguage ?? 'lucene', + } satisfies BuilderSavedChartConfig; + break; case 'markdown': internalConfig = { displayType: DisplayType.Markdown, diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 5dd1798299..af3e5da236 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -424,6 +424,14 @@ const externalDashboardMarkdownChartConfigSchema = z.object({ markdown: z.string().max(50000).optional(), }); +const externalDashboardEventPatternsChartConfigSchema = z.object({ + displayType: z.literal('event_patterns'), + sourceId: objectIdSchema, + select: z.string().max(10000).optional().default(''), + where: z.string().max(10000).optional().default(''), + whereLanguage: whereLanguageSchema, +}); + const externalDashboardBuilderTileConfigSchema = z.discriminatedUnion( 'displayType', [ @@ -435,6 +443,7 @@ const externalDashboardBuilderTileConfigSchema = z.discriminatedUnion( externalDashboardHeatmapChartConfigSchema, externalDashboardMarkdownChartConfigSchema, externalDashboardSearchChartConfigSchema, + externalDashboardEventPatternsChartConfigSchema, ], ); diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 1ed9cf72ad..550e735cc7 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -144,6 +144,7 @@ import DBHeatmapChart, { import { DBPieChart } from './components/DBPieChart'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import OnboardingModal from './components/OnboardingModal'; +import PatternTable from './components/PatternTable'; import SearchWhereInput, { getStoredLanguage, } from './components/SearchInput/SearchWhereInput'; @@ -170,6 +171,7 @@ import { import HDXMarkdownChart from './HDXMarkdownChart'; import { withAppNav } from './layout'; import { + getEventBody, getFirstTimestampValueExpression, useSource, useSources, @@ -1048,6 +1050,55 @@ const Tile = forwardRef( /> )} + {effectiveQueriedConfig?.displayType === + DisplayType.EventPatterns && + isBuilderChartConfig(effectiveQueriedConfig) && + isBuilderSavedChartConfig(chart.config) && ( + + 0 && + !effectiveQueriedConfig.select.includes(',') + ? effectiveQueriedConfig.select + : undefined) ?? + (source ? (getEventBody(source) ?? '') : '') + } + totalCountConfig={{ + ...effectiveQueriedConfig, + displayType: DisplayType.Table, + dateRange: effectiveDateRange, + select: 'count() as total', + groupBy: undefined, + orderBy: undefined, + granularity: undefined, + }} + totalCountQueryKeyPrefix={`dashboard-patterns-${chart.id}`} + /> + + )} )} diff --git a/packages/app/src/components/ChartEditor/constants.tsx b/packages/app/src/components/ChartEditor/constants.tsx index 95287b319b..ffbdde3de1 100644 --- a/packages/app/src/components/ChartEditor/constants.tsx +++ b/packages/app/src/components/ChartEditor/constants.tsx @@ -46,6 +46,7 @@ WHERE TimestampTime >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) [DisplayType.Search]: '', [DisplayType.Heatmap]: '', [DisplayType.Markdown]: '', + [DisplayType.EventPatterns]: '', }; const TIMESERIES_INSTRUCTIONS = ( diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index fbb7084bcf..acbf52f8f8 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -69,6 +69,17 @@ export const isRawSqlDisplayType = ( displayType === DisplayType.Pie || displayType === DisplayType.Number; +/** + * Display types that store `select` as a plain string (column expression) + * rather than a structured `DerivedColumn[]` series array. These types + * don't use the builder series editor and skip series-level validation. + */ +export const isStringSelectDisplayType = ( + displayType: DisplayType | undefined, +): displayType is DisplayType.Search | DisplayType.EventPatterns => + displayType === DisplayType.Search || + displayType === DisplayType.EventPatterns; + export const isPromqlDisplayType = ( displayType: DisplayType | undefined, ): displayType is @@ -145,13 +156,11 @@ export function convertFormStateToSavedChartConfig( // Merge the series and select fields back together, and prevent the series field from being submitted const config: BuilderSavedChartConfig = { ...omit(form, ['series', 'configType', 'sqlTemplate']), - // If the chart type is search, we need to ensure the select is a string - select: - form.displayType === DisplayType.Search - ? typeof form.select === 'string' - ? form.select - : '' - : form.series, + select: isStringSelectDisplayType(form.displayType) + ? typeof form.select === 'string' + ? form.select + : '' + : form.series, where: form.where ?? '', source: source.id, }; @@ -225,9 +234,10 @@ export function convertFormStateToChartConfig( } if (source) { - // Merge the series and select fields back together, and prevent the series field from being submitted - const mergedSelect = - form.displayType === DisplayType.Search ? form.select : form.series; + // Merge the series and select fields back together, and prevent the series field from being submitted. + const mergedSelect = isStringSelectDisplayType(form.displayType) + ? form.select + : form.series; const isSelectEmpty = !mergedSelect || mergedSelect.length === 0; const newConfig: ChartConfigWithDateRange = { @@ -249,10 +259,20 @@ export function convertFormStateToChartConfig( sampleWeightExpression: getSampleWeightExpression(source), metricTables: isMetricSource(source) ? source.metricTables : undefined, where: form.where ?? '', + // When select is empty, the fallback differs by display type: + // - EventPatterns: keep '' — the pattern-mining code resolves the + // body expression from the source at render time. Using + // defaultTableSelectExpression here would inject multi-column + // search-table columns (e.g. SeverityText) that don't belong in + // a single-expression pattern field. + // - Search: fall back to defaultTableSelectExpression — the + // multi-column list is exactly what the search results table needs. select: isSelectEmpty - ? ((isLogSource(source) || isTraceSource(source)) && - source.defaultTableSelectExpression) || - '' + ? form.displayType === DisplayType.EventPatterns + ? '' + : ((isLogSource(source) || isTraceSource(source)) && + source.defaultTableSelectExpression) || + '' : mergedSelect, }; @@ -310,13 +330,15 @@ export const validateChartForm = ( errors.push({ path: `source`, message: 'Source is required' }); } - // Validate that valueExpressions are specified for each series + // Validate that valueExpressions are specified for each series. + // String-select display types (Search, EventPatterns) don't use the + // series array, so skip them. if ( !isRawSqlChart && Array.isArray(form.series) && source?.kind !== SourceKind.Metric && form.displayType !== DisplayType.Markdown && - form.displayType !== DisplayType.Search + !isStringSelectDisplayType(form.displayType) ) { form.series.forEach((s, index) => { if (s.aggFn && s.aggFn !== 'count' && !s.valueExpression) { diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index ce28177822..293c3905f9 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -32,6 +32,7 @@ import SourceSchemaPreview, { import { SourceSelectControlled } from '@/components/SourceSelect'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; import { IS_LOCAL_MODE } from '@/config'; +import { getEventBody } from '@/source'; import { DEFAULT_TILE_ALERT } from '@/utils/alerts'; import { OnClickFormButton } from './OnClickForm/OnClickFormButton'; @@ -136,6 +137,7 @@ export function ChartEditorControls({ {tableSource && activeTab !== 'search' && activeTab !== 'heatmap' && + activeTab !== 'event_patterns' && chartConfigForExplanations && isBuilderChartConfig(chartConfigForExplanations) && ( + ) : displayType === DisplayType.EventPatterns ? ( + + + + setValue('whereLanguage', lang) + } + showLabel={false} + /> + ) : displayType !== DisplayType.Search && Array.isArray(select) ? ( <> {fields.map((field, index) => ( diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx index 4003b8dde4..aeab56b8f0 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx @@ -6,6 +6,7 @@ import { BuilderChartConfigWithOptTimestamp, ChartConfigWithDateRange, ChartConfigWithOptTimestamp, + DisplayType, SourceKind, TSource, } from '@hyperdx/common-utils/dist/types'; @@ -29,7 +30,8 @@ import DBSqlRowTableWithSideBar from '@/components/DBSqlRowTableWithSidebar'; import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; import EmptyState from '@/components/EmptyState'; -import { getFirstTimestampValueExpression } from '@/source'; +import PatternTable from '@/components/PatternTable'; +import { getEventBody, getFirstTimestampValueExpression } from '@/source'; import { orderByStringToSortingState, sortingStateToOrderByString, @@ -291,6 +293,64 @@ export function ChartPreviewPanel({ /> )} + {queryReady && + tableSource && + queriedConfig != null && + isBuilderChartConfig(queriedConfig) && + activeTab === 'event_patterns' && ( +
+ 0 && + !queriedConfig.select.includes(',') + ? queriedConfig.select + : undefined) ?? + getEventBody(tableSource) ?? + '' + } + totalCountConfig={{ + ...queriedConfig, + from: tableSource.from, + connection: tableSource.connection, + timestampValueExpression: tableSource.timestampValueExpression, + displayType: DisplayType.Table, + dateRange, + select: 'count() as total', + groupBy: undefined, + orderBy: undefined, + granularity: undefined, + }} + totalCountQueryKeyPrefix="chart-editor-patterns" + /> +
+ )} {showGeneratedSql && ( <> diff --git a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx index 889c5df4c3..d3caf7235b 100644 --- a/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsx @@ -32,6 +32,7 @@ import { import { useDisclosure, usePrevious } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { + IconBracketsContain, IconChartLine, IconChartPie, IconGrid3x3, @@ -57,6 +58,7 @@ import { convertSavedChartConfigToFormState, isPromqlDisplayType, isRawSqlDisplayType, + isStringSelectDisplayType, validateChartForm, } from '@/components/ChartEditor/utils'; import type { HeatmapScaleType } from '@/components/DBHeatmapChart'; @@ -488,7 +490,10 @@ export default function EditTimeChartForm({ prevDisplayTypeRef.current = displayType; prevConfigTypeRef.current = configType; - if (displayType === DisplayType.Search && typeof select !== 'string') { + if ( + isStringSelectDisplayType(displayType) && + typeof select !== 'string' + ) { setValue('select', ''); setValue('series', []); } else if (displayType === DisplayType.Heatmap) { @@ -713,6 +718,12 @@ export default function EditTimeChartForm({ > Heatmap + } + > + Patterns + } diff --git a/packages/app/src/components/DBEditTimeChartForm/utils.ts b/packages/app/src/components/DBEditTimeChartForm/utils.ts index 7a61bd46ec..9bef448aa7 100644 --- a/packages/app/src/components/DBEditTimeChartForm/utils.ts +++ b/packages/app/src/components/DBEditTimeChartForm/utils.ts @@ -101,6 +101,8 @@ export function displayTypeToActiveTab(displayType: DisplayType): string { return 'number'; case DisplayType.Heatmap: return 'heatmap'; + case DisplayType.EventPatterns: + return 'event_patterns'; default: return 'time'; } diff --git a/packages/app/src/components/PatternTable.tsx b/packages/app/src/components/PatternTable.tsx index b6699c1237..af7e96d720 100644 --- a/packages/app/src/components/PatternTable.tsx +++ b/packages/app/src/components/PatternTable.tsx @@ -25,10 +25,10 @@ export default function PatternTable({ totalCountConfig, totalCountQueryKeyPrefix, bodyValueExpression, - patternColumn, - draftPatternColumn, - onDraftPatternColumnChange, - onSubmit, + patternColumn: externalPatternColumn, + draftPatternColumn: externalDraftPatternColumn, + onDraftPatternColumnChange: externalOnDraftPatternColumnChange, + onSubmit: externalOnSubmit, source, }: { config: BuilderChartConfigWithDateRange; @@ -45,8 +45,16 @@ export default function PatternTable({ const [selectedPattern, setSelectedPattern] = useState(null); + // When external handlers are provided (e.g. search page), the pattern + // column selector is shown inline and the user can override the expression + // at runtime. For dashboard/preview tiles, the pattern expression is + // configured in the tile edit UI (via the config's `select` field) and + // the PatternColumnSelector is not shown. + const hasExternalPatternColumn = + externalOnDraftPatternColumnChange != null && externalOnSubmit != null; + const effectiveBodyValueExpression = buildPatternColumnExpression({ - patternColumn, + patternColumn: externalPatternColumn ?? null, fallback: bodyValueExpression, }); @@ -86,14 +94,16 @@ export default function PatternTable({ return ( <> - + {hasExternalPatternColumn && ( + + )} {error ? ( diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 192fa644dc..b0c3e4670f 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -30,7 +30,14 @@ export type TileConfig = { groupBy?: string; markdown?: string; }; -type SeriesType = 'time' | 'number' | 'table' | 'search' | 'markdown' | 'pie'; +type SeriesType = + | 'time' + | 'number' + | 'table' + | 'search' + | 'markdown' + | 'pie' + | 'event_patterns'; /** * Series data structure for chart verification * Supports all chart types: time, number, table, search, markdown diff --git a/packages/common-utils/src/rawSqlParams.ts b/packages/common-utils/src/rawSqlParams.ts index 1062e2f3f8..e59e83a173 100644 --- a/packages/common-utils/src/rawSqlParams.ts +++ b/packages/common-utils/src/rawSqlParams.ts @@ -91,6 +91,7 @@ export const QUERY_PARAMS_BY_DISPLAY_TYPE: Record< [DisplayType.Search]: [], [DisplayType.Heatmap]: [], [DisplayType.Markdown]: [], + [DisplayType.EventPatterns]: [], }; const TIME_CHART_EXAMPLE_SQL = `SELECT @@ -116,6 +117,7 @@ export const QUERY_PARAM_EXAMPLES: Record = { [DisplayType.Search]: '', [DisplayType.Heatmap]: '', [DisplayType.Markdown]: '', + [DisplayType.EventPatterns]: '', }; export function renderQueryParam(name: keyof typeof QUERY_PARAMS): string { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 067ab8f267..95c991eb61 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -39,6 +39,7 @@ export enum DisplayType { Search = 'search', Heatmap = 'heatmap', Markdown = 'markdown', + EventPatterns = 'event_patterns', } export type KeyValue = { key: Key; value: Value }; From a45ea36d3305085e17b03c24fe1757512455aa10 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Tue, 7 Jul 2026 16:57:11 -0600 Subject: [PATCH 2/3] fix: address PR review feedback - Replace naive comma heuristic with bracket-aware isSingleExpression() helper so expressions like COALESCE(a, b) are not silently discarded as multi-column strings - Normalize where field via stringValueOrDefault in external API EventPatterns conversion, matching the Search arm - Add changeset for the feature --- .changeset/bright-patterns-neab.md | 8 ++++++++ .../routers/external-api/v2/utils/dashboards.ts | 2 +- packages/app/src/DBDashboardPage.tsx | 13 ++++++++----- .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 17 +++++++++++------ packages/app/src/source.ts | 11 +++++++++++ 5 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 .changeset/bright-patterns-neab.md diff --git a/.changeset/bright-patterns-neab.md b/.changeset/bright-patterns-neab.md new file mode 100644 index 0000000000..74a5ccbf1b --- /dev/null +++ b/.changeset/bright-patterns-neab.md @@ -0,0 +1,8 @@ +--- +"@hyperdx/app": minor +"@hyperdx/api": minor +--- + +feat: add event patterns as a first-class dashboard tile type + +Event patterns can now be created, edited, and saved as dashboard tiles with a dedicated "Pattern Expression" editor. Supported across the UI, MCP server, and External API v2. diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 5f1f394067..fe562ea411 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -426,7 +426,7 @@ const convertToExternalTileChartConfig = ( displayType: config.displayType, sourceId, select: stringValueOrDefault(config.select, ''), - where: config.where, + where: stringValueOrDefault(config.where, ''), whereLanguage: config.whereLanguage ?? 'lucene', }; case undefined: diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 550e735cc7..f8de9ad9b7 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -173,6 +173,7 @@ import { withAppNav } from './layout'; import { getEventBody, getFirstTimestampValueExpression, + isSingleExpression, useSource, useSources, } from './source'; @@ -1075,13 +1076,15 @@ const Tile = forwardRef( }} bodyValueExpression={ // Prefer the user's custom pattern expression - // (stored in select) when set. Ignore - // multi-column strings (containing commas) — - // those are stale defaultTableSelectExpression - // values, not a single pattern expression. + // (stored in select) when set. Reject + // multi-column strings — those are stale + // defaultTableSelectExpression values, not a + // single pattern expression. Uses bracket-aware + // splitting so expressions like COALESCE(a, b) + // are correctly treated as single. (typeof effectiveQueriedConfig.select === 'string' && effectiveQueriedConfig.select.length > 0 && - !effectiveQueriedConfig.select.includes(',') + isSingleExpression(effectiveQueriedConfig.select) ? effectiveQueriedConfig.select : undefined) ?? (source ? (getEventBody(source) ?? '') : '') diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx index aeab56b8f0..a833b10901 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx @@ -31,7 +31,11 @@ import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; import EmptyState from '@/components/EmptyState'; import PatternTable from '@/components/PatternTable'; -import { getEventBody, getFirstTimestampValueExpression } from '@/source'; +import { + getEventBody, + getFirstTimestampValueExpression, + isSingleExpression, +} from '@/source'; import { orderByStringToSortingState, sortingStateToOrderByString, @@ -323,13 +327,14 @@ export function ChartPreviewPanel({ }} bodyValueExpression={ // Prefer the user's custom pattern expression (stored in - // queriedConfig.select) when set. Ignore multi-column - // strings (containing commas) — those are stale - // defaultTableSelectExpression values from pre-fix saved - // tiles, not a single pattern expression. + // queriedConfig.select) when set. Reject multi-column + // strings — those are stale defaultTableSelectExpression + // values from pre-fix saved tiles, not a single pattern + // expression. Uses bracket-aware splitting so expressions + // like COALESCE(a, b) are correctly treated as single. (typeof queriedConfig.select === 'string' && queriedConfig.select.length > 0 && - !queriedConfig.select.includes(',') + isSingleExpression(queriedConfig.select) ? queriedConfig.select : undefined) ?? getEventBody(tableSource) ?? diff --git a/packages/app/src/source.ts b/packages/app/src/source.ts index 5bf68b6947..e53fd99e59 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -90,6 +90,17 @@ export function getEventBody(eventModel: TSource) { return multiExpr.length === 1 ? expression : multiExpr[0]; } +/** + * Check if a select string is a single expression (valid as a pattern body + * expression) rather than a multi-column list (stale + * `defaultTableSelectExpression`). Uses bracket-aware comma splitting so + * expressions like `COALESCE(SpanName, Body)` are correctly treated as a + * single expression. + */ +export function isSingleExpression(select: string): boolean { + return splitAndTrimWithBracket(select).length <= 1; +} + // This function is for supporting legacy sources, which did not require this field. // Will be defaulted to `TimestampTime` when queried, if undefined. function addDefaultsToSource(source: TSource): TSource { From b67116a078d2717576a054ab9194a238b4b8daba Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Wed, 8 Jul 2026 10:14:53 -0600 Subject: [PATCH 3/3] fix: address Drew's PR feedback - Show warning when pattern expression is multi-column and will be overridden with the source default - Add EventPatternsChartConfig to OpenAPI JSDoc spec and regenerate openapi.json - Add event_patterns tile to POST and PUT round-trip integration tests - Use minHeight instead of fixed height for event patterns preview --- packages/api/openapi.json | 47 ++++++++++++++++++- .../external-api/__tests__/dashboards.test.ts | 39 +++++++++++++++ .../src/routers/external-api/v2/dashboards.ts | 36 +++++++++++++- .../ChartEditorControls.tsx | 11 ++++- .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 2 +- 5 files changed, 131 insertions(+), 4 deletions(-) diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 7d938b5c14..00605a92c1 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -1983,6 +1983,47 @@ } } }, + "EventPatternsChartConfig": { + "type": "object", + "required": [ + "displayType", + "sourceId" + ], + "description": "Configuration for an event pattern mining tile. Clusters log or trace events by recurring message shapes.", + "properties": { + "displayType": { + "type": "string", + "enum": [ + "event_patterns" + ], + "description": "Display type discriminator. Must be \"event_patterns\" for pattern mining tiles.", + "example": "event_patterns" + }, + "sourceId": { + "type": "string", + "description": "ID of the data source to mine patterns from.", + "example": "65f5e4a3b9e77c001a111111" + }, + "select": { + "type": "string", + "maxLength": 10000, + "description": "Column or expression to mine patterns from. Leave empty to use the source default (Body for logs, SpanName for traces).", + "default": "", + "example": "Body" + }, + "where": { + "type": "string", + "maxLength": 10000, + "description": "Filter condition for the pattern mining query (syntax depends on whereLanguage).", + "default": "", + "example": "level:error" + }, + "whereLanguage": { + "$ref": "#/components/schemas/QueryLanguage", + "description": "Query language for the where clause." + } + } + }, "MarkdownChartConfig": { "type": "object", "required": [ @@ -2495,7 +2536,7 @@ } }, "TileConfig": { - "description": "Tile chart configuration. displayType is the primary discriminant and determines which variant group applies. For displayTypes that support both builder and Raw SQL modes (line, stacked_bar, table, number, pie), configType is the secondary discriminant: omit it for the builder variant or set it to \"sql\" for the Raw SQL variant. The heatmap, search, and markdown displayTypes only have a builder variant.\n", + "description": "Tile chart configuration. displayType is the primary discriminant and determines which variant group applies. For displayTypes that support both builder and Raw SQL modes (line, stacked_bar, table, number, pie), configType is the secondary discriminant: omit it for the builder variant or set it to \"sql\" for the Raw SQL variant. The heatmap, search, event_patterns, and markdown displayTypes only have a builder variant.\n", "oneOf": [ { "$ref": "#/components/schemas/LineChartConfig" @@ -2518,6 +2559,9 @@ { "$ref": "#/components/schemas/SearchChartConfig" }, + { + "$ref": "#/components/schemas/EventPatternsChartConfig" + }, { "$ref": "#/components/schemas/MarkdownChartConfig" } @@ -2532,6 +2576,7 @@ "pie": "#/components/schemas/PieChartConfig", "heatmap": "#/components/schemas/HeatmapChartConfig", "search": "#/components/schemas/SearchChartConfig", + "event_patterns": "#/components/schemas/EventPatternsChartConfig", "markdown": "#/components/schemas/MarkdownChartConfig" } } diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts index 079a1273aa..0a7777d3b6 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -2413,6 +2413,21 @@ describe('External API v2 Dashboards - new format', () => { }, }; + const eventPatternsChart: ExternalDashboardTile = { + name: 'Event Patterns', + x: 18, + y: 3, + w: 6, + h: 3, + config: { + displayType: 'event_patterns', + sourceId: traceSource._id.toString(), + select: 'SpanName', + where: 'level:error', + whereLanguage: 'lucene', + }, + }; + // Act const response = await authRequest('post', BASE_URL) .send({ @@ -2425,6 +2440,7 @@ describe('External API v2 Dashboards - new format', () => { markdownChart, pieChart, heatmapChart, + eventPatternsChart, ], tags: ['round-trip-test'], }) @@ -2438,6 +2454,9 @@ describe('External API v2 Dashboards - new format', () => { expect(omit(response.body.data.tiles[4], ['id'])).toEqual(markdownChart); expect(omit(response.body.data.tiles[5], ['id'])).toEqual(pieChart); expect(omit(response.body.data.tiles[6], ['id'])).toEqual(heatmapChart); + expect(omit(response.body.data.tiles[7], ['id'])).toEqual( + eventPatternsChart, + ); }); // Schema-level rejections that exercise pure Zod constraints @@ -3976,6 +3995,22 @@ describe('External API v2 Dashboards - new format', () => { }, }; + const eventPatternsChart: ExternalDashboardTileWithId = { + id: new ObjectId().toString(), + name: 'Event Patterns', + x: 12, + y: 3, + w: 6, + h: 3, + config: { + displayType: 'event_patterns', + sourceId: traceSource._id.toString(), + select: 'SpanName', + where: 'level:error', + whereLanguage: 'lucene', + }, + }; + // Create an initial dashboard to update const initialDashboard = await createTestDashboard(); @@ -3993,6 +4028,7 @@ describe('External API v2 Dashboards - new format', () => { numberChart, markdownChart, heatmapChart, + eventPatternsChart, ], tags: ['round-trip-test'], }) @@ -4017,6 +4053,9 @@ describe('External API v2 Dashboards - new format', () => { expect(omit(response.body.data.tiles[5], ['id'])).toEqual( omit(heatmapChart, ['id']), ); + expect(omit(response.body.data.tiles[6], ['id'])).toEqual( + omit(eventPatternsChart, ['id']), + ); }); it('can round-trip all raw SQL chart config types', async () => { diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index e812a916d9..9e6d4686f5 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -922,6 +922,38 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * $ref: '#/components/schemas/QueryLanguage' * description: Query language for the where clause. * + * EventPatternsChartConfig: + * type: object + * required: + * - displayType + * - sourceId + * description: Configuration for an event pattern mining tile. Clusters log or trace events by recurring message shapes. + * properties: + * displayType: + * type: string + * enum: [event_patterns] + * description: Display type discriminator. Must be "event_patterns" for pattern mining tiles. + * example: "event_patterns" + * sourceId: + * type: string + * description: ID of the data source to mine patterns from. + * example: "65f5e4a3b9e77c001a111111" + * select: + * type: string + * maxLength: 10000 + * description: Column or expression to mine patterns from. Leave empty to use the source default (Body for logs, SpanName for traces). + * default: "" + * example: "Body" + * where: + * type: string + * maxLength: 10000 + * description: Filter condition for the pattern mining query (syntax depends on whereLanguage). + * default: "" + * example: "level:error" + * whereLanguage: + * $ref: '#/components/schemas/QueryLanguage' + * description: Query language for the where clause. + * * MarkdownChartConfig: * type: object * required: @@ -1299,7 +1331,7 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * both builder and Raw SQL modes (line, stacked_bar, table, number, pie), * configType is the secondary discriminant: omit it for the builder * variant or set it to "sql" for the Raw SQL variant. The heatmap, - * search, and markdown displayTypes only have a builder variant. + * search, event_patterns, and markdown displayTypes only have a builder variant. * oneOf: * - $ref: '#/components/schemas/LineChartConfig' * - $ref: '#/components/schemas/BarChartConfig' @@ -1308,6 +1340,7 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * - $ref: '#/components/schemas/PieChartConfig' * - $ref: '#/components/schemas/HeatmapChartConfig' * - $ref: '#/components/schemas/SearchChartConfig' + * - $ref: '#/components/schemas/EventPatternsChartConfig' * - $ref: '#/components/schemas/MarkdownChartConfig' * discriminator: * propertyName: displayType @@ -1319,6 +1352,7 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * pie: '#/components/schemas/PieChartConfig' * heatmap: '#/components/schemas/HeatmapChartConfig' * search: '#/components/schemas/SearchChartConfig' + * event_patterns: '#/components/schemas/EventPatternsChartConfig' * markdown: '#/components/schemas/MarkdownChartConfig' * * DashboardContainerTab: diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index 293c3905f9..2e4e641c14 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -32,7 +32,7 @@ import SourceSchemaPreview, { import { SourceSelectControlled } from '@/components/SourceSelect'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; import { IS_LOCAL_MODE } from '@/config'; -import { getEventBody } from '@/source'; +import { getEventBody, isSingleExpression } from '@/source'; import { DEFAULT_TILE_ALERT } from '@/utils/alerts'; import { OnClickFormButton } from './OnClickForm/OnClickFormButton'; @@ -169,6 +169,15 @@ export function ChartEditorControls({ onSubmit={onSubmit} label="Pattern Expression" /> + {typeof select === 'string' && + select.length > 0 && + !isSingleExpression(select) && ( + + Pattern expression must be a single column or expression — + multi-column lists are not supported. The source default will be + used instead. + + )}