mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Support JSON Sessions (#1628)
This commit is contained in:
parent
db845604a2
commit
1cf8cebb4b
15 changed files with 681 additions and 383 deletions
6
.changeset/breezy-cougars-shave.md
Normal file
6
.changeset/breezy-cougars-shave.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Support JSON Sessions
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
service:
|
||||
pipelines:
|
||||
# ignore rrweb events
|
||||
logs/out-rrweb:
|
||||
exporters:
|
||||
- nop
|
||||
processors:
|
||||
- memory_limiter
|
||||
- batch
|
||||
receivers:
|
||||
- routing/logs
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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']");
|
||||
});
|
||||
});
|
||||
47
packages/app/src/hooks/useFieldExpressionGenerator.tsx
Normal file
47
packages/app/src/hooks/useFieldExpressionGenerator.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
23
packages/app/src/utils/sessions.ts
Normal file
23
packages/app/src/utils/sessions.ts
Normal 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>;
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
Loading…
Reference in a new issue