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 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/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/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 with + // the 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 [ @@ -275,7 +295,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/EventTag.tsx b/packages/app/src/components/EventTag.tsx index 9b8832fdd..c40735a14 100644 --- a/packages/app/src/components/EventTag.tsx +++ b/packages/app/src/components/EventTag.tsx @@ -2,7 +2,10 @@ 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'; + +import { isLinkableUrl } from '@/utils/highlightedAttributes'; export default function EventTag({ displayedKey, @@ -44,6 +47,8 @@ export default function EventTag({ ); } + const isLink = isLinkableUrl(value); + const searchCondition = nameLanguage === 'sql' ? SqlString.format('? = ?', [SqlString.raw(name), value]) @@ -58,13 +63,32 @@ export default function EventTag({ onChange={setOpened} > -
setOpened(!opened)} - > - {displayedKey || name}: {value} -
+ {isLink ? ( + + + {displayedKey || name} + + + + ) : ( +
setOpened(!opened)} + > + {displayedKey || name}: {value} +
+ )}
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/__tests__/highlightedAttributes.test.ts b/packages/app/src/utils/__tests__/highlightedAttributes.test.ts index 236cde572..e67cfda96 100644 --- a/packages/app/src/utils/__tests__/highlightedAttributes.test.ts +++ b/packages/app/src/utils/__tests__/highlightedAttributes.test.ts @@ -1,6 +1,9 @@ import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; -import { getHighlightedAttributesFromData } from '../highlightedAttributes'; +import { + getHighlightedAttributesFromData, + isLinkableUrl, +} from '../highlightedAttributes'; describe('getHighlightedAttributesFromData', () => { 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 a0deff516..ab114f8b5 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'], ) { @@ -67,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; + } +} diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 2f8350beb..602906562 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -595,6 +595,8 @@ const LogSourceAugmentation = { tableFilterExpression: z.string().optional(), highlightedTraceAttributeExpressions: HighlightedAttributeExpressionsSchema.optional(), + highlightedRowAttributeExpressions: + HighlightedAttributeExpressionsSchema.optional(), }; // Trace source form schema @@ -627,6 +629,8 @@ const TraceSourceAugmentation = { implicitColumnExpression: z.string().optional(), highlightedTraceAttributeExpressions: HighlightedAttributeExpressionsSchema.optional(), + highlightedRowAttributeExpressions: + HighlightedAttributeExpressionsSchema.optional(), }; // Session source form schema