diff --git a/.changeset/breezy-cougars-shave.md b/.changeset/breezy-cougars-shave.md new file mode 100644 index 00000000..26c0866b --- /dev/null +++ b/.changeset/breezy-cougars-shave.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/app": patch +--- + +feat: Support JSON Sessions diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7338a124..67bdc866 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -72,8 +72,6 @@ services: volumes: - ./docker/otel-collector/config.yaml:/etc/otelcol-contrib/config.yaml - ./docker/otel-collector/supervisor_docker.yaml.tmpl:/etc/otel/supervisor.yaml.tmpl - # Add a custom config file - - ./docker/otel-collector/dev.json.config.yaml:/etc/otelcol-contrib/custom.config.yaml ports: - '14318:4318' # OTLP http receiver restart: always diff --git a/docker/otel-collector/dev.json.config.yaml b/docker/otel-collector/dev.json.config.yaml deleted file mode 100644 index 6fd1563b..00000000 --- a/docker/otel-collector/dev.json.config.yaml +++ /dev/null @@ -1,11 +0,0 @@ -service: - pipelines: - # ignore rrweb events - logs/out-rrweb: - exporters: - - nop - processors: - - memory_limiter - - batch - receivers: - - routing/logs diff --git a/packages/app/src/DOMPlayer.tsx b/packages/app/src/DOMPlayer.tsx index 4844f387..1d1a4dd4 100644 --- a/packages/app/src/DOMPlayer.tsx +++ b/packages/app/src/DOMPlayer.tsx @@ -17,6 +17,8 @@ import { import { useRRWebEventStream } from '@/sessions'; import { useDebugMode } from '@/utils'; +import { FieldExpressionGenerator } from './hooks/useFieldExpressionGenerator'; + import styles from '../styles/SessionSubpanelV2.module.scss'; function getPlayerCurrentTime(player: Replayer) { @@ -27,7 +29,7 @@ const URLHoverCard = memo(({ url }: { url: string }) => { let parsedUrl: URL | undefined; try { parsedUrl = new URL(url); - } catch (e) { + } catch { // ignore } @@ -38,7 +40,7 @@ const URLHoverCard = memo(({ url }: { url: string }) => { for (const [key, value] of _searchParams.entries()) { searchParams.push({ key, value }); } - } catch (e) { + } catch { // ignore } @@ -92,6 +94,7 @@ export default function DOMPlayer({ setPlayerFullWidth, playerFullWidth, resizeKey, + getSessionSourceFieldExpression, }: { config: { dateRange: [Date, Date]; @@ -106,14 +109,11 @@ export default function DOMPlayer({ playerSpeed: number; setPlayerStartTimestamp?: (ts: number) => void; setPlayerEndTimestamp?: (ts: number) => void; - // setPlayerSpeed: (playerSpeed: number) => void; skipInactive: boolean; - // setSkipInactive: (skipInactive: boolean) => void; - // highlightedResultId: string | undefined; - // onClick: (logId: string, sortKey: number) => void; resizeKey?: string; setPlayerFullWidth: (fullWidth: boolean) => void; playerFullWidth: boolean; + getSessionSourceFieldExpression: FieldExpressionGenerator; }) { const debug = useDebugMode(); const wrapper = useRef(null); @@ -141,7 +141,7 @@ export default function DOMPlayer({ limit: 1000000, // large enough to get all events onEvent: (event: { b: string; ck: number; tcks: number; t: number }) => { try { - const { b: body, ck: chunk, tcks: totalChunks, t: type } = event; + const { b: body, ck: chunk, tcks: totalChunks } = event; currentRrwebEvent += body; if (!chunk || chunk === totalChunks) { const parsedEvent = JSON.parse(currentRrwebEvent); @@ -202,11 +202,10 @@ export default function DOMPlayer({ } } }, + getSessionSourceFieldExpression, }, { - enabled: dateRange != null, - // @ts-ignore - keepPreviousData: true, // TODO: support streaming + keepPreviousData: true, shouldAbortPendingRequest: true, }, ); @@ -358,7 +357,7 @@ export default function DOMPlayer({ setIsReplayerInitialized(true); if (debug) { - // @ts-ignore + // @ts-expect-error this is for debugging purposes only window.__hdx_replayer = replayer.current; } diff --git a/packages/app/src/Playbar.tsx b/packages/app/src/Playbar.tsx index 56948c97..b41d0408 100644 --- a/packages/app/src/Playbar.tsx +++ b/packages/app/src/Playbar.tsx @@ -1,9 +1,11 @@ import { useMemo } from 'react'; import uniqBy from 'lodash/uniqBy'; import { ChartConfigWithOptDateRange } from '@hyperdx/common-utils/dist/types'; +import { keepPreviousData } from '@tanstack/react-query'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import { SessionRow, sessionRowSchema } from './utils/sessions'; import type { PlaybarMarker } from './PlaybarSlider'; import { PlaybarSlider } from './PlaybarSlider'; import { getShortUrl } from './utils'; @@ -29,14 +31,19 @@ export default function Playbar({ const maxSliderVal = Math.ceil(playbackRange[1].getTime() / 1000) * 1000; const minSliderVal = Math.floor(playbackRange[0].getTime() / 1000) * 1000; - const { data, isLoading, isError, error } = useQueriedChartConfig( - queriedConfig, - { - placeholderData: (prev: any) => prev, - queryKey: ['PlayBar', queriedConfig], - }, + const { data } = useQueriedChartConfig(queriedConfig, { + placeholderData: keepPreviousData, + queryKey: ['PlayBar', queriedConfig], + }); + + const events: SessionRow[] = useMemo( + () => + data?.data + ?.map(row => sessionRowSchema.safeParse(row)) + .filter(parsed => parsed.success) + .map(parsed => parsed.data) ?? [], + [data?.data], ); - const events: any[] = useMemo(() => data?.data ?? [], [data?.data]); const markers = useMemo(() => { return uniqBy( @@ -59,14 +66,14 @@ export default function Playbar({ ) .map(event => { const spanName = event['span_name']; - const locationHref = event['location.href']; + const locationHref = event['location.href'] ?? ''; const shortLocationHref = getShortUrl(locationHref); - const errorMessage = event['error.message']; + const errorMessage = event['error.message'] ?? ''; - const url = event['http.url']; - const statusCode = event['http.status_code']; - const method = event['http.method']; + const url = event['http.url'] ?? ''; + const statusCode = Number(event['http.status_code'] ?? ''); + const method = event['http.method'] ?? ''; const shortUrl = getShortUrl(url); const isNavigation = @@ -94,7 +101,7 @@ export default function Playbar({ ? errorMessage : spanName === 'intercom.onShow' ? 'Intercom Chat Opened' - : event.body, + : (event.body ?? ''), isError, isSuccess, }; diff --git a/packages/app/src/SessionEventList.tsx b/packages/app/src/SessionEventList.tsx index d0cd7436..5accc571 100644 --- a/packages/app/src/SessionEventList.tsx +++ b/packages/app/src/SessionEventList.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import cx from 'classnames'; import { ChartConfigWithOptDateRange } from '@hyperdx/common-utils/dist/types'; import { ScrollArea, Skeleton, Stack } from '@mantine/core'; -import { useThrottledCallback, useThrottledValue } from '@mantine/hooks'; +import { useThrottledValue } from '@mantine/hooks'; import { IconArrowsLeftRight, IconMapPin, @@ -16,6 +16,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import useRowWhere, { RowWhereResult } from '@/hooks/useRowWhere'; import { useQueriedChartConfig } from './hooks/useChartConfig'; +import { SessionRow, sessionRowSchema } from './utils/sessions'; import { useFormatTime } from './useFormatTime'; import { formatmmss, getShortUrl } from './utils'; @@ -23,11 +24,10 @@ import styles from '../styles/SessionSubpanelV2.module.scss'; type SessionEvent = { id: string; - row: Record; // original row object - sortKey: string; + row: SessionRow; // original row object isError: boolean; isSuccess: boolean; - eventSource: 'navigation' | 'chat' | 'network' | 'custom'; + eventSource: 'navigation' | 'chat' | 'network' | 'custom' | 'log'; title: string; description: string; timestamp: Date; @@ -112,31 +112,35 @@ export const SessionEventList = ({ onTimeClick: (ts: number) => void; eventsFollowPlayerPosition: boolean; }) => { - const { data, isLoading, isError, error, isPlaceholderData, isSuccess } = - useQueriedChartConfig(queriedConfig, { - placeholderData: (prev: any) => prev, - queryKey: ['SessionEventList', queriedConfig], - }); + const { data, isLoading } = useQueriedChartConfig(queriedConfig, { + placeholderData: (prev: any) => prev, + queryKey: ['SessionEventList', queriedConfig], + }); const formatTime = useFormatTime(); - const events = React.useMemo(() => data?.data ?? [], [data?.data]); + const events: SessionRow[] = React.useMemo( + () => + data?.data + ?.map(row => sessionRowSchema.safeParse(row)) + .filter(parsed => parsed.success) + .map(parsed => parsed.data) ?? [], + [data?.data], + ); const getRowWhere = useRowWhere({ meta: data?.meta, aliasMap }); const rows = React.useMemo(() => { return ( - events.map((event, i) => { + events.map(event => { const { timestamp, durationInMs } = event; // TODO: we should just use timestamp and durationInMs instead of startOffset and endOffset const startOffset = new Date(timestamp).getTime(); const endOffset = new Date(startOffset).getTime() + durationInMs; - const isHighlighted = false; - - const url = event['http.url']; - const statusCode = event['http.status_code']; - const method = event['http.method']; + const url = event['http.url'] ?? ''; + const statusCode = Number(event['http.status_code'] ?? ''); + const method = event['http.method'] ?? ''; const shortUrl = getShortUrl(url); const isNetworkRequest = @@ -144,15 +148,12 @@ export const SessionEventList = ({ const errorMessage = event['error.message']; - const body = event['body']; + const body = event['body'] ?? ''; const component = event['component']; const spanName = event['span_name']; - const locationHref = event['location.href']; + const locationHref = event['location.href'] ?? ''; const otelLibraryName = event['otel.library.name']; const shortLocationHref = getShortUrl(locationHref); - const isException = - event['exception.group_id'] != '' && - event['exception.group_id'] != null; const isCustomEvent = otelLibraryName === 'custom-action'; const isNavigation = @@ -167,7 +168,6 @@ export const SessionEventList = ({ return { id: event.id, - sortKey: event.sort_key, row: event, isError, isSuccess, @@ -182,29 +182,27 @@ export const SessionEventList = ({ : 'log', title: isNavigation ? `Navigated` - : isException - ? 'Exception' - : spanName === 'console.error' - ? 'console.error' - : spanName === 'console.log' - ? 'console.log' - : spanName === 'console.warn' - ? 'console.warn' - : url.length > 0 - ? `${statusCode} ${method}` - : spanName === 'intercom.onShow' - ? 'Intercom Chat Opened' - : isCustomEvent + : spanName === 'console.error' + ? 'console.error' + : spanName === 'console.log' + ? 'console.log' + : spanName === 'console.warn' + ? 'console.warn' + : url.length > 0 + ? `${statusCode} ${method}` + : spanName === 'intercom.onShow' + ? 'Intercom Chat Opened' + : isCustomEvent + ? spanName + : component === 'console' ? spanName - : component === 'console' - ? spanName - : 'console.error', + : 'console.error', description: isNavigation ? shortLocationHref : errorMessage != null && errorMessage.length > 0 ? errorMessage : component === 'console' - ? body + ? (body ?? '') : url.length > 0 ? shortUrl : '', @@ -215,7 +213,7 @@ export const SessionEventList = ({ format: 'time', }), duration: endOffset - startOffset, - } as SessionEvent; + } satisfies SessionEvent; }) ?? [] ); }, [events, showRelativeTime, minTs, formatTime]); diff --git a/packages/app/src/SessionSubpanel.tsx b/packages/app/src/SessionSubpanel.tsx index 4038dcad..3538c81c 100644 --- a/packages/app/src/SessionSubpanel.tsx +++ b/packages/app/src/SessionSubpanel.tsx @@ -34,6 +34,7 @@ import DBRowSidePanel from '@/components/DBRowSidePanel'; import { RowWhereResult, WithClause } from '@/hooks/useRowWhere'; import { SQLInlineEditorControlled } from './components/SQLInlineEditor'; +import useFieldExpressionGenerator from './hooks/useFieldExpressionGenerator'; import DOMPlayer from './DOMPlayer'; import Playbar from './Playbar'; import SearchInputV2 from './SearchInputV2'; @@ -45,6 +46,192 @@ import styles from '../styles/SessionSubpanelV2.module.scss'; const MemoPlaybar = memo(Playbar); +function useSessionChartConfigs({ + traceSource, + rumSessionId, + where, + whereLanguage, + start, + end, + tab, +}: { + traceSource: TSource; + rumSessionId: string; + where: string; + whereLanguage?: SearchConditionLanguage; + start: Date; + end: Date; + tab: string; +}) { + const { getFieldExpression: getTraceSourceFieldExpression } = + useFieldExpressionGenerator(traceSource); + + if (!getTraceSourceFieldExpression) { + return { + eventsConfig: undefined, + aliasMap: undefined, + }; + } + + // Should produce rows that match the `sessionRowSchema` in packages/app/src/utils/sessions.ts + const select = useMemo( + () => [ + { + valueExpression: `${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', 'message')}`, + alias: 'body', + }, + { + valueExpression: `${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', 'component')}`, + alias: 'component', + }, + { + valueExpression: `toFloat64OrZero(toString(${traceSource.durationExpression})) * pow(10, 3) / pow(10, toInt8OrZero(toString(${traceSource.durationPrecision})))`, + alias: 'durationInMs', + }, + { + valueExpression: `${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', 'error.message')}`, + alias: 'error.message', + }, + { + valueExpression: `${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', 'http.method')}`, + alias: 'http.method', + }, + { + valueExpression: `${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', 'http.status_code')}`, + alias: 'http.status_code', + }, + { + valueExpression: `${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', 'http.url')}`, + alias: 'http.url', + }, + { + // Using toString here because Javascript does not have the precision to accurately represent this + valueExpression: `toString(cityHash64(${traceSource.traceIdExpression}, ${traceSource.parentSpanIdExpression}, ${traceSource.spanIdExpression}))`, + alias: 'id', + }, + { + valueExpression: `${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', 'location.href')}`, + alias: 'location.href', + }, + { + valueExpression: 'ScopeName', // FIXME: add mapping + alias: 'otel.library.name', + }, + { + valueExpression: `${traceSource.parentSpanIdExpression}`, + alias: 'parent_span_id', + }, + { + valueExpression: `${traceSource.statusCodeExpression}`, + alias: 'severity_text', + }, + { + valueExpression: `${traceSource.spanIdExpression}`, + alias: 'span_id', + }, + { + valueExpression: `${traceSource.spanNameExpression}`, + alias: 'span_name', + }, + { + valueExpression: `${traceSource.timestampValueExpression}`, + alias: 'timestamp', + }, + { + valueExpression: `${traceSource.traceIdExpression}`, + alias: 'trace_id', + }, + { + valueExpression: `CAST('span', 'String')`, + alias: 'type', + }, + ], + [traceSource, getTraceSourceFieldExpression], + ); + + // Events shown in the highlighted tab + const highlightedEventsFilter = useMemo( + () => ({ + type: 'lucene' as const, + condition: `${traceSource.resourceAttributesExpression}.rum.sessionId:"${rumSessionId}" + AND ( + ${traceSource.eventAttributesExpression}.http.status_code:>299 + OR ${traceSource.eventAttributesExpression}.component:"error" + OR ${traceSource.spanNameExpression}:"routeChange" + OR ${traceSource.spanNameExpression}:"documentLoad" + OR ${traceSource.spanNameExpression}:"intercom.onShow" + OR ScopeName:"custom-action" + )`, + }), + [traceSource, rumSessionId], + ); + + const allEventsFilter = useMemo( + () => ({ + type: 'lucene' as const, + condition: `${traceSource.resourceAttributesExpression}.rum.sessionId:"${rumSessionId}" + AND ( + ${traceSource.eventAttributesExpression}.http.status_code:* + OR ${traceSource.eventAttributesExpression}.component:"console" + OR ${traceSource.eventAttributesExpression}.component:"error" + OR ${traceSource.spanNameExpression}:"routeChange" + OR ${traceSource.spanNameExpression}:"documentLoad" + OR ${traceSource.spanNameExpression}:"intercom.onShow" + OR ScopeName:"custom-action" + )`, + }), + [traceSource, rumSessionId], + ); + + const eventsConfig = useMemo( + () => ({ + select: select, + from: traceSource.from, + dateRange: [start, end], + whereLanguage, + where, + timestampValueExpression: traceSource.timestampValueExpression, + implicitColumnExpression: traceSource.implicitColumnExpression, + connection: traceSource.connection, + orderBy: `${traceSource.timestampValueExpression} ASC`, + limit: { + limit: 4000, + offset: 0, + }, + filters: [ + tab === 'highlighted' ? highlightedEventsFilter : allEventsFilter, + ], + }), + [ + select, + traceSource, + start, + end, + where, + whereLanguage, + tab, + highlightedEventsFilter, + allEventsFilter, + ], + ); + + const aliasMap = useMemo(() => { + // valueExpression: alias + return select.reduce( + (acc, { valueExpression, alias }) => { + acc[alias] = valueExpression; + return acc; + }, + {} as Record, + ); + }, [select]); + + return { + eventsConfig, + aliasMap, + }; +} + export default function SessionSubpanel({ traceSource, sessionSource, @@ -167,9 +354,7 @@ export default function SessionSubpanel({ ); // Event Filter Input ========================= - const inputRef = useRef(null); const [_inputQuery, setInputQuery] = useState(undefined); - const inputQuery = _inputQuery ?? ''; const [_searchedQuery, setSearchedQuery] = useQueryState('session_q', { history: 'push', }); @@ -227,224 +412,21 @@ export default function SessionSubpanel({ ] as DateRange['dateRange']; }, [playerStartTs, playerEndTs]); - const commonSelect = useMemo( - () => [ - // body - // component - // duration - // end_timestamp - // error.message - // exception.group_id - // http.method - // http.status_code - // http.url - // id - // location.href - // otel.library.name - // parent_span_id - // severity_text - // sort_key - // span_id - // span_name - // timestamp - // trace_id - // type - // _host - // _platform - // _service - { - // valueExpression: `${traceSource.statusCodeExpression}`, - valueExpression: `${traceSource.eventAttributesExpression}['message']`, - alias: 'body', - }, - { - valueExpression: `${traceSource.eventAttributesExpression}['component']`, - alias: 'component', - }, - { - valueExpression: `toFloat64OrZero(toString(${traceSource.durationExpression})) * pow(10, 3) / pow(10, toInt8OrZero(toString(${traceSource.durationPrecision})))`, - alias: 'durationInMs', - }, - { - valueExpression: `${traceSource.eventAttributesExpression}['error.message']`, - alias: 'error.message', - }, - { - valueExpression: `${traceSource.eventAttributesExpression}['http.method']`, - alias: 'http.method', - }, - { - valueExpression: `${traceSource.eventAttributesExpression}['http.status_code']`, - alias: 'http.status_code', - }, - { - valueExpression: `${traceSource.eventAttributesExpression}['http.url']`, - alias: 'http.url', - }, - { - // Using toString here because Javascript does not have the precision to accurately represent this - valueExpression: `toString(cityHash64(${traceSource.traceIdExpression}, ${traceSource.parentSpanIdExpression}, ${traceSource.spanIdExpression}))`, - alias: 'id', - }, - { - valueExpression: `${traceSource.eventAttributesExpression}['location.href']`, - alias: 'location.href', - }, - { - valueExpression: 'ScopeName', // FIXME: add mapping - alias: 'otel.library.name', - }, - { - valueExpression: `${traceSource.parentSpanIdExpression}`, - alias: 'parent_span_id', - }, - { - valueExpression: `${traceSource.statusCodeExpression}`, - alias: 'severity_text', - }, - { - valueExpression: `${traceSource.spanIdExpression}`, - alias: 'span_id', - }, - { - valueExpression: `${traceSource.spanNameExpression}`, - alias: 'span_name', - }, - { - valueExpression: `${traceSource.timestampValueExpression}`, - alias: 'timestamp', - }, - { - valueExpression: `${traceSource.traceIdExpression}`, - alias: 'trace_id', - }, - { - valueExpression: `CAST('span', 'String')`, - alias: 'type', - }, - ], - [traceSource], - ); + const { getFieldExpression: getSessionSourceFieldExpression } = + useFieldExpressionGenerator(sessionSource); - // Events shown in the highlighted tab - const highlightedEventsFilter = useMemo( - () => ({ - type: 'lucene' as const, - condition: `${traceSource.resourceAttributesExpression}.rum.sessionId:"${rumSessionId}" - AND ( - ${traceSource.eventAttributesExpression}.http.status_code:>299 - OR ${traceSource.eventAttributesExpression}.component:"error" - OR ${traceSource.spanNameExpression}:"routeChange" - OR ${traceSource.spanNameExpression}:"documentLoad" - OR ${traceSource.spanNameExpression}:"intercom.onShow" - OR ScopeName:"custom-action" - )`, - }), - [traceSource, rumSessionId], - ); + const { eventsConfig, aliasMap } = useSessionChartConfigs({ + traceSource, + rumSessionId, + where: searchedQuery, + whereLanguage, + start, + end, + tab, + }); - const allEventsFilter = useMemo( - () => ({ - type: 'lucene' as const, - condition: `${traceSource.resourceAttributesExpression}.rum.sessionId:"${rumSessionId}" - AND ( - ${traceSource.eventAttributesExpression}.http.status_code:* - OR ${traceSource.eventAttributesExpression}.component:"console" - OR ${traceSource.eventAttributesExpression}.component:"error" - OR ${traceSource.spanNameExpression}:"routeChange" - OR ${traceSource.spanNameExpression}:"documentLoad" - OR ${traceSource.spanNameExpression}:"intercom.onShow" - OR ScopeName:"custom-action" - )`, - }), - [traceSource, rumSessionId], - ); - - const playBarEventsConfig = useMemo( - () => ({ - select: commonSelect, - from: traceSource.from, - dateRange: [start, end], - whereLanguage, - where: searchedQuery, - timestampValueExpression: traceSource.timestampValueExpression, - implicitColumnExpression: traceSource.implicitColumnExpression, - connection: traceSource.connection, - orderBy: `${traceSource.timestampValueExpression} ASC`, - limit: { - limit: 4000, - offset: 0, - }, - filters: [ - tab === 'highlighted' ? highlightedEventsFilter : allEventsFilter, - // ...(where ? [{ type: whereLanguage, condition: where }] : []), - ], - }), - [ - commonSelect, - traceSource.from, - traceSource.timestampValueExpression, - traceSource.implicitColumnExpression, - traceSource.connection, - start, - end, - whereLanguage, - searchedQuery, - tab, - highlightedEventsFilter, - allEventsFilter, - // where, - ], - ); const [playerFullWidth, setPlayerFullWidth] = useState(false); - const aliasMap = useMemo(() => { - // valueExpression: alias - return commonSelect.reduce( - (acc, { valueExpression, alias }) => { - acc[alias] = valueExpression; - return acc; - }, - {} as Record, - ); - }, [commonSelect]); - - const sessionEventListConfig = useMemo( - () => ({ - select: commonSelect, - from: traceSource.from, - dateRange: [start, end], - whereLanguage, - where: searchedQuery, - timestampValueExpression: traceSource.timestampValueExpression, - implicitColumnExpression: traceSource.implicitColumnExpression, - connection: traceSource.connection, - orderBy: `${traceSource.timestampValueExpression} ASC`, - limit: { - limit: 4000, - offset: 0, - }, - filters: [ - tab === 'highlighted' ? highlightedEventsFilter : allEventsFilter, - // ...(where ? [{ type: whereLanguage, condition: where }] : []), - ], - }), - [ - commonSelect, - traceSource.from, - traceSource.timestampValueExpression, - traceSource.implicitColumnExpression, - traceSource.connection, - start, - end, - whereLanguage, - searchedQuery, - tab, - highlightedEventsFilter, - allEventsFilter, - ], - ); - const handleSetPlayerSpeed = useCallback(() => { if (playerSpeed == 1) { setPlayerSpeed(2); @@ -549,67 +531,74 @@ export default function SessionSubpanel({ - { - setDrawerOpen(true); - setRowId(rowWhere.where); - setAliasWith(rowWhere.aliasWith); - }, - [setDrawerOpen, setRowId, setAliasWith], - )} - focus={focus} - onTimeClick={useCallback( - ts => { - setFocus({ ts, setBy: 'timeline' }); - }, - [setFocus], - )} - minTs={minTs} - showRelativeTime={showRelativeTime} - /> + {eventsConfig && aliasMap && ( + { + setDrawerOpen(true); + setRowId(rowWhere.where); + setAliasWith(rowWhere.aliasWith); + }, + [setDrawerOpen, setRowId, setAliasWith], + )} + focus={focus} + onTimeClick={useCallback( + ts => { + setFocus({ ts, setBy: 'timeline' }); + }, + [setFocus], + )} + minTs={minTs} + showRelativeTime={showRelativeTime} + /> + )}
- { - if (focus?.setBy !== 'player' || focus?.ts !== ts) { - setFocus({ ts, setBy: 'player' }); - } - }, - [focus, setFocus], - )} - config={{ - serviceName: session.serviceName, - sourceId: sessionSource.id, - sessionId: rumSessionId, - dateRange: [start, end], - }} - playerSpeed={playerSpeed} - skipInactive={skipInactive} - setPlayerStartTimestamp={setPlayerStartTs} - setPlayerEndTimestamp={setPlayerEndTs} - setPlayerFullWidth={setPlayerFullWidth} - playerFullWidth={playerFullWidth} - resizeKey={`${playerFullWidth}`} - /> - -
- { + if (focus?.setBy !== 'player' || focus?.ts !== ts) { + setFocus({ ts, setBy: 'player' }); + } + }, + [focus, setFocus], + )} + config={{ + serviceName: session.serviceName, + sourceId: sessionSource.id, + sessionId: rumSessionId, + dateRange: [start, end], + }} + playerSpeed={playerSpeed} + skipInactive={skipInactive} + setPlayerStartTimestamp={setPlayerStartTs} + setPlayerEndTimestamp={setPlayerEndTs} + setPlayerFullWidth={setPlayerFullWidth} + playerFullWidth={playerFullWidth} + resizeKey={`${playerFullWidth}`} + getSessionSourceFieldExpression={getSessionSourceFieldExpression} /> + )} + +
+ {eventsConfig && ( + + )}
diff --git a/packages/app/src/components/DBSessionPanel.tsx b/packages/app/src/components/DBSessionPanel.tsx index 8f72cea0..49485fca 100644 --- a/packages/app/src/components/DBSessionPanel.tsx +++ b/packages/app/src/components/DBSessionPanel.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import Link from 'next/link'; import { Loader } from '@mantine/core'; +import useFieldExpressionGenerator from '@/hooks/useFieldExpressionGenerator'; import SessionSubpanel from '@/SessionSubpanel'; import { useSource } from '@/source'; @@ -21,8 +22,10 @@ export const useSessionId = ({ // trace source const { data: source } = useSource({ id: sourceId }); + const { getFieldExpression } = useFieldExpressionGenerator(source); + const config = useMemo(() => { - if (!source || !traceId) { + if (!source || !traceId || !getFieldExpression) { return; } return { @@ -32,11 +35,11 @@ export const useSessionId = ({ alias: 'Timestamp', }, { - valueExpression: `${source.resourceAttributesExpression}['rum.sessionId']`, + valueExpression: `${getFieldExpression(source.resourceAttributesExpression ?? 'ResourceAttributes', 'rum.sessionId')}`, alias: 'rumSessionId', }, { - valueExpression: `${source.resourceAttributesExpression}['service.name']`, + valueExpression: `${getFieldExpression(source.resourceAttributesExpression ?? 'ResourceAttributes', 'service.name')}`, alias: 'serviceName', }, { @@ -51,7 +54,7 @@ export const useSessionId = ({ where: `${source.traceIdExpression} = '${traceId}'`, whereLanguage: 'sql' as const, }; - }, [source, traceId]); + }, [source, traceId, getFieldExpression]); const { data } = useEventsData({ config: config!, // ok to force unwrap, the query will be disabled if config is null diff --git a/packages/app/src/components/Sources/SourceForm.tsx b/packages/app/src/components/Sources/SourceForm.tsx index 1df60f27..146e83bf 100644 --- a/packages/app/src/components/Sources/SourceForm.tsx +++ b/packages/app/src/components/Sources/SourceForm.tsx @@ -49,7 +49,6 @@ import { IconSettings, IconTrash, } from '@tabler/icons-react'; -import { useQuery } from '@tanstack/react-query'; import { SourceSelectControlled } from '@/components/SourceSelect'; import { IS_METRICS_ENABLED, IS_SESSIONS_ENABLED } from '@/config'; @@ -1437,6 +1436,21 @@ export function SessionTableModelForm({ control }: TableModelProps) { > + + + ); diff --git a/packages/app/src/hooks/__tests__/useFieldExpressionGenerator.test.tsx b/packages/app/src/hooks/__tests__/useFieldExpressionGenerator.test.tsx new file mode 100644 index 00000000..a4f23257 --- /dev/null +++ b/packages/app/src/hooks/__tests__/useFieldExpressionGenerator.test.tsx @@ -0,0 +1,191 @@ +import { TSource } from '@hyperdx/common-utils/dist/types'; +import { renderHook } from '@testing-library/react'; + +import useFieldExpressionGenerator from '../useFieldExpressionGenerator'; +import { useJsonColumns } from '../useMetadata'; + +// Mock dependencies +jest.mock('../useMetadata', () => ({ + useJsonColumns: jest.fn(), +})); + +describe('useFieldExpressionGenerator', () => { + const mockSource = { + from: { + databaseName: 'test_db', + tableName: 'traces', + }, + connection: 'conn1', + } as TSource; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined getFieldExpression when source is undefined', () => { + jest.mocked(useJsonColumns).mockReturnValue({ + data: undefined, + isLoading: false, + } as any); + + const { result } = renderHook(() => useFieldExpressionGenerator(undefined)); + + expect(result.current.getFieldExpression).toBeUndefined(); + expect(result.current.isLoading).toBeFalsy(); + }); + + it('should return isLoading true when data is loading', () => { + jest.mocked(useJsonColumns).mockReturnValue({ + data: undefined, + isLoading: true, + } as any); + + const { result } = renderHook(() => + useFieldExpressionGenerator(mockSource), + ); + + expect(result.current.getFieldExpression).toBeUndefined(); + expect(result.current.isLoading).toBe(true); + }); + + it('should generate JSON column expression with default convertFn (toString)', () => { + jest.mocked(useJsonColumns).mockReturnValue({ + data: ['Body', 'Metadata'], + isLoading: false, + } as any); + + const { result } = renderHook(() => + useFieldExpressionGenerator(mockSource), + ); + + expect(result.current.getFieldExpression).toBeDefined(); + expect(result.current.isLoading).toBeFalsy(); + + const expression = result.current.getFieldExpression!( + 'Body', + 'error.message', + ); + expect(expression).toBe('toString(`Body`.`error`.`message`)'); + }); + + it('should generate JSON column expression with custom convertFn', () => { + jest.mocked(useJsonColumns).mockReturnValue({ + data: ['Body', 'Metadata'], + isLoading: false, + } as any); + + const { result } = renderHook(() => + useFieldExpressionGenerator(mockSource), + ); + + expect(result.current.getFieldExpression).toBeDefined(); + + const expression = result.current.getFieldExpression!( + 'Body', + 'count', + 'toInt64', + ); + expect(expression).toBe('toInt64(`Body`.`count`)'); + }); + + it('should generate Map column expression with bracket notation', () => { + jest.mocked(useJsonColumns).mockReturnValue({ + data: ['Body', 'Metadata'], + isLoading: false, + } as any); + + const { result } = renderHook(() => + useFieldExpressionGenerator(mockSource), + ); + + expect(result.current.getFieldExpression).toBeDefined(); + + const expression = result.current.getFieldExpression!( + 'ResourceAttributes', + 'service.name', + ); + expect(expression).toBe("`ResourceAttributes`['service.name']"); + }); + + it('should handle mixed JSON and Map columns correctly', () => { + jest.mocked(useJsonColumns).mockReturnValue({ + data: ['Body'], + isLoading: false, + } as any); + + const { result } = renderHook(() => + useFieldExpressionGenerator(mockSource), + ); + + // JSON column should use SqlString.format + const jsonExpression = result.current.getFieldExpression!('Body', 'key1'); + expect(jsonExpression).toBe('toString(`Body`.`key1`)'); + + // Map column should use bracket notation + const mapExpression = result.current.getFieldExpression!( + 'ResourceAttributes', + 'key2', + ); + expect(mapExpression).toBe("`ResourceAttributes`['key2']"); + }); + + it('should handle empty jsonColumns array', () => { + jest.mocked(useJsonColumns).mockReturnValue({ + data: [], + isLoading: false, + } as any); + + const { result } = renderHook(() => + useFieldExpressionGenerator(mockSource), + ); + + expect(result.current.getFieldExpression).toBeDefined(); + + // All columns should be treated as Map columns + const expression = result.current.getFieldExpression!( + 'ResourceAttributes', + 'service.name', + ); + expect(expression).toBe("`ResourceAttributes`['service.name']"); + }); + + it('should pass correct tableConnection to useJsonColumns', () => { + jest.mocked(useJsonColumns).mockReturnValue({ + data: [], + isLoading: false, + } as any); + + renderHook(() => useFieldExpressionGenerator(mockSource)); + + expect(useJsonColumns).toHaveBeenCalledWith({ + databaseName: 'test_db', + tableName: 'traces', + connectionId: 'conn1', + }); + }); + + it('should handle special characters in keys correctly', () => { + jest.mocked(useJsonColumns).mockReturnValue({ + data: ['Body'], + isLoading: false, + } as any); + + const { result } = renderHook(() => + useFieldExpressionGenerator(mockSource), + ); + + // JSON column with special characters + const jsonExpression = result.current.getFieldExpression!( + 'Body', + "user's key", + ); + expect(jsonExpression).toBe("toString(`Body`.`user's key`)"); + + // Map column with special characters - bracket notation handles it + const mapExpression = result.current.getFieldExpression!( + 'ResourceAttributes', + "user's key", + ); + expect(mapExpression).toBe("`ResourceAttributes`['user\\'s key']"); + }); +}); diff --git a/packages/app/src/hooks/useFieldExpressionGenerator.tsx b/packages/app/src/hooks/useFieldExpressionGenerator.tsx new file mode 100644 index 00000000..a68f4c45 --- /dev/null +++ b/packages/app/src/hooks/useFieldExpressionGenerator.tsx @@ -0,0 +1,47 @@ +import SqlString from 'sqlstring'; +import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; +import { TSource } from '@hyperdx/common-utils/dist/types'; + +import { useJsonColumns } from './useMetadata'; + +export type FieldExpressionGenerator = ( + /** The column name, either a Map or a JSON column */ + column: string, + /** The map key or JSON path to access */ + key: string, + /** Function to convert a Dynamic field from JSON to a non-Dynamic type. Defaults to toString */ + convertFn?: string, +) => string; + +/** Utility for rendering SQL field access expressions for Maps and JSON types. */ +export default function useFieldExpressionGenerator( + source: TSource | undefined, +): { + isLoading: boolean; + getFieldExpression: FieldExpressionGenerator | undefined; +} { + const { data: jsonColumns, isLoading: isLoadingJsonColumns } = useJsonColumns( + tcFromSource(source), + ); + + if (source && !isLoadingJsonColumns) { + return { + isLoading: false, + getFieldExpression: ( + column: string, + key: string, + convertFn: string = 'toString', + ) => { + const isJson = jsonColumns?.includes(column); + return isJson + ? SqlString.format(`${convertFn}(??.??)`, [column, key]) + : SqlString.format('??[?]', [column, key]); + }, + }; + } else { + return { + isLoading: isLoadingJsonColumns, + getFieldExpression: undefined, + }; + } +} diff --git a/packages/app/src/sessions.ts b/packages/app/src/sessions.ts index d8ccaa9a..ea3960ab 100644 --- a/packages/app/src/sessions.ts +++ b/packages/app/src/sessions.ts @@ -13,6 +13,9 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'; import { usePrevious } from '@/utils'; +import useFieldExpressionGenerator, { + FieldExpressionGenerator, +} from './hooks/useFieldExpressionGenerator'; import { useMetadataWithSettings } from './hooks/useMetadata'; import { getClickhouseClient, useClickhouseClient } from './clickhouse'; import { SESSION_TABLE_EXPRESSIONS, useSource } from './source'; @@ -32,7 +35,6 @@ export type Session = { userName: string; }; -// TODO: support where filtering export function useSessions( { traceSource, @@ -49,6 +51,18 @@ export function useSessions( }, options?: Omit, 'queryKey'>, ) { + const { enabled = true } = options || {}; + + const { + getFieldExpression: getTraceSourceFieldExpression, + isLoading: isLoadingFieldExpressionGenerator, + } = useFieldExpressionGenerator(traceSource); + + const { + getFieldExpression: getSessionsSourceFieldExpression, + isLoading: isLoadingSessionsExpressionGenerator, + } = useFieldExpressionGenerator(sessionSource); + const FIXED_SDK_ATTRIBUTES = ['teamId', 'teamName', 'userEmail', 'userName']; const SESSIONS_CTE_NAME = 'sessions'; const clickhouseClient = useClickhouseClient(); @@ -63,10 +77,20 @@ export function useSessions( whereLanguage, ], queryFn: async () => { - if (!traceSource || !sessionSource) { + if ( + !traceSource || + !sessionSource || + !getTraceSourceFieldExpression || + !getSessionsSourceFieldExpression + ) { return []; } + const traceSessionIdExpression = getTraceSourceFieldExpression( + traceSource.resourceAttributesExpression ?? 'ResourceAttributes', + 'rum.sessionId', + ); + const [ sessionsQuery, sessionIdsWithRecordingsQuery, @@ -80,7 +104,7 @@ export function useSessions( alias: 'serviceName', }, { - valueExpression: `${traceSource.resourceAttributesExpression}['rum.sessionId']`, + valueExpression: traceSessionIdExpression, alias: 'sessionId', }, // TODO: can't use aggFn max/min here for string value field @@ -119,14 +143,14 @@ export function useSessions( alias: 'recordingCount', }, ...FIXED_SDK_ATTRIBUTES.map(attr => ({ - valueExpression: `MAX(${traceSource.eventAttributesExpression}['${attr}'])`, + valueExpression: `MAX(${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', attr)})`, alias: attr, })), ], from: traceSource.from, dateRange, - where: `mapContains(${traceSource.resourceAttributesExpression}, 'rum.sessionId')`, - whereLanguage: 'sql', + where: `${traceSource.resourceAttributesExpression}.rum.sessionId:*`, + whereLanguage: 'lucene', ...(where && { filters: [ { @@ -147,18 +171,16 @@ export function useSessions( { select: [ { - valueExpression: `DISTINCT ${SESSION_TABLE_EXPRESSIONS.resourceAttributesExpression}['rum.sessionId']`, + valueExpression: `DISTINCT ${getSessionsSourceFieldExpression(sessionSource.resourceAttributesExpression ?? 'ResourceAttributes', 'rum.sessionId')}`, alias: 'sessionId', }, ], from: sessionSource.from, dateRange, - where: `${SESSION_TABLE_EXPRESSIONS.resourceAttributesExpression}['rum.sessionId'] IN (SELECT sessions.sessionId FROM ${SESSIONS_CTE_NAME})`, + where: `${getSessionsSourceFieldExpression(sessionSource.resourceAttributesExpression ?? 'ResourceAttributes', 'rum.sessionId')} IN (SELECT sessions.sessionId FROM ${SESSIONS_CTE_NAME})`, whereLanguage: 'sql', - timestampValueExpression: - SESSION_TABLE_EXPRESSIONS.timestampValueExpression, - implicitColumnExpression: - SESSION_TABLE_EXPRESSIONS.implicitColumnExpression, + timestampValueExpression: sessionSource.timestampValueExpression, + implicitColumnExpression: sessionSource.implicitColumnExpression, connection: sessionSource.connection, }, metadata, @@ -168,13 +190,13 @@ export function useSessions( { select: [ { - valueExpression: `DISTINCT ${traceSource.resourceAttributesExpression}['rum.sessionId']`, + valueExpression: `DISTINCT ${getTraceSourceFieldExpression(traceSource.resourceAttributesExpression ?? 'ResourceAttributes', 'rum.sessionId')}`, alias: 'sessionId', }, ], from: traceSource.from, dateRange, - where: `(${traceSource.spanNameExpression}='record init' OR ${traceSource.spanNameExpression}='visibility') AND (${traceSource.resourceAttributesExpression}['rum.sessionId'] IN (SELECT sessions.sessionId FROM ${SESSIONS_CTE_NAME}))`, + where: `(${traceSource.spanNameExpression}='record init' OR ${traceSource.spanNameExpression}='visibility') AND (${getTraceSourceFieldExpression(traceSource.resourceAttributesExpression ?? 'ResourceAttributes', 'rum.sessionId')} IN (SELECT sessions.sessionId FROM ${SESSIONS_CTE_NAME}))`, whereLanguage: 'sql', timestampValueExpression: traceSource.timestampValueExpression, implicitColumnExpression: traceSource.implicitColumnExpression, @@ -232,15 +254,16 @@ export function useSessions( }, staleTime: 1000 * 60 * 5, // Cache every 5 min ...options, + enabled: + !!enabled && + !isLoadingFieldExpressionGenerator && + !isLoadingSessionsExpressionGenerator, }); } // TODO: TO BE DEPRECATED // we want to use clickhouse-proxy instead -class RetriableError extends Error {} -class FatalError extends Error {} class TimeoutError extends Error {} -const EventStreamContentType = 'text/event-stream'; async function* streamToAsyncIterator( stream: ReadableStream, @@ -274,6 +297,7 @@ export function useRRWebEventStream( onEvent, onEnd, resultsKey, + getSessionSourceFieldExpression, }: { serviceName: string; sessionId: string; @@ -284,13 +308,13 @@ export function useRRWebEventStream( onEvent?: (event: any) => void; onEnd?: (error?: any) => void; resultsKey?: string; + getSessionSourceFieldExpression: FieldExpressionGenerator; }, - options?: UseQueryOptions & { + options?: { + keepPreviousData?: boolean; shouldAbortPendingRequest?: boolean; }, ) { - // FIXME: keepPreviousData type - // @ts-ignore const keepPreviousData = options?.keepPreviousData ?? false; const shouldAbortPendingRequest = options?.shouldAbortPendingRequest ?? true; const metadata = useMetadataWithSettings(); @@ -348,11 +372,11 @@ export function useRRWebEventStream( alias: 't', }, { - valueExpression: `${SESSION_TABLE_EXPRESSIONS.eventAttributesExpression}['rr-web.chunk']`, + valueExpression: `${getSessionSourceFieldExpression(SESSION_TABLE_EXPRESSIONS.eventAttributesExpression, 'rr-web.chunk')}`, alias: 'ck', }, { - valueExpression: `${SESSION_TABLE_EXPRESSIONS.eventAttributesExpression}['rr-web.total-chunks']`, + valueExpression: `${getSessionSourceFieldExpression(SESSION_TABLE_EXPRESSIONS.eventAttributesExpression, 'rr-web.total-chunks')}`, alias: 'tcks', }, ], @@ -363,12 +387,11 @@ export function useRRWebEventStream( from: source.from, whereLanguage: 'lucene', where: `ServiceName:"${serviceName}" AND ${SESSION_TABLE_EXPRESSIONS.resourceAttributesExpression}.rum.sessionId:"${sessionId}"`, - timestampValueExpression: - SESSION_TABLE_EXPRESSIONS.timestampValueExpression, + timestampValueExpression: source.timestampValueExpression, implicitColumnExpression: SESSION_TABLE_EXPRESSIONS.implicitColumnExpression, connection: source.connection, - orderBy: `${SESSION_TABLE_EXPRESSIONS.timestampValueExpression} ASC`, + orderBy: `${source.timestampValueExpression} ASC`, limit: { limit: Math.min(MAX_LIMIT, parseInt(queryLimit)), offset: parseInt(offset), @@ -469,6 +492,7 @@ export function useRRWebEventStream( onEnd, resultsKey, metadata, + getSessionSourceFieldExpression, ], ); diff --git a/packages/app/src/source.ts b/packages/app/src/source.ts index a8a15f89..0c6b2548 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -30,7 +30,6 @@ import { import { hdxServer } from '@/api'; import { HDX_LOCAL_DEFAULT_SOURCES } from '@/config'; import { IS_LOCAL_MODE } from '@/config'; -import { getMetadata } from '@/metadata'; import { parseJSON } from '@/utils'; // Columns for the sessions table as of OTEL Collector v0.129.1 @@ -41,6 +40,11 @@ export const SESSION_TABLE_EXPRESSIONS = { implicitColumnExpression: 'Body', } as const; +export const JSON_SESSION_TABLE_EXPRESSIONS = { + ...SESSION_TABLE_EXPRESSIONS, + timestampValueExpression: 'Timestamp', +} as const; + const LOCAL_STORE_SOUCES_KEY = 'hdx-local-source'; function setLocalSources(fn: (prev: TSource[]) => TSource[]) { @@ -96,10 +100,11 @@ export function getEventBody(eventModel: TSource) { function addDefaultsToSource(source: TSourceUnion): TSource { return { ...source, - // Session sources have hard-coded timestampValueExpressions + // Session sources have optional timestampValueExpressions, with default timestampValueExpression: source.kind === SourceKind.Session - ? SESSION_TABLE_EXPRESSIONS.timestampValueExpression + ? source.timestampValueExpression || + SESSION_TABLE_EXPRESSIONS.timestampValueExpression : source.timestampValueExpression, }; } @@ -440,8 +445,6 @@ export async function isValidMetricTable({ return hasAllColumns(columns, ReqMetricTableColumns[metricType]); } -const ReqSessionsTableColumns = Object.values(SESSION_TABLE_EXPRESSIONS); - export async function isValidSessionsTable({ databaseName, tableName, @@ -463,5 +466,8 @@ export async function isValidSessionsTable({ connectionId, }); - return hasAllColumns(columns, ReqSessionsTableColumns); + return ( + hasAllColumns(columns, Object.values(SESSION_TABLE_EXPRESSIONS)) || + hasAllColumns(columns, Object.values(JSON_SESSION_TABLE_EXPRESSIONS)) + ); } diff --git a/packages/app/src/utils/sessions.ts b/packages/app/src/utils/sessions.ts new file mode 100644 index 00000000..ad389570 --- /dev/null +++ b/packages/app/src/utils/sessions.ts @@ -0,0 +1,23 @@ +import z from 'zod'; + +export const sessionRowSchema = z.object({ + body: z.string().nullish(), + component: z.string().nullish(), + durationInMs: z.number(), + 'error.message': z.string().nullish(), + 'http.method': z.string().nullish(), + 'http.status_code': z.string().nullish(), + 'http.url': z.string().nullish(), + id: z.string(), + 'location.href': z.string().nullish(), + 'otel.library.name': z.string(), + parent_span_id: z.string(), + severity_text: z.string(), + span_id: z.string(), + span_name: z.string(), + timestamp: z.string(), + trace_id: z.string(), + type: z.string().nullish(), +}); + +export type SessionRow = z.infer; diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 8e817f92..2712811d 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -735,6 +735,10 @@ const TraceSourceAugmentation = { const SessionSourceAugmentation = { kind: z.literal(SourceKind.Session), + // Optional to support legacy sources, which did not require this field. + // Will be defaulted to `TimestampTime` when queried, if undefined. + timestampValueExpression: z.string().optional(), + // Required fields for sessions traceSourceId: z .string({ message: 'Correlated Trace Source is required' })