From cfdc4751ad738239fd2c5066c984113bd51bbe83 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 17 Nov 2025 15:36:39 -0500 Subject: [PATCH 1/5] feat: Add custom attributes for individual rows --- packages/api/src/models/source.ts | 3 ++ .../app/src/components/DBRowDataPanel.tsx | 9 ++++ .../app/src/components/DBRowOverviewPanel.tsx | 1 - .../app/src/components/DBRowSidePanel.tsx | 38 ++++++++++---- .../src/components/DBRowSidePanelHeader.tsx | 41 ++++----------- packages/app/src/components/SourceForm.tsx | 50 +++++++++++++++---- .../app/src/utils/highlightedAttributes.ts | 8 ++- packages/common-utils/src/types.ts | 4 ++ 8 files changed, 99 insertions(+), 55 deletions(-) diff --git a/packages/api/src/models/source.ts b/packages/api/src/models/source.ts index a1f5c3ded..800f3aed0 100644 --- a/packages/api/src/models/source.ts +++ b/packages/api/src/models/source.ts @@ -69,6 +69,9 @@ export const Source = mongoose.model( highlightedTraceAttributeExpressions: { type: mongoose.Schema.Types.Array, }, + highlightedRowAttributeExpressions: { + type: mongoose.Schema.Types.Array, + }, metricTables: { type: { diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 36e6258e7..66fb45d2b 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -6,6 +6,7 @@ import { Box } from '@mantine/core'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { getDisplayedTimestampValueExpression, getEventBody } from '@/source'; +import { getSelectExpressionsForHighlightedAttributes } from '@/utils/highlightedAttributes'; import { DBRowJsonViewer } from './DBRowJsonViewer'; @@ -37,6 +38,13 @@ export function useRowData({ const severityTextExpr = source.severityTextExpression || source.statusCodeExpression; + const selectHighlightedRowAttributes = + source.kind === SourceKind.Trace || source.kind === SourceKind.Log + ? getSelectExpressionsForHighlightedAttributes( + source.highlightedRowAttributeExpressions, + ) + : []; + const queryResult = useQueriedChartConfig( { connection: source.connection, @@ -116,6 +124,7 @@ export function useRowData({ }, ] : []), + ...selectHighlightedRowAttributes, ], where: rowId ?? '0=1', from: source.from, diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index e9199b223..68f1bb80e 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -165,7 +165,6 @@ export function RowOverviewPanel({ { - const tags: Record = {}; - if (serviceName && source.serviceNameExpression) { - tags[source.serviceNameExpression] = serviceName; + const highlightedAttributeValues = useMemo(() => { + const attributeExpressions: TSource['highlightedRowAttributeExpressions'] = + []; + if ( + (source.kind === SourceKind.Trace || source.kind === SourceKind.Log) && + source.highlightedRowAttributeExpressions + ) { + attributeExpressions.push(...source.highlightedRowAttributeExpressions); } - return tags; - }, [serviceName, source.serviceNameExpression]); + + // Add service name expression to all sources, to maintain compatibility behavior + // prior to the addition of highlightedRowAttributeExpressions + if (source.serviceNameExpression) { + attributeExpressions.push({ + sqlExpression: source.serviceNameExpression, + }); + } + + return rowData + ? getHighlightedAttributesFromData( + source, + attributeExpressions, + rowData.data || [], + rowData.meta || [], + ) + : []; + }, [source, rowData]); const oneHourRange = useMemo(() => { return [ @@ -274,7 +294,7 @@ const DBRowSidePanel = ({ ; + attributes?: HighlightedAttribute[]; severityText?: string; breadcrumbPath?: BreadcrumbPath; onBreadcrumbClick?: BreadcrumbNavigationCallback; @@ -264,35 +267,9 @@ export default function DBRowSidePanelHeader({ )} - - {Object.entries(tags).map(([sqlKey, value]) => { - // Convert SQL syntax to Lucene syntax - // SQL: column['property.foo'] -> Lucene: column.property.foo - // or SQL: column -> Lucene: column - const luceneKey = sqlKey.replace(/\['([^']+)'\]/g, '.$1'); - - return ( - - ); - })} - + + + ); } diff --git a/packages/app/src/components/SourceForm.tsx b/packages/app/src/components/SourceForm.tsx index 1bd103437..22436e181 100644 --- a/packages/app/src/components/SourceForm.tsx +++ b/packages/app/src/components/SourceForm.tsx @@ -151,7 +151,16 @@ function FormRow({ function HighlightedAttributeExpressionsFormRow({ control, watch, -}: TableModelProps) { + name, + label, + helpText, +}: TableModelProps & { + name: + | 'highlightedTraceAttributeExpressions' + | 'highlightedRowAttributeExpressions'; + label: string; + helpText?: string; +}) { const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE); const tableName = watch(`from.tableName`); const connectionId = watch(`connection`); @@ -162,14 +171,11 @@ function HighlightedAttributeExpressionsFormRow({ remove: removeHighlightedAttribute, } = useFieldArray({ control, - name: 'highlightedTraceAttributeExpressions', + name, }); return ( - + {highlightedAttributes.map((field, index) => ( @@ -181,7 +187,7 @@ function HighlightedAttributeExpressionsFormRow({ connectionId, }} control={control} - name={`highlightedTraceAttributeExpressions.${index}.sqlExpression`} + name={`${name}.${index}.sqlExpression`} disableKeywordAutocomplete placeholder="ResourceAttributes['http.host']" /> @@ -191,7 +197,7 @@ function HighlightedAttributeExpressionsFormRow({ AS @@ -208,7 +214,7 @@ function HighlightedAttributeExpressionsFormRow({ @@ -489,7 +495,18 @@ export function LogTableModelForm(props: TableModelProps) { /> - + + ); @@ -758,7 +775,18 @@ export function TraceTableModelForm(props: TableModelProps) { /> - + + ); } diff --git a/packages/app/src/utils/highlightedAttributes.ts b/packages/app/src/utils/highlightedAttributes.ts index a0deff516..27a527c35 100644 --- a/packages/app/src/utils/highlightedAttributes.ts +++ b/packages/app/src/utils/highlightedAttributes.ts @@ -4,7 +4,9 @@ import { TSource } from '@hyperdx/common-utils/dist/types'; import { getJSONColumnNames } from '@/components/DBRowDataPanel'; export function getSelectExpressionsForHighlightedAttributes( - expressions: TSource['highlightedTraceAttributeExpressions'] = [], + expressions: TSource[ + | 'highlightedRowAttributeExpressions' + | 'highlightedTraceAttributeExpressions'] = [], ) { return expressions.map(({ sqlExpression, alias }) => ({ valueExpression: sqlExpression, @@ -14,7 +16,9 @@ export function getSelectExpressionsForHighlightedAttributes( export function getHighlightedAttributesFromData( source: TSource, - attributes: TSource['highlightedTraceAttributeExpressions'] = [], + attributes: TSource[ + | 'highlightedRowAttributeExpressions' + | 'highlightedTraceAttributeExpressions'] = [], data: Record[], meta: ResponseJSON['meta'], ) { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index fbbc78f33..6b85d4ca6 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -580,6 +580,8 @@ const LogSourceAugmentation = { tableFilterExpression: z.string().optional(), highlightedTraceAttributeExpressions: HighlightedAttributeExpressionsSchema.optional(), + highlightedRowAttributeExpressions: + HighlightedAttributeExpressionsSchema.optional(), }; // Trace source form schema @@ -612,6 +614,8 @@ const TraceSourceAugmentation = { implicitColumnExpression: z.string().optional(), highlightedTraceAttributeExpressions: HighlightedAttributeExpressionsSchema.optional(), + highlightedRowAttributeExpressions: + HighlightedAttributeExpressionsSchema.optional(), }; // Session source form schema From 2bca0267fed61d1a4209047fc316259a38b21dc6 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 17 Nov 2025 17:00:24 -0500 Subject: [PATCH 2/5] feat: Infer links from highlighted attribute values --- packages/app/src/components/EventTag.tsx | 41 +++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/EventTag.tsx b/packages/app/src/components/EventTag.tsx index 9b8832fdd..3a76bb94a 100644 --- a/packages/app/src/components/EventTag.tsx +++ b/packages/app/src/components/EventTag.tsx @@ -2,7 +2,17 @@ import { useState } from 'react'; import Link from 'next/link'; import SqlString from 'sqlstring'; import { SearchConditionLanguage } from '@hyperdx/common-utils/dist/types'; -import { Button, Popover, Stack } from '@mantine/core'; +import { Button, Popover, Stack, Tooltip } from '@mantine/core'; +import { IconLink } from '@tabler/icons-react'; + +function isLinkableUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} export default function EventTag({ displayedKey, @@ -44,6 +54,8 @@ export default function EventTag({ ); } + const isLink = isLinkableUrl(value); + const searchCondition = nameLanguage === 'sql' ? SqlString.format('? = ?', [SqlString.raw(name), value]) @@ -58,13 +70,26 @@ export default function EventTag({ onChange={setOpened} > -
setOpened(!opened)} - > - {displayedKey || name}: {value} -
+ {isLink ? ( + + + {displayedKey || name} + + + + ) : ( +
setOpened(!opened)} + > + {displayedKey || name}: {value} +
+ )}
From a34933b9898eb598892a3ead054008b51072bc3f Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Wed, 19 Nov 2025 08:13:00 -0500 Subject: [PATCH 3/5] review: Resolve reivew comments --- .../DBHighlightedAttributesList.tsx | 2 +- .../app/src/components/DBRowSidePanel.tsx | 4 +- packages/app/src/components/EventTag.tsx | 11 +- .../__tests__/highlightedAttributes.test.ts | 285 +++++++++++++++++- .../app/src/utils/highlightedAttributes.ts | 9 + 5 files changed, 298 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/DBHighlightedAttributesList.tsx b/packages/app/src/components/DBHighlightedAttributesList.tsx index 92f36fe9e..32826936b 100644 --- a/packages/app/src/components/DBHighlightedAttributesList.tsx +++ b/packages/app/src/components/DBHighlightedAttributesList.tsx @@ -39,7 +39,7 @@ export function DBHighlightedAttributesList({ displayedKey={displayedKey} name={lucene ? lucene : sql} nameLanguage={lucene ? 'lucene' : 'sql'} - value={value as string} + value={value} key={`${displayedKey}-${value}-${source.id}`} {...(onPropertyAddClick && contextSource?.id === source.id ? { diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 3fcba7daf..1960fb2fc 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -199,8 +199,8 @@ const DBRowSidePanel = ({ attributeExpressions.push(...source.highlightedRowAttributeExpressions); } - // Add service name expression to all sources, to maintain compatibility behavior - // prior to the addition of highlightedRowAttributeExpressions + // Add service name expression to all sources, to maintain compatibility with + // the behavior prior to the addition of highlightedRowAttributeExpressions if (source.serviceNameExpression) { attributeExpressions.push({ sqlExpression: source.serviceNameExpression, diff --git a/packages/app/src/components/EventTag.tsx b/packages/app/src/components/EventTag.tsx index 3a76bb94a..e9e32b871 100644 --- a/packages/app/src/components/EventTag.tsx +++ b/packages/app/src/components/EventTag.tsx @@ -5,14 +5,7 @@ import { SearchConditionLanguage } from '@hyperdx/common-utils/dist/types'; import { Button, Popover, Stack, Tooltip } from '@mantine/core'; import { IconLink } from '@tabler/icons-react'; -function isLinkableUrl(value: string): boolean { - try { - const url = new URL(value); - return url.protocol === 'http:' || url.protocol === 'https:'; - } catch { - return false; - } -} +import { isLinkableUrl } from '@/utils/highlightedAttributes'; export default function EventTag({ displayedKey, @@ -73,7 +66,7 @@ export default function EventTag({ {isLink ? ( { const createBasicSource = ( @@ -477,4 +480,284 @@ describe('getHighlightedAttributesFromData', () => { expect(attr.source).toBe(source); }); }); + + it('extracts highlightedRowAttributeExpressions correctly', () => { + const source: TSource = { + ...createBasicSource(), + highlightedRowAttributeExpressions: [ + { + sqlExpression: 'status', + alias: 'status', + }, + { + sqlExpression: 'endpoint', + luceneExpression: 'http.endpoint', + alias: 'endpoint', + }, + ], + }; + + const data = [ + { status: '200', endpoint: '/api/users' }, + { status: '404', endpoint: '/api/posts' }, + { status: '200', endpoint: '/api/comments' }, + ]; + + const meta = [ + { name: 'status', type: 'String' }, + { name: 'endpoint', type: 'String' }, + ]; + + const attributes = getHighlightedAttributesFromData( + source, + source.highlightedRowAttributeExpressions, + data, + meta, + ); + + expect(attributes).toHaveLength(5); + expect(attributes).toContainEqual({ + sql: 'status', + displayedKey: 'status', + value: '200', + source, + }); + expect(attributes).toContainEqual({ + sql: 'status', + displayedKey: 'status', + value: '404', + source, + }); + expect(attributes).toContainEqual({ + sql: 'endpoint', + displayedKey: 'endpoint', + value: '/api/users', + lucene: 'http.endpoint', + source, + }); + expect(attributes).toContainEqual({ + sql: 'endpoint', + displayedKey: 'endpoint', + value: '/api/posts', + lucene: 'http.endpoint', + source, + }); + expect(attributes).toContainEqual({ + sql: 'endpoint', + displayedKey: 'endpoint', + value: '/api/comments', + lucene: 'http.endpoint', + source, + }); + }); +}); + +describe('isLinkableUrl', () => { + describe('valid http and https URLs', () => { + it('returns true for simple http URL', () => { + expect(isLinkableUrl('http://example.com')).toBe(true); + }); + + it('returns true for simple https URL', () => { + expect(isLinkableUrl('https://example.com')).toBe(true); + }); + + it('returns true for http URL with path', () => { + expect(isLinkableUrl('http://example.com/path/to/resource')).toBe(true); + }); + + it('returns true for https URL with path', () => { + expect(isLinkableUrl('https://example.com/path/to/resource')).toBe(true); + }); + + it('returns true for URL with query parameters', () => { + expect(isLinkableUrl('https://example.com/search?q=test&page=1')).toBe( + true, + ); + }); + + it('returns true for URL with hash fragment', () => { + expect(isLinkableUrl('https://example.com/page#section')).toBe(true); + }); + + it('returns true for URL with port number', () => { + expect(isLinkableUrl('https://example.com:8080/api')).toBe(true); + }); + + it('returns true for URL with authentication', () => { + expect(isLinkableUrl('https://user:pass@example.com/resource')).toBe( + true, + ); + }); + + it('returns true for localhost URL', () => { + expect(isLinkableUrl('http://localhost:3000/api')).toBe(true); + }); + + it('returns true for IP address URL', () => { + expect(isLinkableUrl('http://192.168.1.1:8080/admin')).toBe(true); + }); + + it('returns true for URL with subdomain', () => { + expect(isLinkableUrl('https://api.staging.example.com/v1')).toBe(true); + }); + }); + + describe('XSS prevention - javascript protocol', () => { + it('returns false for javascript: protocol', () => { + expect(isLinkableUrl('javascript:alert("XSS")')).toBe(false); + }); + + it('returns false for javascript: protocol with void', () => { + expect(isLinkableUrl('javascript:void(0)')).toBe(false); + }); + + it('returns false for javascript: protocol with encoded payload', () => { + expect(isLinkableUrl('javascript:eval(atob("YWxlcnQoJ1hTUycp"))')).toBe( + false, + ); + }); + + it('returns false for javascript: protocol with newline bypass attempt', () => { + expect(isLinkableUrl('javascript://example.com%0Aalert(1)')).toBe(false); + }); + + it('returns false for javascript: with mixed case', () => { + expect(isLinkableUrl('JaVaScRiPt:alert(1)')).toBe(false); + }); + }); + + describe('XSS prevention - data protocol', () => { + it('returns false for data: protocol with HTML', () => { + expect( + isLinkableUrl('data:text/html,'), + ).toBe(false); + }); + + it('returns false for data: protocol with base64 encoded script', () => { + expect( + isLinkableUrl( + 'data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=', + ), + ).toBe(false); + }); + + it('returns false for data: protocol with SVG', () => { + expect( + isLinkableUrl('data:image/svg+xml,'), + ).toBe(false); + }); + }); + + describe('XSS prevention - other dangerous protocols', () => { + it('returns false for vbscript: protocol', () => { + expect(isLinkableUrl('vbscript:msgbox("XSS")')).toBe(false); + }); + + it('returns false for file: protocol', () => { + expect(isLinkableUrl('file:///etc/passwd')).toBe(false); + }); + + it('returns false for file: protocol on Windows', () => { + expect(isLinkableUrl('file:///C:/Windows/System32/config/sam')).toBe( + false, + ); + }); + + it('returns false for about: protocol', () => { + expect(isLinkableUrl('about:blank')).toBe(false); + }); + }); + + describe('non-http/https protocols', () => { + it('returns false for ftp: protocol', () => { + expect(isLinkableUrl('ftp://ftp.example.com/file.zip')).toBe(false); + }); + + it('returns false for mailto: protocol', () => { + expect(isLinkableUrl('mailto:test@example.com')).toBe(false); + }); + + it('returns false for tel: protocol', () => { + expect(isLinkableUrl('tel:+1234567890')).toBe(false); + }); + + it('returns false for ssh: protocol', () => { + expect(isLinkableUrl('ssh://user@host:22/path')).toBe(false); + }); + + it('returns false for ws: protocol', () => { + expect(isLinkableUrl('ws://example.com/socket')).toBe(false); + }); + + it('returns false for wss: protocol', () => { + expect(isLinkableUrl('wss://example.com/socket')).toBe(false); + }); + }); + + describe('malformed URLs', () => { + it('returns false for plain text', () => { + expect(isLinkableUrl('not a url')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isLinkableUrl('')).toBe(false); + }); + + it('returns false for URL without protocol', () => { + expect(isLinkableUrl('example.com')).toBe(false); + }); + + it('returns false for protocol without domain', () => { + expect(isLinkableUrl('http://')).toBe(false); + }); + + it('returns false for missing protocol', () => { + expect(isLinkableUrl('://example.com')).toBe(false); + }); + + it('returns false for HTML script tag', () => { + expect(isLinkableUrl('')).toBe(false); + }); + + it('returns false for script tag embedded in URL-like string', () => { + expect(isLinkableUrl('http://')).toBe(false); + }); + + it('returns false for relative URL', () => { + expect(isLinkableUrl('/path/to/resource')).toBe(false); + }); + + it('returns false for protocol-relative URL', () => { + expect(isLinkableUrl('//example.com/path')).toBe(false); + }); + + it('returns false for URL with only whitespace', () => { + expect(isLinkableUrl(' ')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('returns false for null input', () => { + // @ts-expect-error explicitly testing invalid input + expect(isLinkableUrl(null)).toBe(false); + }); + + it('returns false for undefined input', () => { + // @ts-expect-error explicitly testing invalid input + expect(isLinkableUrl(undefined)).toBe(false); + }); + + it('returns true for URL with unusual but valid characters', () => { + expect(isLinkableUrl('https://example.com/path-with_special~chars')).toBe( + true, + ); + }); + + it('returns true for URL with encoded characters', () => { + expect(isLinkableUrl('https://example.com/path%20with%20spaces')).toBe( + true, + ); + }); + }); }); diff --git a/packages/app/src/utils/highlightedAttributes.ts b/packages/app/src/utils/highlightedAttributes.ts index 27a527c35..ab114f8b5 100644 --- a/packages/app/src/utils/highlightedAttributes.ts +++ b/packages/app/src/utils/highlightedAttributes.ts @@ -71,3 +71,12 @@ export function getHighlightedAttributesFromData( })), ); } + +export function isLinkableUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} From 5d1d98dcaeb311538d1408c28b91679409c98a4a Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Wed, 19 Nov 2025 09:21:10 -0500 Subject: [PATCH 4/5] chore: Add changeset --- .changeset/slow-eyes-attack.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/slow-eyes-attack.md diff --git a/.changeset/slow-eyes-attack.md b/.changeset/slow-eyes-attack.md new file mode 100644 index 000000000..30702bdf7 --- /dev/null +++ b/.changeset/slow-eyes-attack.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/app": patch +--- + +feat: Add custom attributes for individual rows From 06c0175a6b58ce53aeac87e30675406937b7be07 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Wed, 19 Nov 2025 16:26:56 -0500 Subject: [PATCH 5/5] fix: Improve word break on EventTag tooltip --- packages/app/src/components/EventTag.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/EventTag.tsx b/packages/app/src/components/EventTag.tsx index e9e32b871..c40735a14 100644 --- a/packages/app/src/components/EventTag.tsx +++ b/packages/app/src/components/EventTag.tsx @@ -64,7 +64,13 @@ export default function EventTag({ > {isLink ? ( - +