diff --git a/.changeset/session-replay-otel-session-id.md b/.changeset/session-replay-otel-session-id.md new file mode 100644 index 0000000000..ab6f75f5c9 --- /dev/null +++ b/.changeset/session-replay-otel-session-id.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +feat: List and play session replays from OpenTelemetry browser SDKs that send `session.id` diff --git a/packages/app/src/DOMPlayer.tsx b/packages/app/src/DOMPlayer.tsx index ba6f4e07a2..a711b5e300 100644 --- a/packages/app/src/DOMPlayer.tsx +++ b/packages/app/src/DOMPlayer.tsx @@ -35,6 +35,7 @@ import { createRrwebChunkAssembler, RRWebStreamRow, } from '@/utils/rrwebChunkAssembler'; +import type { SessionIdKey } from '@/utils/sessionId'; import { FieldExpressionGenerator } from './hooks/useFieldExpressionGenerator'; @@ -101,7 +102,7 @@ const URLHoverCard = memo(({ url }: { url: string }) => { }); export default function DOMPlayer({ - config: { dateRange, serviceName, sessionId, sourceId }, + config: { dateRange, serviceName, sessionId, sessionIdKey, sourceId }, focus, setPlayerTime, playerState, @@ -119,6 +120,7 @@ export default function DOMPlayer({ dateRange: [Date, Date]; serviceName: string; sessionId: string; + sessionIdKey?: SessionIdKey; sourceId: string; }; focus: { ts: number; setBy: string } | undefined; @@ -247,6 +249,7 @@ export default function DOMPlayer({ { serviceName, sessionId, + sessionIdKey, sourceId, startDate: dateRange[0], endDate: dateRange[1], diff --git a/packages/app/src/Playbar.tsx b/packages/app/src/Playbar.tsx index b41d040848..d84694c7c2 100644 --- a/packages/app/src/Playbar.tsx +++ b/packages/app/src/Playbar.tsx @@ -5,7 +5,11 @@ import { keepPreviousData } from '@tanstack/react-query'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; -import { SessionRow, sessionRowSchema } from './utils/sessions'; +import { + getSessionRowHttp, + SessionRow, + sessionRowSchema, +} from './utils/sessions'; import type { PlaybarMarker } from './PlaybarSlider'; import { PlaybarSlider } from './PlaybarSlider'; import { getShortUrl } from './utils'; @@ -71,9 +75,7 @@ export default function Playbar({ const errorMessage = event['error.message'] ?? ''; - const url = event['http.url'] ?? ''; - const statusCode = Number(event['http.status_code'] ?? ''); - const method = event['http.method'] ?? ''; + const { url, statusCode, label } = getSessionRowHttp(event); const shortUrl = getShortUrl(url); const isNavigation = @@ -96,7 +98,7 @@ export default function Playbar({ description: isNavigation ? `Navigated to ${shortLocationHref}` : url.length > 0 - ? `${statusCode} ${method}${url.length > 0 ? ` ${shortUrl}` : ''}` + ? `${label} ${shortUrl}` : errorMessage != null && errorMessage.length > 0 ? errorMessage : spanName === 'intercom.onShow' diff --git a/packages/app/src/SessionEventList.tsx b/packages/app/src/SessionEventList.tsx index 90089def9c..95ecd626a0 100644 --- a/packages/app/src/SessionEventList.tsx +++ b/packages/app/src/SessionEventList.tsx @@ -16,7 +16,11 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import useRowWhere, { RowWhereResult } from '@/hooks/useRowWhere'; import { useQueriedChartConfig } from './hooks/useChartConfig'; -import { SessionRow, sessionRowSchema } from './utils/sessions'; +import { + getSessionRowHttp, + SessionRow, + sessionRowSchema, +} from './utils/sessions'; import { useFormatTime } from './useFormatTime'; import { formatmmss, getShortUrl } from './utils'; @@ -135,9 +139,7 @@ export const SessionEventList = ({ const startOffset = new Date(timestamp).getTime(); const endOffset = new Date(startOffset).getTime() + durationInMs; - const url = event['http.url'] ?? ''; - const statusCode = Number(event['http.status_code'] ?? ''); - const method = event['http.method'] ?? ''; + const { url, method, statusCode, label } = getSessionRowHttp(event); const shortUrl = getShortUrl(url); const isNetworkRequest = @@ -186,7 +188,7 @@ export const SessionEventList = ({ : spanName === 'console.warn' ? 'console.warn' : url.length > 0 - ? `${statusCode} ${method}` + ? label : spanName === 'intercom.onShow' ? 'Intercom Chat Opened' : isCustomEvent diff --git a/packages/app/src/SessionSubpanel.tsx b/packages/app/src/SessionSubpanel.tsx index 9a513375ba..00ab6f9db3 100644 --- a/packages/app/src/SessionSubpanel.tsx +++ b/packages/app/src/SessionSubpanel.tsx @@ -31,6 +31,8 @@ import { } from '@tabler/icons-react'; import { RowWhereResult, WithClause } from '@/hooks/useRowWhere'; +import type { SessionIdKey } from '@/utils/sessionId'; +import { buildSessionEventFilters } from '@/utils/sessions'; import SearchWhereInput from './components/SearchInput/SearchWhereInput'; import useFieldExpressionGenerator from './hooks/useFieldExpressionGenerator'; @@ -47,6 +49,7 @@ const MemoPlaybar = memo(Playbar); function useSessionChartConfigs({ traceSource, rumSessionId, + sessionIdKey, where, whereLanguage, start, @@ -55,6 +58,7 @@ function useSessionChartConfigs({ }: { traceSource: TTraceSource; rumSessionId: string; + sessionIdKey?: SessionIdKey; where: string; whereLanguage?: SearchConditionLanguage; start: Date; @@ -96,6 +100,15 @@ function useSessionChartConfigs({ valueExpression: `${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', 'http.url')}`, alias: 'http.url', }, + ...['url.full', 'http.request.method', 'http.response.status_code'].map( + key => ({ + valueExpression: getTraceSourceFieldExpression( + traceSource.eventAttributesExpression ?? 'SpanAttributes', + key, + ), + alias: key, + }), + ), { // Using toString here because Javascript does not have the precision to accurately represent this valueExpression: `toString(cityHash64(${traceSource.traceIdExpression}, ${traceSource.parentSpanIdExpression}, ${traceSource.spanIdExpression}))`, @@ -140,43 +153,18 @@ function useSessionChartConfigs({ ]; }, [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 eventFilters = useMemo( + () => + buildSessionEventFilters({ + traceSource, + sessionId: rumSessionId, + sessionIdKey, + }), + [traceSource, rumSessionId, sessionIdKey], ); - const allEventsFilter = useMemo(() => { - if (!getTraceSourceFieldExpression) return undefined; - return { - 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, getTraceSourceFieldExpression]); - const eventsConfig = useMemo(() => { - if (!getTraceSourceFieldExpression || !select || !allEventsFilter) - return undefined; + if (!getTraceSourceFieldExpression || !select) return undefined; return { select: select, from: traceSource.from, @@ -193,9 +181,7 @@ function useSessionChartConfigs({ limit: 4000, offset: 0, }, - filters: [ - tab === 'highlighted' ? highlightedEventsFilter : allEventsFilter, - ], + filters: eventFilters[tab === 'highlighted' ? 'highlighted' : 'all'], }; }, [ select, @@ -205,8 +191,7 @@ function useSessionChartConfigs({ where, whereLanguage, tab, - highlightedEventsFilter, - allEventsFilter, + eventFilters, getTraceSourceFieldExpression, ]); @@ -242,7 +227,7 @@ export default function SessionSubpanel({ }: { traceSource: TTraceSource; sessionSource: TSessionSource; - session: { serviceName: string }; + session: { serviceName: string; sessionIdKey?: SessionIdKey }; rumSessionId: string; start: Date; end: Date; @@ -373,6 +358,7 @@ export default function SessionSubpanel({ const { eventsConfig, aliasMap } = useSessionChartConfigs({ traceSource, rumSessionId, + sessionIdKey: session.sessionIdKey, where: searchedQuery, whereLanguage, start, @@ -519,6 +505,7 @@ export default function SessionSubpanel({ serviceName: session.serviceName, sourceId: sessionSource.id, sessionId: rumSessionId, + sessionIdKey: session.sessionIdKey, dateRange: [start, end], }} playerSpeed={playerSpeed} diff --git a/packages/app/src/__tests__/sessions.test.ts b/packages/app/src/__tests__/sessions.test.ts index fcb2a9738e..6a0c98a81e 100644 --- a/packages/app/src/__tests__/sessions.test.ts +++ b/packages/app/src/__tests__/sessions.test.ts @@ -71,6 +71,23 @@ describe('buildRRWebStreamChartConfig', () => { expect(config.dateRange).toEqual([new Date(1000), new Date(2000)]); expect(config.limit).toEqual({ limit: 1000000, offset: 0 }); }); + + it('looks up OTel sessions by session.id', () => { + const otelConfig = buildRRWebStreamChartConfig({ + source: config, + serviceName: 'my-service', + sessionId: 'session-1', + sessionIdKey: 'session.id', + startDate: new Date(1000), + endDate: new Date(2000), + limit: 1000000, + offset: 0, + getSessionSourceFieldExpression, + }); + expect(otelConfig.where).toBe( + 'ServiceName:"my-service" AND LogAttributes.session.id:"session-1"', + ); + }); }); describe('fetchServiceScopeFilters', () => { diff --git a/packages/app/src/__tests__/sessionsListQuery.test.tsx b/packages/app/src/__tests__/sessionsListQuery.test.tsx new file mode 100644 index 0000000000..8665f41449 --- /dev/null +++ b/packages/app/src/__tests__/sessionsListQuery.test.tsx @@ -0,0 +1,142 @@ +import { ReactNode } from 'react'; +import { + SourceKind, + TSessionSource, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useSessions } from '@/sessions'; + +jest.mock('@hyperdx/common-utils/dist/core/renderChartConfig', () => ({ + renderChartConfig: jest.fn(async (config: unknown) => ({ + sql: JSON.stringify(config), + params: {}, + })), +})); + +const mockQuery = jest.fn( + async ({ format }: { query: string; format?: string }) => ({ + json: async () => ({ + data: format === 'JSON' ? [{ serviceName: 'web' }] : [], + }), + }), +); + +jest.mock('@/clickhouse', () => ({ + useClickhouseClient: () => ({ query: mockQuery }), + getClickhouseClient: jest.fn(), +})); + +jest.mock('@/hooks/useMetadata', () => ({ + useMetadataWithSettings: () => ({ + getColumns: async () => [{ name: 'ServiceName' }], + }), +})); + +jest.mock('@/hooks/useFieldExpressionGenerator', () => ({ + __esModule: true, + default: () => ({ + isLoading: false, + getFieldExpression: (column: string, key: string) => `${column}['${key}']`, + }), +})); + +const traceSource: TTraceSource = { + id: 'traces', + name: 'Traces', + kind: SourceKind.Trace, + from: { databaseName: 'default', tableName: 'otel_traces' }, + connection: 'conn', + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp', + durationExpression: 'Duration', + durationPrecision: 9, + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', + parentSpanIdExpression: 'ParentSpanId', + spanNameExpression: 'SpanName', + spanKindExpression: 'SpanKind', + serviceNameExpression: 'ServiceName', +}; + +const sessionSource: TSessionSource = { + id: 'sessions', + name: 'Sessions', + kind: SourceKind.Session, + from: { databaseName: 'default', tableName: 'hyperdx_sessions' }, + connection: 'conn', + timestampValueExpression: 'TimestampTime', + traceSourceId: 'traces', +}; + +const COMBINED_TRACE_KEY = + "if(ResourceAttributes['rum.sessionId'] != '', ResourceAttributes['rum.sessionId'], SpanAttributes['session.id'])"; + +async function renderListSql({ + where, + sessionConnection = 'conn', +}: { + where?: string; + sessionConnection?: string; +}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook( + () => + useSessions({ + traceSource, + sessionSource: { ...sessionSource, connection: sessionConnection }, + dateRange: [new Date(0), new Date(1000)], + where, + whereLanguage: 'lucene', + }), + { wrapper }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const listCall = mockQuery.mock.calls.find(([arg]) => arg.format == null); + return listCall?.[0].query; +} + +describe('useSessions list query', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('merges replay rows into the list when there is no search', async () => { + const sql = await renderListSql({}); + + expect(sql).toContain('_recordings AS ('); + expect(sql).toContain('"tableName":"hyperdx_sessions"'); + expect(sql).toContain(COMBINED_TRACE_KEY); + expect(sql).toContain( + 'ResourceAttributes.rum.sessionId:* OR SpanAttributes.session.id:*', + ); + expect(sql).toContain( + 'HAVING max(fromReplay) = 1 OR interactionCount > 0 OR recordingCount > 0', + ); + expect(sql).toContain('LIMIT 500'); + }); + + it('checks replay membership on both keys when searching', async () => { + const sql = await renderListSql({ where: 'ServiceName:web' }); + + expect(sql).toContain('UNION DISTINCT'); + expect(sql).toContain("DISTINCT LogAttributes['session.id']"); + expect(sql).not.toContain('_recordings'); + expect(sql).not.toContain('HAVING'); + }); + + it('keeps the legacy span-only list when the sources use different connections', async () => { + const sql = await renderListSql({ sessionConnection: 'other' }); + + expect(sql).not.toMatch(/Attributes(\['session\.id'\]|\.session\.id)/); + expect(sql).not.toContain('_recordings'); + expect(sql).toContain('HAVING interactionCount > 0 OR recordingCount > 0'); + }); +}); diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 30e4b718d8..a43536865d 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -727,7 +727,7 @@ export const DBRowSidePanelInner = ({ [parentContext, handleOpenLinkedTrace, source], ); - const { rumSessionId, rumServiceName } = useSessionId({ + const { rumSessionId, rumServiceName, sessionIdKey } = useSessionId({ sourceId: traceSourceId, traceId, dateRange: oneHourRange, @@ -1276,6 +1276,7 @@ export const DBRowSidePanelInner = ({ traceSourceId={traceSourceId} serviceName={rumServiceName} rumSessionId={rumSessionId} + sessionIdKey={sessionIdKey} /> diff --git a/packages/app/src/components/DBSessionPanel.tsx b/packages/app/src/components/DBSessionPanel.tsx index c9d84a20e2..48f9d433ff 100644 --- a/packages/app/src/components/DBSessionPanel.tsx +++ b/packages/app/src/components/DBSessionPanel.tsx @@ -7,9 +7,45 @@ import useFieldExpressionGenerator from '@/hooks/useFieldExpressionGenerator'; import { WithClause } from '@/hooks/useRowWhere'; import SessionSubpanel from '@/SessionSubpanel'; import { useSource } from '@/source'; +import { SessionIdKey, traceSessionIdColumns } from '@/utils/sessionId'; import { useEventsData } from './DBTraceWaterfallChart'; +type SessionIdRow = { + rumSessionId?: string; + otelSessionId?: string; + serviceName?: string; +}; + +/** + * A legacy SDK id anywhere in the trace wins over `session.id` rows, so a trace + * that carries one always opens the legacy replay. + */ +export function pickSessionIdRow(rows: SessionIdRow[]): { + rumSessionId?: string; + rumServiceName?: string; + sessionIdKey?: SessionIdKey; +} { + const rumRow = rows.find(row => row.rumSessionId); + if (rumRow) { + return { + rumSessionId: rumRow.rumSessionId, + rumServiceName: rumRow.serviceName, + sessionIdKey: 'rum.sessionId', + }; + } + + const otelRow = rows.find(row => row.otelSessionId); + if (otelRow) { + return { + rumSessionId: otelRow.otelSessionId, + rumServiceName: otelRow.serviceName, + sessionIdKey: 'session.id', + }; + } + return {}; +} + export const useSessionId = ({ sourceId, traceId, @@ -32,6 +68,7 @@ export const useSessionId = ({ if (!source || !traceId || !getFieldExpression) { return; } + const { attrs } = traceSessionIdColumns(source); return { select: [ { @@ -47,8 +84,10 @@ export const useSessionId = ({ alias: 'serviceName', }, { - valueExpression: `${source.parentSpanIdExpression}`, - alias: 'parentSpanId', + // Only browser SDK spans carry the page URL; other `session.id` + // spans (AI agent conversations, backends) have no replay. + valueExpression: `if(${getFieldExpression(attrs, 'browser.document.url.full')} != '', ${getFieldExpression(attrs, 'session.id')}, '')`, + alias: 'otelSessionId', }, ], from: source.from, @@ -67,40 +106,24 @@ export const useSessionId = ({ enabled: enabled && !!source && !!config, }); - const result = useMemo(() => { - const rowData = data?.data || []; - let row = rowData.find( - row => row.parentSpanId === null && row.rumSessionId, - ); - if (!row) { - // otherwise just return the first session id - row = rowData.find(row => row.rumSessionId); - } - if (row) { - return { - rumServiceName: row.serviceName, - rumSessionId: row.rumSessionId, - }; - } - return { rumServiceName: undefined, rumSessionId: undefined }; - }, [data]); - - return result; + return useMemo(() => pickSessionIdRow(data?.data ?? []), [data]); }; export const DBSessionPanel = ({ traceSourceId, rumSessionId, + sessionIdKey, dateRange, focusDate, serviceName, onEventNavigate, }: { traceSourceId?: string; - rumSessionId: string; + rumSessionId?: string; + sessionIdKey?: SessionIdKey; dateRange: [Date, Date]; focusDate: Date; - serviceName: string; + serviceName?: string; onEventNavigate?: (rowId: string, aliasWith: WithClause[]) => void; }) => { const { data: traceSource } = useSource({ @@ -134,7 +157,7 @@ export const DBSessionPanel = ({ start={dateRange[0]} end={dateRange[1]} traceSource={traceSource} - session={{ serviceName }} + session={{ serviceName: serviceName ?? '', sessionIdKey }} sessionSource={sessionSource} rumSessionId={rumSessionId} onEventNavigate={onEventNavigate} diff --git a/packages/app/src/components/__tests__/DBSessionPanel.test.ts b/packages/app/src/components/__tests__/DBSessionPanel.test.ts new file mode 100644 index 0000000000..00d080b740 --- /dev/null +++ b/packages/app/src/components/__tests__/DBSessionPanel.test.ts @@ -0,0 +1,29 @@ +import { pickSessionIdRow } from '@/components/DBSessionPanel'; + +describe('pickSessionIdRow', () => { + it('prefers a legacy rum.sessionId row anywhere in the trace', () => { + expect( + pickSessionIdRow([ + { rumSessionId: '', otelSessionId: 'otel-1', serviceName: 'api' }, + { rumSessionId: 'rum-1', otelSessionId: '', serviceName: 'web' }, + ]), + ).toEqual({ + rumSessionId: 'rum-1', + rumServiceName: 'web', + sessionIdKey: 'rum.sessionId', + }); + }); + + it('falls back to a browser session.id row', () => { + expect( + pickSessionIdRow([ + { rumSessionId: '', otelSessionId: '', serviceName: 'api' }, + { rumSessionId: '', otelSessionId: 'otel-1', serviceName: 'web' }, + ]), + ).toEqual({ + rumSessionId: 'otel-1', + rumServiceName: 'web', + sessionIdKey: 'session.id', + }); + }); +}); diff --git a/packages/app/src/sessions.ts b/packages/app/src/sessions.ts index 825e2fc036..edc5525869 100644 --- a/packages/app/src/sessions.ts +++ b/packages/app/src/sessions.ts @@ -8,6 +8,7 @@ import { import type { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/browser'; import type { Metadata } from '@hyperdx/common-utils/dist/core/metadata'; import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig'; +import { getFirstTimestampValueExpression } from '@hyperdx/common-utils/dist/core/utils'; import { filtersToQuery } from '@hyperdx/common-utils/dist/filters'; import { DateRange, @@ -22,12 +23,29 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'; import { useQueryAttribution } from '@/queryAttribution'; import { usePrevious } from '@/utils'; +import { + anySessionIdPresence, + rumSessionIdPresence, + sessionIdFilter, + SessionIdKey, + sessionIdKeyExpr, + sessionTableSessionIdColumns, + traceSessionIdColumns, +} from '@/utils/sessionId'; import useFieldExpressionGenerator, { FieldExpressionGenerator, } from './hooks/useFieldExpressionGenerator'; import { useMetadataWithSettings } from './hooks/useMetadata'; import { getClickhouseClient, useClickhouseClient } from './clickhouse'; +import { makeCoalescedFieldsAccessQuery } from './serviceDashboard'; +import { + buildOtelSessionIdsWithRecordingsChartConfig, + buildRecordingsChartConfig, + buildSessionsCtes, + isOtelSessionListEnabled, + unionSessionIds, +} from './sessionsListQuery'; import { SESSION_TABLE_EXPRESSIONS, useSource } from './source'; export type Session = { @@ -39,6 +57,8 @@ export type Session = { serviceName: string; sessionCount: string; sessionId: string; + /** Which attribute holds `sessionId`, for single-key lookups. */ + sessionIdKey: SessionIdKey; teamId: string; teamName: string; userEmail: string; @@ -163,7 +183,12 @@ export function useSessions( isLoading: isLoadingSessionsExpressionGenerator, } = useFieldExpressionGenerator(sessionSource); - const FIXED_SDK_ATTRIBUTES = ['teamId', 'teamName', 'userEmail', 'userName']; + const FIXED_SDK_ATTRIBUTES = ['teamId', 'teamName']; + // OTel semconv keys first, then the legacy SDK's + const USER_ATTRIBUTES = { + userEmail: ['user.email', 'userEmail'], + userName: ['user.name', 'userName'], + }; const SESSIONS_CTE_NAME = 'sessions'; const clickhouseClient = useClickhouseClient(); const metadata = useMetadataWithSettings(); @@ -186,10 +211,7 @@ export function useSessions( return []; } - const traceSessionIdExpression = getTraceSourceFieldExpression( - traceSource.resourceAttributesExpression ?? 'ResourceAttributes', - 'rum.sessionId', - ); + const traceSessionIdCols = traceSessionIdColumns(traceSource); const serviceScopeFilters = await fetchServiceScopeFilters({ traceSource, @@ -199,6 +221,19 @@ export function useSessions( clickhouseClient, }); + const includeOtelSessions = isOtelSessionListEnabled({ + traceSource, + sessionSource, + serviceScopeFilters, + }); + + const traceSessionIdExpression = includeOtelSessions + ? sessionIdKeyExpr(getTraceSourceFieldExpression, traceSessionIdCols) + : getTraceSourceFieldExpression( + traceSessionIdCols.resource, + 'rum.sessionId', + ); + const sessionsQueryFilters = buildSessionsQueryFilters( serviceScopeFilters, where, @@ -209,6 +244,8 @@ export function useSessions( sessionsQuery, sessionIdsWithRecordingsQuery, sessionIdsWithUserActivityQuery, + recordingsQuery, + otelSessionIdsWithRecordingsQuery, ] = await Promise.all([ renderChartConfig( { @@ -224,11 +261,11 @@ export function useSessions( // TODO: can't use aggFn max/min here for string value field { alias: 'maxTimestamp', - valueExpression: `MAX(${traceSource.timestampValueExpression})`, + valueExpression: `MAX(${getFirstTimestampValueExpression(traceSource.timestampValueExpression)})`, }, { alias: 'minTimestamp', - valueExpression: `MIN(${traceSource.timestampValueExpression})`, + valueExpression: `MIN(${getFirstTimestampValueExpression(traceSource.timestampValueExpression)})`, }, { aggFn: 'count', @@ -256,14 +293,37 @@ export function useSessions( valueExpression: '', alias: 'recordingCount', }, + { + aggFn: 'count', + aggConditionLanguage: 'lucene', + aggCondition: rumSessionIdPresence(traceSessionIdCols), + valueExpression: '', + alias: 'rumRows', + }, ...FIXED_SDK_ATTRIBUTES.map(attr => ({ valueExpression: `MAX(${getTraceSourceFieldExpression(traceSource.eventAttributesExpression ?? 'SpanAttributes', attr)})`, alias: attr, })), + ...Object.entries(USER_ATTRIBUTES).map(([alias, keys]) => ({ + // The non-JSON form also works on JSON columns, since the + // generator already wraps JSON paths in toString(). + valueExpression: `MAX(${makeCoalescedFieldsAccessQuery( + keys.map(key => + getTraceSourceFieldExpression( + traceSource.eventAttributesExpression ?? 'SpanAttributes', + key, + ), + ), + false, + )})`, + alias, + })), ], from: traceSource.from, dateRange, - where: `${traceSource.resourceAttributesExpression}.rum.sessionId:*`, + where: includeOtelSessions + ? anySessionIdPresence(traceSessionIdCols) + : rumSessionIdPresence(traceSessionIdCols), whereLanguage: 'lucene', ...(sessionsQueryFilters.length > 0 && { filters: sessionsQueryFilters, @@ -274,7 +334,11 @@ export function useSessions( traceSource.useTextIndexForImplicitColumn, ...pickSampleWeightExpressionProps(traceSource), connection: traceSource.connection, - groupBy: 'serviceName, sessionId', + groupBy: [ + { valueExpression: 'serviceName' }, + { valueExpression: 'sessionId' }, + ], + selectGroupBy: false, }, metadata, traceSource.querySettings, @@ -327,28 +391,50 @@ export function useSessions( metadata, traceSource.querySettings, ), + includeOtelSessions && !where + ? renderChartConfig( + buildRecordingsChartConfig({ + sessionSource, + dateRange, + getSessionSourceFieldExpression: + getSessionsSourceFieldExpression, + }), + metadata, + sessionSource.querySettings, + ) + : undefined, + includeOtelSessions && where + ? renderChartConfig( + buildOtelSessionIdsWithRecordingsChartConfig({ + sessionSource, + dateRange, + getSessionSourceFieldExpression: + getSessionsSourceFieldExpression, + sessionsCteName: SESSIONS_CTE_NAME, + }), + metadata, + sessionSource.querySettings, + ) + : undefined, ]); - const sessionsCTE = chSql` - WITH _${SESSIONS_CTE_NAME} AS (${sessionsQuery}), - ${SESSIONS_CTE_NAME} AS ( - SELECT * - FROM _${SESSIONS_CTE_NAME} - ${ - // If the user is giving us an explicit query, we don't need to filter out sessions with no interactions - // this is because the events that match the query might not be user interactions, and we'll just show 0 results otherwise. - where ? '' : 'HAVING interactionCount > 0 OR recordingCount > 0' - } - ORDER BY maxTimestamp DESC - LIMIT 500 - ) - `; + const sessionsCTE = buildSessionsCtes({ + name: SESSIONS_CTE_NAME, + spans: sessionsQuery, + recordings: recordingsQuery, + // If the user is giving us an explicit query, we don't need to filter out sessions with no interactions + // this is because the events that match the query might not be user interactions, and we'll just show 0 results otherwise. + withActivityFilter: !where, + }); const finalQuery = where && where.length > 0 ? chSql` ${sessionsCTE}, - sessionIdsWithRecordings AS (${sessionIdsWithRecordingsQuery}), + sessionIdsWithRecordings AS (${unionSessionIds( + sessionIdsWithRecordingsQuery, + otelSessionIdsWithRecordingsQuery, + )}), sessionIdsWithUserActivity AS (${sessionIdsWithUserActivityQuery}) SELECT * FROM ${SESSIONS_CTE_NAME} @@ -357,11 +443,13 @@ export function useSessions( ) OR ${SESSIONS_CTE_NAME}.sessionId IN ( SELECT sessionIdsWithUserActivity.sessionId FROM sessionIdsWithUserActivity ) + ORDER BY maxTimestamp DESC ` : chSql` ${sessionsCTE} SELECT * FROM ${SESSIONS_CTE_NAME} + ORDER BY maxTimestamp DESC `; const json = await clickhouseClient @@ -419,6 +507,7 @@ export function buildRRWebStreamChartConfig({ source, serviceName, sessionId, + sessionIdKey, startDate, endDate, limit, @@ -432,6 +521,7 @@ export function buildRRWebStreamChartConfig({ }; serviceName: string; sessionId: string; + sessionIdKey?: SessionIdKey; startDate: Date; endDate: Date; limit: number; @@ -471,7 +561,7 @@ export function buildRRWebStreamChartConfig({ dateRange: [startDate, endDate] as [Date, Date], from: source.from, whereLanguage: 'lucene' as const, - where: `ServiceName:"${serviceName}" AND ${SESSION_TABLE_EXPRESSIONS.resourceAttributesExpression}.rum.sessionId:"${sessionId}"`, + where: `ServiceName:"${serviceName}" AND ${sessionIdFilter(sessionTableSessionIdColumns(), sessionId, sessionIdKey)}`, timestampValueExpression: source.timestampValueExpression, implicitColumnExpression: SESSION_TABLE_EXPRESSIONS.implicitColumnExpression, @@ -488,6 +578,7 @@ export function useRRWebEventStream( { serviceName, sessionId, + sessionIdKey, sourceId, startDate, endDate, @@ -499,6 +590,7 @@ export function useRRWebEventStream( }: { serviceName: string; sessionId: string; + sessionIdKey?: SessionIdKey; sourceId: string; startDate: Date; endDate: Date; @@ -562,6 +654,7 @@ export function useRRWebEventStream( source, serviceName, sessionId, + sessionIdKey, startDate: new Date(parseInt(startTime)), endDate: new Date(parseInt(endTime)), limit: Math.min(MAX_LIMIT, parseInt(queryLimit)), @@ -663,6 +756,7 @@ export function useRRWebEventStream( source, serviceName, sessionId, + sessionIdKey, startDate, endDate, limit, diff --git a/packages/app/src/sessionsListQuery.ts b/packages/app/src/sessionsListQuery.ts new file mode 100644 index 0000000000..a35d631d1a --- /dev/null +++ b/packages/app/src/sessionsListQuery.ts @@ -0,0 +1,181 @@ +import { ChSql, chSql } from '@hyperdx/common-utils/dist/clickhouse'; +import { getFirstTimestampValueExpression } from '@hyperdx/common-utils/dist/core/utils'; +import { + BuilderChartConfigWithDateRange, + Filter, + TSessionSource, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; + +import type { FieldExpressionGenerator } from './hooks/useFieldExpressionGenerator'; +import { + anySessionIdPresence, + rumSessionIdPresence, + sessionIdKeyExpr, + sessionTableSessionIdColumns, +} from './utils/sessionId'; + +/** + * The service scope keeps the `session.id` check off spans outside the replay + * services and makes the session table's `ServiceName` match the trace service + * names, so both halves group into the same rows. The recordings half also + * needs the trace source's connection. + */ +export function isOtelSessionListEnabled({ + traceSource, + sessionSource, + serviceScopeFilters, +}: { + traceSource: TTraceSource; + sessionSource: TSessionSource; + serviceScopeFilters: Filter[]; +}): boolean { + return ( + traceSource.connection === sessionSource.connection && + serviceScopeFilters.length > 0 + ); +} + +/** Sessions with replay rows, so sessions without matching spans are listed too. */ +export function buildRecordingsChartConfig({ + sessionSource, + dateRange, + getSessionSourceFieldExpression, +}: { + sessionSource: TSessionSource; + dateRange: [Date, Date]; + getSessionSourceFieldExpression: FieldExpressionGenerator; +}): BuilderChartConfigWithDateRange { + const timestamp = getFirstTimestampValueExpression( + sessionSource.timestampValueExpression, + ); + return { + select: [ + { valueExpression: 'ServiceName', alias: 'serviceName' }, + { + valueExpression: sessionIdKeyExpr( + getSessionSourceFieldExpression, + sessionTableSessionIdColumns(), + ), + alias: 'sessionId', + }, + { valueExpression: `MAX(${timestamp})`, alias: 'maxTimestamp' }, + { valueExpression: `MIN(${timestamp})`, alias: 'minTimestamp' }, + { + aggFn: 'count', + aggConditionLanguage: 'lucene', + aggCondition: rumSessionIdPresence(sessionTableSessionIdColumns()), + valueExpression: '', + alias: 'rumRows', + }, + ], + from: sessionSource.from, + dateRange, + where: anySessionIdPresence(sessionTableSessionIdColumns()), + whereLanguage: 'lucene', + timestampValueExpression: sessionSource.timestampValueExpression, + connection: sessionSource.connection, + groupBy: [ + { valueExpression: 'serviceName' }, + { valueExpression: 'sessionId' }, + ], + selectGroupBy: false, + }; +} + +/** `session.id` sessions in the list CTE that have replay rows. */ +export function buildOtelSessionIdsWithRecordingsChartConfig({ + sessionSource, + dateRange, + getSessionSourceFieldExpression, + sessionsCteName, +}: { + sessionSource: TSessionSource; + dateRange: [Date, Date]; + getSessionSourceFieldExpression: FieldExpressionGenerator; + sessionsCteName: string; +}): BuilderChartConfigWithDateRange { + const otelSessionId = getSessionSourceFieldExpression( + sessionTableSessionIdColumns().attrs, + 'session.id', + ); + return { + select: [ + { valueExpression: `DISTINCT ${otelSessionId}`, alias: 'sessionId' }, + ], + from: sessionSource.from, + dateRange, + where: `${otelSessionId} IN (SELECT ${sessionsCteName}.sessionId FROM ${sessionsCteName})`, + whereLanguage: 'sql', + timestampValueExpression: sessionSource.timestampValueExpression, + connection: sessionSource.connection, + }; +} + +/** + * Two single-key `IN` lookups stay index-served where one OR lookup wouldn't. + * This only tests membership, so a session with both keys can't count twice. + */ +export function unionSessionIds(rumIds: ChSql, otelIds: ChSql | undefined) { + return otelIds == null + ? rumIds + : chSql`SELECT sessionId FROM (${rumIds}) UNION DISTINCT SELECT sessionId FROM (${otelIds})`; +} + +/** + * Merges the span and recordings halves into one row per (service, session). + * Legacy sessions with spans keep span-only times; others use both halves. + * Inner aliases differ from outer ones because reusing them raises + * ILLEGAL_AGGREGATION. + */ +export function buildSessionsCtes({ + name, + spans, + recordings, + withActivityFilter, +}: { + name: string; + spans: ChSql; + recordings: ChSql | undefined; + withActivityFilter: boolean; +}): ChSql { + const recordingsUnion = + recordings != null + ? `UNION ALL + SELECT serviceName AS svc, sessionId AS sid, maxTimestamp AS maxTs, + minTimestamp AS minTs, 0 AS spans, 0 AS errors, 0 AS interactions, + 0 AS recordInits, rumRows, '' AS email, '' AS name, '' AS tid, + '' AS tname, 1 AS fromReplay + FROM _recordings` + : ''; + const having = withActivityFilter + ? `HAVING ${recordings != null ? 'max(fromReplay) = 1 OR ' : ''}interactionCount > 0 OR recordingCount > 0` + : ''; + return chSql` + WITH _spans AS (${spans}), + ${recordings != null ? chSql`_recordings AS (${recordings}),` : ''} + ${name} AS ( + SELECT svc AS serviceName, sid AS sessionId, + if(sum(rumRows) > 0, 'rum.sessionId', 'session.id') AS sessionIdKey, + if(sum(rumRows) > 0 AND sum(spans) > 0, maxIf(maxTs, fromReplay = 0), max(maxTs)) AS maxTimestamp, + if(sum(rumRows) > 0 AND sum(spans) > 0, minIf(minTs, fromReplay = 0), min(minTs)) AS minTimestamp, + sum(spans) AS sessionCount, sum(errors) AS errorCount, + sum(interactions) AS interactionCount, sum(recordInits) AS recordingCount, + ifNull(max(email), '') AS userEmail, ifNull(max(name), '') AS userName, + max(tid) AS teamId, max(tname) AS teamName + FROM ( + SELECT serviceName AS svc, sessionId AS sid, maxTimestamp AS maxTs, + minTimestamp AS minTs, sessionCount AS spans, errorCount AS errors, + interactionCount AS interactions, recordingCount AS recordInits, + rumRows, userEmail AS email, userName AS name, teamId AS tid, + teamName AS tname, 0 AS fromReplay + FROM _spans + ${recordingsUnion} + ) + GROUP BY svc, sid + ${having} + ORDER BY maxTimestamp DESC + LIMIT 500 + ) + `; +} diff --git a/packages/app/src/utils/__tests__/sessions.test.ts b/packages/app/src/utils/__tests__/sessions.test.ts new file mode 100644 index 0000000000..814b430be5 --- /dev/null +++ b/packages/app/src/utils/__tests__/sessions.test.ts @@ -0,0 +1,35 @@ +import { buildSessionEventFilters } from '@/utils/sessions'; + +const traceSource = { spanNameExpression: 'SpanName' }; + +describe('buildSessionEventFilters', () => { + const filters = buildSessionEventFilters({ + traceSource, + sessionId: 'abc', + sessionIdKey: 'session.id', + }); + + it('highlights failed requests from either HTTP key set', () => { + expect(filters.highlighted[0]).toEqual({ + type: 'lucene', + condition: 'SpanAttributes.session.id:"abc"', + }); + expect(filters.highlighted[1].condition).toContain( + 'SpanAttributes.http.status_code:>299', + ); + expect(filters.highlighted[1].condition).toContain( + 'SpanAttributes.http.response.status_code:>299', + ); + expect(filters.highlighted[1].condition).toContain( + 'SpanAttributes.error.type:*', + ); + }); + + it('lists every request, including ones without a status code', () => { + expect(filters.all[0]).toEqual(filters.highlighted[0]); + expect(filters.all[1].condition).toContain( + 'SpanAttributes.http.status_code:*', + ); + expect(filters.all[1].condition).toContain('SpanAttributes.url.full:*'); + }); +}); diff --git a/packages/app/src/utils/sessionId.ts b/packages/app/src/utils/sessionId.ts new file mode 100644 index 0000000000..32f6e4b628 --- /dev/null +++ b/packages/app/src/utils/sessionId.ts @@ -0,0 +1,68 @@ +import type { TTraceSource } from '@hyperdx/common-utils/dist/types'; + +import type { FieldExpressionGenerator } from '@/hooks/useFieldExpressionGenerator'; +import { SESSION_TABLE_EXPRESSIONS } from '@/source'; + +/** `rum.sessionId` is the legacy HyperDX SDK's key, `session.id` is OTel's. */ +export type SessionIdKey = 'rum.sessionId' | 'session.id'; + +type SessionIdColumns = { resource: string; attrs: string }; + +// The session source has no event attributes setting, the player already +// hardcodes these columns, and isValidSessionsTable requires both, so a bad +// custom resource expression can't break the list or the player. Read on call: +// test suites that stub '@/source' import this module indirectly. +export function sessionTableSessionIdColumns(): SessionIdColumns { + return { + resource: SESSION_TABLE_EXPRESSIONS.resourceAttributesExpression, + attrs: SESSION_TABLE_EXPRESSIONS.eventAttributesExpression, + }; +} + +export function traceSessionIdColumns( + source: Pick< + TTraceSource, + 'resourceAttributesExpression' | 'eventAttributesExpression' + >, +): SessionIdColumns { + return { + resource: source.resourceAttributesExpression ?? 'ResourceAttributes', + attrs: source.eventAttributesExpression ?? 'SpanAttributes', + }; +} + +/** + * One key per row for grouping. `rum.sessionId` wins because legacy SDK spans + * can carry an app's own `session.id` while their replay rows carry only + * `rum.sessionId`; rows where the collector copied one key to the other hold + * equal values. + */ +export function sessionIdKeyExpr( + getFieldExpression: FieldExpressionGenerator, + cols: SessionIdColumns, +): string { + const rumSessionId = getFieldExpression(cols.resource, 'rum.sessionId'); + return `if(${rumSessionId} != '', ${rumSessionId}, ${getFieldExpression(cols.attrs, 'session.id')})`; +} + +/** + * A lookup on the one key the session uses. An OR of both keys can't use skip + * indexes before ClickHouse 25.12 (`use_skip_indexes_for_disjunctions`). + */ +export function sessionIdFilter( + cols: SessionIdColumns, + sessionId: string, + key: SessionIdKey = 'rum.sessionId', +): string { + return key === 'session.id' + ? `${cols.attrs}.session.id:"${sessionId}"` + : `${cols.resource}.rum.sessionId:"${sessionId}"`; +} + +export function rumSessionIdPresence(cols: SessionIdColumns): string { + return `${cols.resource}.rum.sessionId:*`; +} + +export function anySessionIdPresence(cols: SessionIdColumns): string { + return `${rumSessionIdPresence(cols)} OR ${cols.attrs}.session.id:*`; +} diff --git a/packages/app/src/utils/sessions.ts b/packages/app/src/utils/sessions.ts index ad3895701f..8ce263d70b 100644 --- a/packages/app/src/utils/sessions.ts +++ b/packages/app/src/utils/sessions.ts @@ -1,4 +1,17 @@ import z from 'zod'; +import type { + ConditionFilter, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; + +import { getHttpMethod, getHttpStatusCode } from './httpSpanAttributes'; +import { getHttpUrl } from './httpUrl'; +import { + sessionIdFilter, + SessionIdKey, + traceSessionIdColumns, +} from './sessionId'; +import { isSet } from './spanAttributeReaders'; export const sessionRowSchema = z.object({ body: z.string().nullish(), @@ -6,6 +19,8 @@ export const sessionRowSchema = z.object({ durationInMs: z.number(), 'error.message': z.string().nullish(), 'http.method': z.string().nullish(), + 'http.request.method': z.string().nullish(), + 'http.response.status_code': z.string().nullish(), 'http.status_code': z.string().nullish(), 'http.url': z.string().nullish(), id: z.string(), @@ -18,6 +33,72 @@ export const sessionRowSchema = z.object({ timestamp: z.string(), trace_id: z.string(), type: z.string().nullish(), + 'url.full': z.string().nullish(), }); export type SessionRow = z.infer; + +/** Reads both the stable HTTP keys (OTel SDKs) and the deprecated ones. */ +export function getSessionRowHttp(row: SessionRow) { + const statusCode = getHttpStatusCode(row); + const method = getHttpMethod(row) ?? ''; + return { + url: getHttpUrl(row) ?? '', + method, + statusCode: statusCode ?? 0, + label: [statusCode, method].filter(isSet).join(' '), + }; +} + +export function buildSessionEventFilters({ + traceSource, + sessionId, + sessionIdKey, +}: { + traceSource: Pick< + TTraceSource, + | 'resourceAttributesExpression' + | 'eventAttributesExpression' + | 'spanNameExpression' + >; + sessionId: string; + sessionIdKey?: SessionIdKey; +}): Record<'highlighted' | 'all', ConditionFilter[]> { + const cols = traceSessionIdColumns(traceSource); + const attrs = cols.attrs; + const spanName = traceSource.spanNameExpression; + const sessionFilter: ConditionFilter = { + type: 'lucene', + condition: sessionIdFilter(cols, sessionId, sessionIdKey), + }; + const legacyEvents = `${spanName}:"routeChange" + OR ${spanName}:"documentLoad" + OR ${spanName}:"intercom.onShow" + OR ScopeName:"custom-action"`; + return { + highlighted: [ + sessionFilter, + { + type: 'lucene', + condition: `${attrs}.http.status_code:>299 + OR ${attrs}.http.response.status_code:>299 + OR ${attrs}.error.type:* + OR ${attrs}.component:"error" + OR ${legacyEvents}`, + }, + ], + all: [ + sessionFilter, + { + type: 'lucene', + // url.full rather than the stable status code key, because OTel + // network failures have no status code + condition: `${attrs}.http.status_code:* + OR ${attrs}.url.full:* + OR ${attrs}.component:"console" + OR ${attrs}.component:"error" + OR ${legacyEvents}`, + }, + ], + }; +} diff --git a/packages/app/tests/e2e/features/sessions.spec.ts b/packages/app/tests/e2e/features/sessions.spec.ts index ca1cbb367d..d51932f288 100644 --- a/packages/app/tests/e2e/features/sessions.spec.ts +++ b/packages/app/tests/e2e/features/sessions.spec.ts @@ -1,4 +1,5 @@ import { SessionsPage } from '../page-objects/SessionsPage'; +import { OTEL_SESSIONS } from '../seed-otel-sessions'; import { expect, test } from '../utils/base-test'; import { clearLuceneInput, @@ -46,6 +47,43 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => { }); }); + test('lists and opens a session from the OpenTelemetry browser SDK', async ({ + page, + }) => { + await page.goto('/search'); + await sessionsPage.goto(); + await sessionsPage.selectDataSource(); + await switchWhereToLucene(sessionsPage.whereLanguageSwitch); + await sessionsPage.search( + `SpanAttributes.session.id:"${OTEL_SESSIONS.newSdk}"`, + ); + + const card = page.getByTestId(`session-card-${OTEL_SESSIONS.newSdk}`); + await expect(card).toBeVisible(); + await card.click(); + + await expect(sessionsPage.sessionSidePanel).toBeVisible(); + await expect( + sessionsPage.getSessionEventRows().filter({ hasText: '500 GET' }), + ).toBeVisible({ timeout: 15000 }); + }); + + test('shows one card for a session that carries both session id keys', async ({ + page, + }) => { + await page.goto('/search'); + await sessionsPage.goto(); + await sessionsPage.selectDataSource(); + await switchWhereToLucene(sessionsPage.whereLanguageSwitch); + await sessionsPage.search( + `SpanAttributes.session.id:"${OTEL_SESSIONS.bothKeys}"`, + ); + + const card = page.getByTestId(`session-card-${OTEL_SESSIONS.bothKeys}`); + await expect(card).toHaveCount(1); + await expect(card).toContainText('1 Events'); + }); + // Both WHERE inputs on this page query the session source's *trace* source, // not the session table — the sessions page filters sessions by the spans // they contain, and the replay drawer lists those spans as events. Neither diff --git a/packages/app/tests/e2e/page-objects/SessionsPage.ts b/packages/app/tests/e2e/page-objects/SessionsPage.ts index 748c63f1ba..8330d18a5d 100644 --- a/packages/app/tests/e2e/page-objects/SessionsPage.ts +++ b/packages/app/tests/e2e/page-objects/SessionsPage.ts @@ -32,6 +32,13 @@ export class SessionsPage { await dataSourceOption.click(); } + /** Run a search over the sessions' spans. */ + async search(query: string) { + await this.whereInput.fill(query); + await this.whereInput.blur(); + await this.searchForm.getByRole('button', { name: 'Run' }).click(); + } + /** * Get all session cards */ diff --git a/packages/app/tests/e2e/seed-clickhouse.ts b/packages/app/tests/e2e/seed-clickhouse.ts index 9736ffdaea..5c4f25c48c 100644 --- a/packages/app/tests/e2e/seed-clickhouse.ts +++ b/packages/app/tests/e2e/seed-clickhouse.ts @@ -29,6 +29,10 @@ import { E2E_TRACES_MV_TABLE, E2E_TRACES_TABLE, } from './utils/constants'; +import { + generateOtelSessionReplayRows, + generateOtelSessionSpans, +} from './seed-otel-sessions'; interface ClickHouseConfig { host: string; @@ -1004,7 +1008,8 @@ export async function seedClickHouse(): Promise { Duration, StatusCode, StatusMessage, \`Events.Timestamp\`, \`Events.Name\`, \`Events.Attributes\`, \`Links.TraceId\`, \`Links.SpanId\`, \`Links.TraceState\`, \`Links.Attributes\` - ) VALUES ${generateSessionTraces(numDataPoints, startMs, endMs)} + ) VALUES ${generateSessionTraces(numDataPoints, startMs, endMs)}, + ${generateOtelSessionSpans(seedRef)} `); console.log(` Inserted ${numDataPoints} session trace data`); @@ -1015,7 +1020,8 @@ export async function seedClickHouse(): Promise { Timestamp, TraceId, SpanId, TraceFlags, SeverityText, SeverityNumber, ServiceName, Body, ResourceSchemaUrl, ResourceAttributes, ScopeSchemaUrl, ScopeName, ScopeVersion, ScopeAttributes, LogAttributes - ) VALUES ${generateSessionData(numDataPoints, startMs, endMs)} + ) VALUES ${generateSessionData(numDataPoints, startMs, endMs)}, + ${generateOtelSessionReplayRows(seedRef)} `); console.log(` Inserted ${numDataPoints} session entries`); diff --git a/packages/app/tests/e2e/seed-otel-sessions.ts b/packages/app/tests/e2e/seed-otel-sessions.ts new file mode 100644 index 0000000000..4035cfc041 --- /dev/null +++ b/packages/app/tests/e2e/seed-otel-sessions.ts @@ -0,0 +1,61 @@ +/** + * Sessions keyed on `session.id`, as the OpenTelemetry browser SDK sends them. + * The rest of the session seed uses the legacy SDK's `rum.sessionId`. + */ + +export const OTEL_SESSIONS = { + /** `session.id` only: replay rows and one failed fetch span. */ + newSdk: 'e2e-new-sdk-session', + /** Both keys with the same value, as after the collector copies one to the other. */ + bothKeys: 'e2e-both-keys-session', +} as const; + +const SERVICE = 'browser'; +const PAGE_URL = 'http://localhost:8080/'; + +function replayRows( + startMs: number, + resourceAttributes: string, + sessionId: string, +): string[] { + const events = [ + { type: 4, data: { href: PAGE_URL, width: 1280, height: 720 } }, + { + type: 2, + data: { + node: { type: 0, childNodes: [], id: 1 }, + initialOffset: { left: 0, top: 0 }, + }, + }, + ]; + return events.map((event, i) => { + const timestampMs = startMs + i * 1000; + const body = JSON.stringify({ ...event, timestamp: timestampMs }); + return `('${timestampMs * 1_000_000}', '', '', 0, '', 0, '${SERVICE}', '${body}', '', ${resourceAttributes}, '', 'rum.rr-web', '', {}, {'session.id':'${sessionId}','rr-web.event':'${i + 1}','rr-web.offset':'${i + 1}','rr-web.chunk':'1','rr-web.total-chunks':'1'})`; + }); +} + +export function generateOtelSessionReplayRows(seedRef: number): string { + const startMs = seedRef - 5 * 60 * 1000; + return [ + ...replayRows( + startMs, + `{'service.name':'${SERVICE}'}`, + OTEL_SESSIONS.newSdk, + ), + ...replayRows( + startMs, + `{'rum.sessionId':'${OTEL_SESSIONS.bothKeys}','service.name':'${SERVICE}'}`, + OTEL_SESSIONS.bothKeys, + ), + ].join(',\n'); +} + +export function generateOtelSessionSpans(seedRef: number): string { + const ns = (seedRef - 5 * 60 * 1000 + 500) * 1_000_000; + const { newSdk, bothKeys } = OTEL_SESSIONS; + return [ + `('${ns}', '${newSdk}-trace', '${newSdk}-span', '', '', 'GET', 'SPAN_KIND_CLIENT', '${SERVICE}', {'service.name':'${SERVICE}'}, '@opentelemetry/instrumentation-fetch', '', {'session.id':'${newSdk}','browser.document.url.full':'${PAGE_URL}','url.full':'${PAGE_URL}api/error','http.request.method':'GET','http.response.status_code':'500','error.type':'500'}, 12000000, 'Error', '', [], [], [], [], [], [], [])`, + `('${ns}', '${bothKeys}-trace', '${bothKeys}-span', '', '', 'documentFetch', 'SPAN_KIND_INTERNAL', '${SERVICE}', {'rum.sessionId':'${bothKeys}','service.name':'${SERVICE}'}, '', '', {'session.id':'${bothKeys}','component':'document-load'}, 3000000, 'Unset', '', [], [], [], [], [], [], [])`, + ].join(',\n'); +}