feat: Support JSON Sessions (#1628)

This commit is contained in:
Drew Davis 2026-01-21 19:25:39 -05:00 committed by GitHub
parent db845604a2
commit 1cf8cebb4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 681 additions and 383 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---
feat: Support JSON Sessions

View file

@ -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

View file

@ -1,11 +0,0 @@
service:
pipelines:
# ignore rrweb events
logs/out-rrweb:
exporters:
- nop
processors:
- memory_limiter
- batch
receivers:
- routing/logs

View file

@ -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<HTMLDivElement>(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;
}

View file

@ -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<PlaybarMarker[]>(() => {
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,
};

View file

@ -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<string, string>; // 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]);

View file

@ -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<ChartConfigWithOptDateRange>(
() => ({
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<string, string>,
);
}, [select]);
return {
eventsConfig,
aliasMap,
};
}
export default function SessionSubpanel({
traceSource,
sessionSource,
@ -167,9 +354,7 @@ export default function SessionSubpanel({
);
// Event Filter Input =========================
const inputRef = useRef<HTMLTextAreaElement>(null);
const [_inputQuery, setInputQuery] = useState<string | undefined>(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<ChartConfigWithOptDateRange>(
() => ({
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<string, string>,
);
}, [commonSelect]);
const sessionEventListConfig = useMemo<ChartConfigWithOptDateRange>(
() => ({
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({
</Group>
</div>
<SessionEventList
eventsFollowPlayerPosition={eventsFollowPlayerPosition}
aliasMap={aliasMap}
queriedConfig={sessionEventListConfig}
onClick={useCallback(
(rowWhere: RowWhereResult) => {
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 && (
<SessionEventList
eventsFollowPlayerPosition={eventsFollowPlayerPosition}
aliasMap={aliasMap}
queriedConfig={eventsConfig}
onClick={useCallback(
(rowWhere: RowWhereResult) => {
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}
/>
)}
</div>
<div className={styles.player}>
<DOMPlayer
playerState={playerState}
setPlayerState={setPlayerState}
focus={focus}
setPlayerTime={useCallback(
ts => {
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}`}
/>
<div className={styles.playerPlaybar}>
<MemoPlaybar
{getSessionSourceFieldExpression && (
<DOMPlayer
playerState={playerState}
setPlayerState={setPlayerState}
focus={focus}
setFocus={setFocus}
playbackRange={playbackRange}
queriedConfig={playBarEventsConfig}
setPlayerTime={useCallback(
ts => {
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}
/>
)}
<div className={styles.playerPlaybar}>
{eventsConfig && (
<MemoPlaybar
playerState={playerState}
setPlayerState={setPlayerState}
focus={focus}
setFocus={setFocus}
playbackRange={playbackRange}
queriedConfig={eventsConfig}
/>
)}
</div>
<div className={styles.playerToolbar}>
<div className={styles.playerTimestamp}>

View file

@ -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

View file

@ -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) {
>
<SourceSelectControlled control={control} name="traceSourceId" />
</FormRow>
<FormRow
label={'Timestamp Column'}
helpText="DateTime column or expression that is part of your table's primary key."
>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="timestampValueExpression"
disableKeywordAutocomplete
/>
</FormRow>
</Stack>
</>
);

View file

@ -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']");
});
});

View file

@ -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,
};
}
}

View file

@ -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<UseQueryOptions<any, Error>, '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<T = any>(
stream: ReadableStream<T>,
@ -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<any, Error> & {
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,
],
);

View file

@ -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))
);
}

View file

@ -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<typeof sessionRowSchema>;

View file

@ -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' })