From cf9ce848a47f4c27af397d6101244e9bf205b173 Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:18:36 -0700 Subject: [PATCH 01/15] [Agent Traces] Add Sessions tab and semconv-aware message previews Sessions group traces by gen_ai.conversation.id (OTel GenAI semconv): - Sessions tab with first/last message, duration, trace count, tokens; Total Sessions metric - Session flyout with trace list, conversation navigation, and a View All Traces drill-in that opens the existing trace flyout - Input/Output previews parse gen_ai.*.messages per the semconv JSON schema (last user message, assistant text, part placeholders), with a gen_ai.tool.call.arguments/result fallback, across all tables Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- src/plugins/agent_traces/common/index.ts | 5 + .../sessions/hooks/use_session_detail.ts | 103 ++++ .../pages/sessions/hooks/use_sessions.ts | 120 ++++ .../pages/sessions/session_details_flyout.tsx | 513 ++++++++++++++++++ .../pages/sessions/session_utils.test.ts | 192 +++++++ .../pages/sessions/session_utils.ts | 216 ++++++++ .../pages/sessions/sessions_tab.scss | 147 +++++ .../pages/sessions/sessions_tab.tsx | 143 +++++ .../pages/sessions/sessions_table.test.tsx | 94 ++++ .../pages/sessions/sessions_table.tsx | 160 ++++++ .../hooks/genai_message_preview.test.ts | 174 ++++++ .../traces/hooks/genai_message_preview.ts | 191 +++++++ .../pages/traces/hooks/use_trace_metrics.ts | 18 +- .../pages/traces/trace_metrics_bar.test.tsx | 12 + .../pages/traces/trace_metrics_bar.tsx | 19 + .../public/application/register_tabs.test.ts | 19 +- .../public/application/register_tabs.ts | 16 + .../table_cell/trace_utils/trace_utils.tsx | 23 +- 18 files changed, 2151 insertions(+), 14 deletions(-) create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/hooks/use_session_detail.ts create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/sessions_table.test.tsx create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx create mode 100644 src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.test.ts create mode 100644 src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.ts diff --git a/src/plugins/agent_traces/common/index.ts b/src/plugins/agent_traces/common/index.ts index 4f19987b008f..c3401c05d7c5 100644 --- a/src/plugins/agent_traces/common/index.ts +++ b/src/plugins/agent_traces/common/index.ts @@ -18,6 +18,11 @@ export const AGENT_TRACES_DEFAULT_LANGUAGE = 'PPL'; export const AGENT_TRACES_TRACES_TAB_ID = 'traces'; export const AGENT_TRACES_SPANS_TAB_ID = 'spans'; export const AGENT_TRACES_VISUALIZATION_TAB_ID = 'visualization'; +export const AGENT_TRACES_SESSIONS_TAB_ID = 'sessions'; + +/** Span attribute that groups traces into a session (OTel GenAI semantic conventions). + * Data Prepper normalizes OpenInference/ADOT `session.id` to this key at ingest. */ +export const AGENT_TRACES_SESSION_ID_FIELD = 'attributes.gen_ai.conversation.id'; export enum AgentTracesFlavor { Traces = 'traces', diff --git a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_session_detail.ts b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_session_detail.ts new file mode 100644 index 000000000000..3737fdedea72 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_session_detail.ts @@ -0,0 +1,103 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useState } from 'react'; +import { usePPLQueryDeps } from '../../traces/hooks/use_ppl_query_deps'; +import { transformPPLDataToTraceHits } from '../../traces/trace_details/traces/ppl_to_trace_hits'; +import { TraceRow, buildFullSpanTree, hitsToAgentSpans } from '../../traces/hooks/tree_utils'; +import { buildSessionSpansQuery, getSourceCommand, rawStart } from '../session_utils'; + +export interface SessionTrace { + traceId: string; + /** Root row of the trace (carries input/output, latency, tokens). */ + root: TraceRow; + /** Full span tree for the trace, as the trace flyout expects. */ + tree: TraceRow[]; + /** All spans of the trace flattened, earliest first. */ + spans: TraceRow[]; +} + +export interface UseSessionDetailResult { + traces: SessionTrace[]; + loading: boolean; + error: string | null; +} + +const flatten = (rows: TraceRow[], out: TraceRow[] = []): TraceRow[] => { + for (const row of rows) { + out.push(row); + if (row.children?.length) flatten(row.children as TraceRow[], out); + } + return out; +}; + +/** Load every span of a session's traces and build one tree per trace, earliest trace first. */ +export const useSessionDetail = ( + traceIds: string[] | null, + formatTs: (ts: string) => string +): UseSessionDetailResult => { + const { pplService, datasetParam, baseQueryString } = usePPLQueryDeps(); + const [traces, setTraces] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const key = traceIds ? traceIds.join(',') : ''; + + useEffect(() => { + if (!traceIds || traceIds.length === 0 || !pplService || !datasetParam || !baseQueryString) { + setTraces([]); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + + (async () => { + try { + // No time filter: a session's early turns can fall outside the picked range. + const datasetWithoutTime = { + id: datasetParam.id, + title: datasetParam.title, + type: datasetParam.type, + ...(datasetParam.dataSource && { dataSource: datasetParam.dataSource }), + }; + const source = getSourceCommand(baseQueryString); + const response = await pplService.executeQuery( + datasetWithoutTime as typeof datasetParam, + buildSessionSpansQuery(source, traceIds) + ); + const spans = hitsToAgentSpans(transformPPLDataToTraceHits(response)); + + const byTrace = new Map(); + for (const span of spans) { + const list = byTrace.get(span.traceId) ?? []; + list.push(span); + byTrace.set(span.traceId, list); + } + + const result: SessionTrace[] = []; + for (const [traceId, traceSpans] of byTrace) { + const tree = buildFullSpanTree(traceSpans, formatTs) as TraceRow[]; + const root = tree.find((r) => !r.parentSpanId) ?? tree[0]; + if (!root) continue; + result.push({ traceId, root, tree, spans: flatten(tree) }); + } + result.sort((a, b) => rawStart(a.root).localeCompare(rawStart(b.root))); + + if (!cancelled) setTraces(result); + } catch (err) { + if (!cancelled) setError((err as Error).message || 'Failed to load session'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, pplService, datasetParam, baseQueryString, formatTs]); + + return { traces, loading, error }; +}; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts new file mode 100644 index 000000000000..be7ecff07f4e --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { AGENT_TRACES_SESSION_ID_FIELD } from '../../../../../common'; +import { RootState } from '../../../utils/state_management/store'; +import { usePPLQueryDeps, useTimeVersion } from '../../traces/hooks/use_ppl_query_deps'; +import { splitPplWhereAndTail } from '../../traces/table_shared'; +import { transformPPLDataToTraceHits } from '../../traces/trace_details/traces/ppl_to_trace_hits'; +import { hitsToAgentSpans, spanToRow } from '../../traces/hooks/tree_utils'; +import { + SessionRow, + assembleSessionRows, + buildRootSpansQuery, + buildSessionStatsQuery, + buildTraceSessionMapQuery, + getSourceCommand, + parseSessionStats, + pplResponseToRecords, +} from '../session_utils'; + +export interface UseSessionsResult { + sessions: SessionRow[]; + loading: boolean; + error: string | null; + elapsedMs: number | null; + refresh: () => void; +} + +/** + * Fetch the sessions list for the current query and time range. + * + * 1. Stats query: one row per session id (trace count, start/end). + * 2. Trace map: which traces belong to each session (any span may carry the id). + * 3. Root spans of those traces: first/last message, tokens, user id. + */ +export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult => { + const { services, pplService, datasetParam, baseQueryString } = usePPLQueryDeps(); + const fetchVersion = useSelector((state: RootState) => state.queryEditor.fetchVersion); + const timeVersion = useTimeVersion(services); + + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [elapsedMs, setElapsedMs] = useState(null); + const [refreshCounter, setRefreshCounter] = useState(0); + const requestIdRef = useRef(0); + + const fetchSessions = useCallback(async () => { + if (!pplService || !datasetParam || !baseQueryString) return; + const requestId = ++requestIdRef.current; + const started = Date.now(); + setLoading(true); + setError(null); + + try { + const { whereQuery } = splitPplWhereAndTail(baseQueryString); + const source = getSourceCommand(whereQuery); + + const statsResponse = await pplService.executeQuery( + datasetParam, + buildSessionStatsQuery(whereQuery) + ); + const stats = parseSessionStats(pplResponseToRecords(statsResponse)); + + let rows: SessionRow[] = []; + if (stats.length > 0) { + const mapResponse = await pplService.executeQuery( + datasetParam, + buildTraceSessionMapQuery( + source, + stats.map((s) => s.sessionId) + ) + ); + const traceToSession = new Map(); + for (const rec of pplResponseToRecords(mapResponse)) { + const traceId = rec.traceId; + const sessionId = rec[AGENT_TRACES_SESSION_ID_FIELD]; + if (traceId && sessionId) traceToSession.set(String(traceId), String(sessionId)); + } + + const traceIds = [...traceToSession.keys()]; + let rootRows: Array> = []; + if (traceIds.length > 0) { + const rootsResponse = await pplService.executeQuery( + datasetParam, + buildRootSpansQuery(source, traceIds) + ); + rootRows = hitsToAgentSpans(transformPPLDataToTraceHits(rootsResponse)).map((span, i) => + spanToRow(span, i, formatTs) + ); + } + rows = assembleSessionRows(stats, traceToSession, rootRows); + } + + if (requestId !== requestIdRef.current) return; // a newer request superseded this one + setSessions(rows); + setElapsedMs(Date.now() - started); + } catch (err) { + if (requestId !== requestIdRef.current) return; + // eslint-disable-next-line no-console + console.error('Failed to fetch sessions:', err); + setError((err as Error).message || 'Failed to fetch sessions'); + setSessions([]); + } finally { + if (requestId === requestIdRef.current) setLoading(false); + } + }, [pplService, datasetParam, baseQueryString, formatTs]); + + useEffect(() => { + fetchSessions(); + }, [fetchSessions, refreshCounter, timeVersion, fetchVersion]); + + const refresh = useCallback(() => setRefreshCounter((c) => c + 1), []); + + return { sessions, loading, error, elapsedMs, refresh }; +}; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx new file mode 100644 index 000000000000..00e2c2d3e8f7 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx @@ -0,0 +1,513 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { + EuiBadge, + EuiBasicTableColumn, + EuiButtonEmpty, + EuiButtonIcon, + EuiCallOut, + EuiCopy, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiHealth, + EuiIcon, + EuiInMemoryTable, + EuiLink, + EuiLoadingSpinner, + EuiPanel, + EuiSpacer, + EuiTab, + EuiTabs, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { TraceRow } from '../traces/hooks/tree_utils'; +import { useTraceFlyout } from '../traces/flyout/trace_flyout_context'; +import { getCategoryMeta, getSpanCategory } from '../../../services/span_categorization'; +import { TokenIcon } from '../../../components/data_table/table_cell/trace_utils/trace_utils'; +import { SessionTrace, useSessionDetail } from './hooks/use_session_detail'; +import { previewInputMessages, previewOutputMessages } from '../traces/hooks/genai_message_preview'; +import { SessionRow, formatSessionDuration, shortenId } from './session_utils'; + +interface SessionDetailsFlyoutProps { + session: SessionRow; + formatTs: (ts: string) => string; + onClose: () => void; +} + +type DrillTab = 'traces' | 'spans'; + +const tokensOf = (row: TraceRow): number | null => + typeof row.totalTokens === 'number' ? row.totalTokens : null; + +const MetaItem: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+ + {label} + + + {children} + +
+); + +const LatencyAndTokens: React.FC<{ row: TraceRow }> = ({ row }) => { + const tokens = tokensOf(row); + return ( + + + + {row.latency} + + + + {tokens === null ? '—' : tokens.toLocaleString()} + + + ); +}; + +const KindBadge: React.FC<{ row: TraceRow }> = ({ row }) => { + const meta = getCategoryMeta(getSpanCategory(row)); + return ( + + {meta.label} + + ); +}; + +const TruncatedText: React.FC<{ text: string }> = ({ text }) => + text ? ( + + {text} + + ) : ( + <>— + ); + +/** Human/AI turns for one trace in the session conversation. */ +const ConversationTurn: React.FC<{ + index: number; + trace: SessionTrace; + focused: boolean; + turnRef: (el: HTMLDivElement | null) => void; + onOpenTrace: () => void; +}> = ({ index, trace, focused, turnRef, onOpenTrace }) => { + const userText = previewInputMessages(trace.root.input); + const aiText = previewOutputMessages(trace.root.output); + return ( +
+ + {i18n.translate('agentTraces.sessions.flyout.traceLink', { + defaultMessage: 'Trace #{index}', + values: { index }, + })}{' '} + + + + +
+ + + {i18n.translate('agentTraces.sessions.flyout.human', { defaultMessage: 'Human' })} + +
+
{userText || '—'}
+
+ + +
+ + + {i18n.translate('agentTraces.sessions.flyout.ai', { defaultMessage: 'AI' })} + +
+
{aiText || '—'}
+
+ +
+ ); +}; + +export const SessionDetailsFlyout: React.FC = ({ + session, + formatTs, + onClose, +}) => { + const { traces, loading, error } = useSessionDetail(session.traceIds, formatTs); + const { openFlyout, updateFlyoutFullTree } = useTraceFlyout(); + + const [view, setView] = useState<'overview' | 'all'>('overview'); + const [drillTab, setDrillTab] = useState('traces'); + /** Index of the trace in focus; the conversation arrows step through traces. */ + const [focusedIndex, setFocusedIndex] = useState(0); + const turnRefs = useRef>([]); + + const totalTokens = useMemo(() => { + const values = traces.map((t) => tokensOf(t.root)).filter((v): v is number => v !== null); + return values.length ? values.reduce((a, b) => a + b, 0) : session.totalTokens; + }, [traces, session.totalTokens]); + + /** Open the existing trace flyout for a row (root or any span) of a session trace. */ + const openTrace = useCallback( + (row: TraceRow) => { + const trace = traces.find((t) => t.traceId === row.traceId); + openFlyout(row); + if (trace) updateFlyoutFullTree(trace.tree, false); + }, + [traces, openFlyout, updateFlyoutFullTree] + ); + + /** Focus a trace: highlight it in the list and scroll its turn into view. */ + const focusTrace = useCallback( + (index: number) => { + if (index < 0 || index >= traces.length) return; + setFocusedIndex(index); + turnRefs.current[index]?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); + }, + [traces.length] + ); + + const drillColumns: Array> = useMemo( + () => [ + { + field: 'status', + name: i18n.translate('agentTraces.sessions.drill.status', { defaultMessage: 'Status' }), + width: '70px', + render: (status: string) => ( + + ), + }, + { + field: 'kind', + name: i18n.translate('agentTraces.sessions.drill.kind', { defaultMessage: 'Kind' }), + width: '110px', + render: (_: string, row: TraceRow) => , + }, + { + field: 'name', + name: i18n.translate('agentTraces.sessions.drill.name', { defaultMessage: 'Name' }), + width: '20%', + render: (name: string) => , + }, + { + field: 'input', + name: i18n.translate('agentTraces.sessions.drill.input', { defaultMessage: 'Input' }), + render: (_: string, row: TraceRow) => , + }, + { + field: 'output', + name: i18n.translate('agentTraces.sessions.drill.output', { defaultMessage: 'Output' }), + render: (_: string, row: TraceRow) => , + }, + { + field: 'latency', + name: i18n.translate('agentTraces.sessions.drill.latency', { defaultMessage: 'Latency' }), + width: '100px', + render: (latency: string) => ( + + + {latency} + + ), + }, + ], + [] + ); + + const drillItems = useMemo( + () => (drillTab === 'traces' ? traces.map((t) => t.root) : traces.flatMap((t) => t.spans)), + [drillTab, traces] + ); + + return ( + + + + + +

+ {i18n.translate('agentTraces.sessions.flyout.title', { + defaultMessage: 'SessionID: {id}', + values: { id: shortenId(session.sessionId) }, + })} +

+
+
+ + + {(copy) => ( + + {i18n.translate('agentTraces.sessions.flyout.copyId', { + defaultMessage: 'Session ID', + })} + + )} + + +
+
+ + {' '} + {formatSessionDuration(session.durationMs)} + + + + {totalTokens === null ? '—' : totalTokens.toLocaleString()} + + + {session.totalTraces.toLocaleString()} + +
+
+ + + {error && ( + + {error} + + )} + {loading && ( + + + + + + )} + + {!loading && !error && view === 'overview' && ( + + + + + +

+ {i18n.translate('agentTraces.sessions.flyout.traceList', { + defaultMessage: 'Trace list ({count})', + values: { count: traces.length }, + })} +

+
+
+ + setView('all')} + data-test-subj="agentTracesSessionViewAllTraces" + > + {i18n.translate('agentTraces.sessions.flyout.viewAll', { + defaultMessage: 'View All Traces', + })} + + +
+ + {traces.map((trace, i) => ( + + ))} +
+ + + + + +

+ {i18n.translate('agentTraces.sessions.flyout.conversation', { + defaultMessage: 'Session Conversation', + })} +

+
+
+ + + + = traces.length - 1} + onClick={() => focusTrace(focusedIndex + 1)} + aria-label={i18n.translate('agentTraces.sessions.flyout.nextTrace', { + defaultMessage: 'Next trace', + })} + data-test-subj="agentTracesSessionNextTrace" + /> + + + focusTrace(focusedIndex - 1)} + aria-label={i18n.translate('agentTraces.sessions.flyout.previousTrace', { + defaultMessage: 'Previous trace', + })} + data-test-subj="agentTracesSessionPreviousTrace" + /> + + + +
+ + {traces.map((trace, i) => ( + { + turnRefs.current[i] = el; + }} + onOpenTrace={() => openTrace(trace.root)} + /> + ))} +
+
+ )} + + {!loading && !error && view === 'all' && ( + <> + + + setView('overview')} + data-test-subj="agentTracesSessionBack" + > + + {i18n.translate('agentTraces.sessions.flyout.viewAll', { + defaultMessage: 'View All Traces', + })} + + + + + + {i18n.translate('agentTraces.sessions.flyout.sessionChip', { + defaultMessage: 'Session ID: {id}', + values: { id: shortenId(session.sessionId, 12, 0) }, + })} + + + + + setDrillTab('traces')} + data-test-subj="agentTracesSessionDrillTraces" + > + {i18n.translate('agentTraces.sessions.drill.traces', { defaultMessage: 'Traces' })} + + setDrillTab('spans')} + data-test-subj="agentTracesSessionDrillSpans" + > + {i18n.translate('agentTraces.sessions.drill.spans', { defaultMessage: 'Spans' })} + + + + + key={drillTab} + items={drillItems} + columns={drillColumns} + pagination={{ initialPageSize: 25, pageSizeOptions: [25, 50, 100] }} + tableLayout="fixed" + className="agtSessionsTable" + rowProps={(row: TraceRow) => ({ + className: 'agtSessionsTable__row', + onClick: () => openTrace(row), + })} + data-test-subj="agentTracesSessionDrillTable" + /> + + )} +
+
+ ); +}; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts new file mode 100644 index 000000000000..6529d1e19b13 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts @@ -0,0 +1,192 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + assembleSessionRows, + buildRootSpansQuery, + buildSessionSpansQuery, + buildSessionStatsQuery, + buildTraceSessionMapQuery, + formatSessionDuration, + getSourceCommand, + getSpanAttribute, + parseSessionStats, + pplResponseToRecords, + shortenId, +} from './session_utils'; +import { BaseRow } from '../traces/hooks/tree_utils'; + +const FIELD = 'attributes.gen_ai.conversation.id'; + +const msg = (role: string, text: string) => + JSON.stringify([{ role, parts: [{ type: 'text', content: text }] }]); + +const root = ( + traceId: string, + startTime: string, + opts: Partial & { userId?: string } = {} +): BaseRow => ({ + id: traceId, + spanId: `${traceId}-root`, + traceId, + parentSpanId: null, + status: 'success', + kind: 'invoke_agent', + name: 'POST /plan', + input: opts.input ?? msg('user', `q-${traceId}`), + output: opts.output ?? msg('assistant', `a-${traceId}`), + startTime, + endTime: startTime, + latency: '1.00s', + durationNanos: 1e9, + totalTokens: opts.totalTokens ?? 100, + inputTokens: 60, + outputTokens: 40, + totalCost: '—', + rawDocument: { + startTime, + attributes: opts.userId ? { 'user.id': opts.userId } : {}, + }, +}); + +describe('session_utils', () => { + describe('query builders', () => { + it('builds the stats query grouped by conversation id', () => { + const q = buildSessionStatsQuery('source = otel-v1-apm-span-* | where serviceName = "a"', 50); + expect(q).toBe( + 'source = otel-v1-apm-span-* | where serviceName = "a" | where isnotnull(`attributes.gen_ai.conversation.id`) | stats distinct_count(traceId) as total_traces, min(startTime) as start_time, max(endTime) as end_time by `attributes.gen_ai.conversation.id` | sort - start_time | head 50' + ); + }); + + it('escapes ids in IN lists', () => { + expect(buildTraceSessionMapQuery('source = x', ['a"b', 'c'])).toContain('in ("a\\"b", "c")'); + expect(buildRootSpansQuery('source = x', ['t1'])).toContain( + 'where parentSpanId = "" and traceId in ("t1")' + ); + expect(buildSessionSpansQuery('source = x', ['t1', 't2'], 10)).toBe( + 'source = x | where traceId in ("t1", "t2") | head 10' + ); + }); + + it('extracts the source command', () => { + expect(getSourceCommand('source = idx | where a = 1')).toBe('source = idx'); + expect(getSourceCommand('source = idx')).toBe('source = idx'); + }); + }); + + describe('pplResponseToRecords', () => { + it('parses JDBC datarows', () => { + const records = pplResponseToRecords({ + schema: [{ name: 'a' }, { name: 'b' }], + datarows: [ + [1, 'x'], + [2, 'y'], + ], + }); + expect(records).toEqual([ + { a: 1, b: 'x' }, + { a: 2, b: 'y' }, + ]); + }); + + it('parses data_frame fields', () => { + const records = pplResponseToRecords({ + type: 'data_frame', + body: { + fields: [ + { name: 'a', values: [1, 2] }, + { name: 'b', values: ['x', 'y'] }, + ], + size: 2, + }, + }); + expect(records).toEqual([ + { a: 1, b: 'x' }, + { a: 2, b: 'y' }, + ]); + }); + + it('returns [] for empty or unknown responses', () => { + expect(pplResponseToRecords(undefined)).toEqual([]); + expect(pplResponseToRecords({ type: 'data_frame', body: { fields: [], size: 0 } })).toEqual( + [] + ); + }); + }); + + describe('getSpanAttribute', () => { + it('reads flat dotted, nested and top-level flattened keys', () => { + expect(getSpanAttribute({ attributes: { 'user.id': 'u1' } }, 'user.id')).toBe('u1'); + expect(getSpanAttribute({ attributes: { user: { id: 'u2' } } }, 'user.id')).toBe('u2'); + expect(getSpanAttribute({ 'attributes.user.id': 'u3' }, 'user.id')).toBe('u3'); + expect(getSpanAttribute({ attributes: {} }, 'user.id')).toBeUndefined(); + }); + }); + + describe('assembleSessionRows', () => { + const stats = parseSessionStats([ + { + [FIELD]: 's1', + total_traces: 2, + start_time: '2026-09-28 22:17:18.659', + end_time: '2026-09-28 22:17:27.692', + }, + { [FIELD]: 's2', total_traces: 1, start_time: '2026-09-28 22:00:00', end_time: '' }, + { [FIELD]: null, total_traces: 5 }, + ]); + + it('drops rows without a session id', () => { + expect(stats.map((s) => s.sessionId)).toEqual(['s1', 's2']); + }); + + it('picks first input and last output by trace start time and sums tokens', () => { + const traceToSession = new Map([ + ['t2', 's1'], + ['t1', 's1'], + ['t3', 's2'], + ]); + const rows = assembleSessionRows(stats, traceToSession, [ + root('t2', '2026-09-28 22:17:25', { totalTokens: 50, userId: 'user-9' }), + root('t1', '2026-09-28 22:17:18', { totalTokens: 100 }), + root('t3', '2026-09-28 22:00:00', { totalTokens: '—', input: '—', output: '—' }), + ]); + + expect(rows[0]).toMatchObject({ + sessionId: 's1', + totalTraces: 2, + totalTokens: 150, + firstMessage: 'q-t1', + lastMessage: 'a-t2', + userId: 'user-9', + traceIds: ['t1', 't2'], + durationMs: 9033, + }); + // No tokens, no text, invalid end time + expect(rows[1]).toMatchObject({ + sessionId: 's2', + totalTokens: null, + firstMessage: '', + lastMessage: '', + userId: null, + durationMs: 0, + }); + }); + }); + + describe('formatting', () => { + it('formats session durations', () => { + expect(formatSessionDuration(0)).toBe('—'); + expect(formatSessionDuration(3200)).toBe('3.2s'); + expect(formatSessionDuration((42 * 60 + 5) * 1000)).toBe('42m:05s'); + expect(formatSessionDuration((2 * 3600 + 48 * 60) * 1000)).toBe('2h:48m'); + }); + + it('shortens long ids', () => { + expect(shortenId('9c229eb8-3a5a-4b1c-9d33-aa12bc0cd891')).toBe('9c229eb8...d891'); + expect(shortenId('short')).toBe('short'); + expect(shortenId('sess_4c6af3f740894eac', 12, 0)).toBe('sess_4c6af3f...'); + }); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts new file mode 100644 index 000000000000..b238f4427cea --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts @@ -0,0 +1,216 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import moment from 'moment-timezone'; +import { AGENT_TRACES_SESSION_ID_FIELD } from '../../../../common'; +import { escapePPLValue } from '../traces/trace_details/data_fetching/ppl_request_helpers'; +import { BaseRow } from '../traces/hooks/tree_utils'; +import { previewInputMessages, previewOutputMessages } from '../traces/hooks/genai_message_preview'; + +/** Backtick-quoted session id field for use in PPL. */ +export const SESSION_FIELD_PPL = `\`${AGENT_TRACES_SESSION_ID_FIELD}\``; + +/** Max sessions listed in the Sessions tab. */ +export const SESSIONS_PAGE_LIMIT = 100; + +/** Max traces fetched for the root-span lookup behind the sessions list. */ +export const SESSION_ROOTS_LIMIT = 2000; + +/** Max spans fetched when opening a single session. */ +export const SESSION_SPANS_LIMIT = 5000; + +export interface SessionRow { + sessionId: string; + /** Raw PPL timestamp of the earliest span in the session. */ + startTime: string; + /** Raw PPL timestamp of the latest span end in the session. */ + endTime: string; + durationMs: number; + totalTraces: number; + totalTokens: number | null; + firstMessage: string; + lastMessage: string; + userId: string | null; + /** Trace ids in the session, ordered earliest first. */ + traceIds: string[]; +} + +/** Session-level summary computed from the stats query. */ +interface SessionStats { + sessionId: string; + startTime: string; + endTime: string; + totalTraces: number; +} + +/** + * Convert a PPL response (JDBC `datarows` or `data_frame` fields format) to an + * array of records keyed by column name. + */ +export const pplResponseToRecords = (response: any): Array> => { + if (response?.datarows && response?.schema) { + const schema = response.schema as Array<{ name: string }>; + return (response.datarows as any[][]).map((row) => { + const record: Record = {}; + schema.forEach((col, idx) => { + record[col.name] = row[idx]; + }); + return record; + }); + } + + const data = response?.type === 'data_frame' && response?.body ? response.body : response; + if (data?.fields && data?.size > 0) { + const records: Array> = []; + for (let i = 0; i < data.size; i++) { + const record: Record = {}; + data.fields.forEach((field: { name: string; values: any[] }) => { + record[field.name] = field.values?.[i]; + }); + records.push(record); + } + return records; + } + + return []; +}; + +const inList = (values: string[]): string => values.map((v) => escapePPLValue(v)).join(', '); + +/** + * Sessions list: one row per session id with trace count and time bounds. + * `whereQuery` is the user's query (source + where clauses, no tail commands). + */ +export const buildSessionStatsQuery = (whereQuery: string, limit = SESSIONS_PAGE_LIMIT): string => + `${whereQuery} | where isnotnull(${SESSION_FIELD_PPL}) | stats distinct_count(traceId) as total_traces, min(startTime) as start_time, max(endTime) as end_time by ${SESSION_FIELD_PPL} | sort - start_time | head ${limit}`; + +/** Maps each trace id to its session id. Any span in a trace may carry the session id. */ +export const buildTraceSessionMapQuery = (source: string, sessionIds: string[]): string => + `${source} | where ${SESSION_FIELD_PPL} in (${inList( + sessionIds + )}) | dedup traceId | fields traceId, ${SESSION_FIELD_PPL} | head ${SESSION_ROOTS_LIMIT}`; + +/** Root spans of the given traces (they carry per-trace input, output and token totals). */ +export const buildRootSpansQuery = (source: string, traceIds: string[]): string => + `${source} | where parentSpanId = "" and traceId in (${inList( + traceIds + )}) | sort startTime | head ${SESSION_ROOTS_LIMIT}`; + +/** Every span in the given traces, for the session detail flyout. */ +export const buildSessionSpansQuery = ( + source: string, + traceIds: string[], + limit = SESSION_SPANS_LIMIT +): string => `${source} | where traceId in (${inList(traceIds)}) | head ${limit}`; + +/** Extract the `source = ...` command from a PPL query string. */ +export const getSourceCommand = (query: string): string => { + const firstPipe = query.indexOf('|'); + return (firstPipe === -1 ? query : query.slice(0, firstPipe)).trim(); +}; + +const toMs = (ts: string): number => { + const m = moment.utc(ts); + return m.isValid() ? m.valueOf() : NaN; +}; + +export const parseSessionStats = (records: Array>): SessionStats[] => + records + .map((r) => ({ + sessionId: String(r[AGENT_TRACES_SESSION_ID_FIELD] ?? ''), + startTime: String(r.start_time ?? ''), + endTime: String(r.end_time ?? ''), + totalTraces: Number(r.total_traces ?? 0), + })) + .filter((s) => s.sessionId !== ''); + +/** Read a span attribute that may be stored as a flat dotted key or a nested object. */ +export const getSpanAttribute = (doc: Record | undefined, key: string): unknown => { + if (!doc) return undefined; + const attrs = doc.attributes; + if (attrs && typeof attrs === 'object') { + if (key in attrs) return attrs[key]; + let cur: any = attrs; + for (const part of key.split('.')) { + if (cur === null || cur === undefined || typeof cur !== 'object') return undefined; + cur = cur[part]; + } + if (cur !== undefined) return cur; + } + return doc[`attributes.${key}`]; +}; + +const rowTokens = (row: BaseRow): number | null => + typeof row.totalTokens === 'number' ? row.totalTokens : null; + +/** + * Assemble session rows from the stats query, the trace to session map, and root span rows. + * First/last message come from the earliest/latest trace that has text; tokens are the sum of + * per-trace root-span totals (matching the Traces tab). + */ +export const assembleSessionRows = ( + stats: SessionStats[], + traceToSession: Map, + rootRows: BaseRow[] +): SessionRow[] => { + const rootsBySession = new Map(); + for (const row of rootRows) { + const sessionId = traceToSession.get(row.traceId); + if (!sessionId) continue; + const list = rootsBySession.get(sessionId) ?? []; + list.push(row); + rootsBySession.set(sessionId, list); + } + + return stats.map((s) => { + const roots = (rootsBySession.get(s.sessionId) ?? []).sort( + (a, b) => toMs(rawStart(a)) - toMs(rawStart(b)) + ); + const withInput = roots.filter((r) => previewInputMessages(r.input) !== ''); + const withOutput = roots.filter((r) => previewOutputMessages(r.output) !== ''); + const tokenValues = roots.map(rowTokens).filter((t): t is number => t !== null); + const userRoot = roots.find((r) => getSpanAttribute(r.rawDocument, 'user.id') != null); + const start = toMs(s.startTime); + const end = toMs(s.endTime); + + return { + sessionId: s.sessionId, + startTime: s.startTime, + endTime: s.endTime, + durationMs: Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0, + totalTraces: s.totalTraces, + totalTokens: tokenValues.length ? tokenValues.reduce((a, b) => a + b, 0) : null, + firstMessage: withInput.length ? previewInputMessages(withInput[0].input) : '', + lastMessage: withOutput.length + ? previewOutputMessages(withOutput[withOutput.length - 1].output) + : '', + userId: userRoot ? String(getSpanAttribute(userRoot.rawDocument, 'user.id')) : null, + traceIds: roots.map((r) => r.traceId), + }; + }); +}; + +/** Raw (unformatted) start time from a row's source document. */ +export const rawStart = (row: BaseRow): string => + String((row.rawDocument as Record | undefined)?.startTime ?? ''); + +/** Session duration in the mock's `2h:48m` style; short sessions use `42m:05s` or `3.2s`. */ +export const formatSessionDuration = (ms: number): string => { + if (!ms || ms <= 0) return '—'; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h:${String(minutes).padStart(2, '0')}m`; + if (minutes > 0) return `${minutes}m:${String(seconds).padStart(2, '0')}s`; + return `${(ms / 1000).toFixed(1)}s`; +}; + +/** Shorten a long id as `abcd1234...wxyz` for headers. */ +export const shortenId = (id: string, head = 8, tail = 4): string => { + if (id.length <= head + tail + 3) return id; + // slice(-0) would return the whole string, so handle tail === 0 explicitly + return `${id.slice(0, head)}...${tail > 0 ? id.slice(-tail) : ''}`; +}; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss new file mode 100644 index 000000000000..5fcdfd286bc1 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss @@ -0,0 +1,147 @@ +.agentTraces-sessions-tab { + display: flex; + flex-direction: column; + overflow: auto; +} + +// Tab containers are column-flex; keep the info bar and table at their natural height. +.agtSessionsTab__infoBar { + flex-grow: 0; + padding: $euiSizeS $euiSizeS 0; +} + +// Match the Traces/Spans DataTable typography (data_table.scss): code font at XS size. +.agtSessionsTable { + @include ouiCodeFont; + + font-size: $euiFontSizeXS; + line-height: 18px; +} + +.agtSessionsTable__row { + cursor: pointer; +} + +.agtSessionsTable__tooltipAnchor { + display: block; + max-width: 100%; + overflow: hidden; +} + +.agtSessionsTable__text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &--wrap { + white-space: pre-wrap; + word-break: break-word; + } + + &--mono { + @include ouiCodeFont; + + font-size: $euiFontSizeXS; + } +} + +.agtSessionFlyout { + &__metaRow { + display: flex; + flex-wrap: wrap; + gap: $euiSizeL; + margin-top: $euiSizeS; + } + + &__metaItem { + display: flex; + align-items: center; + gap: $euiSizeXS; + } + + &__metaLabel { + font-weight: $euiFontWeightSemiBold; + color: $euiTextSubduedColor; + } + + &__metaValue { + display: inline-flex; + align-items: center; + gap: $euiSizeXS; + } + + &__traceList { + position: sticky; + top: 0; + border-right: $euiBorderThin; + padding-right: $euiSizeM; + } + + // Flex items are column-flex; stop the panel headers from growing and centering. + &__panelHeader { + flex-grow: 0; + } + + &__traceItem { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + padding: $euiSizeS; + border-radius: $euiBorderRadius; + text-align: left; + border-left: $euiBorderWidthThick solid transparent; + + &:hover, + &:focus { + background: $euiColorLightestShade; + } + + &--focused { + background: $euiColorLightShade; + border-left-color: $euiColorPrimary; + } + } + + &__turn { + scroll-margin-top: $euiSizeS; + } + + &__stats { + display: inline-flex; + gap: $euiSizeM; + color: $euiTextSubduedColor; + font-size: $euiFontSizeXS; + } + + &__stat { + display: inline-flex; + align-items: center; + gap: $euiSizeXS; + } + + &__message { + overflow: hidden; + } + + &__messageHeader { + display: flex; + align-items: center; + gap: $euiSizeS; + padding: $euiSizeS $euiSizeM; + background: $euiColorLightestShade; + border-bottom: $euiBorderThin; + } + + &__message--ai &__messageHeader { + background: tintOrShade($euiColorPrimary, 90%, 70%); + } + + &__messageBody { + padding: $euiSizeM; + white-space: pre-wrap; + word-break: break-word; + font-size: $euiFontSizeS; + } +} diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx new file mode 100644 index 000000000000..04d8d45d2ffc --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx @@ -0,0 +1,143 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import moment from 'moment-timezone'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import { + EuiCallOut, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiText, +} from '@elastic/eui'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { AgentTracesServices } from '../../../types'; +import { formatTimestamp } from '../traces/hooks/tree_utils'; +import { TableLoadingState } from '../traces/table_shared'; +import { useSessions } from './hooks/use_sessions'; +import { SessionsTable } from './sessions_table'; +import { SessionDetailsFlyout } from './session_details_flyout'; +import { SessionRow } from './session_utils'; +import './sessions_tab.scss'; + +export const SessionsTab = () => { + const { services } = useOpenSearchDashboards(); + const uiSettings = services.uiSettings; + + const timezone = useMemo(() => { + const tz = uiSettings?.get('dateFormat:tz'); + if (tz && tz !== 'Browser') return tz; + return moment.tz.guess() || moment().format('Z'); + }, [uiSettings]); + const formatTs = useCallback((ts: string) => formatTimestamp(ts, timezone), [timezone]); + + const { sessions, loading, error, elapsedMs } = useSessions(formatTs); + const [wrapCellText, setWrapCellText] = useState(false); + const [selected, setSelected] = useState(null); + + let body: React.ReactNode; + if (loading && sessions.length === 0) { + body = ( + + } + /> + ); + } else if (error) { + body = ( + + {error} + + ); + } else if (sessions.length === 0) { + body = ( + + {i18n.translate('agentTraces.sessions.emptyTitle', { + defaultMessage: 'No sessions found', + })} + + } + body={ +

+ gen_ai.conversation.id }} + /> +

+ } + data-test-subj="agentTracesSessionsEmpty" + /> + ); + } else { + body = ( + + ); + } + + return ( +
+ + + + + + + + setWrapCellText(e.target.checked)} + compressed + data-test-subj="agentTracesSessionsWrapSwitch" + /> + + + {body} + {selected && ( + setSelected(null)} + /> + )} +
+ ); +}; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.test.tsx b/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.test.tsx new file mode 100644 index 000000000000..3aa0d680dfb5 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { SessionsTable } from './sessions_table'; +import { SessionRow } from './session_utils'; + +const session = (overrides: Partial = {}): SessionRow => ({ + sessionId: 'sess_1', + startTime: '2026-09-28 22:17:18.659', + endTime: '2026-09-28 22:17:27.692', + durationMs: 9033, + totalTraces: 3, + totalTokens: 8016, + firstMessage: 'Plan a trip to Paris', + lastMessage: 'Great choice! Paris looks wonderful.', + userId: null, + traceIds: ['t1', 't2', 't3'], + ...overrides, +}); + +describe('SessionsTable', () => { + const formatTs = (ts: string) => `fmt(${ts})`; + + it('renders session rows with messages, counts and tokens', () => { + render( + + ); + expect(screen.getByText('fmt(2026-09-28 22:17:18.659)')).toBeInTheDocument(); + expect(screen.getByText('sess_1')).toBeInTheDocument(); + expect(screen.getByText('Plan a trip to Paris')).toBeInTheDocument(); + expect(screen.getByText('Great choice! Paris looks wonderful.')).toBeInTheDocument(); + expect(screen.getByText('9.0s')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('8,016')).toBeInTheDocument(); + }); + + it('shows a dash for missing tokens', () => { + render( + + ); + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1); + }); + + it('hides the User ID column unless a session has a user id', () => { + const { rerender } = render( + + ); + expect(screen.queryByText('User ID')).not.toBeInTheDocument(); + + rerender( + + ); + expect(screen.getAllByText('User ID').length).toBeGreaterThan(0); + expect(screen.getByText('user-42')).toBeInTheDocument(); + }); + + it('calls onSessionClick when the time link is clicked', () => { + const onSessionClick = jest.fn(); + const row = session(); + render( + + ); + fireEvent.click(screen.getByTestId('agentTracesSessionTimeLink')); + expect(onSessionClick).toHaveBeenCalledWith(row); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx b/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx new file mode 100644 index 000000000000..3d275c85fbcf --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx @@ -0,0 +1,160 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo } from 'react'; +import { i18n } from '@osd/i18n'; +import { + EuiBadge, + EuiBasicTableColumn, + EuiInMemoryTable, + EuiLink, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { TokenIcon } from '../../../components/data_table/table_cell/trace_utils/trace_utils'; +import { SessionRow, formatSessionDuration } from './session_utils'; + +interface SessionsTableProps { + sessions: SessionRow[]; + formatTs: (ts: string) => string; + wrapCellText: boolean; + onSessionClick: (session: SessionRow) => void; +} + +/** Text cell: single line with a tooltip, or full text when wrapping is on. */ +const TextCell: React.FC<{ text: string; wrap: boolean; mono?: boolean }> = ({ + text, + wrap, + mono, +}) => { + if (!text) return <>—; + const className = `agtSessionsTable__text${wrap ? ' agtSessionsTable__text--wrap' : ''}${ + mono ? ' agtSessionsTable__text--mono' : '' + }`; + if (wrap) return {text}; + return ( + + {text} + + ); +}; + +export const TokensBadge: React.FC<{ tokens: number | null }> = ({ tokens }) => + tokens === null ? ( + <>— + ) : ( + } style={{ borderRadius: 999 }}> + {tokens.toLocaleString()} + + ); + +export const SessionsTable: React.FC = ({ + sessions, + formatTs, + wrapCellText, + onSessionClick, +}) => { + // User ID comes from the optional `user.id` attribute; hide the column when no session has one. + const hasUserIds = useMemo(() => sessions.some((s) => !!s.userId), [sessions]); + + const columns: Array> = useMemo( + () => [ + { + field: 'startTime', + name: i18n.translate('agentTraces.sessions.column.time', { defaultMessage: 'Time' }), + sortable: true, + width: '215px', + render: (startTime: string, session: SessionRow) => ( + onSessionClick(session)} + data-test-subj="agentTracesSessionTimeLink" + > + {formatTs(startTime)} + + ), + }, + { + field: 'sessionId', + name: i18n.translate('agentTraces.sessions.column.sessionId', { + defaultMessage: 'Session ID', + }), + width: '170px', + render: (id: string) => , + }, + { + field: 'firstMessage', + name: i18n.translate('agentTraces.sessions.column.firstMessage', { + defaultMessage: 'First Message', + }), + render: (text: string) => , + }, + { + field: 'lastMessage', + name: i18n.translate('agentTraces.sessions.column.lastMessage', { + defaultMessage: 'Last Message', + }), + render: (text: string) => , + }, + { + field: 'durationMs', + name: i18n.translate('agentTraces.sessions.column.duration', { + defaultMessage: 'Duration', + }), + sortable: true, + width: '100px', + render: (ms: number) => formatSessionDuration(ms), + }, + ...(hasUserIds + ? [ + { + field: 'userId', + name: i18n.translate('agentTraces.sessions.column.userId', { + defaultMessage: 'User ID', + }), + width: '140px', + render: (id: string | null) => , + }, + ] + : []), + { + field: 'totalTraces', + name: i18n.translate('agentTraces.sessions.column.totalTraces', { + defaultMessage: 'Total Traces', + }), + sortable: true, + width: '105px', + render: (n: number) => {n.toLocaleString()}, + }, + { + field: 'totalTokens', + name: i18n.translate('agentTraces.sessions.column.totalTokens', { + defaultMessage: 'Total Tokens', + }), + sortable: true, + width: '120px', + render: (tokens: number | null) => , + }, + ], + [formatTs, wrapCellText, onSessionClick, hasUserIds] + ); + + return ( + + items={sessions} + itemId="sessionId" + columns={columns} + sorting={{ sort: { field: 'startTime', direction: 'desc' } }} + pagination={{ initialPageSize: 25, pageSizeOptions: [25, 50, 100] }} + rowProps={(session: SessionRow) => ({ + className: 'agtSessionsTable__row', + onClick: () => onSessionClick(session), + 'data-test-subj': `agentTracesSessionRow-${session.sessionId}`, + })} + tableLayout="fixed" + className="agtSessionsTable" + data-test-subj="agentTracesSessionsTable" + /> + ); +}; diff --git a/src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.test.ts b/src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.test.ts new file mode 100644 index 000000000000..754007c404e8 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + parseGenAiMessages, + previewInputMessages, + previewMessage, + previewOutputMessages, + previewSpanInput, + previewSpanOutput, +} from './genai_message_preview'; + +const text = (content: string) => ({ type: 'text', content }); + +describe('genai_message_preview', () => { + describe('parseGenAiMessages', () => { + it('accepts the schema as a JSON string or structured value', () => { + const messages = [{ role: 'user', parts: [text('hi')] }]; + expect(parseGenAiMessages(JSON.stringify(messages))).toEqual(messages); + expect(parseGenAiMessages(messages)).toEqual(messages); + }); + + it('rejects values that do not follow the schema', () => { + expect(parseGenAiMessages('plain text')).toBeNull(); + expect(parseGenAiMessages([])).toBeNull(); + expect(parseGenAiMessages({ role: 'user', parts: [text('x')] })).toBeNull(); // not an array + expect(parseGenAiMessages([{ role: 'user', content: 'legacy' }])).toBeNull(); // no parts + expect(parseGenAiMessages([{ role: 'user', parts: [{ content: 'no type' }] }])).toBeNull(); + expect(parseGenAiMessages('[not json')).toBeNull(); + }); + }); + + describe('previewMessage', () => { + it('joins text parts and ignores other parts when text exists', () => { + expect( + previewMessage({ + role: 'assistant', + parts: [ + { type: 'reasoning', content: 'thinking' }, + text('Line 1'), + { type: 'tool_call', name: 'get_weather' }, + text('Line 2'), + ], + }) + ).toBe('Line 1\nLine 2'); + }); + + it('uses placeholders when there is no text', () => { + expect( + previewMessage({ + role: 'assistant', + parts: [ + { type: 'tool_call', id: 'c1', name: 'get_weather', arguments: { city: 'Paris' } }, + { type: 'server_tool_call', name: 'code_interpreter', server_tool_call: {} }, + ], + }) + ).toBe('[tool_call: get_weather] [server_tool_call: code_interpreter]'); + expect( + previewMessage({ role: 'tool', parts: [{ type: 'tool_call_response', response: {} }] }) + ).toBe('[tool_call_response]'); + expect( + previewMessage({ + role: 'user', + parts: [ + { type: 'blob', modality: 'image', content: 'base64' }, + { type: 'uri', modality: 'audio', uri: 's3://a' }, + { type: 'file', modality: 'video', file_id: 'f1' }, + ], + }) + ).toBe('[image] [audio] [video]'); + }); + + it('shows reasoning only when nothing else is present, and skips compaction', () => { + expect( + previewMessage({ role: 'assistant', parts: [{ type: 'reasoning', content: 'x' }] }) + ).toBe('[reasoning]'); + expect(previewMessage({ role: 'assistant', parts: [{ type: 'compaction' }] })).toBe(''); + expect(previewMessage({ role: 'assistant', parts: [{ type: 'custom_part' }] })).toBe( + '[custom_part]' + ); + }); + }); + + describe('previewInputMessages', () => { + it('previews the last user message of the chat history', () => { + const history = JSON.stringify([ + { role: 'system', parts: [text('You are a travel agent')] }, + { role: 'user', parts: [text('Plan a trip to Paris')] }, + { role: 'assistant', parts: [text('Sure, when?')] }, + { role: 'user', parts: [text('Next weekend')] }, + ]); + expect(previewInputMessages(history)).toBe('Next weekend'); + }); + + it('falls back to the last message when there is no user message', () => { + const value = [ + { role: 'assistant', parts: [{ type: 'tool_call', name: 'search' }] }, + { role: 'tool', parts: [{ type: 'tool_call_response', response: 'ok' }] }, + ]; + expect(previewInputMessages(value)).toBe('[tool_call_response]'); + }); + + it('tolerates non-conforming values', () => { + expect( + previewInputMessages( + JSON.stringify([ + { role: 'user', content: 'first' }, + { role: 'user', content: 'legacy last' }, + ]) + ) + ).toBe('legacy last'); + expect(previewInputMessages('just text')).toBe('just text'); + expect(previewInputMessages('[not json')).toBe('[not json'); + expect(previewInputMessages(undefined)).toBe(''); + expect(previewInputMessages('—')).toBe(''); + }); + }); + + describe('previewOutputMessages', () => { + it('joins one line per generation', () => { + const value = JSON.stringify([ + { role: 'assistant', parts: [text('Choice A')], finish_reason: 'stop' }, + { role: 'assistant', parts: [text('Choice B')], finish_reason: 'stop' }, + ]); + expect(previewOutputMessages(value)).toBe('Choice A\nChoice B'); + }); + + it('shows a tool call when the model only called a tool', () => { + const value = [ + { + role: 'assistant', + parts: [{ type: 'tool_call', name: 'get_weather' }], + finish_reason: 'tool_call', + }, + ]; + expect(previewOutputMessages(value)).toBe('[tool_call: get_weather]'); + }); + }); + + describe('previewSpanInput / previewSpanOutput', () => { + it('prefers message previews', () => { + const row = { + input: JSON.stringify([{ role: 'user', parts: [text('hello')] }]), + output: JSON.stringify([{ role: 'assistant', parts: [text('hi there')] }]), + rawDocument: { attributes: { 'gen_ai.tool.call.arguments': '{"x":1}' } }, + }; + expect(previewSpanInput(row)).toBe('hello'); + expect(previewSpanOutput(row)).toBe('hi there'); + }); + + it('falls back to tool call arguments/result on execute_tool spans', () => { + const flat = { + input: '—', + output: '—', + rawDocument: { + attributes: { + 'gen_ai.tool.call.arguments': '{"city": "Paris"}', + 'gen_ai.tool.call.result': { temp: 20 }, + }, + }, + }; + expect(previewSpanInput(flat)).toBe('{"city": "Paris"}'); + expect(previewSpanOutput(flat)).toBe('{"temp":20}'); + + const nested = { + rawDocument: { attributes: { gen_ai: { tool: { call: { arguments: '{"q":"x"}' } } } } }, + }; + expect(previewSpanInput(nested)).toBe('{"q":"x"}'); + expect(previewSpanOutput(nested)).toBe(''); + }); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.ts b/src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.ts new file mode 100644 index 000000000000..d50a415a4390 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.ts @@ -0,0 +1,191 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * One-line previews of OTel GenAI message attributes for table cells and the session + * conversation. + * + * Per the GenAI semantic conventions (`model/gen-ai/gen-ai-input-messages.json`, + * `gen-ai-output-messages.json`), `gen_ai.input.messages` and `gen_ai.output.messages` + * MUST be an array of `{ role, parts[], name? }` messages (output messages add + * `finish_reason`). On spans the value MAY be a JSON string. Input messages are the full + * chat history in send order, so the input preview is the last `user` message. + */ + +export type GenAiRole = 'system' | 'user' | 'assistant' | 'tool' | string; + +export interface GenAiMessagePart { + type: string; + [key: string]: unknown; +} + +export interface GenAiMessage { + role: GenAiRole; + parts: GenAiMessagePart[]; + name?: string; + finish_reason?: string; +} + +const isObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +const isConformingMessage = (v: unknown): v is GenAiMessage => + isObject(v) && + typeof v.role === 'string' && + Array.isArray(v.parts) && + v.parts.every((p) => isObject(p) && typeof p.type === 'string'); + +/** Decode the attribute value (JSON string or structured) without validating its shape. */ +const decode = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +}; + +/** + * Parse a message attribute that conforms to the semconv schema. + * Returns null when the value is missing or does not conform (callers fall back). + */ +export const parseGenAiMessages = (value: unknown): GenAiMessage[] | null => { + const decoded = decode(value); + if (!Array.isArray(decoded) || decoded.length === 0) return null; + return decoded.every(isConformingMessage) ? (decoded as GenAiMessage[]) : null; +}; + +/** Short placeholder for a non-text part, e.g. `[tool_call: get_weather]` or `[image]`. */ +const partPlaceholder = (part: GenAiMessagePart): string | null => { + switch (part.type) { + case 'text': + return null; + case 'tool_call': + case 'server_tool_call': + return `[${part.type}${typeof part.name === 'string' ? `: ${part.name}` : ''}]`; + case 'tool_call_response': + case 'server_tool_call_response': + return `[${part.type}]`; + case 'blob': + case 'uri': + case 'file': + return `[${typeof part.modality === 'string' ? part.modality : part.type}]`; + case 'compaction': + return null; // context-management marker, not user-visible content + default: + return `[${part.type}]`; + } +}; + +/** + * Preview one message: its text parts if any; otherwise placeholders for the other parts. + * Reasoning is only shown when there is nothing else. + */ +export const previewMessage = (message: GenAiMessage): string => { + const text = message.parts + .filter((p) => p.type === 'text' && typeof p.content === 'string') + .map((p) => p.content as string) + .join('\n') + .trim(); + if (text) return text; + + const placeholders = message.parts + .filter((p) => p.type !== 'reasoning') + .map(partPlaceholder) + .filter((p): p is string => p !== null); + if (placeholders.length) return placeholders.join(' '); + + return message.parts.some((p) => p.type === 'reasoning') ? '[reasoning]' : ''; +}; + +/** Fallback for values that do not follow the schema (legacy `{role, content}`, plain text). */ +const legacyPreview = (value: unknown, pick: 'lastUser' | 'all'): string => { + const decoded = decode(value); + if (typeof decoded === 'string') return decoded === '—' ? '' : decoded.trim(); + const list = Array.isArray(decoded) ? decoded : [decoded]; + const messages = list.filter(isObject); + const candidates = + pick === 'lastUser' + ? messages.filter((m) => m.role === 'user').slice(-1) + : messages.filter((m) => m.role !== 'system'); + const chosen = candidates.length ? candidates : messages.slice(-1); + return chosen + .map((m) => { + if (typeof m.content === 'string') return m.content; + if (Array.isArray(m.content)) { + return m.content + .map((c) => (isObject(c) && typeof c.text === 'string' ? c.text : '')) + .join(''); + } + return ''; + }) + .join('\n') + .trim(); +}; + +/** Input preview: the last `user` message (input messages carry the full chat history). */ +export const previewInputMessages = (value: unknown): string => { + if (value === null || value === undefined || value === '' || value === '—') return ''; + const messages = parseGenAiMessages(value); + if (!messages) return legacyPreview(value, 'lastUser'); + const lastUser = [...messages].reverse().find((m) => m.role === 'user'); + return previewMessage(lastUser ?? messages[messages.length - 1]); +}; + +/** Output preview: each output message is one generation (choice); join them. */ +export const previewOutputMessages = (value: unknown): string => { + if (value === null || value === undefined || value === '' || value === '—') return ''; + const messages = parseGenAiMessages(value); + if (!messages) return legacyPreview(value, 'all'); + return messages.map(previewMessage).filter(Boolean).join('\n').trim(); +}; + +/** Read a span attribute stored either as a flat dotted key or as nested objects. */ +const readAttribute = (doc: Record | undefined, key: string): unknown => { + if (!doc) return undefined; + const attrs = doc.attributes; + if (isObject(attrs)) { + if (key in attrs) return attrs[key]; + let cur: unknown = attrs; + for (const part of key.split('.')) { + if (!isObject(cur)) return undefined; + cur = cur[part]; + } + if (cur !== undefined) return cur; + } + return doc[`attributes.${key}`]; +}; + +/** Tool call arguments/results may be JSON strings or structured values. */ +const previewToolValue = (value: unknown): string => { + if (value === null || value === undefined || value === '') return ''; + if (typeof value === 'string') return value.trim(); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +interface SpanLike { + input?: unknown; + output?: unknown; + rawDocument?: Record; +} + +/** + * Input preview for any span row: message preview first, then (for `execute_tool` spans, + * per semconv) `gen_ai.tool.call.arguments`. + */ +export const previewSpanInput = (row: SpanLike): string => + previewInputMessages(row.input) || + previewToolValue(readAttribute(row.rawDocument, 'gen_ai.tool.call.arguments')); + +/** Output preview for any span row: message preview first, then `gen_ai.tool.call.result`. */ +export const previewSpanOutput = (row: SpanLike): string => + previewOutputMessages(row.output) || + previewToolValue(readAttribute(row.rawDocument, 'gen_ai.tool.call.result')); diff --git a/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts b/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts index 3bd56333291b..99a4fef8b3f4 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts +++ b/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts @@ -10,6 +10,7 @@ import { Dataset } from '../../../../../../data/common'; import { usePPLQueryDeps, useTimeVersion } from './use_ppl_query_deps'; import { RootState } from '../../../utils/state_management/store'; import { splitPplWhereAndTail } from '../table_shared'; +import { AGENT_TRACES_SESSION_ID_FIELD } from '../../../../../common'; export interface TraceMetrics { totalTraces: number; @@ -21,6 +22,8 @@ export interface TraceMetrics { latencyP99Nanos: number; errorTraces: number; errorSpans: number; + /** Distinct gen_ai.conversation.id values; null when unavailable (e.g. field not mapped). */ + totalSessions: number | null; } export interface UseTraceMetricsResult { @@ -74,7 +77,7 @@ const doFetchMetrics = async ( // Note: User non-where commands (head, sort, etc.) are intentionally excluded from stats queries. // When a user types `| head 10`, it limits the data display but should not affect aggregate counts // or statistics. The UI hides the "of X total" text when head is detected (see queryEndsWithHead). - const [countStats, filteredStats, filteredCounts] = await Promise.all([ + const [countStats, filteredStats, filteredCounts, sessionStats] = await Promise.all([ // Query A — Counts (source-only, no user filter): // Total Traces, Total Spans, and their error counts are unaffected by user query filters (async () => { @@ -97,6 +100,18 @@ const doFetchMetrics = async ( const response = await pplService.executeQuery(datasetParam, query); return parseStatsResponse(response); })(), + // Query D — Total Sessions (source-only, like Total Traces). Isolated with its own + // catch: indices without the session field would otherwise fail the whole batch. + (async () => { + try { + const sessionField = `\`${AGENT_TRACES_SESSION_ID_FIELD}\``; + const query = `${sourceOnlyQuery} | where isnotnull(${sessionField}) | stats distinct_count(${sessionField}) as total_sessions`; + const response = await pplService.executeQuery(datasetParam, query); + return parseStatsResponse(response); + } catch { + return {} as Record; + } + })(), ]); return { @@ -109,6 +124,7 @@ const doFetchMetrics = async ( latencyP99Nanos: filteredStats.p99_latency ?? 0, errorTraces: countStats.error_traces ?? 0, errorSpans: countStats.error_spans ?? 0, + totalSessions: sessionStats.total_sessions ?? null, }; }; diff --git a/src/plugins/agent_traces/public/application/pages/traces/trace_metrics_bar.test.tsx b/src/plugins/agent_traces/public/application/pages/traces/trace_metrics_bar.test.tsx index 91839d92a0cd..1cc8bd95f688 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/trace_metrics_bar.test.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/trace_metrics_bar.test.tsx @@ -39,6 +39,7 @@ const mockMetrics: TraceMetrics = { latencyP99Nanos: 3_500_000_000, errorTraces: 23, errorSpans: 89, + totalSessions: 8, }; describe('TraceMetricsBar', () => { @@ -69,6 +70,17 @@ describe('TraceMetricsBar', () => { expect(screen.getByText('Latency P99')).toBeInTheDocument(); }); + it('renders Total Sessions', () => { + render(); + expect(screen.getByText('Total Sessions')).toBeInTheDocument(); + expect(screen.getByTestId('agentTracesMetricsTotalSessions')).toHaveTextContent('8'); + }); + + it('renders a dash when sessions are unavailable', () => { + render(); + expect(screen.getByTestId('agentTracesMetricsTotalSessions')).toHaveTextContent('—'); + }); + it('renders error counts when present', () => { render(); expect(screen.getByText('23 errors')).toBeInTheDocument(); diff --git a/src/plugins/agent_traces/public/application/pages/traces/trace_metrics_bar.tsx b/src/plugins/agent_traces/public/application/pages/traces/trace_metrics_bar.tsx index 30217398ce0f..75c741ed9fbb 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/trace_metrics_bar.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/trace_metrics_bar.tsx @@ -136,6 +136,25 @@ export const TraceMetricsBar: React.FC = ({ metrics, onErr )} + + + {i18n.translate('agentTraces.metricsBar.totalSessions', { + defaultMessage: 'Total Sessions', + })} + + } + titleSize="s" + isLoading={showLoading} + data-test-subj="agentTracesMetricsTotalSessions" + /> + ({ jest.mock('./pages/traces/vis_tab', () => ({ VisTab: () => null, })); +jest.mock('./pages/sessions/sessions_tab', () => ({ + SessionsTab: () => null, +})); describe('registerBuiltInTabs', () => { let tabRegistry: TabRegistryService; @@ -30,10 +34,10 @@ describe('registerBuiltInTabs', () => { tabRegistry = new TabRegistryService(); }); - it('should register three tabs', () => { + it('should register four tabs', () => { registerBuiltInTabs(tabRegistry); const tabs = tabRegistry.getAllTabs(); - expect(tabs).toHaveLength(3); + expect(tabs).toHaveLength(4); }); it('should register tabs with correct IDs', () => { @@ -41,14 +45,16 @@ describe('registerBuiltInTabs', () => { expect(tabRegistry.getTab(AGENT_TRACES_TRACES_TAB_ID)).toBeDefined(); expect(tabRegistry.getTab(AGENT_TRACES_SPANS_TAB_ID)).toBeDefined(); expect(tabRegistry.getTab(AGENT_TRACES_VISUALIZATION_TAB_ID)).toBeDefined(); + expect(tabRegistry.getTab(AGENT_TRACES_SESSIONS_TAB_ID)).toBeDefined(); }); - it('should register tabs in order: Traces, Spans, Visualization', () => { + it('should register tabs in order: Traces, Spans, Sessions, Visualization', () => { registerBuiltInTabs(tabRegistry); const tabs = tabRegistry.getAllTabs(); expect(tabs[0].id).toBe(AGENT_TRACES_TRACES_TAB_ID); expect(tabs[1].id).toBe(AGENT_TRACES_SPANS_TAB_ID); - expect(tabs[2].id).toBe(AGENT_TRACES_VISUALIZATION_TAB_ID); + expect(tabs[2].id).toBe(AGENT_TRACES_SESSIONS_TAB_ID); + expect(tabs[3].id).toBe(AGENT_TRACES_VISUALIZATION_TAB_ID); }); it('should set correct labels', () => { @@ -56,6 +62,7 @@ describe('registerBuiltInTabs', () => { expect(tabRegistry.getTab(AGENT_TRACES_TRACES_TAB_ID)!.label).toBe('Traces'); expect(tabRegistry.getTab(AGENT_TRACES_SPANS_TAB_ID)!.label).toBe('Spans'); expect(tabRegistry.getTab(AGENT_TRACES_VISUALIZATION_TAB_ID)!.label).toBe('Visualization'); + expect(tabRegistry.getTab(AGENT_TRACES_SESSIONS_TAB_ID)!.label).toBe('Sessions'); }); it('should assign Traces flavor to all tabs', () => { @@ -158,7 +165,7 @@ describe('registerTabs', () => { registerTabs(services); - expect(tabRegistry.getAllTabs()).toHaveLength(4); + expect(tabRegistry.getAllTabs()).toHaveLength(5); expect(tabRegistry.getTab('custom_tab')).toBeDefined(); }); @@ -168,6 +175,6 @@ describe('registerTabs', () => { registerTabs(services); - expect(tabRegistry.getAllTabs()).toHaveLength(3); + expect(tabRegistry.getAllTabs()).toHaveLength(4); }); }); diff --git a/src/plugins/agent_traces/public/application/register_tabs.ts b/src/plugins/agent_traces/public/application/register_tabs.ts index 13f37707364a..733b762cb9a3 100644 --- a/src/plugins/agent_traces/public/application/register_tabs.ts +++ b/src/plugins/agent_traces/public/application/register_tabs.ts @@ -7,6 +7,7 @@ import { i18n } from '@osd/i18n'; import { TracesTab } from './pages/traces/traces_tab'; import { SpansTab } from './pages/traces/spans_tab'; import { VisTab } from './pages/traces/vis_tab'; +import { SessionsTab } from './pages/sessions/sessions_tab'; import { TabDefinition, TabRegistryService } from '../services/tab_registry/tab_registry_service'; import { AgentTracesServices } from '../types'; import { @@ -15,6 +16,7 @@ import { AGENT_TRACES_TRACES_TAB_ID, AGENT_TRACES_SPANS_TAB_ID, AGENT_TRACES_VISUALIZATION_TAB_ID, + AGENT_TRACES_SESSIONS_TAB_ID, } from '../../common'; import { defaultPrepareQueryString } from './utils/state_management/actions/query_actions'; import { buildPplSortClause, splitPplWhereAndTail } from './pages/traces/table_shared'; @@ -68,6 +70,20 @@ export const registerBuiltInTabs = (tabRegistry: TabRegistryService) => { }; tabRegistry.registerTab(spansTabDefinition); + // Register Sessions Tab: traces grouped by gen_ai.conversation.id. + // No prepareQuery: the tab runs its own stats + root-span queries (see use_sessions.ts), + // since sessions are aggregates rather than span documents. + tabRegistry.registerTab({ + id: AGENT_TRACES_SESSIONS_TAB_ID, + label: i18n.translate('agentTraces.sessionsTab.label', { + defaultMessage: 'Sessions', + }), + flavor: [AgentTracesFlavor.Traces], + order: 25, + supportedLanguages: [AGENT_TRACES_DEFAULT_LANGUAGE], + component: SessionsTab, + }); + // Register Visualization Tab tabRegistry.registerTab({ id: AGENT_TRACES_VISUALIZATION_TAB_ID, diff --git a/src/plugins/agent_traces/public/components/data_table/table_cell/trace_utils/trace_utils.tsx b/src/plugins/agent_traces/public/components/data_table/table_cell/trace_utils/trace_utils.tsx index 73f619d4b2dd..4cb7b8e5b767 100644 --- a/src/plugins/agent_traces/public/components/data_table/table_cell/trace_utils/trace_utils.tsx +++ b/src/plugins/agent_traces/public/components/data_table/table_cell/trace_utils/trace_utils.tsx @@ -30,6 +30,10 @@ import { getCategoryMeta, getOperationNamesForCategory, } from '../../../../services/span_categorization'; +import { + previewSpanInput, + previewSpanOutput, +} from '../../../../application/pages/traces/hooks/genai_message_preview'; import { useTraceExpansion } from '../../../../application/pages/traces/trace_expansion_context'; export const isOnTracesPage = (): boolean => { @@ -288,7 +292,7 @@ const AgentTracesStatusCell: React.FC<{ status: string }> = ({ status }) => (
); -const TokenIcon: React.FC = () => ( +export const TokenIcon: React.FC = () => ( = ({ /> ); break; - case 'input': - content = traceRow.input; - if (typeof traceRow.input === 'string') truncationTooltipText = traceRow.input; + // Preview per the OTel GenAI message schema; raw JSON stays in the trace flyout panels. + case 'input': { + const preview = previewSpanInput(traceRow); + content = preview || '—'; + if (preview) truncationTooltipText = preview; break; - case 'output': - content = traceRow.output; - if (typeof traceRow.output === 'string') truncationTooltipText = traceRow.output; + } + case 'output': { + const preview = previewSpanOutput(traceRow); + content = preview || '—'; + if (preview) truncationTooltipText = preview; break; + } default: content = null; } From 31bd7de1314e6f74e88544c016f2ead4cdfcc8ea Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:22:42 -0700 Subject: [PATCH 02/15] [Agent Traces] Unify Sessions tables and pin session flyout panels - Sessions list and View All Traces use the Traces/Spans DataTable markup: same header, typography, sort and infinite scroll (no pager), with the open session highlighted - DataTableInfoBar supports a session entity - Session flyout: only the conversation scrolls; the trace list and the conversation header with prev/next arrows stay pinned - Focusing a trace highlights its turn; the list chevron opens the trace Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../pages/sessions/session_details_flyout.tsx | 119 ++------ .../sessions/session_spans_table.test.tsx | 59 ++++ .../pages/sessions/session_spans_table.tsx | 163 ++++++++++ .../pages/sessions/sessions_tab.scss | 53 +++- .../pages/sessions/sessions_tab.tsx | 70 ++--- .../pages/sessions/sessions_table.tsx | 287 ++++++++++++------ .../application/pages/traces/table_shared.tsx | 20 +- 7 files changed, 525 insertions(+), 246 deletions(-) create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.test.tsx create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.tsx diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx index 00e2c2d3e8f7..bfd8561e637d 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { i18n } from '@osd/i18n'; import { EuiBadge, - EuiBasicTableColumn, EuiButtonEmpty, EuiButtonIcon, EuiCallOut, @@ -17,9 +16,7 @@ import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, - EuiHealth, EuiIcon, - EuiInMemoryTable, EuiLink, EuiLoadingSpinner, EuiPanel, @@ -28,13 +25,12 @@ import { EuiTabs, EuiText, EuiTitle, - EuiToolTip, } from '@elastic/eui'; import { TraceRow } from '../traces/hooks/tree_utils'; import { useTraceFlyout } from '../traces/flyout/trace_flyout_context'; -import { getCategoryMeta, getSpanCategory } from '../../../services/span_categorization'; import { TokenIcon } from '../../../components/data_table/table_cell/trace_utils/trace_utils'; import { SessionTrace, useSessionDetail } from './hooks/use_session_detail'; +import { SessionSpansTable } from './session_spans_table'; import { previewInputMessages, previewOutputMessages } from '../traces/hooks/genai_message_preview'; import { SessionRow, formatSessionDuration, shortenId } from './session_utils'; @@ -76,28 +72,6 @@ const LatencyAndTokens: React.FC<{ row: TraceRow }> = ({ row }) => { ); }; -const KindBadge: React.FC<{ row: TraceRow }> = ({ row }) => { - const meta = getCategoryMeta(getSpanCategory(row)); - return ( - - {meta.label} - - ); -}; - -const TruncatedText: React.FC<{ text: string }> = ({ text }) => - text ? ( - - {text} - - ) : ( - <>— - ); - /** Human/AI turns for one trace in the session conversation. */ const ConversationTurn: React.FC<{ index: number; @@ -189,53 +163,6 @@ export const SessionDetailsFlyout: React.FC = ({ [traces.length] ); - const drillColumns: Array> = useMemo( - () => [ - { - field: 'status', - name: i18n.translate('agentTraces.sessions.drill.status', { defaultMessage: 'Status' }), - width: '70px', - render: (status: string) => ( - - ), - }, - { - field: 'kind', - name: i18n.translate('agentTraces.sessions.drill.kind', { defaultMessage: 'Kind' }), - width: '110px', - render: (_: string, row: TraceRow) => , - }, - { - field: 'name', - name: i18n.translate('agentTraces.sessions.drill.name', { defaultMessage: 'Name' }), - width: '20%', - render: (name: string) => , - }, - { - field: 'input', - name: i18n.translate('agentTraces.sessions.drill.input', { defaultMessage: 'Input' }), - render: (_: string, row: TraceRow) => , - }, - { - field: 'output', - name: i18n.translate('agentTraces.sessions.drill.output', { defaultMessage: 'Output' }), - render: (_: string, row: TraceRow) => , - }, - { - field: 'latency', - name: i18n.translate('agentTraces.sessions.drill.latency', { defaultMessage: 'Latency' }), - width: '100px', - render: (latency: string) => ( - - - {latency} - - ), - }, - ], - [] - ); - const drillItems = useMemo( () => (drillTab === 'traces' ? traces.map((t) => t.root) : traces.flatMap((t) => t.spans)), [drillTab, traces] @@ -363,18 +290,38 @@ export const SessionDetailsFlyout: React.FC = ({ {traces.map((trace, i) => ( - + ))} @@ -492,19 +439,7 @@ export const SessionDetailsFlyout: React.FC = ({ - - key={drillTab} - items={drillItems} - columns={drillColumns} - pagination={{ initialPageSize: 25, pageSizeOptions: [25, 50, 100] }} - tableLayout="fixed" - className="agtSessionsTable" - rowProps={(row: TraceRow) => ({ - className: 'agtSessionsTable__row', - onClick: () => openTrace(row), - })} - data-test-subj="agentTracesSessionDrillTable" - /> + )} diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.test.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.test.tsx new file mode 100644 index 000000000000..b49dcd755326 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { SessionSpansTable } from './session_spans_table'; +import { TraceRow } from '../traces/hooks/tree_utils'; + +const row = (overrides: Partial = {}): TraceRow => ({ + id: 's1', + spanId: 's1', + traceId: 't1', + parentSpanId: null, + status: 'success', + kind: 'invoke_agent', + name: 'POST /plan', + input: JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'Plan a trip' }] }]), + output: JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'Sure!' }] }]), + startTime: '', + endTime: '', + latency: '2.70s', + durationNanos: 2.7e9, + totalTokens: 100, + inputTokens: 60, + outputTokens: 40, + totalCost: '—', + ...overrides, +}); + +describe('SessionSpansTable', () => { + it('renders status, kind, name, message previews and latency', () => { + render(); + expect(screen.getByText('Success')).toBeInTheDocument(); + expect(screen.getByText('POST /plan')).toBeInTheDocument(); + expect(screen.getByText('Plan a trip')).toBeInTheDocument(); + expect(screen.getByText('Sure!')).toBeInTheDocument(); + expect(screen.getByText('2.70s')).toBeInTheDocument(); + }); + + it('shows Error status and a dash for empty previews', () => { + render( + + ); + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getAllByText('—').length).toBe(2); + }); + + it('calls onRowClick with the row', () => { + const onRowClick = jest.fn(); + const r = row(); + render(); + fireEvent.click(screen.getByText('POST /plan')); + expect(onRowClick).toHaveBeenCalledWith(r); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.tsx new file mode 100644 index 000000000000..641ad476e630 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.tsx @@ -0,0 +1,163 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiBadge, EuiHealth, EuiProgress } from '@elastic/eui'; +import { TableHeaderColumn } from '../../../components/data_table/table_header/table_header_column'; +import { getCategoryMeta, getSpanCategory } from '../../../services/span_categorization'; +import { TraceRow } from '../traces/hooks/tree_utils'; +import { previewSpanInput, previewSpanOutput } from '../traces/hooks/genai_message_preview'; + +const LAZY_LOAD_BATCH_SIZE = 50; + +export const KindBadge: React.FC<{ row: TraceRow }> = ({ row }) => { + const meta = getCategoryMeta(getSpanCategory(row)); + return ( + + {meta.label} + + ); +}; + +const setTitleIfTruncated = (text: string) => (e: React.MouseEvent) => { + const el = e.currentTarget; + el.title = el.scrollWidth > el.clientWidth ? text : ''; +}; + +interface Column { + key: 'status' | 'kind' | 'name' | 'input' | 'output' | 'latency'; + label: string; + wideText?: boolean; +} + +const COLUMNS: Column[] = [ + { + key: 'status', + label: i18n.translate('agentTraces.sessions.drill.status', { defaultMessage: 'Status' }), + }, + { + key: 'kind', + label: i18n.translate('agentTraces.sessions.drill.kind', { defaultMessage: 'Kind' }), + }, + { + key: 'name', + label: i18n.translate('agentTraces.sessions.drill.name', { defaultMessage: 'Name' }), + }, + { + key: 'input', + label: i18n.translate('agentTraces.sessions.drill.input', { defaultMessage: 'Input' }), + wideText: true, + }, + { + key: 'output', + label: i18n.translate('agentTraces.sessions.drill.output', { defaultMessage: 'Output' }), + wideText: true, + }, + { + key: 'latency', + label: i18n.translate('agentTraces.sessions.drill.latency', { defaultMessage: 'Latency' }), + }, +]; + +interface SessionSpansTableProps { + rows: TraceRow[]; + onRowClick: (row: TraceRow) => void; +} + +/** Session-scoped traces/spans table, using the same markup and styles as the Traces tab. */ +export const SessionSpansTable: React.FC = ({ rows, onRowClick }) => { + const [renderedCount, setRenderedCount] = useState(LAZY_LOAD_BATCH_SIZE); + useEffect(() => setRenderedCount(LAZY_LOAD_BATCH_SIZE), [rows]); + const observerRef = useRef(null); + const sentinelRef = useCallback((node: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + if (node && typeof IntersectionObserver !== 'undefined') { + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) setRenderedCount((c) => c + LAZY_LOAD_BATCH_SIZE); + }, + { threshold: 0.1 } + ); + observerRef.current.observe(node); + } + }, []); + useEffect(() => () => observerRef.current?.disconnect(), []); + + const renderCell = (row: TraceRow, key: Column['key']) => { + switch (key) { + case 'status': + return ( + + {row.status === 'success' + ? i18n.translate('agentTraces.dataTable.statusSuccess', { defaultMessage: 'Success' }) + : i18n.translate('agentTraces.dataTable.statusError', { defaultMessage: 'Error' })} + + ); + case 'kind': + return ; + case 'latency': + return row.latency; + default: { + const text = + key === 'input' + ? previewSpanInput(row) + : key === 'output' + ? previewSpanOutput(row) + : row.name; + return text ? {text} : '—'; + } + } + }; + + return ( +
+ + + + {COLUMNS.map((c) => ( + + ))} + + + + {rows.slice(0, renderedCount).map((row) => ( + onRowClick(row)}> + {COLUMNS.map((c) => ( + + ))} + + ))} + +
+
+ + {renderCell(row, c.key)} + +
+
+ {renderedCount < rows.length && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss index 5fcdfd286bc1..e4d88f11bb41 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss @@ -1,14 +1,5 @@ -.agentTraces-sessions-tab { - display: flex; - flex-direction: column; - overflow: auto; -} - -// Tab containers are column-flex; keep the info bar and table at their natural height. -.agtSessionsTab__infoBar { - flex-grow: 0; - padding: $euiSizeS $euiSizeS 0; -} +// Flyout header height plus body padding; the overview fills the rest of the viewport. +$agtSessionFlyoutChrome: 150px; // Match the Traces/Spans DataTable typography (data_table.scss): code font at XS size. .agtSessionsTable { @@ -71,18 +62,39 @@ gap: $euiSizeXS; } + // Overview fills the flyout body; the trace list stays put and only the + // conversation scrolls. Offset = flyout header + body padding. + &__overview { + height: calc(100vh - #{$agtSessionFlyoutChrome}); + } + &__traceList { - position: sticky; - top: 0; + height: 100%; + overflow-y: auto; border-right: $euiBorderThin; padding-right: $euiSizeM; } + &__conversation { + height: 100%; + overflow-y: auto; + padding-right: $euiSizeS; + } + // Flex items are column-flex; stop the panel headers from growing and centering. &__panelHeader { flex-grow: 0; } + // Keep the "Session Conversation" title and its prev/next arrows visible while scrolling. + &__conversation > &__panelHeader { + position: sticky; + top: 0; + z-index: 1; + padding-bottom: $euiSizeS; + background: $euiColorEmptyShade; + } + &__traceItem { display: flex; width: 100%; @@ -104,8 +116,21 @@ } } + &__traceItemLabel { + display: inline-flex; + align-items: center; + gap: $euiSizeXS; + } + &__turn { - scroll-margin-top: $euiSizeS; + scroll-margin-top: $euiSizeXXL; // clear the sticky conversation header + padding-left: $euiSizeS; + border-left: $euiBorderWidthThick solid transparent; + transition: border-color $euiAnimSpeedFast; + + &--focused { + border-left-color: $euiColorPrimary; + } } &__stats { diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx index 04d8d45d2ffc..8fac65c5827e 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx @@ -7,18 +7,12 @@ import React, { useCallback, useMemo, useState } from 'react'; import moment from 'moment-timezone'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; -import { - EuiCallOut, - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiSwitch, - EuiText, -} from '@elastic/eui'; +import { EuiCallOut, EuiEmptyPrompt } from '@elastic/eui'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; import { AgentTracesServices } from '../../../types'; import { formatTimestamp } from '../traces/hooks/tree_utils'; -import { TableLoadingState } from '../traces/table_shared'; +import { DataTableInfoBar, TableLoadingState } from '../traces/table_shared'; +import '../traces/traces_table.scss'; import { useSessions } from './hooks/use_sessions'; import { SessionsTable } from './sessions_table'; import { SessionDetailsFlyout } from './session_details_flyout'; @@ -89,48 +83,32 @@ export const SessionsTab = () => { ); } else { body = ( - +
+ +
); } return (
- - - - - - - - setWrapCellText(e.target.checked)} - compressed - data-test-subj="agentTracesSessionsWrapSwitch" - /> - - - {body} +
+ + {body} +
{selected && ( string; wrapCellText: boolean; onSessionClick: (session: SessionRow) => void; + /** Session currently open in the flyout; its row is highlighted. */ + selectedSessionId?: string; } -/** Text cell: single line with a tooltip, or full text when wrapping is on. */ -const TextCell: React.FC<{ text: string; wrap: boolean; mono?: boolean }> = ({ - text, - wrap, - mono, -}) => { - if (!text) return <>—; - const className = `agtSessionsTable__text${wrap ? ' agtSessionsTable__text--wrap' : ''}${ - mono ? ' agtSessionsTable__text--mono' : '' - }`; - if (wrap) return {text}; - return ( - - {text} - - ); -}; +type SessionColumnKey = + | 'startTime' + | 'sessionId' + | 'firstMessage' + | 'lastMessage' + | 'durationMs' + | 'userId' + | 'totalTraces' + | 'totalTokens'; + +interface SessionColumn { + key: SessionColumnKey; + label: string; + sortable: boolean; + wideText?: boolean; +} export const TokensBadge: React.FC<{ tokens: number | null }> = ({ tokens }) => tokens === null ? ( @@ -50,111 +51,213 @@ export const TokensBadge: React.FC<{ tokens: number | null }> = ({ tokens }) => ); +/** Show the full text as a native tooltip only when the cell is truncated (like DataTable). */ +const setTitleIfTruncated = (text: string) => (e: React.MouseEvent) => { + const el = e.currentTarget; + el.title = el.scrollWidth > el.clientWidth ? text : ''; +}; + +const compareSessions = (a: SessionRow, b: SessionRow, key: SessionColumnKey): number => { + const va = a[key]; + const vb = b[key]; + if (va === vb) return 0; + if (va === null || va === undefined) return 1; // missing values last + if (vb === null || vb === undefined) return -1; + if (typeof va === 'number' && typeof vb === 'number') return va - vb; + return String(va).localeCompare(String(vb)); // PPL timestamps sort lexicographically +}; + export const SessionsTable: React.FC = ({ sessions, formatTs, wrapCellText, onSessionClick, + selectedSessionId, }) => { + const [sortOrder, setSortOrder] = useState(DEFAULT_SORT); + // User ID comes from the optional `user.id` attribute; hide the column when no session has one. const hasUserIds = useMemo(() => sessions.some((s) => !!s.userId), [sessions]); - const columns: Array> = useMemo( - () => [ + const columns: SessionColumn[] = useMemo(() => { + const cols: SessionColumn[] = [ { - field: 'startTime', - name: i18n.translate('agentTraces.sessions.column.time', { defaultMessage: 'Time' }), + key: 'startTime', + label: i18n.translate('agentTraces.sessions.column.time', { defaultMessage: 'Time' }), sortable: true, - width: '215px', - render: (startTime: string, session: SessionRow) => ( - onSessionClick(session)} - data-test-subj="agentTracesSessionTimeLink" - > - {formatTs(startTime)} - - ), }, { - field: 'sessionId', - name: i18n.translate('agentTraces.sessions.column.sessionId', { + key: 'sessionId', + label: i18n.translate('agentTraces.sessions.column.sessionId', { defaultMessage: 'Session ID', }), - width: '170px', - render: (id: string) => , + sortable: true, }, { - field: 'firstMessage', - name: i18n.translate('agentTraces.sessions.column.firstMessage', { + key: 'firstMessage', + label: i18n.translate('agentTraces.sessions.column.firstMessage', { defaultMessage: 'First Message', }), - render: (text: string) => , + sortable: false, + wideText: true, }, { - field: 'lastMessage', - name: i18n.translate('agentTraces.sessions.column.lastMessage', { + key: 'lastMessage', + label: i18n.translate('agentTraces.sessions.column.lastMessage', { defaultMessage: 'Last Message', }), - render: (text: string) => , + sortable: false, + wideText: true, }, { - field: 'durationMs', - name: i18n.translate('agentTraces.sessions.column.duration', { + key: 'durationMs', + label: i18n.translate('agentTraces.sessions.column.duration', { defaultMessage: 'Duration', }), sortable: true, - width: '100px', - render: (ms: number) => formatSessionDuration(ms), }, - ...(hasUserIds - ? [ - { - field: 'userId', - name: i18n.translate('agentTraces.sessions.column.userId', { - defaultMessage: 'User ID', - }), - width: '140px', - render: (id: string | null) => , - }, - ] - : []), + ]; + if (hasUserIds) { + cols.push({ + key: 'userId', + label: i18n.translate('agentTraces.sessions.column.userId', { defaultMessage: 'User ID' }), + sortable: true, + }); + } + cols.push( { - field: 'totalTraces', - name: i18n.translate('agentTraces.sessions.column.totalTraces', { + key: 'totalTraces', + label: i18n.translate('agentTraces.sessions.column.totalTraces', { defaultMessage: 'Total Traces', }), sortable: true, - width: '105px', - render: (n: number) => {n.toLocaleString()}, }, { - field: 'totalTokens', - name: i18n.translate('agentTraces.sessions.column.totalTokens', { + key: 'totalTokens', + label: i18n.translate('agentTraces.sessions.column.totalTokens', { defaultMessage: 'Total Tokens', }), sortable: true, - width: '120px', - render: (tokens: number | null) => , - }, - ], - [formatTs, wrapCellText, onSessionClick, hasUserIds] - ); + } + ); + return cols; + }, [hasUserIds]); + + const sortedSessions = useMemo(() => { + const [field, direction] = (sortOrder[0] ?? DEFAULT_SORT[0]) as [SessionColumnKey, string]; + const sign = direction === 'asc' ? 1 : -1; + return [...sessions].sort((a, b) => sign * compareSessions(a, b, field)); + }, [sessions, sortOrder]); + + // Infinite-scroll lazy loading, mirroring the Traces/Spans DataTable. + const [renderedCount, setRenderedCount] = useState(LAZY_LOAD_BATCH_SIZE); + useEffect(() => setRenderedCount(LAZY_LOAD_BATCH_SIZE), [sessions, sortOrder]); + const observerRef = useRef(null); + const sentinelRef = useCallback((node: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + if (node && typeof IntersectionObserver !== 'undefined') { + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) setRenderedCount((c) => c + LAZY_LOAD_BATCH_SIZE); + }, + { threshold: 0.1 } + ); + observerRef.current.observe(node); + } + }, []); + useEffect(() => () => observerRef.current?.disconnect(), []); + + const visible = sortedSessions.slice(0, renderedCount); + + const renderCell = (session: SessionRow, column: SessionColumn) => { + switch (column.key) { + case 'startTime': + return ( + { + e.stopPropagation(); + onSessionClick(session); + }} + data-test-subj="agentTracesSessionTimeLink" + > + {formatTs(session.startTime)} + + ); + case 'durationMs': + return formatSessionDuration(session.durationMs); + case 'totalTraces': + return session.totalTraces.toLocaleString(); + case 'totalTokens': + return ; + default: { + const text = (session[column.key] as string | null) ?? ''; + return text ? ( + {text} + ) : ( + '—' + ); + } + } + }; return ( - - items={sessions} - itemId="sessionId" - columns={columns} - sorting={{ sort: { field: 'startTime', direction: 'desc' } }} - pagination={{ initialPageSize: 25, pageSizeOptions: [25, 50, 100] }} - rowProps={(session: SessionRow) => ({ - className: 'agtSessionsTable__row', - onClick: () => onSessionClick(session), - 'data-test-subj': `agentTracesSessionRow-${session.sessionId}`, - })} - tableLayout="fixed" - className="agtSessionsTable" - data-test-subj="agentTracesSessionsTable" - /> +
+ + + + {columns.map((column) => ( + setSortOrder(next.length ? next : DEFAULT_SORT)} + /> + ))} + + + + {visible.map((session) => ( + onSessionClick(session)} + data-test-subj={`agentTracesSessionRow-${session.sessionId}`} + > + {columns.map((column) => ( + + ))} + + ))} + +
+
+ + {renderCell(session, column)} + +
+
+ {renderedCount < sortedSessions.length && ( +
+ +
+ )} +
); }; diff --git a/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx b/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx index ad3b952056e0..2f555df2e945 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx @@ -297,7 +297,7 @@ interface DataTableInfoBarProps { hitsCount: number; totalCount: number; elapsedMs: number | undefined; - entityName: 'span' | 'trace'; + entityName: 'span' | 'trace' | 'session'; wrapCellText: boolean; onWrapCellTextChange: (v: boolean) => void; } @@ -365,6 +365,17 @@ const TraceCountMessage: React.FC = ({ /> ); +const SessionCountMessage: React.FC = ({ hitsCount, elapsedMs }) => ( + {hitsCount.toLocaleString()}, + elapsed: {elapsedMs != null ? elapsedMs.toLocaleString() : '—'}, + }} + /> +); + export const DataTableInfoBar: React.FC = ({ hasHead, hitsCount, @@ -374,7 +385,12 @@ export const DataTableInfoBar: React.FC = ({ wrapCellText, onWrapCellTextChange, }) => { - const CountMessage = entityName === 'span' ? SpanCountMessage : TraceCountMessage; + const CountMessage = + entityName === 'span' + ? SpanCountMessage + : entityName === 'session' + ? SessionCountMessage + : TraceCountMessage; return ( Date: Tue, 29 Sep 2026 08:35:11 -0700 Subject: [PATCH 03/15] [Agent Traces] Session facets in the fields panel - TabDefinition gains optional facetFields; groupFields/DiscoverSidebar take the active tab's list (defaults unchanged for Traces/Spans) - Sessions shares the Traces root-span prepareQuery (same cache key) so the fields panel has data, with service/agent/status facets - Facet filters select which sessions are listed; each session's stats are computed unfiltered so trace counts and durations stay complete Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../pages/sessions/hooks/use_sessions.ts | 26 ++++++++++++++----- .../pages/sessions/session_utils.test.ts | 16 +++++++++--- .../pages/sessions/session_utils.ts | 17 +++++++++--- .../public/application/register_tabs.ts | 22 ++++++++++------ .../fields_selector/discover_sidebar.tsx | 20 +++++++++++--- .../fields_selector/fields_selector_panel.tsx | 1 + .../fields_selector/lib/group_fields.test.ts | 6 +++++ .../fields_selector/lib/group_fields.ts | 14 ++++++---- .../tab_registry/tab_registry_service.ts | 6 +++++ 9 files changed, 97 insertions(+), 31 deletions(-) diff --git a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts index be7ecff07f4e..379e7bcf60c9 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts @@ -15,6 +15,7 @@ import { SessionRow, assembleSessionRows, buildRootSpansQuery, + buildMatchingSessionIdsQuery, buildSessionStatsQuery, buildTraceSessionMapQuery, getSourceCommand, @@ -33,9 +34,10 @@ export interface UseSessionsResult { /** * Fetch the sessions list for the current query and time range. * - * 1. Stats query: one row per session id (trace count, start/end). - * 2. Trace map: which traces belong to each session (any span may carry the id). - * 3. Root spans of those traces: first/last message, tokens, user id. + * 1. Session ids matching the user's query (the filter selects sessions). + * 2. Unfiltered stats for those sessions (trace count, start/end). + * 3. Trace map: which traces belong to each session (any span may carry the id). + * 4. Root spans of those traces: first/last message, tokens, user id. */ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult => { const { services, pplService, datasetParam, baseQueryString } = usePPLQueryDeps(); @@ -60,11 +62,23 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult const { whereQuery } = splitPplWhereAndTail(baseQueryString); const source = getSourceCommand(whereQuery); - const statsResponse = await pplService.executeQuery( + // 1. Sessions matching the user's query; 2. their full stats (not narrowed by the filter). + const idsResponse = await pplService.executeQuery( datasetParam, - buildSessionStatsQuery(whereQuery) + buildMatchingSessionIdsQuery(whereQuery) ); - const stats = parseSessionStats(pplResponseToRecords(statsResponse)); + const sessionIds = pplResponseToRecords(idsResponse) + .map((r) => r[AGENT_TRACES_SESSION_ID_FIELD]) + .filter((id): id is string => typeof id === 'string' && id !== ''); + + let stats: ReturnType = []; + if (sessionIds.length > 0) { + const statsResponse = await pplService.executeQuery( + datasetParam, + buildSessionStatsQuery(source, sessionIds) + ); + stats = parseSessionStats(pplResponseToRecords(statsResponse)); + } let rows: SessionRow[] = []; if (stats.length > 0) { diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts index 6529d1e19b13..825656019e4a 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts @@ -7,6 +7,7 @@ import { assembleSessionRows, buildRootSpansQuery, buildSessionSpansQuery, + buildMatchingSessionIdsQuery, buildSessionStatsQuery, buildTraceSessionMapQuery, formatSessionDuration, @@ -53,10 +54,17 @@ const root = ( describe('session_utils', () => { describe('query builders', () => { - it('builds the stats query grouped by conversation id', () => { - const q = buildSessionStatsQuery('source = otel-v1-apm-span-* | where serviceName = "a"', 50); - expect(q).toBe( - 'source = otel-v1-apm-span-* | where serviceName = "a" | where isnotnull(`attributes.gen_ai.conversation.id`) | stats distinct_count(traceId) as total_traces, min(startTime) as start_time, max(endTime) as end_time by `attributes.gen_ai.conversation.id` | sort - start_time | head 50' + it('selects matching session ids with the user filter, most recent first', () => { + expect( + buildMatchingSessionIdsQuery('source = otel-v1-apm-span-* | where serviceName = "a"', 50) + ).toBe( + 'source = otel-v1-apm-span-* | where serviceName = "a" | where isnotnull(`attributes.gen_ai.conversation.id`) | stats max(endTime) as last_seen by `attributes.gen_ai.conversation.id` | sort - last_seen | head 50' + ); + }); + + it('computes unfiltered stats for the selected sessions', () => { + expect(buildSessionStatsQuery('source = otel-v1-apm-span-*', ['s1', 's2'])).toBe( + 'source = otel-v1-apm-span-* | where `attributes.gen_ai.conversation.id` in ("s1", "s2") | stats distinct_count(traceId) as total_traces, min(startTime) as start_time, max(endTime) as end_time by `attributes.gen_ai.conversation.id` | sort - start_time' ); }); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts index b238f4427cea..a03b15d8611a 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts @@ -80,11 +80,20 @@ export const pplResponseToRecords = (response: any): Array> const inList = (values: string[]): string => values.map((v) => escapePPLValue(v)).join(', '); /** - * Sessions list: one row per session id with trace count and time bounds. - * `whereQuery` is the user's query (source + where clauses, no tail commands). + * Session ids matching the user's query (source + where clauses), most recent first. + * The user's filter selects which sessions are listed; their stats are computed unfiltered. */ -export const buildSessionStatsQuery = (whereQuery: string, limit = SESSIONS_PAGE_LIMIT): string => - `${whereQuery} | where isnotnull(${SESSION_FIELD_PPL}) | stats distinct_count(traceId) as total_traces, min(startTime) as start_time, max(endTime) as end_time by ${SESSION_FIELD_PPL} | sort - start_time | head ${limit}`; +export const buildMatchingSessionIdsQuery = ( + whereQuery: string, + limit = SESSIONS_PAGE_LIMIT +): string => + `${whereQuery} | where isnotnull(${SESSION_FIELD_PPL}) | stats max(endTime) as last_seen by ${SESSION_FIELD_PPL} | sort - last_seen | head ${limit}`; + +/** Full (unfiltered) stats for the given sessions: trace count and time bounds. */ +export const buildSessionStatsQuery = (source: string, sessionIds: string[]): string => + `${source} | where ${SESSION_FIELD_PPL} in (${inList( + sessionIds + )}) | stats distinct_count(traceId) as total_traces, min(startTime) as start_time, max(endTime) as end_time by ${SESSION_FIELD_PPL} | sort - start_time`; /** Maps each trace id to its session id. Any span in a trace may carry the session id. */ export const buildTraceSessionMapQuery = (source: string, sessionIds: string[]): string => diff --git a/src/plugins/agent_traces/public/application/register_tabs.ts b/src/plugins/agent_traces/public/application/register_tabs.ts index 733b762cb9a3..e77f957cfb31 100644 --- a/src/plugins/agent_traces/public/application/register_tabs.ts +++ b/src/plugins/agent_traces/public/application/register_tabs.ts @@ -22,6 +22,14 @@ import { defaultPrepareQueryString } from './utils/state_management/actions/quer import { buildPplSortClause, splitPplWhereAndTail } from './pages/traces/table_shared'; import { prepareQueryForLanguage } from './utils/languages'; +/** Root agent spans (one per trace). Shared by Traces and Sessions so they share a cache key. */ +const prepareRootSpansQuery: NonNullable = (query, sort) => { + const baseQuery = defaultPrepareQueryString(query); + const { whereQuery, tailCommands } = splitPplWhereAndTail(baseQuery); + const sortClause = sort?.length ? ` ${buildPplSortClause(sort[0][0], sort[0][1])}` : ''; + return `${whereQuery} | where parentSpanId = "" AND isnotnull(\`attributes.gen_ai.operation.name\`) ${tailCommands}${sortClause}`; +}; + /** * Registers built-in tabs with the tab registry * Agent Traces only supports Traces @@ -37,12 +45,7 @@ export const registerBuiltInTabs = (tabRegistry: TabRegistryService) => { order: 10, supportedLanguages: [AGENT_TRACES_DEFAULT_LANGUAGE], - prepareQuery: (query, sort) => { - const baseQuery = defaultPrepareQueryString(query); - const { whereQuery, tailCommands } = splitPplWhereAndTail(baseQuery); - const sortClause = sort?.length ? ` ${buildPplSortClause(sort[0][0], sort[0][1])}` : ''; - return `${whereQuery} | where parentSpanId = "" AND isnotnull(\`attributes.gen_ai.operation.name\`) ${tailCommands}${sortClause}`; - }, + prepareQuery: prepareRootSpansQuery, component: TracesTab, }; @@ -71,8 +74,9 @@ export const registerBuiltInTabs = (tabRegistry: TabRegistryService) => { tabRegistry.registerTab(spansTabDefinition); // Register Sessions Tab: traces grouped by gen_ai.conversation.id. - // No prepareQuery: the tab runs its own stats + root-span queries (see use_sessions.ts), - // since sessions are aggregates rather than span documents. + // The sessions list itself comes from its own stats queries (see use_sessions.ts). The + // root-span prepareQuery (same cache key as Traces) feeds the fields panel so facet + // filters work; a facet filter narrows sessions to those containing matching traces. tabRegistry.registerTab({ id: AGENT_TRACES_SESSIONS_TAB_ID, label: i18n.translate('agentTraces.sessionsTab.label', { @@ -81,6 +85,8 @@ export const registerBuiltInTabs = (tabRegistry: TabRegistryService) => { flavor: [AgentTracesFlavor.Traces], order: 25, supportedLanguages: [AGENT_TRACES_DEFAULT_LANGUAGE], + prepareQuery: prepareRootSpansQuery, + facetFields: ['serviceName', 'attributes.gen_ai.agent.name', 'status.code'], component: SessionsTab, }); diff --git a/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.tsx b/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.tsx index a71368f42a3e..64064c19c71e 100644 --- a/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.tsx +++ b/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.tsx @@ -27,6 +27,10 @@ export interface DiscoverSidebarProps { * the selected columns displayed in the doc table in discover */ columns: string[]; + /** + * Fields shown as faceted fields; defaults to the span-level facets + */ + facetFields?: readonly string[]; /** * a statistics of the distribution of fields in the given hits */ @@ -64,8 +68,15 @@ export interface DiscoverSidebarProps { } export function DiscoverSidebar(props: DiscoverSidebarProps) { - const { columns, fieldCounts, hits, selectedDataSet, isEnhancementsEnabledOverride, onCollapse } = - props; + const { + columns, + fieldCounts, + hits, + selectedDataSet, + isEnhancementsEnabledOverride, + onCollapse, + facetFields, + } = props; const [fieldFilterState, setFieldFilterState] = useState(getDefaultFieldFilter()); const flavorId = useFlavorId(); const shortDotsEnabled = useMemo(() => { @@ -103,9 +114,10 @@ export function DiscoverSidebar(props: DiscoverSidebarProps) { effectiveColumns, fieldCounts, fieldFilterState, - showFacetedFields + showFacetedFields, + facetFields ); - }, [flavorId, fields, columns, fieldCounts, fieldFilterState, selectedDataSet]); + }, [flavorId, fields, columns, fieldCounts, fieldFilterState, selectedDataSet, facetFields]); const fieldTypes = useMemo(() => { const result = ['any']; diff --git a/src/plugins/agent_traces/public/components/fields_selector/fields_selector_panel.tsx b/src/plugins/agent_traces/public/components/fields_selector/fields_selector_panel.tsx index 5e0f259dc66e..838e298b67ea 100644 --- a/src/plugins/agent_traces/public/components/fields_selector/fields_selector_panel.tsx +++ b/src/plugins/agent_traces/public/components/fields_selector/fields_selector_panel.tsx @@ -89,6 +89,7 @@ export function DiscoverPanel({ collapsePanel }: IDiscoverPanelProps) { return ( { diff --git a/src/plugins/agent_traces/public/components/fields_selector/lib/group_fields.test.ts b/src/plugins/agent_traces/public/components/fields_selector/lib/group_fields.test.ts index 0b634f8180a0..b002e2bad0fc 100644 --- a/src/plugins/agent_traces/public/components/fields_selector/lib/group_fields.test.ts +++ b/src/plugins/agent_traces/public/components/fields_selector/lib/group_fields.test.ts @@ -212,6 +212,12 @@ describe('group_fields', function () { ); expect(actual.facetedFields.map((f) => f.name)).toContain('status.code'); expect(actual.queryFields.map((f) => f.name)).toContain('regularField'); + + // A tab can supply its own facet list (e.g. Sessions) + const custom = groupFields(fields as any, columns, fieldCounts, fieldFilterState, true, [ + 'regularField', + ]); + expect(custom.facetedFields.map((f) => f.name)).toEqual(['regularField']); }); it('should handle empty or null fields', function () { diff --git a/src/plugins/agent_traces/public/components/fields_selector/lib/group_fields.ts b/src/plugins/agent_traces/public/components/fields_selector/lib/group_fields.ts index fcb0f9f0f24b..9f2c80ed96d1 100644 --- a/src/plugins/agent_traces/public/components/fields_selector/lib/group_fields.ts +++ b/src/plugins/agent_traces/public/components/fields_selector/lib/group_fields.ts @@ -33,12 +33,15 @@ import { FieldFilterState, isFieldFiltered } from './field_filter'; import { AGENT_TRACES_COLUMN_DISPLAY_NAMES } from '../../../../common'; // TODO: Use data set defined faceted field -const FACET_FIELDS = ['attributes.gen_ai.operation.name', 'status.code'] as const; +export const DEFAULT_FACET_FIELDS: readonly string[] = [ + 'attributes.gen_ai.operation.name', + 'status.code', +]; -function isFacetedField(fieldName: string): fieldName is (typeof FACET_FIELDS)[number] { +function isFacetedField(fieldName: string, facetFields: readonly string[]): boolean { // Remove invisiable char const normalizedFieldName = fieldName.replace(/[\u200b-\u200f\uFEFF]/g, ''); - return (FACET_FIELDS as readonly string[]).includes(normalizedFieldName); + return facetFields.includes(normalizedFieldName); } interface GroupedFields { @@ -56,7 +59,8 @@ export function groupFields( columns: string[], fieldCounts: Record, fieldFilterState: FieldFilterState, - showFacetedFields: boolean = false + showFacetedFields: boolean = false, + facetFields: readonly string[] = DEFAULT_FACET_FIELDS ): GroupedFields { const result: GroupedFields = { facetedFields: [], @@ -91,7 +95,7 @@ export function groupFields( if (!isFieldFiltered(field, fieldFilterState, fieldCounts) || field.type === '_source') { continue; } - if (showFacetedFields && isFacetedField(field.name)) { + if (showFacetedFields && isFacetedField(field.name, facetFields)) { result.facetedFields.push(field); } if (columns.includes(field.name)) { diff --git a/src/plugins/agent_traces/public/services/tab_registry/tab_registry_service.ts b/src/plugins/agent_traces/public/services/tab_registry/tab_registry_service.ts index ba3971577cae..60a58dd4bee1 100644 --- a/src/plugins/agent_traces/public/services/tab_registry/tab_registry_service.ts +++ b/src/plugins/agent_traces/public/services/tab_registry/tab_registry_service.ts @@ -33,6 +33,12 @@ export interface TabDefinition { // Language-aware query handling supportedLanguages: string[]; + /** + * Fields shown as "Faceted fields" in the fields panel while this tab is active. + * Defaults to the span-level facets when omitted. + */ + facetFields?: string[]; + // Transform query string for cache key generation. // When sort is provided, it is appended as a PPL sort clause. prepareQuery?: (query: Query, sort?: SortOrder[]) => string; From b5fa79bd115659719052d24f66e72500c6f4a8a6 Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:48:19 -0700 Subject: [PATCH 04/15] [Agent Traces] Formatted/JSON message views with copy - Shared MessageContent view: Formatted renders semconv messages as role-labeled markdown (EuiMarkdownFormat); JSON shows the attribute pretty-printed; copy follows the active view - Trace flyout Input/Output gets the toggle and per-section copy, with a gen_ai.tool.call.* fallback for execute_tool spans - Session conversation gets a global toggle and per-message copy Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../pages/sessions/session_details_flyout.tsx | 131 ++++++++---- .../pages/sessions/sessions_tab.scss | 6 +- .../traces/flyout/flyout_detail_panel.tsx | 109 ++++++---- .../traces/flyout/message_content_view.scss | 25 +++ .../flyout/message_content_view.test.tsx | 84 ++++++++ .../traces/flyout/message_content_view.tsx | 199 ++++++++++++++++++ 6 files changed, 469 insertions(+), 85 deletions(-) create mode 100644 src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.scss create mode 100644 src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.test.tsx create mode 100644 src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.tsx diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx index bfd8561e637d..f0a80a9fcaac 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx @@ -31,6 +31,14 @@ import { useTraceFlyout } from '../traces/flyout/trace_flyout_context'; import { TokenIcon } from '../../../components/data_table/table_cell/trace_utils/trace_utils'; import { SessionTrace, useSessionDetail } from './hooks/use_session_detail'; import { SessionSpansTable } from './session_spans_table'; +import { + CopyContentButton, + MessageContent, + MessageViewMode, + MessageViewModeToggle, + copyTextFor, +} from '../traces/flyout/message_content_view'; +import '../traces/flyout/message_content_view.scss'; import { previewInputMessages, previewOutputMessages } from '../traces/hooks/genai_message_preview'; import { SessionRow, formatSessionDuration, shortenId } from './session_utils'; @@ -72,57 +80,81 @@ const LatencyAndTokens: React.FC<{ row: TraceRow }> = ({ row }) => { ); }; +/** Human/AI turns for one trace in the session conversation. */ +const MessagePanel: React.FC<{ + kind: 'human' | 'ai'; + value: unknown; + text: string; + mode: MessageViewMode; +}> = ({ kind, value, text, mode }) => { + const label = + kind === 'human' + ? i18n.translate('agentTraces.sessions.flyout.human', { defaultMessage: 'Human' }) + : i18n.translate('agentTraces.sessions.flyout.ai', { defaultMessage: 'AI' }); + return ( + +
+ + {label} + + + +
+
+ +
+
+ ); +}; + /** Human/AI turns for one trace in the session conversation. */ const ConversationTurn: React.FC<{ index: number; trace: SessionTrace; focused: boolean; + mode: MessageViewMode; turnRef: (el: HTMLDivElement | null) => void; onOpenTrace: () => void; -}> = ({ index, trace, focused, turnRef, onOpenTrace }) => { - const userText = previewInputMessages(trace.root.input); - const aiText = previewOutputMessages(trace.root.output); - return ( -
- - {i18n.translate('agentTraces.sessions.flyout.traceLink', { - defaultMessage: 'Trace #{index}', - values: { index }, - })}{' '} - - - - -
- - - {i18n.translate('agentTraces.sessions.flyout.human', { defaultMessage: 'Human' })} - -
-
{userText || '—'}
-
- - -
- - - {i18n.translate('agentTraces.sessions.flyout.ai', { defaultMessage: 'AI' })} - -
-
{aiText || '—'}
-
- -
- ); -}; +}> = ({ index, trace, focused, mode, turnRef, onOpenTrace }) => ( +
+ + {i18n.translate('agentTraces.sessions.flyout.traceLink', { + defaultMessage: 'Trace #{index}', + values: { index }, + })}{' '} + + + + + + + +
+); export const SessionDetailsFlyout: React.FC = ({ session, @@ -136,6 +168,7 @@ export const SessionDetailsFlyout: React.FC = ({ const [drillTab, setDrillTab] = useState('traces'); /** Index of the trace in focus; the conversation arrows step through traces. */ const [focusedIndex, setFocusedIndex] = useState(0); + const [messageMode, setMessageMode] = useState('formatted'); const turnRefs = useRef>([]); const totalTokens = useMemo(() => { @@ -351,7 +384,14 @@ export const SessionDetailsFlyout: React.FC = ({ - + + + + = ({ index={i + 1} trace={trace} focused={i === focusedIndex} + mode={messageMode} turnRef={(el) => { turnRefs.current[i] = el; }} diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss index e4d88f11bb41..77884018394f 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss @@ -159,14 +159,16 @@ $agtSessionFlyoutChrome: 150px; border-bottom: $euiBorderThin; } + &__messageActions { + margin-left: auto; + } + &__message--ai &__messageHeader { background: tintOrShade($euiColorPrimary, 90%, 70%); } &__messageBody { padding: $euiSizeM; - white-space: pre-wrap; word-break: break-word; - font-size: $euiFontSizeS; } } diff --git a/src/plugins/agent_traces/public/application/pages/traces/flyout/flyout_detail_panel.tsx b/src/plugins/agent_traces/public/application/pages/traces/flyout/flyout_detail_panel.tsx index 0f20a25e5313..2684ae853e39 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/flyout/flyout_detail_panel.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/flyout/flyout_detail_panel.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@osd/i18n'; import { EuiTitle, @@ -22,6 +22,16 @@ import { import { TraceRow } from '../hooks/tree_utils'; import { TreeNode } from './tree_helpers'; +import { + CopyContentButton, + MessageContent, + MessageViewMode, + MessageViewModeToggle, + copyTextFor, + toDisplayMessages, +} from './message_content_view'; +import { previewSpanInput, previewSpanOutput } from '../hooks/genai_message_preview'; +import './message_content_view.scss'; export const formatJsonOrString = (value: string | undefined): string => { if (!value || value === '—') @@ -47,8 +57,18 @@ export const FlyoutDetailPanel: React.FC = ({ selectedTraceRow, onSelectNode, }) => { + const [ioMode, setIoMode] = useState('formatted'); + const row = selectedTraceRow; + // Message attributes first; execute_tool spans fall back to gen_ai.tool.call.* (semconv). + const ioSection = (value: unknown, toolPreview: string) => + toDisplayMessages(value).length > 0 || !toolPreview + ? { value, formattedText: undefined } + : { value: toolPreview, formattedText: toolPreview }; + const ioInput = ioSection(row?.input, row ? previewSpanInput(row) : ''); + const ioOutput = ioSection(row?.output, row ? previewSpanOutput(row) : ''); + return ( = ({ })} } + extraAction={ + + } initialIsOpen paddingSize="m" > -
- - - {i18n.translate('agentTraces.detailPanel.input', { - defaultMessage: 'INPUT', - })} - - - - - {formatJsonOrString(row?.input)} - -
- - - -
- - - {i18n.translate('agentTraces.detailPanel.output', { - defaultMessage: 'OUTPUT', - })} - - - - - {formatJsonOrString(row?.output)} - -
+ {( + [ + { + key: 'input', + label: i18n.translate('agentTraces.detailPanel.input', { defaultMessage: 'INPUT' }), + copyLabel: i18n.translate('agentTraces.detailPanel.copyInput', { + defaultMessage: 'Copy input', + }), + value: ioInput.value, + formattedText: ioInput.formattedText, + }, + { + key: 'output', + label: i18n.translate('agentTraces.detailPanel.output', { defaultMessage: 'OUTPUT' }), + copyLabel: i18n.translate('agentTraces.detailPanel.copyOutput', { + defaultMessage: 'Copy output', + }), + value: ioOutput.value, + formattedText: ioOutput.formattedText, + }, + ] as const + ).map((section, i) => ( +
+ {i > 0 && } + + + + {section.label} + + + + + + + +
+ +
+
+ ))} diff --git a/src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.scss b/src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.scss new file mode 100644 index 000000000000..b6f65245bdae --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.scss @@ -0,0 +1,25 @@ +.agtMessageContent { + display: flex; + flex-direction: column; + gap: $euiSizeS; +} + +.agtMessageContent__message { + padding: $euiSizeS; + border: $euiBorderThin; + border-radius: $euiBorderRadius; + background: $euiColorEmptyShade; +} + +.agtMessageContent__role { + margin-bottom: $euiSizeXS; +} + +.agtMessageContent__markdown, +.agtMessageContent__plain { + word-break: break-word; +} + +.agtMessageContent__plain { + white-space: pre-wrap; +} diff --git a/src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.test.tsx b/src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.test.tsx new file mode 100644 index 000000000000..499c55e1a396 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { + MessageContent, + MessageViewModeToggle, + copyTextFor, + toDisplayMessages, + toPrettyJson, +} from './message_content_view'; + +jest.mock('@elastic/eui', () => { + const actual = jest.requireActual('@elastic/eui'); + return { + ...actual, + // Render markdown as plain text in tests (the real component parses asynchronously) + EuiMarkdownFormat: ({ children }: { children: string }) => ( +
{children}
+ ), + }; +}); + +const chat = JSON.stringify([ + { role: 'system', parts: [{ type: 'text', content: 'You are helpful' }] }, + { role: 'user', parts: [{ type: 'text', content: '**Plan** a trip' }] }, + { role: 'assistant', parts: [{ type: 'tool_call', name: 'get_weather' }] }, +]); + +describe('message_content_view', () => { + it('splits semconv messages into role-labeled display messages', () => { + expect(toDisplayMessages(chat)).toEqual([ + { role: 'system', text: 'You are helpful' }, + { role: 'user', text: '**Plan** a trip' }, + { role: 'assistant', text: '[tool_call: get_weather]' }, + ]); + expect(toDisplayMessages('plain')).toEqual([{ role: null, text: 'plain' }]); + expect(toDisplayMessages('—')).toEqual([]); + }); + + it('pretty-prints JSON strings and structured values', () => { + expect(toPrettyJson('{"a":1}')).toBe('{\n "a": 1\n}'); + expect(toPrettyJson({ a: 1 })).toBe('{\n "a": 1\n}'); + expect(toPrettyJson('not json')).toBe('not json'); + expect(toPrettyJson(undefined)).toBe(''); + }); + + it('renders formatted messages with roles, and raw JSON in json mode', () => { + const { rerender } = render(); + expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByText('**Plan** a trip')).toBeInTheDocument(); + expect(screen.getByText('[tool_call: get_weather]')).toBeInTheDocument(); + + rerender(); + expect(screen.queryByText('User')).not.toBeInTheDocument(); + expect(screen.getByText(/"role": "user"/)).toBeInTheDocument(); + }); + + it('uses formattedText when given and shows the empty text otherwise', () => { + const { rerender } = render( + + ); + expect(screen.getByText('just this turn')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('nothing')).toBeInTheDocument(); + }); + + it('copies what is shown in the current mode', () => { + expect(copyTextFor(chat, 'formatted')).toBe( + 'system: You are helpful\n\nuser: **Plan** a trip\n\nassistant: [tool_call: get_weather]' + ); + expect(copyTextFor(chat, 'formatted', 'turn text')).toBe('turn text'); + expect(copyTextFor('{"a":1}', 'json')).toBe('{\n "a": 1\n}'); + }); + + it('toggles between Formatted and JSON', () => { + const onChange = jest.fn(); + render(); + fireEvent.click(screen.getByText('JSON')); + expect(onChange).toHaveBeenCalledWith('json'); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.tsx b/src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.tsx new file mode 100644 index 000000000000..3a21b8f143f0 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/traces/flyout/message_content_view.tsx @@ -0,0 +1,199 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { + EuiButtonGroup, + EuiButtonIcon, + EuiCodeBlock, + EuiCopy, + EuiMarkdownFormat, + EuiText, +} from '@elastic/eui'; +import { parseGenAiMessages, previewMessage } from '../hooks/genai_message_preview'; + +/** How GenAI message content is shown: rendered markdown or the raw attribute JSON. */ +export type MessageViewMode = 'formatted' | 'json'; + +/** Markdown rendering is skipped for very large content (kept as plain text). */ +const MAX_MARKDOWN_LENGTH = 20000; + +const isEmpty = (value: unknown) => + value === null || value === undefined || value === '' || value === '—'; + +/** Pretty-print the raw attribute value (JSON string or structured) for the JSON view. */ +export const toPrettyJson = (value: unknown): string => { + if (isEmpty(value)) return ''; + if (typeof value !== 'string') { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } +}; + +export interface DisplayMessage { + role: string | null; + text: string; +} + +/** + * Split a GenAI message attribute into displayable messages per the semconv schema. + * Non-conforming values become a single message with no role. + */ +export const toDisplayMessages = (value: unknown): DisplayMessage[] => { + if (isEmpty(value)) return []; + const messages = parseGenAiMessages(value); + if (messages) { + return messages + .map((m) => ({ role: m.role, text: previewMessage(m) })) + .filter((m) => m.text !== ''); + } + const text = typeof value === 'string' ? value : toPrettyJson(value); + return text ? [{ role: null, text }] : []; +}; + +/** Render text as markdown, falling back to plain text for very large content. */ +export const MarkdownText: React.FC<{ text: string }> = ({ text }) => + text.length > MAX_MARKDOWN_LENGTH ? ( + + {text} + + ) : ( + + {text} + + ); + +export const MessageViewModeToggle: React.FC<{ + mode: MessageViewMode; + onChange: (mode: MessageViewMode) => void; + idPrefix: string; +}> = ({ mode, onChange, idPrefix }) => ( + onChange(id.endsWith('-json') ? 'json' : 'formatted')} + /> +); + +export const CopyContentButton: React.FC<{ text: string; label: string }> = ({ text, label }) => + text ? ( + + {(copy) => ( + { + e.stopPropagation(); + copy(); + }} + aria-label={label} + data-test-subj="agentTracesCopyMessage" + /> + )} + + ) : null; + +const roleLabel = (role: string | null): string | null => { + if (!role) return null; + return role.charAt(0).toUpperCase() + role.slice(1); +}; + +/** + * Message attribute content in the chosen view: role-labeled markdown blocks (Formatted) + * or the pretty-printed attribute (JSON). + */ +export const MessageContent: React.FC<{ + value: unknown; + mode: MessageViewMode; + /** Formatted view override (e.g. a single turn's preview text). */ + formattedText?: string; + emptyText?: string; +}> = ({ value, mode, formattedText, emptyText }) => { + const noData = + emptyText ?? + i18n.translate('agentTraces.messageView.noData', { + defaultMessage: '(no data)', + }); + + if (mode === 'json') { + const json = toPrettyJson(value); + return json ? ( + + {json} + + ) : ( + + {noData} + + ); + } + + if (formattedText !== undefined) { + return formattedText ? ( + + ) : ( + + {noData} + + ); + } + + const messages = toDisplayMessages(value); + if (messages.length === 0) { + return ( + + {noData} + + ); + } + return ( +
+ {messages.map((m, i) => ( +
+ {roleLabel(m.role) && ( + + {roleLabel(m.role)} + + )} + +
+ ))} +
+ ); +}; + +/** Text copied for the current view. */ +export const copyTextFor = (value: unknown, mode: MessageViewMode, formattedText?: string) => + mode === 'json' + ? toPrettyJson(value) + : (formattedText ?? + toDisplayMessages(value) + .map((m) => (m.role ? `${m.role}: ${m.text}` : m.text)) + .join('\n\n')); From fd3d7f879871e61cc085747e32b0c5df55a8a6ee Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:00:39 -0700 Subject: [PATCH 05/15] [Agent Traces] Session flyout: full session id, message roles, aligned panels - Title shows the full session id (ellipsis only when the flyout is narrow). - Label conversation messages with their OTel GenAI roles (User/Assistant, System/Tool when present) instead of Human/AI. - Remove horizontal scroll in the conversation (flex gutter overflow). - Scroll only the conversation column and land each turn below the sticky header, so the Trace #n link stays visible. - Align the Trace list and Session Conversation headers. Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../sessions/session_details_flyout.test.tsx | 22 ++ .../pages/sessions/session_details_flyout.tsx | 243 ++++++++++-------- .../pages/sessions/sessions_tab.scss | 40 ++- 3 files changed, 193 insertions(+), 112 deletions(-) create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx new file mode 100644 index 000000000000..896e15c8762a --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { turnRole } from './session_details_flyout'; + +const msgs = (...roles: string[]) => + JSON.stringify(roles.map((role) => ({ role, parts: [{ type: 'text', content: role }] }))); + +describe('turnRole', () => { + it('uses the last user message role for input and the first generation for output', () => { + expect(turnRole(msgs('system', 'user', 'assistant', 'user'), 'input')).toBe('user'); + expect(turnRole(msgs('system', 'tool'), 'input')).toBe('tool'); + expect(turnRole(msgs('assistant'), 'output')).toBe('assistant'); + }); + + it('falls back to user/assistant for non-schema values', () => { + expect(turnRole('plain text', 'input')).toBe('user'); + expect(turnRole(undefined, 'output')).toBe('assistant'); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx index f0a80a9fcaac..a7e97f5bc28d 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx @@ -39,8 +39,12 @@ import { copyTextFor, } from '../traces/flyout/message_content_view'; import '../traces/flyout/message_content_view.scss'; -import { previewInputMessages, previewOutputMessages } from '../traces/hooks/genai_message_preview'; -import { SessionRow, formatSessionDuration, shortenId } from './session_utils'; +import { + parseGenAiMessages, + previewInputMessages, + previewOutputMessages, +} from '../traces/hooks/genai_message_preview'; +import { SessionRow, formatSessionDuration } from './session_utils'; interface SessionDetailsFlyoutProps { session: SessionRow; @@ -80,25 +84,56 @@ const LatencyAndTokens: React.FC<{ row: TraceRow }> = ({ row }) => { ); }; -/** Human/AI turns for one trace in the session conversation. */ +/** + * Role of the message a turn preview shows, per the OTel GenAI message schema: the last + * user message for input, the first generation for output. Falls back to user/assistant. + */ +export const turnRole = (value: unknown, side: 'input' | 'output'): string => { + const messages = parseGenAiMessages(value); + const fallback = side === 'input' ? 'user' : 'assistant'; + if (!messages || messages.length === 0) return fallback; + if (side === 'output') return messages[0].role || fallback; + const lastUser = [...messages].reverse().find((m) => m.role === 'user'); + return (lastUser ?? messages[messages.length - 1]).role || fallback; +}; + +const roleLabel = (role: string): string => { + switch (role) { + case 'user': + return i18n.translate('agentTraces.sessions.flyout.roleUser', { defaultMessage: 'User' }); + case 'assistant': + return i18n.translate('agentTraces.sessions.flyout.roleAssistant', { + defaultMessage: 'Assistant', + }); + case 'system': + return i18n.translate('agentTraces.sessions.flyout.roleSystem', { defaultMessage: 'System' }); + case 'tool': + return i18n.translate('agentTraces.sessions.flyout.roleTool', { defaultMessage: 'Tool' }); + default: + return role.charAt(0).toUpperCase() + role.slice(1); + } +}; + +/** One side of a conversation turn, labeled with the message role. */ const MessagePanel: React.FC<{ - kind: 'human' | 'ai'; + side: 'input' | 'output'; value: unknown; text: string; mode: MessageViewMode; -}> = ({ kind, value, text, mode }) => { - const label = - kind === 'human' - ? i18n.translate('agentTraces.sessions.flyout.human', { defaultMessage: 'Human' }) - : i18n.translate('agentTraces.sessions.flyout.ai', { defaultMessage: 'AI' }); +}> = ({ side, value, text, mode }) => { + const role = turnRole(value, side); + const label = roleLabel(role); return (
- + {label} = ({ const [focusedIndex, setFocusedIndex] = useState(0); const [messageMode, setMessageMode] = useState('formatted'); const turnRefs = useRef>([]); + const conversationRef = useRef(null); + const conversationHeaderRef = useRef(null); const totalTokens = useMemo(() => { const values = traces.map((t) => tokensOf(t.root)).filter((v): v is number => v !== null); @@ -191,7 +228,21 @@ export const SessionDetailsFlyout: React.FC = ({ (index: number) => { if (index < 0 || index >= traces.length) return; setFocusedIndex(index); - turnRefs.current[index]?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); + // Scroll only the conversation column (not the flyout body), landing the turn + // just below the sticky header so its "Trace #n" link stays visible. + const container = conversationRef.current; + const turn = turnRefs.current[index]; + if (!container || !turn) return; + const header = conversationHeaderRef.current; + const headerHeight = header + ? header.offsetHeight + parseFloat(getComputedStyle(header).marginBottom || '0') + : 0; + const top = + container.scrollTop + + turn.getBoundingClientRect().top - + container.getBoundingClientRect().top - + headerHeight; + container.scrollTo?.({ top: Math.max(0, top), behavior: 'smooth' }); }, [traces.length] ); @@ -212,12 +263,16 @@ export const SessionDetailsFlyout: React.FC = ({ > - + -

+

{i18n.translate('agentTraces.sessions.flyout.title', { - defaultMessage: 'SessionID: {id}', - values: { id: shortenId(session.sessionId) }, + defaultMessage: 'Session: {id}', + values: { id: session.sessionId }, })}

@@ -294,34 +349,24 @@ export const SessionDetailsFlyout: React.FC = ({ className="agtSessionFlyout__overview" > - - - -

- {i18n.translate('agentTraces.sessions.flyout.traceList', { - defaultMessage: 'Trace list ({count})', - values: { count: traces.length }, - })} -

-
-
- - setView('all')} - data-test-subj="agentTracesSessionViewAllTraces" - > - {i18n.translate('agentTraces.sessions.flyout.viewAll', { - defaultMessage: 'View All Traces', +
+ +

+ {i18n.translate('agentTraces.sessions.flyout.traceList', { + defaultMessage: 'Trace list ({count})', + values: { count: traces.length }, })} - - - - +

+
+ setView('all')} + data-test-subj="agentTracesSessionViewAllTraces" + > + {i18n.translate('agentTraces.sessions.flyout.viewAll', { + defaultMessage: 'View All Traces', + })} + +
{traces.map((trace, i) => (
= ({ - - +
+

{i18n.translate('agentTraces.sessions.flyout.conversation', { @@ -382,57 +422,48 @@ export const SessionDetailsFlyout: React.FC = ({ })}

- - - - - - - - = traces.length - 1} - onClick={() => focusTrace(focusedIndex + 1)} - aria-label={i18n.translate('agentTraces.sessions.flyout.nextTrace', { - defaultMessage: 'Next trace', - })} - data-test-subj="agentTracesSessionNextTrace" - /> - - - focusTrace(focusedIndex - 1)} - aria-label={i18n.translate('agentTraces.sessions.flyout.previousTrace', { - defaultMessage: 'Previous trace', - })} - data-test-subj="agentTracesSessionPreviousTrace" - /> - - - - - - {traces.map((trace, i) => ( - { - turnRefs.current[i] = el; - }} - onOpenTrace={() => openTrace(trace.root)} - /> - ))} +
+ + = traces.length - 1} + onClick={() => focusTrace(focusedIndex + 1)} + aria-label={i18n.translate('agentTraces.sessions.flyout.nextTrace', { + defaultMessage: 'Next trace', + })} + data-test-subj="agentTracesSessionNextTrace" + /> + focusTrace(focusedIndex - 1)} + aria-label={i18n.translate('agentTraces.sessions.flyout.previousTrace', { + defaultMessage: 'Previous trace', + })} + data-test-subj="agentTracesSessionPreviousTrace" + /> +
+
+ {traces.map((trace, i) => ( + { + turnRefs.current[i] = el; + }} + onOpenTrace={() => openTrace(trace.root)} + /> + ))} +
)} @@ -457,8 +488,8 @@ export const SessionDetailsFlyout: React.FC = ({ {i18n.translate('agentTraces.sessions.flyout.sessionChip', { - defaultMessage: 'Session ID: {id}', - values: { id: shortenId(session.sessionId, 12, 0) }, + defaultMessage: 'Session: {id}', + values: { id: session.sessionId }, })} diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss index 77884018394f..428db3a2b14d 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss @@ -77,24 +77,53 @@ $agtSessionFlyoutChrome: 150px; &__conversation { height: 100%; + min-width: 0; + } + + &__conversationScroll { + height: 100%; + overflow-x: hidden; overflow-y: auto; padding-right: $euiSizeS; } - // Flex items are column-flex; stop the panel headers from growing and centering. + // Both panel headers share one height so "Trace list" and "Session Conversation" + // sit on the same line. &__panelHeader { + display: flex; flex-grow: 0; + align-items: center; + justify-content: space-between; + gap: $euiSizeS; + min-height: $euiSizeXXL; + margin-bottom: $euiSizeS; } - // Keep the "Session Conversation" title and its prev/next arrows visible while scrolling. - &__conversation > &__panelHeader { + &__panelActions { + display: flex; + align-items: center; + gap: $euiSizeXS; + } + + // Keep the "Session Conversation" title and its controls visible while scrolling. + &__conversationScroll > &__panelHeader { position: sticky; top: 0; z-index: 1; - padding-bottom: $euiSizeS; background: $euiColorEmptyShade; } + // Full session id; ellipsis only when the flyout is too narrow. + &__titleItem { + min-width: 0; + } + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + &__traceItem { display: flex; width: 100%; @@ -123,7 +152,6 @@ $agtSessionFlyoutChrome: 150px; } &__turn { - scroll-margin-top: $euiSizeXXL; // clear the sticky conversation header padding-left: $euiSizeS; border-left: $euiBorderWidthThick solid transparent; transition: border-color $euiAnimSpeedFast; @@ -163,7 +191,7 @@ $agtSessionFlyoutChrome: 150px; margin-left: auto; } - &__message--ai &__messageHeader { + &__message--output &__messageHeader { background: tintOrShade($euiColorPrimary, 90%, 70%); } From f7c44e03594010b9f2a97b0f54daa78d31e5f38e Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:07:05 -0700 Subject: [PATCH 06/15] Changeset file for PR #12846 created/updated Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- changelogs/fragments/12846.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/12846.yml diff --git a/changelogs/fragments/12846.yml b/changelogs/fragments/12846.yml new file mode 100644 index 000000000000..e0c5cdbe1db3 --- /dev/null +++ b/changelogs/fragments/12846.yml @@ -0,0 +1,2 @@ +feat: +- Add a Sessions tab to Agent Traces that groups traces by `gen_ai.conversation.id` ([#12846](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/12846)) From 0e4388082272ca8463d494bc1492c5e0313e5f64 Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:28:53 -0700 Subject: [PATCH 07/15] [Agent Traces] Session-accurate facets and fixed columns in the Sessions fields panel - Sessions facets count distinct sessions per value (stats query under the user's query) instead of fetched root spans from a 500-row sample. - Tabs can supply facet buckets (facetBuckets$) and declare fixed columns; the Sessions tab hides "Selected" and add/remove column actions. - Keep short Sessions cells (time, ids, counts) on one line in wrap mode. Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../pages/sessions/hooks/use_sessions.ts | 27 ++++++++ .../pages/sessions/session_facets.ts | 13 ++++ .../pages/sessions/session_utils.test.ts | 26 ++++++++ .../pages/sessions/session_utils.ts | 30 +++++++++ .../pages/sessions/sessions_tab.scss | 4 ++ .../pages/sessions/sessions_table.tsx | 9 ++- .../public/application/register_tabs.ts | 6 +- .../fields_selector/discover_field.tsx | 9 ++- .../fields_selector/discover_sidebar.test.tsx | 30 ++++++++- .../fields_selector/discover_sidebar.tsx | 66 ++++++++++++++----- .../components/fields_selector/field_list.tsx | 2 + .../fields_selector/fields_selector_panel.tsx | 12 +++- .../tab_registry/tab_registry_service.ts | 15 +++++ 13 files changed, 227 insertions(+), 22 deletions(-) create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/session_facets.ts diff --git a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts index 379e7bcf60c9..2648e3e05742 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts @@ -18,10 +18,14 @@ import { buildMatchingSessionIdsQuery, buildSessionStatsQuery, buildTraceSessionMapQuery, + buildSessionFacetQuery, + parseFacetBuckets, + SESSION_FACET_FIELDS, getSourceCommand, parseSessionStats, pplResponseToRecords, } from '../session_utils'; +import { sessionFacetBuckets$ } from '../session_facets'; export interface UseSessionsResult { sessions: SessionRow[]; @@ -62,6 +66,26 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult const { whereQuery } = splitPplWhereAndTail(baseQueryString); const source = getSourceCommand(whereQuery); + // Fields panel facets, counted per session (runs alongside the list queries). + sessionFacetBuckets$.next(null); + void Promise.all( + SESSION_FACET_FIELDS.map(async (field) => { + try { + const response = await pplService.executeQuery( + datasetParam, + buildSessionFacetQuery(whereQuery, field) + ); + return [field, parseFacetBuckets(pplResponseToRecords(response), field)] as const; + } catch { + return [field, []] as const; // e.g. the field is not mapped in this index + } + }) + ).then((entries) => { + if (requestId === requestIdRef.current) { + sessionFacetBuckets$.next(Object.fromEntries(entries)); + } + }); + // 1. Sessions matching the user's query; 2. their full stats (not narrowed by the filter). const idsResponse = await pplService.executeQuery( datasetParam, @@ -128,6 +152,9 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult fetchSessions(); }, [fetchSessions, refreshCounter, timeVersion, fetchVersion]); + // Drop session facets when the tab unmounts so they never outlive the Sessions view. + useEffect(() => () => sessionFacetBuckets$.next(null), []); + const refresh = useCallback(() => setRefreshCounter((c) => c + 1), []); return { sessions, loading, error, elapsedMs, refresh }; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_facets.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_facets.ts new file mode 100644 index 000000000000..2e1c17cc3f67 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_facets.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { Bucket } from '../../../components/fields_selector/types'; + +/** + * Session-level facet values for the fields panel, published by the Sessions tab. + * Null while loading or when the tab is not mounted. + */ +export const sessionFacetBuckets$ = new BehaviorSubject | null>(null); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts index 825656019e4a..28e96dd8dd0a 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts @@ -16,6 +16,8 @@ import { parseSessionStats, pplResponseToRecords, shortenId, + buildSessionFacetQuery, + parseFacetBuckets, } from './session_utils'; import { BaseRow } from '../traces/hooks/tree_utils'; @@ -198,3 +200,27 @@ describe('session_utils', () => { }); }); }); + +describe('session facets', () => { + it('counts distinct sessions per facet value under the user query', () => { + expect(buildSessionFacetQuery('source = spans | where a = 1', 'serviceName')).toBe( + 'source = spans | where a = 1 | where isnotnull(`attributes.gen_ai.conversation.id`) and isnotnull(`serviceName`) | stats distinct_count(`attributes.gen_ai.conversation.id`) as sessions by `serviceName` | sort - sessions | head 10' + ); + }); + + it('parses stats records into fields-panel buckets', () => { + expect( + parseFacetBuckets( + [ + { serviceName: 'travel-planner', sessions: 3 }, + { serviceName: 'weather-agent', sessions: 1 }, + { serviceName: null, sessions: 5 }, + ], + 'serviceName' + ) + ).toEqual([ + { value: 'travel-planner', display: 'travel-planner', count: 3, percent: 75 }, + { value: 'weather-agent', display: 'weather-agent', count: 1, percent: 25 }, + ]); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts index a03b15d8611a..829c2c9da0c8 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts @@ -7,6 +7,7 @@ import moment from 'moment-timezone'; import { AGENT_TRACES_SESSION_ID_FIELD } from '../../../../common'; import { escapePPLValue } from '../traces/trace_details/data_fetching/ppl_request_helpers'; import { BaseRow } from '../traces/hooks/tree_utils'; +import { Bucket } from '../../../components/fields_selector/types'; import { previewInputMessages, previewOutputMessages } from '../traces/hooks/genai_message_preview'; /** Backtick-quoted session id field for use in PPL. */ @@ -89,6 +90,35 @@ export const buildMatchingSessionIdsQuery = ( ): string => `${whereQuery} | where isnotnull(${SESSION_FIELD_PPL}) | stats max(endTime) as last_seen by ${SESSION_FIELD_PPL} | sort - last_seen | head ${limit}`; +/** Fields shown as facets on the Sessions tab. */ +export const SESSION_FACET_FIELDS = ['serviceName', 'attributes.gen_ai.agent.name', 'status.code']; +export const SESSION_FACET_LIMIT = 10; + +/** + * Sessions per value of a facet field, under the user's query. A session counts toward a + * value when any of its spans has it, matching how filter-for narrows the session list. + */ +export const buildSessionFacetQuery = ( + whereQuery: string, + field: string, + limit = SESSION_FACET_LIMIT +): string => + `${whereQuery} | where isnotnull(${SESSION_FIELD_PPL}) and isnotnull(\`${field}\`) | stats distinct_count(${SESSION_FIELD_PPL}) as sessions by \`${field}\` | sort - sessions | head ${limit}`; + +/** Convert facet stats records into fields-panel buckets. */ +export const parseFacetBuckets = (records: Array>, field: string): Bucket[] => { + const rows = records + .map((r) => ({ value: r[field], count: Number(r.sessions ?? 0) })) + .filter((r) => r.value !== null && r.value !== undefined && r.count > 0); + const total = rows.reduce((sum, r) => sum + r.count, 0); + return rows.map((r) => ({ + value: String(r.value), + display: String(r.value), + count: r.count, + percent: total ? (r.count / total) * 100 : 0, + })); +}; + /** Full (unfiltered) stats for the given sessions: trace count and time bounds. */ export const buildSessionStatsQuery = (source: string, sessionIds: string[]): string => `${source} | where ${SESSION_FIELD_PPL} in (${inList( diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss index 428db3a2b14d..093edfdf1e4b 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss @@ -13,6 +13,10 @@ $agtSessionFlyoutChrome: 150px; cursor: pointer; } +.agtSessionsTable__nowrap { + white-space: nowrap; +} + .agtSessionsTable__tooltipAnchor { display: block; max-width: 100%; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx b/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx index 953a1a0a2ee8..888cb9cdb047 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx @@ -244,7 +244,14 @@ export const SessionsTable: React.FC = ({ >
- {renderCell(session, column)} + {column.wideText ? ( + renderCell(session, column) + ) : ( + // Short values (time, ids, counts) never wrap, as in Traces/Spans + + {renderCell(session, column)} + + )}
diff --git a/src/plugins/agent_traces/public/application/register_tabs.ts b/src/plugins/agent_traces/public/application/register_tabs.ts index e77f957cfb31..64ec03ab8405 100644 --- a/src/plugins/agent_traces/public/application/register_tabs.ts +++ b/src/plugins/agent_traces/public/application/register_tabs.ts @@ -8,6 +8,8 @@ import { TracesTab } from './pages/traces/traces_tab'; import { SpansTab } from './pages/traces/spans_tab'; import { VisTab } from './pages/traces/vis_tab'; import { SessionsTab } from './pages/sessions/sessions_tab'; +import { SESSION_FACET_FIELDS } from './pages/sessions/session_utils'; +import { sessionFacetBuckets$ } from './pages/sessions/session_facets'; import { TabDefinition, TabRegistryService } from '../services/tab_registry/tab_registry_service'; import { AgentTracesServices } from '../types'; import { @@ -86,7 +88,9 @@ export const registerBuiltInTabs = (tabRegistry: TabRegistryService) => { order: 25, supportedLanguages: [AGENT_TRACES_DEFAULT_LANGUAGE], prepareQuery: prepareRootSpansQuery, - facetFields: ['serviceName', 'attributes.gen_ai.agent.name', 'status.code'], + facetFields: SESSION_FACET_FIELDS, + facetBuckets$: sessionFacetBuckets$, + fixedColumns: true, component: SessionsTab, }); diff --git a/src/plugins/agent_traces/public/components/fields_selector/discover_field.tsx b/src/plugins/agent_traces/public/components/fields_selector/discover_field.tsx index 6bda34f84bbf..1ab4cd171d16 100644 --- a/src/plugins/agent_traces/public/components/fields_selector/discover_field.tsx +++ b/src/plugins/agent_traces/public/components/fields_selector/discover_field.tsx @@ -94,6 +94,10 @@ export interface DiscoverFieldProps { * When true, the field cannot be removed from the table (no remove button shown) */ nonRemovable?: boolean; + /** + * When true, no add/remove column button is shown (the table has fixed columns) + */ + hideColumnActions?: boolean; } export const DiscoverField = ({ @@ -108,6 +112,7 @@ export const DiscoverField = ({ useShortDots, showSummary, nonRemovable, + hideColumnActions, }: DiscoverFieldProps) => { const addLabelAria = i18n.translate( 'agentTraces.discover.fieldChooser.discoverField.addButtonAriaLabel', @@ -158,7 +163,9 @@ export const DiscoverField = ({ ); let actionButton; - if (!isSourceField && !selected) { + if (hideColumnActions) { + actionButton = undefined; + } else if (!isSourceField && !selected) { actionButton = ( ({ })); jest.mock('./facet_list', () => ({ - FacetList: ({ title, fields }: { title: string; fields: any[] }) => ( + FacetList: ({ + title, + fields, + getDetailsByField, + }: { + title: string; + fields: any[]; + getDetailsByField: (field: any) => { buckets: Array<{ value: string; count: number }> }; + }) => (

{title}

{fields.map((field, index) => (
{field.name} + {getDetailsByField(field).buckets.map((b) => ( + + {`${b.value}:${b.count}`} + + ))}
))}
@@ -239,4 +252,19 @@ describe('discover sidebar', function () { const allFields = screen.getAllByTestId('fieldList-field'); expect(allFields.length).toBeGreaterThan(0); }); + + it('uses tab-supplied facet buckets and hides Selected for fixed-column tabs', function () { + mockUseFlavorId.mockReturnValue(AgentTracesFlavor.Traces); + const props = getCompProps(); + render( + + ); + expect(screen.getByTestId('facetList-bucket')).toHaveTextContent('jpg:7'); + expect(screen.queryByTestId('mocked-field-list-selected')).not.toBeInTheDocument(); + }); }); diff --git a/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.tsx b/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.tsx index 64064c19c71e..b7e1cb1b40e4 100644 --- a/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.tsx +++ b/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.tsx @@ -14,6 +14,7 @@ import { DiscoverFieldSearch } from './discover_field_search'; import './discover_sidebar.scss'; import { getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter'; import { getDetails } from './lib/get_details'; +import { Bucket, FieldDetails } from './types'; import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list'; import { groupFields } from './lib/group_fields'; import { DiscoverFieldHeader } from './discover_field_header'; @@ -31,6 +32,15 @@ export interface DiscoverSidebarProps { * Fields shown as faceted fields; defaults to the span-level facets */ facetFields?: readonly string[]; + /** + * Facet values supplied by the active tab (keyed by field name); null while loading. + * When omitted, facet values are counted from `hits`. + */ + facetBuckets?: Record | null; + /** + * The active tab has fixed columns: hide "Selected" and the add/remove column actions. + */ + fixedColumns?: boolean; /** * a statistics of the distribution of fields in the given hits */ @@ -76,6 +86,8 @@ export function DiscoverSidebar(props: DiscoverSidebarProps) { isEnhancementsEnabledOverride, onCollapse, facetFields, + facetBuckets, + fixedColumns, } = props; const [fieldFilterState, setFieldFilterState] = useState(getDefaultFieldFilter()); const flavorId = useFlavorId(); @@ -101,6 +113,16 @@ export function DiscoverSidebar(props: DiscoverSidebarProps) { [hits, selectedDataSet] ); + const getFacetDetailsByField = useCallback( + (ipField: DataViewField): FieldDetails => { + if (facetBuckets === undefined) return getDetailsByField(ipField); + const buckets = facetBuckets?.[ipField.name] ?? []; + const total = buckets.reduce((sum, b) => sum + b.count, 0); + return { buckets, exists: total, total, error: '' }; + }, + [facetBuckets, getDetailsByField] + ); + const { facetedFields, selectedFields, queryFields, discoveredFields } = useMemo(() => { const showFacetedFields = flavorId === AgentTracesFlavor.Traces; const services = getServices(); @@ -109,15 +131,25 @@ export function DiscoverSidebar(props: DiscoverSidebarProps) { const timeField = selectedDataSet?.timeFieldName; const effectiveColumns = !hideTimeCol && timeField && !columns.includes(timeField) ? [timeField, ...columns] : columns; + const groupColumns = fixedColumns ? [] : effectiveColumns; return groupFields( fields as DataViewField[], - effectiveColumns, + groupColumns, fieldCounts, fieldFilterState, showFacetedFields, facetFields ); - }, [flavorId, fields, columns, fieldCounts, fieldFilterState, selectedDataSet, facetFields]); + }, [ + flavorId, + fields, + columns, + fieldCounts, + fieldFilterState, + selectedDataSet, + facetFields, + fixedColumns, + ]); const fieldTypes = useMemo(() => { const result = ['any']; @@ -178,7 +210,7 @@ export function DiscoverSidebar(props: DiscoverSidebarProps) { {facetedFields.length > 0 && ( )} - + {!fixedColumns && ( + + )} {queryFields.length > 0 && ( { const [expanded, setExpanded] = useState(true); @@ -75,6 +76,7 @@ export const FieldList = ({ getDetails={getDetailsByField} useShortDots={shortDotsEnabled} showSummary + hideColumnActions={fixedColumns} nonRemovable={ category === 'selected' && (AGENT_TRACES_DEFAULT_COLUMNS.includes(field.name) || diff --git a/src/plugins/agent_traces/public/components/fields_selector/fields_selector_panel.tsx b/src/plugins/agent_traces/public/components/fields_selector/fields_selector_panel.tsx index 838e298b67ea..7b018f98c012 100644 --- a/src/plugins/agent_traces/public/components/fields_selector/fields_selector_panel.tsx +++ b/src/plugins/agent_traces/public/components/fields_selector/fields_selector_panel.tsx @@ -5,6 +5,8 @@ import { useEffect, useRef, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; +import { useObservable } from 'react-use'; +import { of } from 'rxjs'; import { UI_SETTINGS } from '../../../../data/public'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { @@ -30,6 +32,8 @@ export interface IDiscoverPanelProps { collapsePanel?: () => void; } +const NO_FACET_BUCKETS$ = of(null); + export function DiscoverPanel({ collapsePanel }: IDiscoverPanelProps) { const { services } = useOpenSearchDashboards(); const { uiSettings } = services; @@ -37,10 +41,12 @@ export function DiscoverPanel({ collapsePanel }: IDiscoverPanelProps) { const { onAddFilter } = useChangeQueryEditor(); const columns = useSelector(selectColumns); const activeTabId = useSelector((state: RootState) => state.ui.activeTabId); + const activeTab = services.tabRegistry.getTab(activeTabId); + // Tabs such as Sessions supply their own facet counts (per session, not per fetched span) + const tabFacetBuckets = useObservable(activeTab?.facetBuckets$ ?? NO_FACET_BUCKETS$, null); // Use the active tab's prepareQuery to look up results, matching how executeQueries stores them const rawResults = useSelector((state: RootState) => { - const activeTab = services.tabRegistry.getTab(activeTabId); const prepareQuery = activeTab?.prepareQuery || defaultPrepareQueryString; const key = prepareQuery(state.query, state.legacy.sort); return key ? state.results[key] : null; @@ -89,7 +95,9 @@ export function DiscoverPanel({ collapsePanel }: IDiscoverPanelProps) { return ( { diff --git a/src/plugins/agent_traces/public/services/tab_registry/tab_registry_service.ts b/src/plugins/agent_traces/public/services/tab_registry/tab_registry_service.ts index 60a58dd4bee1..d172cb7a75c0 100644 --- a/src/plugins/agent_traces/public/services/tab_registry/tab_registry_service.ts +++ b/src/plugins/agent_traces/public/services/tab_registry/tab_registry_service.ts @@ -4,11 +4,13 @@ */ import { MemoExoticComponent } from 'react'; +import { Observable } from 'rxjs'; import { QueryState } from '../../application/utils/state_management/slices'; import { QueryExecutionStatus } from '../../application/utils/state_management/types'; import { Query } from '../../../../data/common'; import { AgentTracesFlavor } from '../../../common'; import { SortOrder } from '../../types/saved_agent_traces_types'; +import { Bucket } from '../../components/fields_selector/types'; /** * Props passed to tab components @@ -39,6 +41,19 @@ export interface TabDefinition { */ facetFields?: string[]; + /** + * Facet values computed by the tab itself (for example, counted per session instead of + * per fetched span). Keyed by field name; null while loading. When omitted, facets are + * counted from the fetched hits. + */ + facetBuckets$?: Observable | null>; + + /** + * The tab renders a fixed set of columns, so the fields panel hides "Selected" and the + * add/remove column actions. + */ + fixedColumns?: boolean; + // Transform query string for cache key generation. // When sort is provided, it is appended as a PPL sort clause. prepareQuery?: (query: Query, sort?: SortOrder[]) => string; From ae61f99114685f9184a56dee21449fb550cfd73d Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:50:32 -0700 Subject: [PATCH 08/15] [Agent Traces] Replace Spans with Sessions in the side nav Agent monitoring nav now lists Traces and Sessions (navTicketing icon), with the same new-search / browse-saved popover. Spans stays a tab next to Traces, and the agentTraces/spans app is still registered so existing links keep working. Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- src/plugins/agent_traces/common/index.ts | 1 + .../helpers/get_flavor_from_app_id.test.ts | 2 ++ .../public/helpers/get_flavor_from_app_id.ts | 8 ++++++- .../agent_traces/public/nav_popover.test.tsx | 4 ++-- .../agent_traces/public/nav_popover.tsx | 19 ++++++++------- src/plugins/agent_traces/public/plugin.ts | 24 +++++++++++++------ 6 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/plugins/agent_traces/common/index.ts b/src/plugins/agent_traces/common/index.ts index c3401c05d7c5..a15491174966 100644 --- a/src/plugins/agent_traces/common/index.ts +++ b/src/plugins/agent_traces/common/index.ts @@ -7,6 +7,7 @@ export const PLUGIN_ID = 'agentTraces'; export const PLUGIN_NAME = 'Agent Traces'; export const AGENT_TRACES_NAV_ID = 'agentTraces/traces'; export const AGENT_SPANS_NAV_ID = 'agentTraces/spans'; +export const AGENT_SESSIONS_NAV_ID = 'agentTraces/sessions'; export const DEFAULT_COLUMNS_SETTING = 'defaultColumns'; export const SAMPLE_SIZE_SETTING = 'discover:sampleSize'; export const SORT_DEFAULT_ORDER_SETTING = 'discover:sort:defaultOrder'; diff --git a/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.test.ts b/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.test.ts index 16c68414f32f..5fc667fd0173 100644 --- a/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.test.ts +++ b/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.test.ts @@ -10,6 +10,7 @@ import { PLUGIN_ID, AGENT_TRACES_NAV_ID, AGENT_SPANS_NAV_ID, + AGENT_SESSIONS_NAV_ID, } from '../../common'; import { AgentTracesServices } from '../types'; @@ -33,6 +34,7 @@ describe('getFlavorFromAppId', () => { it('returns Traces for the agent spans nav ID', () => { expect(getFlavorFromAppId(AGENT_SPANS_NAV_ID)).toBe(AgentTracesFlavor.Traces); + expect(getFlavorFromAppId(AGENT_SESSIONS_NAV_ID)).toBe(AgentTracesFlavor.Traces); }); it('extracts flavor from slash-separated app ID', () => { diff --git a/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.ts b/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.ts index 46a3c1e43445..270f9accdeca 100644 --- a/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.ts +++ b/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.ts @@ -9,6 +9,7 @@ import { PLUGIN_ID, AGENT_TRACES_NAV_ID, AGENT_SPANS_NAV_ID, + AGENT_SESSIONS_NAV_ID, } from '../../common'; import { AgentTracesServices } from '../types'; @@ -20,7 +21,12 @@ import { AgentTracesServices } from '../types'; export const getFlavorFromAppId = (appId: string | undefined): AgentTracesFlavor | null => { // The base plugin and both sidebar entry points (traces/spans) belong to the // Traces flavor — they mount the same app with different initial tab state. - if (appId === PLUGIN_ID || appId === AGENT_TRACES_NAV_ID || appId === AGENT_SPANS_NAV_ID) { + if ( + appId === PLUGIN_ID || + appId === AGENT_TRACES_NAV_ID || + appId === AGENT_SPANS_NAV_ID || + appId === AGENT_SESSIONS_NAV_ID + ) { return AgentTracesFlavor.Traces; } const flavorFromAppId = appId?.split('/')?.[1]; diff --git a/src/plugins/agent_traces/public/nav_popover.test.tsx b/src/plugins/agent_traces/public/nav_popover.test.tsx index 021e2d9831fe..77a8e1de3fdc 100644 --- a/src/plugins/agent_traces/public/nav_popover.test.tsx +++ b/src/plugins/agent_traces/public/nav_popover.test.tsx @@ -4,7 +4,7 @@ */ import { of } from 'rxjs'; -import { agentTracesNavPopover, agentSpansNavPopover } from './nav_popover'; +import { agentTracesNavPopover, agentSessionsNavPopover } from './nav_popover'; import { httpServiceMock } from '../../../core/public/mocks'; import { NavPopoverServices } from '../../../core/public'; @@ -20,7 +20,7 @@ describe('agent_traces nav popovers', () => { describe.each([ ['Traces', agentTracesNavPopover, 'agentTraces/traces', 'traces'], - ['Spans', agentSpansNavPopover, 'agentTraces/spans', 'spans'], + ['Sessions', agentSessionsNavPopover, 'agentTraces/sessions', 'sessions'], ])('%s popover', (_label, popover, appId, tab) => { it('declares exactly newSearch + browseSaved (no Logs action)', () => { expect((popover.actions ?? []).map((a) => a.id)).toEqual(['newSearch', 'browseSaved']); diff --git a/src/plugins/agent_traces/public/nav_popover.tsx b/src/plugins/agent_traces/public/nav_popover.tsx index 1a540d2466a6..4e283b2d4403 100644 --- a/src/plugins/agent_traces/public/nav_popover.tsx +++ b/src/plugins/agent_traces/public/nav_popover.tsx @@ -5,7 +5,7 @@ import { i18n } from '@osd/i18n'; import { NavPopoverConfig } from '../../../core/public'; -import { AGENT_TRACES_NAV_ID, AGENT_SPANS_NAV_ID } from '../common'; +import { AGENT_TRACES_NAV_ID, AGENT_SESSIONS_NAV_ID } from '../common'; /** * Hash path that lands the agent-traces app on the given tab and asks it to open @@ -13,19 +13,20 @@ import { AGENT_TRACES_NAV_ID, AGENT_SPANS_NAV_ID } from '../common'; * in the hash query). A nav-popover action only receives navigateToApp, so it * can't open the flyout itself — it navigates with this marker instead. */ -const openSavedPath = (tab: 'traces' | 'spans') => +type AgentNavTab = 'traces' | 'sessions'; + +const openSavedPath = (tab: AgentNavTab) => `#/?_openSaved=true&_a=(ui:(activeTabId:${tab},showHistogram:!t))`; /** Hash path for a fresh search on the given tab. */ -const newSearchPath = (tab: 'traces' | 'spans') => - `#/?_a=(ui:(activeTabId:${tab},showHistogram:!t))`; +const newSearchPath = (tab: AgentNavTab) => `#/?_a=(ui:(activeTabId:${tab},showHistogram:!t))`; /** - * Nav-popover config for an agent-monitoring flavor (Traces/Spans): quick + * Nav-popover config for an agent-monitoring entry (Traces/Sessions): quick * actions to start a new search or browse saved searches. The item still * navigates to the flavor on direct click. */ -function buildAgentNavPopover(appId: string, tab: 'traces' | 'spans'): NavPopoverConfig { +function buildAgentNavPopover(appId: string, tab: AgentNavTab): NavPopoverConfig { return { actions: [ { @@ -54,7 +55,7 @@ export const agentTracesNavPopover: NavPopoverConfig = buildAgentNavPopover( 'traces' ); -export const agentSpansNavPopover: NavPopoverConfig = buildAgentNavPopover( - AGENT_SPANS_NAV_ID, - 'spans' +export const agentSessionsNavPopover: NavPopoverConfig = buildAgentNavPopover( + AGENT_SESSIONS_NAV_ID, + 'sessions' ); diff --git a/src/plugins/agent_traces/public/plugin.ts b/src/plugins/agent_traces/public/plugin.ts index 2a56acc73dd6..5ea61d639f55 100644 --- a/src/plugins/agent_traces/public/plugin.ts +++ b/src/plugins/agent_traces/public/plugin.ts @@ -33,6 +33,7 @@ import { PLUGIN_NAME, AGENT_TRACES_NAV_ID, AGENT_SPANS_NAV_ID, + AGENT_SESSIONS_NAV_ID, } from '../common'; import { setDocViewsRegistry, @@ -63,8 +64,7 @@ import { ABORT_DATA_QUERY_TRIGGER } from '../../ui_actions/public'; import { abortAllActiveQueries } from './application/utils/state_management/actions/query_actions'; import { setServices } from './services/services'; import { AgentTracesIcon } from './assets/agent_traces_icon'; -import { AgentSpansIcon } from './assets/agent_spans_icon'; -import { agentTracesNavPopover, agentSpansNavPopover } from './nav_popover'; +import { agentTracesNavPopover, agentSessionsNavPopover } from './nav_popover'; import { SlotRegistryService } from './services/slot_registry'; // Log Actions @@ -300,6 +300,8 @@ export class AgentTracesPlugin implements Plugin< defaultPath: '#/?_a=(ui:(activeTabId:traces,showHistogram:!t))', }) ); + // Spans keeps its app (existing /app/agentTraces/spans links still work) but no + // longer has a nav entry; Spans stays one tab away from Traces and Sessions. core.application.register( createAgentTracesApp({ id: AGENT_SPANS_NAV_ID, @@ -307,6 +309,13 @@ export class AgentTracesPlugin implements Plugin< defaultPath: '#/?_a=(ui:(activeTabId:spans,showHistogram:!t))', }) ); + core.application.register( + createAgentTracesApp({ + id: AGENT_SESSIONS_NAV_ID, + title: 'Agent Sessions', + defaultPath: '#/?_a=(ui:(activeTabId:sessions,showHistogram:!t))', + }) + ); core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ { @@ -318,12 +327,12 @@ export class AgentTracesPlugin implements Plugin< navPopover: agentTracesNavPopover, }, { - id: AGENT_SPANS_NAV_ID, - title: 'Spans', + id: AGENT_SESSIONS_NAV_ID, + title: 'Sessions', category: DEFAULT_APP_CATEGORIES.agentMonitoring, order: 200, - euiIconType: AgentSpansIcon, - navPopover: agentSpansNavPopover, + euiIconType: 'navTicketing', + navPopover: agentSessionsNavPopover, }, ]); } @@ -377,7 +386,8 @@ export class AgentTracesPlugin implements Plugin< if ( app.id === PLUGIN_ID || app.id === AGENT_TRACES_NAV_ID || - app.id === AGENT_SPANS_NAV_ID + app.id === AGENT_SPANS_NAV_ID || + app.id === AGENT_SESSIONS_NAV_ID ) { return { navLinkStatus: agentTracesNavStatus }; } From 5346cb974e66618bcda70a52e6b883ffffe71734 Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:50:47 -0700 Subject: [PATCH 09/15] [Agent Traces] Apply row filters after eval in Sessions and metrics; explain ignored commands - New quote-aware extractSpanFilterQuery keeps every row-level command (where, eval, parse, grok, regex, fillnull) in order. The old split stopped at the first non-where command, so '| eval x = 1 | where x = 2' silently dropped the filter in the Sessions list, its facets, and the metrics bar. - Sessions shows a notice listing commands it does not apply (stats, head, fields, ...) and points to the Traces or Visualization tab. - An empty Sessions list under a filter now says no sessions match the query, instead of the setup hint about gen_ai.conversation.id. Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../pages/sessions/hooks/use_sessions.ts | 16 +++++-- .../pages/sessions/sessions_tab.scss | 4 ++ .../pages/sessions/sessions_tab.tsx | 37 +++++++++++++- .../pages/traces/hooks/use_trace_metrics.ts | 5 +- .../pages/traces/table_shared.test.tsx | 27 +++++++++++ .../application/pages/traces/table_shared.tsx | 48 +++++++++++++++++++ 6 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts index 2648e3e05742..e645c9e21bd7 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts @@ -8,7 +8,7 @@ import { useSelector } from 'react-redux'; import { AGENT_TRACES_SESSION_ID_FIELD } from '../../../../../common'; import { RootState } from '../../../utils/state_management/store'; import { usePPLQueryDeps, useTimeVersion } from '../../traces/hooks/use_ppl_query_deps'; -import { splitPplWhereAndTail } from '../../traces/table_shared'; +import { extractSpanFilterQuery, splitPplCommands } from '../../traces/table_shared'; import { transformPPLDataToTraceHits } from '../../traces/trace_details/traces/ppl_to_trace_hits'; import { hitsToAgentSpans, spanToRow } from '../../traces/hooks/tree_utils'; import { @@ -33,6 +33,10 @@ export interface UseSessionsResult { error: string | null; elapsedMs: number | null; refresh: () => void; + /** Commands in the user's query that the Sessions view does not apply (stats, head, ...). */ + ignoredCommands: string[]; + /** Whether the user's query filters spans (so an empty list means "no match"). */ + hasFilter: boolean; } /** @@ -53,6 +57,8 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult const [error, setError] = useState(null); const [elapsedMs, setElapsedMs] = useState(null); const [refreshCounter, setRefreshCounter] = useState(0); + const [ignoredCommands, setIgnoredCommands] = useState([]); + const [hasFilter, setHasFilter] = useState(false); const requestIdRef = useRef(0); const fetchSessions = useCallback(async () => { @@ -63,7 +69,11 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult setError(null); try { - const { whereQuery } = splitPplWhereAndTail(baseQueryString); + // Row-level filters select which spans (and so which sessions) match. + const { filterQuery: whereQuery, ignoredCommands: ignored } = + extractSpanFilterQuery(baseQueryString); + setIgnoredCommands(ignored); + setHasFilter(splitPplCommands(whereQuery).length > 1); const source = getSourceCommand(whereQuery); // Fields panel facets, counted per session (runs alongside the list queries). @@ -157,5 +167,5 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult const refresh = useCallback(() => setRefreshCounter((c) => c + 1), []); - return { sessions, loading, error, elapsedMs, refresh }; + return { sessions, loading, error, elapsedMs, refresh, ignoredCommands, hasFilter }; }; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss index 093edfdf1e4b..7846e96fbe7f 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.scss @@ -204,3 +204,7 @@ $agtSessionFlyoutChrome: 150px; word-break: break-word; } } + +.agtSessionsTab__ignoredCallout { + margin: 0 $euiSizeS $euiSizeS; +} diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx index 8fac65c5827e..86f5b552b8d9 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx @@ -30,7 +30,7 @@ export const SessionsTab = () => { }, [uiSettings]); const formatTs = useCallback((ts: string) => formatTimestamp(ts, timezone), [timezone]); - const { sessions, loading, error, elapsedMs } = useSessions(formatTs); + const { sessions, loading, error, elapsedMs, ignoredCommands, hasFilter } = useSessions(formatTs); const [wrapCellText, setWrapCellText] = useState(false); const [selected, setSelected] = useState(null); @@ -58,6 +58,28 @@ export const SessionsTab = () => { {error} ); + } else if (sessions.length === 0 && hasFilter) { + body = ( + + {i18n.translate('agentTraces.sessions.noMatchTitle', { + defaultMessage: 'No sessions match your query', + })} + + } + body={ +

+ {i18n.translate('agentTraces.sessions.noMatchBody', { + defaultMessage: + 'No span in any session matches the current filter and time range. Try a broader filter or time range.', + })} +

+ } + data-test-subj="agentTracesSessionsNoMatch" + /> + ); } else if (sessions.length === 0) { body = ( { wrapCellText={wrapCellText} onWrapCellTextChange={setWrapCellText} /> + {ignoredCommands.length > 0 && !error && ( + + )} {body}
{selected && ( diff --git a/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts b/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts index 99a4fef8b3f4..9be247f439c9 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts +++ b/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts @@ -9,7 +9,7 @@ import { PPLService } from '../trace_details/data_fetching/ppl_request_helpers'; import { Dataset } from '../../../../../../data/common'; import { usePPLQueryDeps, useTimeVersion } from './use_ppl_query_deps'; import { RootState } from '../../../utils/state_management/store'; -import { splitPplWhereAndTail } from '../table_shared'; +import { extractSpanFilterQuery } from '../table_shared'; import { AGENT_TRACES_SESSION_ID_FIELD } from '../../../../../common'; export interface TraceMetrics { @@ -71,7 +71,8 @@ const doFetchMetrics = async ( filteredQuery: string ): Promise => { const genAiFilter = `where isnotnull(\`attributes.gen_ai.operation.name\`)`; - const { whereQuery } = splitPplWhereAndTail(filteredQuery); + // Row-level filters only (where, eval, ...): a filter placed after eval still applies. + const { filterQuery: whereQuery } = extractSpanFilterQuery(filteredQuery); // Run all queries in parallel // Note: User non-where commands (head, sort, etc.) are intentionally excluded from stats queries. diff --git a/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx b/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx index 056a4d9f5e3e..700f29085ed7 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx @@ -14,6 +14,8 @@ import { TableEmptyState, hitToBaseRow, DataTableInfoBar, + extractSpanFilterQuery, + splitPplCommands, } from './table_shared'; describe('table_shared', () => { @@ -326,3 +328,28 @@ describe('table_shared', () => { }); }); }); + +describe('splitPplCommands', () => { + it('splits on top-level pipes only', () => { + expect(splitPplCommands("source = t | where name = 'a|b' | where `x|y` = 1")).toEqual([ + 'source = t', + "where name = 'a|b'", + 'where `x|y` = 1', + ]); + }); +}); + +describe('extractSpanFilterQuery', () => { + it('keeps row-level commands in order, including filters after eval', () => { + expect(extractSpanFilterQuery('source = t | eval x = 1 | where x = 2')).toEqual({ + filterQuery: 'source = t | eval x = 1 | where x = 2', + ignoredCommands: [], + }); + }); + + it('reports reshaping commands and drops sort silently', () => { + expect( + extractSpanFilterQuery('source = t | where a = 1 | sort - b | stats count() by c | head 5') + ).toEqual({ filterQuery: 'source = t | where a = 1', ignoredCommands: ['stats', 'head'] }); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx b/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx index 2f555df2e945..1e372fcff998 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx @@ -92,6 +92,54 @@ export const splitPplWhereAndTail = ( }; }; +/** PPL commands that filter or annotate span rows without changing their shape. */ +const ROW_LEVEL_COMMANDS = new Set(['where', 'eval', 'parse', 'grok', 'regex', 'fillnull']); + +/** Split a PPL query on top-level pipes, ignoring pipes inside quotes or backticks. */ +export const splitPplCommands = (queryString: string): string[] => { + const parts: string[] = []; + let current = ''; + let quote: string | null = null; + for (const ch of queryString) { + if (quote) { + if (ch === quote) quote = null; + current += ch; + } else if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + current += ch; + } else if (ch === '|') { + parts.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + parts.push(current.trim()); + return parts.filter(Boolean); +}; + +/** + * The span-level filter in a PPL query: the source plus every row-level command + * (where, eval, parse, ...), in order. Commands that reshape rows (stats, head, fields, + * dedup, ...) are returned separately so callers can tell the user they were not applied. + * `sort` is dropped silently since it never changes which rows match. + */ +export const extractSpanFilterQuery = ( + queryString: string +): { filterQuery: string; ignoredCommands: string[] } => { + const kept: string[] = []; + const ignored: string[] = []; + splitPplCommands(queryString).forEach((part, index) => { + const command = part.split(/\s+/)[0].toLowerCase(); + if ((index === 0 && command.startsWith('source')) || ROW_LEVEL_COMMANDS.has(command)) { + kept.push(part); + } else if (command !== 'sort') { + ignored.push(command); + } + }); + return { filterQuery: kept.join(' | '), ignoredCommands: [...new Set(ignored)] }; +}; + /** * Checks if the main query ends with a head command (optionally followed by `from N` or `| where`). * Subquery brackets [...] are masked so that head inside subqueries is ignored. From e18be6379684643b1abe5314607a981185b64334 Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:48:20 -0700 Subject: [PATCH 10/15] [Agent Traces] Show capped session counts and whole-session totals - The Sessions list loads at most 100 sessions; the info bar now reads "100 of N sessions" from a separate count query instead of "100 sessions". - The time range selects which sessions appear; per-session totals (traces, duration, tokens, first/last message) cover the whole session, so the list and the session flyout agree for sessions that started before the range. Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../pages/sessions/hooks/use_sessions.ts | 40 ++++++++++++++----- .../pages/sessions/session_utils.test.ts | 17 ++++++++ .../pages/sessions/session_utils.ts | 14 +++++++ .../pages/sessions/sessions_tab.tsx | 5 ++- .../application/pages/traces/table_shared.tsx | 31 +++++++++----- 5 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts index e645c9e21bd7..16b30bbab947 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts @@ -16,6 +16,8 @@ import { assembleSessionRows, buildRootSpansQuery, buildMatchingSessionIdsQuery, + buildMatchingSessionCountQuery, + withoutTimeRange, buildSessionStatsQuery, buildTraceSessionMapQuery, buildSessionFacetQuery, @@ -37,6 +39,8 @@ export interface UseSessionsResult { ignoredCommands: string[]; /** Whether the user's query filters spans (so an empty list means "no match"). */ hasFilter: boolean; + /** Sessions matching the query and time range; the list shows at most SESSIONS_PAGE_LIMIT. */ + totalSessions: number | null; } /** @@ -59,6 +63,7 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult const [refreshCounter, setRefreshCounter] = useState(0); const [ignoredCommands, setIgnoredCommands] = useState([]); const [hasFilter, setHasFilter] = useState(false); + const [totalSessions, setTotalSessions] = useState(null); const requestIdRef = useRef(0); const fetchSessions = useCallback(async () => { @@ -96,11 +101,18 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult } }); - // 1. Sessions matching the user's query; 2. their full stats (not narrowed by the filter). - const idsResponse = await pplService.executeQuery( - datasetParam, - buildMatchingSessionIdsQuery(whereQuery) - ); + // 1. Sessions matching the user's query and time range (plus how many there are); + // 2-4. their full stats, not narrowed by the filter or the time range. + const [idsResponse, countResponse] = await Promise.all([ + pplService.executeQuery(datasetParam, buildMatchingSessionIdsQuery(whereQuery)), + pplService + .executeQuery(datasetParam, buildMatchingSessionCountQuery(whereQuery)) + .catch(() => null), + ]); + const total = countResponse + ? Number(pplResponseToRecords(countResponse)[0]?.total_sessions) + : NaN; + const wholeSession = withoutTimeRange(datasetParam); const sessionIds = pplResponseToRecords(idsResponse) .map((r) => r[AGENT_TRACES_SESSION_ID_FIELD]) .filter((id): id is string => typeof id === 'string' && id !== ''); @@ -108,7 +120,7 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult let stats: ReturnType = []; if (sessionIds.length > 0) { const statsResponse = await pplService.executeQuery( - datasetParam, + wholeSession, buildSessionStatsQuery(source, sessionIds) ); stats = parseSessionStats(pplResponseToRecords(statsResponse)); @@ -117,7 +129,7 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult let rows: SessionRow[] = []; if (stats.length > 0) { const mapResponse = await pplService.executeQuery( - datasetParam, + wholeSession, buildTraceSessionMapQuery( source, stats.map((s) => s.sessionId) @@ -134,7 +146,7 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult let rootRows: Array> = []; if (traceIds.length > 0) { const rootsResponse = await pplService.executeQuery( - datasetParam, + wholeSession, buildRootSpansQuery(source, traceIds) ); rootRows = hitsToAgentSpans(transformPPLDataToTraceHits(rootsResponse)).map((span, i) => @@ -146,6 +158,7 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult if (requestId !== requestIdRef.current) return; // a newer request superseded this one setSessions(rows); + setTotalSessions(Number.isFinite(total) ? Math.max(total, rows.length) : null); setElapsedMs(Date.now() - started); } catch (err) { if (requestId !== requestIdRef.current) return; @@ -167,5 +180,14 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult const refresh = useCallback(() => setRefreshCounter((c) => c + 1), []); - return { sessions, loading, error, elapsedMs, refresh, ignoredCommands, hasFilter }; + return { + sessions, + loading, + error, + elapsedMs, + refresh, + ignoredCommands, + hasFilter, + totalSessions, + }; }; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts index 28e96dd8dd0a..0d654db26be7 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts @@ -17,6 +17,8 @@ import { pplResponseToRecords, shortenId, buildSessionFacetQuery, + buildMatchingSessionCountQuery, + withoutTimeRange, parseFacetBuckets, } from './session_utils'; import { BaseRow } from '../traces/hooks/tree_utils'; @@ -224,3 +226,18 @@ describe('session facets', () => { ]); }); }); + +describe('session totals', () => { + it('counts sessions matching the query', () => { + expect(buildMatchingSessionCountQuery('source = spans')).toBe( + 'source = spans | where isnotnull(`attributes.gen_ai.conversation.id`) | stats distinct_count(`attributes.gen_ai.conversation.id`) as total_sessions' + ); + }); + + it('drops only the time field from the dataset', () => { + expect(withoutTimeRange({ id: 'd', title: 't', timeFieldName: 'endTime' })).toEqual({ + id: 'd', + title: 't', + }); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts index 829c2c9da0c8..60f3620be3a5 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts @@ -119,6 +119,20 @@ export const parseFacetBuckets = (records: Array>, field: st })); }; +/** Number of sessions matching the user's query (the list itself is capped). */ +export const buildMatchingSessionCountQuery = (whereQuery: string): string => + `${whereQuery} | where isnotnull(${SESSION_FIELD_PPL}) | stats distinct_count(${SESSION_FIELD_PPL}) as total_sessions`; + +/** + * The dataset without its time field, so a query is not limited to the picked time + * range. The time range selects which sessions appear; their totals cover the whole + * session, including turns before the range started. + */ +export const withoutTimeRange = (dataset: T): T => { + const { timeFieldName, ...rest } = dataset; + return rest as T; +}; + /** Full (unfiltered) stats for the given sessions: trace count and time bounds. */ export const buildSessionStatsQuery = (source: string, sessionIds: string[]): string => `${source} | where ${SESSION_FIELD_PPL} in (${inList( diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx index 86f5b552b8d9..a032d3d1f364 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx @@ -30,7 +30,8 @@ export const SessionsTab = () => { }, [uiSettings]); const formatTs = useCallback((ts: string) => formatTimestamp(ts, timezone), [timezone]); - const { sessions, loading, error, elapsedMs, ignoredCommands, hasFilter } = useSessions(formatTs); + const { sessions, loading, error, elapsedMs, ignoredCommands, hasFilter, totalSessions } = + useSessions(formatTs); const [wrapCellText, setWrapCellText] = useState(false); const [selected, setSelected] = useState(null); @@ -123,7 +124,7 @@ export const SessionsTab = () => { = ({ /> ); -const SessionCountMessage: React.FC = ({ hitsCount, elapsedMs }) => ( - {hitsCount.toLocaleString()}, - elapsed: {elapsedMs != null ? elapsedMs.toLocaleString() : '—'}, - }} - /> -); +const SessionCountMessage: React.FC = ({ hitsCount, totalCount, elapsedMs }) => + totalCount > hitsCount ? ( + {hitsCount.toLocaleString()}, + total: {totalCount.toLocaleString()}, + elapsed: {elapsedMs != null ? elapsedMs.toLocaleString() : '—'}, + }} + /> + ) : ( + {hitsCount.toLocaleString()}, + elapsed: {elapsedMs != null ? elapsedMs.toLocaleString() : '—'}, + }} + /> + ); export const DataTableInfoBar: React.FC = ({ hasHead, From 05620362cff89401260e1c8fb0a3a08e95f1e953 Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:07:58 -0700 Subject: [PATCH 11/15] [Agent Traces] Render Traces, Spans and Sessions saved searches as agent tables on dashboards Saved searches from the Traces, Spans and Sessions tabs used to embed as raw span documents (every span, with Status/Latency/Tokens/Input/Output empty). - The panel reads the tab the search was saved from and runs that tab's query (Traces: root agent spans; Spans: all gen_ai spans), rendered with the agent columns. Sessions run the same queries as the Sessions tab (shared fetchSessions) and render the Sessions table with a capped count. - Rows open the trace or session in Agent Traces with the dashboard time range. - Tab queries move to tab_queries.ts so the panel can use them without the app's tab registry, which is only filled when the app mounts. - Fix plural forms in the Traces/Spans/Sessions count messages ("1 traces"). Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../pages/sessions/fetch_sessions.test.ts | 53 +++++++ .../pages/sessions/fetch_sessions.ts | 147 ++++++++++++++++++ .../pages/sessions/hooks/use_sessions.ts | 122 ++------------- .../application/pages/traces/table_shared.tsx | 9 +- .../public/application/register_tabs.ts | 18 +-- .../public/application/tab_queries.ts | 26 ++++ .../embeddable/agent_traces_embeddable.tsx | 114 +++++++++++++- .../agent_traces_embeddable_component.tsx | 17 ++ .../embeddable/agent_view_panel.test.tsx | 84 ++++++++++ .../public/embeddable/agent_view_panel.tsx | 121 ++++++++++++++ 10 files changed, 586 insertions(+), 125 deletions(-) create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.test.ts create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.ts create mode 100644 src/plugins/agent_traces/public/application/tab_queries.ts create mode 100644 src/plugins/agent_traces/public/embeddable/agent_view_panel.test.tsx create mode 100644 src/plugins/agent_traces/public/embeddable/agent_view_panel.tsx diff --git a/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.test.ts b/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.test.ts new file mode 100644 index 000000000000..780003589cd1 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fetchSessions, sessionFilterFor } from './fetch_sessions'; + +const SESSION = 'attributes.gen_ai.conversation.id'; +const ppl = (schema: string[], rows: unknown[][]) => ({ + schema: schema.map((name) => ({ name })), + datarows: rows, +}); + +describe('fetchSessions', () => { + it('reports ignored commands and whether the query filters', () => { + expect(sessionFilterFor('source = t | where a = 1 | head 5')).toEqual({ + whereQuery: 'source = t | where a = 1', + ignoredCommands: ['head'], + hasFilter: true, + }); + expect(sessionFilterFor('source = t').hasFilter).toBe(false); + }); + + it('selects sessions under the time range and totals them over the whole session', async () => { + const executeQuery = jest.fn(async (dataset: any, query: string) => { + if (query.includes('as total_sessions')) return ppl(['total_sessions'], [[7]]); + if (query.includes('as last_seen')) return ppl([SESSION, 'last_seen'], [['s1', 'x']]); + if (query.includes('as total_traces')) + return ppl( + [SESSION, 'total_traces', 'start_time', 'end_time'], + [['s1', 2, '2026-09-29 10:00:00', '2026-09-29 10:01:00']] + ); + if (query.includes('dedup traceId')) return ppl(['traceId', SESSION], [['t1', 's1']]); + return ppl([], []); + }); + const dataset = { id: 'd', title: 'spans', type: 'INDEX_PATTERN', timeFieldName: 'endTime' }; + + const result = await fetchSessions( + { executeQuery }, + dataset as any, + 'source = spans', + (t) => t + ); + + expect(result.totalSessions).toBe(7); + expect(result.sessions.map((s) => [s.sessionId, s.totalTraces])).toEqual([['s1', 2]]); + const byQuery = (part: string) => + executeQuery.mock.calls.find(([, q]) => q.includes(part))?.[0]; + // The matching query keeps the time range; per-session totals drop it. + expect(byQuery('as last_seen').timeFieldName).toBe('endTime'); + expect(byQuery('as total_traces').timeFieldName).toBeUndefined(); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.ts b/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.ts new file mode 100644 index 000000000000..675f433fddb1 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.ts @@ -0,0 +1,147 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AGENT_TRACES_SESSION_ID_FIELD } from '../../../../common'; +import { Dataset } from '../../../../../data/common'; +import { extractSpanFilterQuery, splitPplCommands } from '../traces/table_shared'; +import { transformPPLDataToTraceHits } from '../traces/trace_details/traces/ppl_to_trace_hits'; +import { hitsToAgentSpans, spanToRow } from '../traces/hooks/tree_utils'; +import { Bucket } from '../../../components/fields_selector/types'; +import { + SessionRow, + assembleSessionRows, + buildRootSpansQuery, + buildMatchingSessionIdsQuery, + buildMatchingSessionCountQuery, + withoutTimeRange, + buildSessionStatsQuery, + buildTraceSessionMapQuery, + buildSessionFacetQuery, + parseFacetBuckets, + SESSION_FACET_FIELDS, + getSourceCommand, + parseSessionStats, + pplResponseToRecords, +} from './session_utils'; + +/** Anything that can run a PPL query against a dataset (PPLService in the app and embeddable). */ +export interface PPLQueryRunner { + executeQuery(dataset: Dataset, pplQuery: string): Promise; +} + +export interface FetchSessionsResult { + sessions: SessionRow[]; + /** Sessions matching the query and time range; `sessions` holds at most the page limit. */ + totalSessions: number | null; + /** Commands in the query the Sessions view does not apply (stats, head, ...). */ + ignoredCommands: string[]; + /** Whether the query filters spans (so an empty list means "no match"). */ + hasFilter: boolean; +} + +/** The span-level filter a Sessions query applies, plus what it leaves out. */ +export const sessionFilterFor = (baseQueryString: string) => { + const { filterQuery, ignoredCommands } = extractSpanFilterQuery(baseQueryString); + return { + whereQuery: filterQuery, + ignoredCommands, + hasFilter: splitPplCommands(filterQuery).length > 1, + }; +}; + +/** Session-level facet values for the fields panel (distinct sessions per value). */ +export const fetchSessionFacets = async ( + ppl: PPLQueryRunner, + dataset: Dataset, + whereQuery: string +): Promise> => { + const entries = await Promise.all( + SESSION_FACET_FIELDS.map(async (field) => { + try { + const response = await ppl.executeQuery(dataset, buildSessionFacetQuery(whereQuery, field)); + return [field, parseFacetBuckets(pplResponseToRecords(response), field)] as const; + } catch { + return [field, [] as Bucket[]] as const; // e.g. the field is not mapped in this index + } + }) + ); + return Object.fromEntries(entries); +}; + +/** + * Fetch the sessions list for a query. Shared by the Sessions tab and the dashboard panel. + * + * 1. Session ids matching the query and time range (the filter selects sessions), plus a count. + * 2. Stats for those sessions (trace count, start/end), over the whole session. + * 3. Trace map: which traces belong to each session (any span may carry the id). + * 4. Root spans of those traces: first/last message, tokens, user id. + */ +export const fetchSessions = async ( + ppl: PPLQueryRunner, + dataset: Dataset, + baseQueryString: string, + formatTs: (ts: string) => string +): Promise => { + const { whereQuery, ignoredCommands, hasFilter } = sessionFilterFor(baseQueryString); + const source = getSourceCommand(whereQuery); + + const [idsResponse, countResponse] = await Promise.all([ + ppl.executeQuery(dataset, buildMatchingSessionIdsQuery(whereQuery)), + ppl.executeQuery(dataset, buildMatchingSessionCountQuery(whereQuery)).catch(() => null), + ]); + const total = countResponse + ? Number(pplResponseToRecords(countResponse)[0]?.total_sessions) + : NaN; + const wholeSession = withoutTimeRange(dataset); + const sessionIds = pplResponseToRecords(idsResponse) + .map((r) => r[AGENT_TRACES_SESSION_ID_FIELD]) + .filter((id): id is string => typeof id === 'string' && id !== ''); + + let stats: ReturnType = []; + if (sessionIds.length > 0) { + const statsResponse = await ppl.executeQuery( + wholeSession, + buildSessionStatsQuery(source, sessionIds) + ); + stats = parseSessionStats(pplResponseToRecords(statsResponse)); + } + + let sessions: SessionRow[] = []; + if (stats.length > 0) { + const mapResponse = await ppl.executeQuery( + wholeSession, + buildTraceSessionMapQuery( + source, + stats.map((s) => s.sessionId) + ) + ); + const traceToSession = new Map(); + for (const rec of pplResponseToRecords(mapResponse)) { + const traceId = rec.traceId; + const sessionId = rec[AGENT_TRACES_SESSION_ID_FIELD]; + if (traceId && sessionId) traceToSession.set(String(traceId), String(sessionId)); + } + + const traceIds = [...traceToSession.keys()]; + let rootRows: Array> = []; + if (traceIds.length > 0) { + const rootsResponse = await ppl.executeQuery( + wholeSession, + buildRootSpansQuery(source, traceIds) + ); + rootRows = hitsToAgentSpans(transformPPLDataToTraceHits(rootsResponse)).map((span, i) => + spanToRow(span, i, formatTs) + ); + } + sessions = assembleSessionRows(stats, traceToSession, rootRows); + } + + return { + sessions, + totalSessions: Number.isFinite(total) ? Math.max(total, sessions.length) : null, + ignoredCommands, + hasFilter, + }; +}; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts index 16b30bbab947..9ec884f83ef2 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts @@ -5,28 +5,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import { AGENT_TRACES_SESSION_ID_FIELD } from '../../../../../common'; import { RootState } from '../../../utils/state_management/store'; import { usePPLQueryDeps, useTimeVersion } from '../../traces/hooks/use_ppl_query_deps'; -import { extractSpanFilterQuery, splitPplCommands } from '../../traces/table_shared'; -import { transformPPLDataToTraceHits } from '../../traces/trace_details/traces/ppl_to_trace_hits'; -import { hitsToAgentSpans, spanToRow } from '../../traces/hooks/tree_utils'; +import { SessionRow } from '../session_utils'; import { - SessionRow, - assembleSessionRows, - buildRootSpansQuery, - buildMatchingSessionIdsQuery, - buildMatchingSessionCountQuery, - withoutTimeRange, - buildSessionStatsQuery, - buildTraceSessionMapQuery, - buildSessionFacetQuery, - parseFacetBuckets, - SESSION_FACET_FIELDS, - getSourceCommand, - parseSessionStats, - pplResponseToRecords, -} from '../session_utils'; + fetchSessionFacets, + fetchSessions as fetchSessionRows, + sessionFilterFor, +} from '../fetch_sessions'; import { sessionFacetBuckets$ } from '../session_facets'; export interface UseSessionsResult { @@ -43,14 +29,7 @@ export interface UseSessionsResult { totalSessions: number | null; } -/** - * Fetch the sessions list for the current query and time range. - * - * 1. Session ids matching the user's query (the filter selects sessions). - * 2. Unfiltered stats for those sessions (trace count, start/end). - * 3. Trace map: which traces belong to each session (any span may carry the id). - * 4. Root spans of those traces: first/last message, tokens, user id. - */ +/** Sessions list for the current query and time range (see `fetchSessions`). */ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult => { const { services, pplService, datasetParam, baseQueryString } = usePPLQueryDeps(); const fetchVersion = useSelector((state: RootState) => state.queryEditor.fetchVersion); @@ -74,91 +53,24 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult setError(null); try { - // Row-level filters select which spans (and so which sessions) match. - const { filterQuery: whereQuery, ignoredCommands: ignored } = - extractSpanFilterQuery(baseQueryString); + const { + whereQuery, + ignoredCommands: ignored, + hasFilter: filtered, + } = sessionFilterFor(baseQueryString); setIgnoredCommands(ignored); - setHasFilter(splitPplCommands(whereQuery).length > 1); - const source = getSourceCommand(whereQuery); + setHasFilter(filtered); // Fields panel facets, counted per session (runs alongside the list queries). sessionFacetBuckets$.next(null); - void Promise.all( - SESSION_FACET_FIELDS.map(async (field) => { - try { - const response = await pplService.executeQuery( - datasetParam, - buildSessionFacetQuery(whereQuery, field) - ); - return [field, parseFacetBuckets(pplResponseToRecords(response), field)] as const; - } catch { - return [field, []] as const; // e.g. the field is not mapped in this index - } - }) - ).then((entries) => { - if (requestId === requestIdRef.current) { - sessionFacetBuckets$.next(Object.fromEntries(entries)); - } + void fetchSessionFacets(pplService, datasetParam, whereQuery).then((buckets) => { + if (requestId === requestIdRef.current) sessionFacetBuckets$.next(buckets); }); - // 1. Sessions matching the user's query and time range (plus how many there are); - // 2-4. their full stats, not narrowed by the filter or the time range. - const [idsResponse, countResponse] = await Promise.all([ - pplService.executeQuery(datasetParam, buildMatchingSessionIdsQuery(whereQuery)), - pplService - .executeQuery(datasetParam, buildMatchingSessionCountQuery(whereQuery)) - .catch(() => null), - ]); - const total = countResponse - ? Number(pplResponseToRecords(countResponse)[0]?.total_sessions) - : NaN; - const wholeSession = withoutTimeRange(datasetParam); - const sessionIds = pplResponseToRecords(idsResponse) - .map((r) => r[AGENT_TRACES_SESSION_ID_FIELD]) - .filter((id): id is string => typeof id === 'string' && id !== ''); - - let stats: ReturnType = []; - if (sessionIds.length > 0) { - const statsResponse = await pplService.executeQuery( - wholeSession, - buildSessionStatsQuery(source, sessionIds) - ); - stats = parseSessionStats(pplResponseToRecords(statsResponse)); - } - - let rows: SessionRow[] = []; - if (stats.length > 0) { - const mapResponse = await pplService.executeQuery( - wholeSession, - buildTraceSessionMapQuery( - source, - stats.map((s) => s.sessionId) - ) - ); - const traceToSession = new Map(); - for (const rec of pplResponseToRecords(mapResponse)) { - const traceId = rec.traceId; - const sessionId = rec[AGENT_TRACES_SESSION_ID_FIELD]; - if (traceId && sessionId) traceToSession.set(String(traceId), String(sessionId)); - } - - const traceIds = [...traceToSession.keys()]; - let rootRows: Array> = []; - if (traceIds.length > 0) { - const rootsResponse = await pplService.executeQuery( - wholeSession, - buildRootSpansQuery(source, traceIds) - ); - rootRows = hitsToAgentSpans(transformPPLDataToTraceHits(rootsResponse)).map((span, i) => - spanToRow(span, i, formatTs) - ); - } - rows = assembleSessionRows(stats, traceToSession, rootRows); - } - + const result = await fetchSessionRows(pplService, datasetParam, baseQueryString, formatTs); if (requestId !== requestIdRef.current) return; // a newer request superseded this one - setSessions(rows); - setTotalSessions(Number.isFinite(total) ? Math.max(total, rows.length) : null); + setSessions(result.sessions); + setTotalSessions(result.totalSessions); setElapsedMs(Date.now() - started); } catch (err) { if (requestId !== requestIdRef.current) return; diff --git a/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx b/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx index 9f1670e78e6e..222fe14285dd 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx @@ -366,9 +366,10 @@ const SpanCountMessage: React.FC = ({ hasHead ? ( {hitsCount.toLocaleString()}, + hitsCount, elapsed: {elapsedMs != null ? elapsedMs.toLocaleString() : '—'}, }} /> @@ -394,9 +395,10 @@ const TraceCountMessage: React.FC = ({ hasHead ? ( {hitsCount.toLocaleString()}, + hitsCount, elapsed: {elapsedMs != null ? elapsedMs.toLocaleString() : '—'}, }} /> @@ -427,9 +429,10 @@ const SessionCountMessage: React.FC = ({ hitsCount, totalCoun ) : ( {hitsCount.toLocaleString()}, + hitsCount, elapsed: {elapsedMs != null ? elapsedMs.toLocaleString() : '—'}, }} /> diff --git a/src/plugins/agent_traces/public/application/register_tabs.ts b/src/plugins/agent_traces/public/application/register_tabs.ts index 64ec03ab8405..fcdeec9b3327 100644 --- a/src/plugins/agent_traces/public/application/register_tabs.ts +++ b/src/plugins/agent_traces/public/application/register_tabs.ts @@ -20,18 +20,9 @@ import { AGENT_TRACES_VISUALIZATION_TAB_ID, AGENT_TRACES_SESSIONS_TAB_ID, } from '../../common'; -import { defaultPrepareQueryString } from './utils/state_management/actions/query_actions'; -import { buildPplSortClause, splitPplWhereAndTail } from './pages/traces/table_shared'; +import { prepareAgentSpansQuery, prepareRootSpansQuery } from './tab_queries'; import { prepareQueryForLanguage } from './utils/languages'; -/** Root agent spans (one per trace). Shared by Traces and Sessions so they share a cache key. */ -const prepareRootSpansQuery: NonNullable = (query, sort) => { - const baseQuery = defaultPrepareQueryString(query); - const { whereQuery, tailCommands } = splitPplWhereAndTail(baseQuery); - const sortClause = sort?.length ? ` ${buildPplSortClause(sort[0][0], sort[0][1])}` : ''; - return `${whereQuery} | where parentSpanId = "" AND isnotnull(\`attributes.gen_ai.operation.name\`) ${tailCommands}${sortClause}`; -}; - /** * Registers built-in tabs with the tab registry * Agent Traces only supports Traces @@ -64,12 +55,7 @@ export const registerBuiltInTabs = (tabRegistry: TabRegistryService) => { supportedLanguages: [AGENT_TRACES_DEFAULT_LANGUAGE], // Filter to all gen_ai spans (not just root spans) - prepareQuery: (query, sort) => { - const baseQuery = defaultPrepareQueryString(query); - const { whereQuery, tailCommands } = splitPplWhereAndTail(baseQuery); - const sortClause = sort?.length ? ` ${buildPplSortClause(sort[0][0], sort[0][1])}` : ''; - return `${whereQuery} | where isnotnull(\`attributes.gen_ai.operation.name\`) ${tailCommands}${sortClause}`; - }, + prepareQuery: prepareAgentSpansQuery, component: SpansTab, }; diff --git a/src/plugins/agent_traces/public/application/tab_queries.ts b/src/plugins/agent_traces/public/application/tab_queries.ts new file mode 100644 index 000000000000..251d277d0b56 --- /dev/null +++ b/src/plugins/agent_traces/public/application/tab_queries.ts @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TabDefinition } from '../services/tab_registry/tab_registry_service'; +import { defaultPrepareQueryString } from './utils/state_management/actions/query_actions'; +import { buildPplSortClause, splitPplWhereAndTail } from './pages/traces/table_shared'; + +type PrepareQuery = NonNullable; + +/** Root agent spans (one per trace). Shared by Traces and Sessions so they share a cache key. */ +export const prepareRootSpansQuery: PrepareQuery = (query, sort) => { + const baseQuery = defaultPrepareQueryString(query); + const { whereQuery, tailCommands } = splitPplWhereAndTail(baseQuery); + const sortClause = sort?.length ? ` ${buildPplSortClause(sort[0][0], sort[0][1])}` : ''; + return `${whereQuery} | where parentSpanId = "" AND isnotnull(\`attributes.gen_ai.operation.name\`) ${tailCommands}${sortClause}`; +}; + +/** All gen_ai spans (not just root spans). */ +export const prepareAgentSpansQuery: PrepareQuery = (query, sort) => { + const baseQuery = defaultPrepareQueryString(query); + const { whereQuery, tailCommands } = splitPplWhereAndTail(baseQuery); + const sortClause = sort?.length ? ` ${buildPplSortClause(sort[0][0], sort[0][1])}` : ''; + return `${whereQuery} | where isnotnull(\`attributes.gen_ai.operation.name\`) ${tailCommands}${sortClause}`; +}; diff --git a/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx b/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx index 716e5cb1d72d..6c843ce4d673 100644 --- a/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx +++ b/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx @@ -41,6 +41,15 @@ import { defaultPrepareQueryString } from '../application/utils/state_management import { addPPLSourceClause } from '../application/utils/languages/ppl/get_query_string_with_source'; import { VisualizationBuilder } from '../../../explore/public'; import { ExecutionContextSearch } from '../../../expressions/common'; +import { setStateToOsdUrl } from '../../../opensearch_dashboards_utils/public'; +import { PLUGIN_ID, AGENT_TRACES_SESSION_ID_FIELD } from '../../common'; +import { formatTimestamp, TraceRow } from '../application/pages/traces/hooks/tree_utils'; +import { hitToBaseRow } from '../application/pages/traces/table_shared'; +import { PPLService } from '../application/pages/traces/trace_details/data_fetching/ppl_request_helpers'; +import { fetchSessions } from '../application/pages/sessions/fetch_sessions'; +import { SessionRow } from '../application/pages/sessions/session_utils'; +import { AgentView, isAgentView } from './agent_view_panel'; +import { prepareAgentSpansQuery, prepareRootSpansQuery } from '../application/tab_queries'; export interface SearchProps { description?: string; @@ -64,8 +73,27 @@ export interface SearchProps { onFilter?: (field: IFieldType, value: string[], operator: string) => void; visualizationBuilder?: VisualizationBuilder; searchContext?: ExecutionContextSearch; + /** Saved from the Traces, Spans or Sessions tab: render the agent table for that tab. */ + agentView?: AgentView; + agentRows?: TraceRow[]; + sessions?: SessionRow[]; + agentTotal?: number | null; + agentError?: string; + formatTs?: (ts: string) => string; + onOpenTrace?: (row: TraceRow) => void; + onOpenSession?: (session: SessionRow) => void; } +/** Read the tab a saved search was saved from (its `uiState.activeTab`). */ +const savedActiveTab = (uiState: unknown): string | undefined => { + try { + const parsed = typeof uiState === 'string' ? JSON.parse(uiState || '{}') : uiState; + return (parsed as { activeTab?: string })?.activeTab; + } catch { + return undefined; + } +}; + interface AgentTracesEmbeddableConfig { savedAgentTraces: SavedAgentTraces; editUrl: string; @@ -100,6 +128,10 @@ export class AgentTracesEmbeddable private node?: HTMLElement; private root?: Root; private visualizationBuilder?: VisualizationBuilder; + private agentView?: AgentView; + /** Base PPL query (source + user query) for the Sessions view, which runs its own queries. */ + private sessionsBaseQuery?: string; + private savedQuery?: Query; constructor( { @@ -182,6 +214,13 @@ export class AgentTracesEmbeddable displayTimeColumn: this.services.uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false), title: this.savedAgentTraces.title, visualizationBuilder: this.visualizationBuilder, + formatTs: this.formatTs, + onOpenTrace: (row) => this.openInAgentTraces('traces', `| where traceId = "${row.traceId}"`), + onOpenSession: (session) => + this.openInAgentTraces( + 'sessions', + `| where \`${AGENT_TRACES_SESSION_ID_FIELD}\` = "${session.sessionId.replace(/"/g, '\\"')}"` + ), }; const timeRangeSearchSource = searchSource.create(); timeRangeSearchSource.setField('filter', () => { @@ -191,8 +230,22 @@ export class AgentTracesEmbeddable this.filtersSearchSource = searchSource.create(); this.filtersSearchSource.setParent(timeRangeSearchSource); searchSource.setParent(this.filtersSearchSource); + const activeTab = savedActiveTab(this.savedAgentTraces.uiState); + this.agentView = !isVisualizationTab && isAgentView(activeTab) ? activeTab : undefined; + searchProps.agentView = this.agentView; const query = this.savedAgentTraces.searchSource.getField('query'); - if (query) { + this.savedQuery = query ? { ...query } : undefined; + if (query && this.agentView) { + // Same query the tab runs in the app (e.g. Traces = root agent spans). + this.sessionsBaseQuery = defaultPrepareQueryString(query); + // The tab registry is only filled when the app mounts, so use the tab queries directly. + query.query = + this.agentView === 'traces' + ? prepareRootSpansQuery(query) + : this.agentView === 'spans' + ? prepareAgentSpansQuery(query) + : this.sessionsBaseQuery; + } else if (query) { if (isVisualizationTab) { // Visualization tabs keep the full query (including stats) to get aggregated data. // Only add the source clause without stripping stats. @@ -313,8 +366,62 @@ export class AgentTracesEmbeddable } } + private formatTs = (ts: string) => { + const tz = this.services.uiSettings.get('dateFormat:tz'); + return formatTimestamp( + ts, + tz && tz !== 'Browser' ? tz : Intl.DateTimeFormat().resolvedOptions().timeZone + ); + }; + + /** Open a trace or session in the Agent Traces app, with the dashboard's time range. */ + private openInAgentTraces(tab: AgentView, filter: string) { + const query = this.savedQuery; + let path = '#/'; + path = setStateToOsdUrl('_a', { ui: { activeTabId: tab } }, { useHash: false }, path); + path = setStateToOsdUrl( + '_q', + { dataset: query?.dataset, language: query?.language ?? 'PPL', query: filter }, + { useHash: false }, + path + ); + path = setStateToOsdUrl('_g', { time: this.input.timeRange }, { useHash: false }, path); + this.services.core.application.navigateToApp(PLUGIN_ID, { path }); + } + + /** Sessions run their own queries (matching ids, per-session stats, root spans). */ + private fetchSessionsView = async () => { + if (!this.searchProps) return; + const dataset = this.savedQuery?.dataset; + this.updateOutput({ loading: true, error: undefined }); + this.searchProps.isLoading = true; + try { + if (!dataset || !this.sessionsBaseQuery) throw new Error('Saved search has no dataset'); + const result = await fetchSessions( + new PPLService(this.services.data), + dataset, + this.sessionsBaseQuery, + this.formatTs + ); + this.searchProps.sessions = result.sessions; + this.searchProps.agentTotal = result.totalSessions; + this.searchProps.agentError = undefined; + this.searchProps.rows = result.sessions; + this.searchProps.hits = result.sessions.length; + } catch (error: any) { + this.searchProps.sessions = []; + this.searchProps.agentError = error?.message || String(error); + } + this.searchProps.isLoading = false; + this.updateOutput({ loading: false, error: undefined }); + }; + private fetch = async () => { if (!this.searchProps) return; + if (this.agentView === 'sessions') { + await this.fetchSessionsView(); + return; + } const { searchSource } = this.savedAgentTraces; if (this.abortController) this.abortController.abort(); this.abortController = new AbortController(); @@ -364,6 +471,11 @@ export class AgentTracesEmbeddable this.updateOutput({ loading: false, error: undefined }); inspectorRequest.stats(getResponseInspectorStats(resp, searchSource)).ok({ json: resp }); this.searchProps.rows = rows; + if (this.agentView) { + this.searchProps.agentRows = rows.map( + (hit: any) => hitToBaseRow(hit, this.formatTs) as TraceRow + ); + } // NOTE: PPL response is not the same as OpenSearch response, resp.hits.total here is 0. this.searchProps.hits = resp.hits.hits.length; this.searchProps.isLoading = false; diff --git a/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable_component.tsx b/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable_component.tsx index d736b9f7d95c..65e21275dd12 100644 --- a/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable_component.tsx +++ b/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable_component.tsx @@ -15,6 +15,7 @@ import { import { DataTable } from '../components/data_table/data_table'; import { getLegacyDisplayedColumns } from '../helpers/data_table_helper'; import { SAMPLE_SIZE_SETTING } from '../../common'; +import { AgentViewPanel } from './agent_view_panel'; interface AgentTracesEmbeddableProps { searchProps: SearchProps; @@ -75,6 +76,22 @@ export const AgentTracesEmbeddableComponent = ({ searchProps }: AgentTracesEmbed }, [searchProps]); const getEmbeddableContent = () => { + // Saved from Traces, Spans or Sessions: the agent table for that tab (not raw spans). + if (searchProps.agentView && (searchProps.agentError || searchProps.rows?.length)) { + return ( + ts)} + onOpenTrace={searchProps.onOpenTrace ?? (() => {})} + onOpenSession={searchProps.onOpenSession ?? (() => {})} + /> + ); + } + if (searchProps?.rows?.length === 0) { return ( diff --git a/src/plugins/agent_traces/public/embeddable/agent_view_panel.test.tsx b/src/plugins/agent_traces/public/embeddable/agent_view_panel.test.tsx new file mode 100644 index 000000000000..edd9d08de2a7 --- /dev/null +++ b/src/plugins/agent_traces/public/embeddable/agent_view_panel.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { AgentViewPanel, isAgentView } from './agent_view_panel'; +import { TraceRow } from '../application/pages/traces/hooks/tree_utils'; +import { SessionRow } from '../application/pages/sessions/session_utils'; + +const trace = { + id: 's1', + spanId: 's1', + traceId: 't1', + parentSpanId: null, + status: 'success', + kind: 'invoke_agent', + name: 'POST /plan', + input: 'Plan a trip', + output: 'Sure', + startTime: '', + endTime: '', + latency: '2.1s', + durationNanos: 2.1e9, + totalTokens: 10, + inputTokens: 5, + outputTokens: 5, + totalCost: '—', +} as TraceRow; + +const session = { + sessionId: 'sess_1', + startTime: '2026-09-29 10:00:00', + endTime: '2026-09-29 10:01:00', + durationMs: 60000, + totalTraces: 2, + totalTokens: 100, + firstMessage: 'Hi', + lastMessage: 'Bye', + userId: null, + traceIds: ['t1', 't2'], +} as unknown as SessionRow; + +const base = { + formatTs: (ts: string) => ts, + onOpenTrace: jest.fn(), + onOpenSession: jest.fn(), +}; + +describe('AgentViewPanel', () => { + it('recognizes agent tabs', () => { + expect(isAgentView('sessions')).toBe(true); + expect(isAgentView('visualization')).toBe(false); + }); + + it('renders traces and opens a trace on click', () => { + const onOpenTrace = jest.fn(); + render(); + expect(screen.getByText('1 trace')).toBeInTheDocument(); + fireEvent.click(screen.getByText('POST /plan')); + expect(onOpenTrace).toHaveBeenCalledWith(trace); + }); + + it('renders sessions with a capped count and opens a session on click', () => { + const onOpenSession = jest.fn(); + render( + + ); + expect(screen.getByText('1 of 40 sessions')).toBeInTheDocument(); + fireEvent.click(screen.getByText('sess_1')); + expect(onOpenSession).toHaveBeenCalledWith(session); + }); + + it('shows an error', () => { + render(); + expect(screen.getByText('boom')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/agent_traces/public/embeddable/agent_view_panel.tsx b/src/plugins/agent_traces/public/embeddable/agent_view_panel.tsx new file mode 100644 index 000000000000..143d93a23d8a --- /dev/null +++ b/src/plugins/agent_traces/public/embeddable/agent_view_panel.tsx @@ -0,0 +1,121 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { SessionsTable } from '../application/pages/sessions/sessions_table'; +import { SessionSpansTable } from '../application/pages/sessions/session_spans_table'; +import { SessionRow } from '../application/pages/sessions/session_utils'; +import { TraceRow } from '../application/pages/traces/hooks/tree_utils'; +import '../application/pages/traces/traces_table.scss'; +import '../application/pages/sessions/sessions_tab.scss'; + +/** Agent Traces tabs whose saved searches render as agent tables on a dashboard. */ +export type AgentView = 'traces' | 'spans' | 'sessions'; + +export const isAgentView = (tab: unknown): tab is AgentView => + tab === 'traces' || tab === 'spans' || tab === 'sessions'; + +export interface AgentViewPanelProps { + view: AgentView; + /** Rows for the Traces / Spans views */ + rows?: TraceRow[]; + /** Rows for the Sessions view */ + sessions?: SessionRow[]; + /** Total matching items when the panel shows a capped list */ + total?: number | null; + error?: string; + formatTs: (ts: string) => string; + onOpenTrace: (row: TraceRow) => void; + onOpenSession: (session: SessionRow) => void; +} + +/** + * Dashboard panel body for saved Traces, Spans and Sessions searches: the same agent + * tables the Agent Traces app shows, instead of raw span documents. Rows open the item + * in Agent Traces. + */ +export const AgentViewPanel: React.FC = ({ + view, + rows = [], + sessions = [], + total, + error, + formatTs, + onOpenTrace, + onOpenSession, +}) => { + if (error) { + return ( + + {error} + + ); + } + + const shown = view === 'sessions' ? sessions.length : rows.length; + const noun = (n: number) => { + switch (view) { + case 'sessions': + return i18n.translate('agentTraces.embeddable.sessionsNoun', { + defaultMessage: '{n, plural, one {session} other {sessions}}', + values: { n }, + }); + case 'spans': + return i18n.translate('agentTraces.embeddable.spansNoun', { + defaultMessage: '{n, plural, one {span} other {spans}}', + values: { n }, + }); + default: + return i18n.translate('agentTraces.embeddable.tracesNoun', { + defaultMessage: '{n, plural, one {trace} other {traces}}', + values: { n }, + }); + } + }; + const summary = + total && total > shown + ? i18n.translate('agentTraces.embeddable.agentViewCountOfTotal', { + defaultMessage: '{shown} of {total} {noun}', + values: { + shown: shown.toLocaleString(), + total: total.toLocaleString(), + noun: noun(total), + }, + }) + : i18n.translate('agentTraces.embeddable.agentViewCount', { + defaultMessage: '{shown} {noun}', + values: { shown: shown.toLocaleString(), noun: noun(shown) }, + }); + + return ( +
+ + {summary} + +
+ {view === 'sessions' ? ( + + ) : ( + + )} +
+
+ ); +}; From bdc377634dffdf01e7b2d860e26abf71cf345852 Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:02:03 -0700 Subject: [PATCH 12/15] Revert "[Agent Traces] Replace Spans with Sessions in the side nav" This reverts commit ae61f99114685f9184a56dee21449fb550cfd73d. Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- src/plugins/agent_traces/common/index.ts | 1 - .../helpers/get_flavor_from_app_id.test.ts | 2 -- .../public/helpers/get_flavor_from_app_id.ts | 8 +------ .../agent_traces/public/nav_popover.test.tsx | 4 ++-- .../agent_traces/public/nav_popover.tsx | 19 +++++++-------- src/plugins/agent_traces/public/plugin.ts | 24 ++++++------------- 6 files changed, 19 insertions(+), 39 deletions(-) diff --git a/src/plugins/agent_traces/common/index.ts b/src/plugins/agent_traces/common/index.ts index a15491174966..c3401c05d7c5 100644 --- a/src/plugins/agent_traces/common/index.ts +++ b/src/plugins/agent_traces/common/index.ts @@ -7,7 +7,6 @@ export const PLUGIN_ID = 'agentTraces'; export const PLUGIN_NAME = 'Agent Traces'; export const AGENT_TRACES_NAV_ID = 'agentTraces/traces'; export const AGENT_SPANS_NAV_ID = 'agentTraces/spans'; -export const AGENT_SESSIONS_NAV_ID = 'agentTraces/sessions'; export const DEFAULT_COLUMNS_SETTING = 'defaultColumns'; export const SAMPLE_SIZE_SETTING = 'discover:sampleSize'; export const SORT_DEFAULT_ORDER_SETTING = 'discover:sort:defaultOrder'; diff --git a/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.test.ts b/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.test.ts index 5fc667fd0173..16c68414f32f 100644 --- a/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.test.ts +++ b/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.test.ts @@ -10,7 +10,6 @@ import { PLUGIN_ID, AGENT_TRACES_NAV_ID, AGENT_SPANS_NAV_ID, - AGENT_SESSIONS_NAV_ID, } from '../../common'; import { AgentTracesServices } from '../types'; @@ -34,7 +33,6 @@ describe('getFlavorFromAppId', () => { it('returns Traces for the agent spans nav ID', () => { expect(getFlavorFromAppId(AGENT_SPANS_NAV_ID)).toBe(AgentTracesFlavor.Traces); - expect(getFlavorFromAppId(AGENT_SESSIONS_NAV_ID)).toBe(AgentTracesFlavor.Traces); }); it('extracts flavor from slash-separated app ID', () => { diff --git a/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.ts b/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.ts index 270f9accdeca..46a3c1e43445 100644 --- a/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.ts +++ b/src/plugins/agent_traces/public/helpers/get_flavor_from_app_id.ts @@ -9,7 +9,6 @@ import { PLUGIN_ID, AGENT_TRACES_NAV_ID, AGENT_SPANS_NAV_ID, - AGENT_SESSIONS_NAV_ID, } from '../../common'; import { AgentTracesServices } from '../types'; @@ -21,12 +20,7 @@ import { AgentTracesServices } from '../types'; export const getFlavorFromAppId = (appId: string | undefined): AgentTracesFlavor | null => { // The base plugin and both sidebar entry points (traces/spans) belong to the // Traces flavor — they mount the same app with different initial tab state. - if ( - appId === PLUGIN_ID || - appId === AGENT_TRACES_NAV_ID || - appId === AGENT_SPANS_NAV_ID || - appId === AGENT_SESSIONS_NAV_ID - ) { + if (appId === PLUGIN_ID || appId === AGENT_TRACES_NAV_ID || appId === AGENT_SPANS_NAV_ID) { return AgentTracesFlavor.Traces; } const flavorFromAppId = appId?.split('/')?.[1]; diff --git a/src/plugins/agent_traces/public/nav_popover.test.tsx b/src/plugins/agent_traces/public/nav_popover.test.tsx index 77a8e1de3fdc..021e2d9831fe 100644 --- a/src/plugins/agent_traces/public/nav_popover.test.tsx +++ b/src/plugins/agent_traces/public/nav_popover.test.tsx @@ -4,7 +4,7 @@ */ import { of } from 'rxjs'; -import { agentTracesNavPopover, agentSessionsNavPopover } from './nav_popover'; +import { agentTracesNavPopover, agentSpansNavPopover } from './nav_popover'; import { httpServiceMock } from '../../../core/public/mocks'; import { NavPopoverServices } from '../../../core/public'; @@ -20,7 +20,7 @@ describe('agent_traces nav popovers', () => { describe.each([ ['Traces', agentTracesNavPopover, 'agentTraces/traces', 'traces'], - ['Sessions', agentSessionsNavPopover, 'agentTraces/sessions', 'sessions'], + ['Spans', agentSpansNavPopover, 'agentTraces/spans', 'spans'], ])('%s popover', (_label, popover, appId, tab) => { it('declares exactly newSearch + browseSaved (no Logs action)', () => { expect((popover.actions ?? []).map((a) => a.id)).toEqual(['newSearch', 'browseSaved']); diff --git a/src/plugins/agent_traces/public/nav_popover.tsx b/src/plugins/agent_traces/public/nav_popover.tsx index 4e283b2d4403..1a540d2466a6 100644 --- a/src/plugins/agent_traces/public/nav_popover.tsx +++ b/src/plugins/agent_traces/public/nav_popover.tsx @@ -5,7 +5,7 @@ import { i18n } from '@osd/i18n'; import { NavPopoverConfig } from '../../../core/public'; -import { AGENT_TRACES_NAV_ID, AGENT_SESSIONS_NAV_ID } from '../common'; +import { AGENT_TRACES_NAV_ID, AGENT_SPANS_NAV_ID } from '../common'; /** * Hash path that lands the agent-traces app on the given tab and asks it to open @@ -13,20 +13,19 @@ import { AGENT_TRACES_NAV_ID, AGENT_SESSIONS_NAV_ID } from '../common'; * in the hash query). A nav-popover action only receives navigateToApp, so it * can't open the flyout itself — it navigates with this marker instead. */ -type AgentNavTab = 'traces' | 'sessions'; - -const openSavedPath = (tab: AgentNavTab) => +const openSavedPath = (tab: 'traces' | 'spans') => `#/?_openSaved=true&_a=(ui:(activeTabId:${tab},showHistogram:!t))`; /** Hash path for a fresh search on the given tab. */ -const newSearchPath = (tab: AgentNavTab) => `#/?_a=(ui:(activeTabId:${tab},showHistogram:!t))`; +const newSearchPath = (tab: 'traces' | 'spans') => + `#/?_a=(ui:(activeTabId:${tab},showHistogram:!t))`; /** - * Nav-popover config for an agent-monitoring entry (Traces/Sessions): quick + * Nav-popover config for an agent-monitoring flavor (Traces/Spans): quick * actions to start a new search or browse saved searches. The item still * navigates to the flavor on direct click. */ -function buildAgentNavPopover(appId: string, tab: AgentNavTab): NavPopoverConfig { +function buildAgentNavPopover(appId: string, tab: 'traces' | 'spans'): NavPopoverConfig { return { actions: [ { @@ -55,7 +54,7 @@ export const agentTracesNavPopover: NavPopoverConfig = buildAgentNavPopover( 'traces' ); -export const agentSessionsNavPopover: NavPopoverConfig = buildAgentNavPopover( - AGENT_SESSIONS_NAV_ID, - 'sessions' +export const agentSpansNavPopover: NavPopoverConfig = buildAgentNavPopover( + AGENT_SPANS_NAV_ID, + 'spans' ); diff --git a/src/plugins/agent_traces/public/plugin.ts b/src/plugins/agent_traces/public/plugin.ts index 5ea61d639f55..2a56acc73dd6 100644 --- a/src/plugins/agent_traces/public/plugin.ts +++ b/src/plugins/agent_traces/public/plugin.ts @@ -33,7 +33,6 @@ import { PLUGIN_NAME, AGENT_TRACES_NAV_ID, AGENT_SPANS_NAV_ID, - AGENT_SESSIONS_NAV_ID, } from '../common'; import { setDocViewsRegistry, @@ -64,7 +63,8 @@ import { ABORT_DATA_QUERY_TRIGGER } from '../../ui_actions/public'; import { abortAllActiveQueries } from './application/utils/state_management/actions/query_actions'; import { setServices } from './services/services'; import { AgentTracesIcon } from './assets/agent_traces_icon'; -import { agentTracesNavPopover, agentSessionsNavPopover } from './nav_popover'; +import { AgentSpansIcon } from './assets/agent_spans_icon'; +import { agentTracesNavPopover, agentSpansNavPopover } from './nav_popover'; import { SlotRegistryService } from './services/slot_registry'; // Log Actions @@ -300,8 +300,6 @@ export class AgentTracesPlugin implements Plugin< defaultPath: '#/?_a=(ui:(activeTabId:traces,showHistogram:!t))', }) ); - // Spans keeps its app (existing /app/agentTraces/spans links still work) but no - // longer has a nav entry; Spans stays one tab away from Traces and Sessions. core.application.register( createAgentTracesApp({ id: AGENT_SPANS_NAV_ID, @@ -309,13 +307,6 @@ export class AgentTracesPlugin implements Plugin< defaultPath: '#/?_a=(ui:(activeTabId:spans,showHistogram:!t))', }) ); - core.application.register( - createAgentTracesApp({ - id: AGENT_SESSIONS_NAV_ID, - title: 'Agent Sessions', - defaultPath: '#/?_a=(ui:(activeTabId:sessions,showHistogram:!t))', - }) - ); core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ { @@ -327,12 +318,12 @@ export class AgentTracesPlugin implements Plugin< navPopover: agentTracesNavPopover, }, { - id: AGENT_SESSIONS_NAV_ID, - title: 'Sessions', + id: AGENT_SPANS_NAV_ID, + title: 'Spans', category: DEFAULT_APP_CATEGORIES.agentMonitoring, order: 200, - euiIconType: 'navTicketing', - navPopover: agentSessionsNavPopover, + euiIconType: AgentSpansIcon, + navPopover: agentSpansNavPopover, }, ]); } @@ -386,8 +377,7 @@ export class AgentTracesPlugin implements Plugin< if ( app.id === PLUGIN_ID || app.id === AGENT_TRACES_NAV_ID || - app.id === AGENT_SPANS_NAV_ID || - app.id === AGENT_SESSIONS_NAV_ID + app.id === AGENT_SPANS_NAV_ID ) { return { navLinkStatus: agentTracesNavStatus }; } From 1d966a3899892fa401b49533571065a09ae714fd Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:15:35 -0700 Subject: [PATCH 13/15] [Agent Traces] Session flyout: neutral latency icons, error indicator on traces - Latency and duration clock icons use the subdued color instead of danger, so they no longer read as errors. - Traces in the session trace list that contain error spans show an alert icon, with the number of error spans in its tooltip. Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../sessions/session_details_flyout.test.tsx | 15 +++++++++- .../pages/sessions/session_details_flyout.tsx | 28 +++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx index 896e15c8762a..c07cfa50b298 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { turnRole } from './session_details_flyout'; +import { errorSpanCount, turnRole } from './session_details_flyout'; const msgs = (...roles: string[]) => JSON.stringify(roles.map((role) => ({ role, parts: [{ type: 'text', content: role }] }))); @@ -20,3 +20,16 @@ describe('turnRole', () => { expect(turnRole(undefined, 'output')).toBe('assistant'); }); }); + +describe('errorSpanCount', () => { + it('counts error spans in a trace', () => { + const trace = { + traceId: 't1', + root: { status: 'success' }, + tree: [], + spans: [{ status: 'success' }, { status: 'error' }, { status: 'error' }], + } as any; + expect(errorSpanCount(trace)).toBe(2); + expect(errorSpanCount({ ...trace, spans: [{ status: 'success' }] })).toBe(0); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx index a7e97f5bc28d..041bea3de3ef 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.tsx @@ -25,6 +25,7 @@ import { EuiTabs, EuiText, EuiTitle, + EuiToolTip, } from '@elastic/eui'; import { TraceRow } from '../traces/hooks/tree_utils'; import { useTraceFlyout } from '../traces/flyout/trace_flyout_context'; @@ -57,6 +58,10 @@ type DrillTab = 'traces' | 'spans'; const tokensOf = (row: TraceRow): number | null => typeof row.totalTokens === 'number' ? row.totalTokens : null; +/** Number of spans in a trace that ended in error. */ +export const errorSpanCount = (trace: SessionTrace): number => + trace.spans.filter((span) => span.status === 'error').length; + const MetaItem: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
@@ -73,7 +78,7 @@ const LatencyAndTokens: React.FC<{ row: TraceRow }> = ({ row }) => { return ( - + {row.latency} @@ -300,7 +305,7 @@ export const SessionDetailsFlyout: React.FC = ({ defaultMessage: 'Total Duration', })} > - {' '} + {' '} {formatSessionDuration(session.durationMs)} = ({ values: { index: i + 1 }, })} + {errorSpanCount(trace) > 0 && ( + + + + )}
From dcee3ee3a06b8c6df3b27e636593bdff4b2408ed Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:15:16 -0700 Subject: [PATCH 14/15] [Agent Traces] Address review: filter extraction, trace map cap, flyout reset, types, escaping - extractSpanFilterQuery stops at the first command that reshapes rows (stats, rename, fields, ...). Filters after it may use fields that only exist after it (e.g. 'stats count() as c by x | where c > 5', 'rename a as b | where b = 1'), so they are reported as not applied instead of run against span rows. sort is still skipped. The Sessions notice reads the full query so it names stats too. - The trace-to-session map is one row per (trace, session) from stats, sized by the sessions' own trace counts instead of an unsorted head 2000, so no trace is dropped silently. A session's trace list includes traces whose root span is missing. Above 5000 traces the tab says details come from a subset. - SessionDetailsFlyout is keyed by session id, so switching sessions resets the focused trace, view and tab. - Replace any with unknown/typed records in new code (PPL records, attribute lookup, fetcher, embeddable, tests). - escapePPLValue escapes backslashes as well as quotes, and the dashboard panel's open-in-app links use it for trace and session ids. Tested: agent_traces jest (190 suites, 2003 tests), scoped tsc, eslint; live in the browser (both review queries, a 25-trace session, switching sessions, panel links) and directly against PPL (new map/root queries, escaped literals). Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../pages/sessions/fetch_sessions.test.ts | 42 +++++++- .../pages/sessions/fetch_sessions.ts | 35 ++++-- .../pages/sessions/hooks/use_sessions.ts | 28 +++-- .../sessions/session_details_flyout.test.tsx | 7 +- .../pages/sessions/session_utils.test.ts | 26 +++++ .../pages/sessions/session_utils.ts | 100 +++++++++++++----- .../pages/sessions/sessions_tab.test.tsx | 69 ++++++++++++ .../pages/sessions/sessions_tab.tsx | 27 ++++- .../pages/traces/hooks/use_trace_metrics.ts | 2 +- .../pages/traces/table_shared.test.tsx | 23 ++++ .../application/pages/traces/table_shared.tsx | 16 ++- .../ppl_request_helpers.test.tsx | 8 +- .../data_fetching/ppl_request_helpers.tsx | 8 +- .../fields_selector/discover_sidebar.test.tsx | 6 +- .../embeddable/agent_traces_embeddable.tsx | 18 ++-- 15 files changed, 346 insertions(+), 69 deletions(-) create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.test.tsx diff --git a/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.test.ts b/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.test.ts index 780003589cd1..200bff15b899 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.test.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.test.ts @@ -22,7 +22,7 @@ describe('fetchSessions', () => { }); it('selects sessions under the time range and totals them over the whole session', async () => { - const executeQuery = jest.fn(async (dataset: any, query: string) => { + const executeQuery = jest.fn(async (_dataset: { timeFieldName?: string }, query: string) => { if (query.includes('as total_sessions')) return ppl(['total_sessions'], [[7]]); if (query.includes('as last_seen')) return ppl([SESSION, 'last_seen'], [['s1', 'x']]); if (query.includes('as total_traces')) @@ -37,7 +37,7 @@ describe('fetchSessions', () => { const result = await fetchSessions( { executeQuery }, - dataset as any, + dataset as never, 'source = spans', (t) => t ); @@ -47,7 +47,41 @@ describe('fetchSessions', () => { const byQuery = (part: string) => executeQuery.mock.calls.find(([, q]) => q.includes(part))?.[0]; // The matching query keeps the time range; per-session totals drop it. - expect(byQuery('as last_seen').timeFieldName).toBe('endTime'); - expect(byQuery('as total_traces').timeFieldName).toBeUndefined(); + expect(byQuery('as last_seen')?.timeFieldName).toBe('endTime'); + expect(byQuery('as total_traces')?.timeFieldName).toBeUndefined(); + }); + + it('sizes the trace map by the sessions trace counts and flags partial results', async () => { + const executeQuery = jest.fn(async (_dataset: unknown, query: string) => { + if (query.includes('as total_sessions')) return ppl(['total_sessions'], [[1]]); + if (query.includes('as last_seen')) return ppl([SESSION, 'last_seen'], [['s1', 'x']]); + if (query.includes('as total_traces')) + return ppl( + [SESSION, 'total_traces', 'start_time', 'end_time'], + [['s1', 30, '2026-09-29 10:00:00', '2026-09-29 10:01:00']] + ); + if (query.includes('as spans by traceId')) return ppl(['traceId', SESSION], [['t1', 's1']]); + return ppl([], []); + }); + const dataset = { id: 'd', title: 'spans', type: 'INDEX_PATTERN' }; + + const result = await fetchSessions( + { executeQuery }, + dataset as never, + 'source = spans', + (t) => t + ); + const mapQuery = executeQuery.mock.calls.find(([, q]) => q.includes('as spans by traceId')); + expect(mapQuery?.[1]).toMatch(/\| head 31$/); // 30 traces + one per session of slack + expect(result.partial).toBe(false); + + const capped = await fetchSessions( + { executeQuery }, + dataset as never, + 'source = spans', + (t) => t, + { maxTraces: 10 } + ); + expect(capped.partial).toBe(true); }); }); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.ts b/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.ts index 675f433fddb1..8248eb6ca46a 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/fetch_sessions.ts @@ -6,7 +6,10 @@ import { AGENT_TRACES_SESSION_ID_FIELD } from '../../../../common'; import { Dataset } from '../../../../../data/common'; import { extractSpanFilterQuery, splitPplCommands } from '../traces/table_shared'; -import { transformPPLDataToTraceHits } from '../traces/trace_details/traces/ppl_to_trace_hits'; +import { + PPLResponse, + transformPPLDataToTraceHits, +} from '../traces/trace_details/traces/ppl_to_trace_hits'; import { hitsToAgentSpans, spanToRow } from '../traces/hooks/tree_utils'; import { Bucket } from '../../../components/fields_selector/types'; import { @@ -21,6 +24,7 @@ import { buildSessionFacetQuery, parseFacetBuckets, SESSION_FACET_FIELDS, + SESSION_TRACES_LIMIT, getSourceCommand, parseSessionStats, pplResponseToRecords, @@ -28,7 +32,7 @@ import { /** Anything that can run a PPL query against a dataset (PPLService in the app and embeddable). */ export interface PPLQueryRunner { - executeQuery(dataset: Dataset, pplQuery: string): Promise; + executeQuery(dataset: Dataset, pplQuery: string): Promise; } export interface FetchSessionsResult { @@ -39,6 +43,16 @@ export interface FetchSessionsResult { ignoredCommands: string[]; /** Whether the query filters spans (so an empty list means "no match"). */ hasFilter: boolean; + /** + * The listed sessions hold more traces than `maxTraces`: per-session details (first and + * last message, tokens, trace list) come from a subset of their traces. + */ + partial: boolean; +} + +export interface FetchSessionsOptions { + /** Max traces looked up across the listed sessions. */ + maxTraces?: number; } /** The span-level filter a Sessions query applies, plus what it leaves out. */ @@ -82,7 +96,8 @@ export const fetchSessions = async ( ppl: PPLQueryRunner, dataset: Dataset, baseQueryString: string, - formatTs: (ts: string) => string + formatTs: (ts: string) => string, + { maxTraces = SESSION_TRACES_LIMIT }: FetchSessionsOptions = {} ): Promise => { const { whereQuery, ignoredCommands, hasFilter } = sessionFilterFor(baseQueryString); const source = getSourceCommand(whereQuery); @@ -109,12 +124,17 @@ export const fetchSessions = async ( } let sessions: SessionRow[] = []; + // Size the trace map from the sessions' own trace counts (plus one row of slack per + // session for traces that carry more than one session id), capped at maxTraces. + const tracesInSessions = stats.reduce((sum, s) => sum + s.totalTraces, 0); + const partial = tracesInSessions > maxTraces; if (stats.length > 0) { const mapResponse = await ppl.executeQuery( wholeSession, buildTraceSessionMapQuery( source, - stats.map((s) => s.sessionId) + stats.map((s) => s.sessionId), + Math.min(tracesInSessions + stats.length, maxTraces) ) ); const traceToSession = new Map(); @@ -131,9 +151,9 @@ export const fetchSessions = async ( wholeSession, buildRootSpansQuery(source, traceIds) ); - rootRows = hitsToAgentSpans(transformPPLDataToTraceHits(rootsResponse)).map((span, i) => - spanToRow(span, i, formatTs) - ); + // The root-span query returns span rows in the PPL response shape. + const rootHits = transformPPLDataToTraceHits(rootsResponse as PPLResponse); + rootRows = hitsToAgentSpans(rootHits).map((span, i) => spanToRow(span, i, formatTs)); } sessions = assembleSessionRows(stats, traceToSession, rootRows); } @@ -143,5 +163,6 @@ export const fetchSessions = async ( totalSessions: Number.isFinite(total) ? Math.max(total, sessions.length) : null, ignoredCommands, hasFilter, + partial, }; }; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts index 9ec884f83ef2..2bf7ce4754bb 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/hooks/use_sessions.ts @@ -3,9 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { RootState } from '../../../utils/state_management/store'; +import { addPPLSourceClause } from '../../../utils/languages/ppl'; import { usePPLQueryDeps, useTimeVersion } from '../../traces/hooks/use_ppl_query_deps'; import { SessionRow } from '../session_utils'; import { @@ -27,11 +28,23 @@ export interface UseSessionsResult { hasFilter: boolean; /** Sessions matching the query and time range; the list shows at most SESSIONS_PAGE_LIMIT. */ totalSessions: number | null; + /** Per-session details come from a subset of traces (see FetchSessionsResult.partial). */ + partial: boolean; } /** Sessions list for the current query and time range (see `fetchSessions`). */ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult => { const { services, pplService, datasetParam, baseQueryString } = usePPLQueryDeps(); + const query = useSelector((state: RootState) => state.query); + // The base query has `stats ...` already stripped (for the data tabs); the notice reads + // the user's full query so it can name stats and anything after it too. + const fullQueryString = useMemo(() => { + try { + return addPPLSourceClause(query).query; + } catch { + return null; + } + }, [query]); const fetchVersion = useSelector((state: RootState) => state.queryEditor.fetchVersion); const timeVersion = useTimeVersion(services); @@ -43,6 +56,7 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult const [ignoredCommands, setIgnoredCommands] = useState([]); const [hasFilter, setHasFilter] = useState(false); const [totalSessions, setTotalSessions] = useState(null); + const [partial, setPartial] = useState(false); const requestIdRef = useRef(0); const fetchSessions = useCallback(async () => { @@ -53,12 +67,8 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult setError(null); try { - const { - whereQuery, - ignoredCommands: ignored, - hasFilter: filtered, - } = sessionFilterFor(baseQueryString); - setIgnoredCommands(ignored); + const { whereQuery, hasFilter: filtered } = sessionFilterFor(baseQueryString); + setIgnoredCommands(sessionFilterFor(fullQueryString ?? baseQueryString).ignoredCommands); setHasFilter(filtered); // Fields panel facets, counted per session (runs alongside the list queries). @@ -71,6 +81,7 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult if (requestId !== requestIdRef.current) return; // a newer request superseded this one setSessions(result.sessions); setTotalSessions(result.totalSessions); + setPartial(result.partial); setElapsedMs(Date.now() - started); } catch (err) { if (requestId !== requestIdRef.current) return; @@ -81,7 +92,7 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult } finally { if (requestId === requestIdRef.current) setLoading(false); } - }, [pplService, datasetParam, baseQueryString, formatTs]); + }, [pplService, datasetParam, baseQueryString, fullQueryString, formatTs]); useEffect(() => { fetchSessions(); @@ -101,5 +112,6 @@ export const useSessions = (formatTs: (ts: string) => string): UseSessionsResult ignoredCommands, hasFilter, totalSessions, + partial, }; }; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx index c07cfa50b298..7f4f7a4144e8 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_details_flyout.test.tsx @@ -4,6 +4,7 @@ */ import { errorSpanCount, turnRole } from './session_details_flyout'; +import { SessionTrace } from './hooks/use_session_detail'; const msgs = (...roles: string[]) => JSON.stringify(roles.map((role) => ({ role, parts: [{ type: 'text', content: role }] }))); @@ -28,8 +29,10 @@ describe('errorSpanCount', () => { root: { status: 'success' }, tree: [], spans: [{ status: 'success' }, { status: 'error' }, { status: 'error' }], - } as any; + } as unknown as SessionTrace; expect(errorSpanCount(trace)).toBe(2); - expect(errorSpanCount({ ...trace, spans: [{ status: 'success' }] })).toBe(0); + expect( + errorSpanCount({ ...trace, spans: [{ status: 'success' }] } as unknown as SessionTrace) + ).toBe(0); }); }); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts index 0d654db26be7..a19b3401e66c 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts @@ -82,6 +82,14 @@ describe('session_utils', () => { ); }); + it('maps every trace to its session with a sized limit', () => { + // One row per (trace, session) pair; the limit is sized by the caller, never a fixed head. + expect(buildTraceSessionMapQuery('source = x', ['s1'], 42)).toBe( + 'source = x | where `attributes.gen_ai.conversation.id` in ("s1") | stats count() as spans by traceId, `attributes.gen_ai.conversation.id` | head 42' + ); + expect(buildRootSpansQuery('source = x', ['t1', 't2'])).toContain('| head 2'); + }); + it('extracts the source command', () => { expect(getSourceCommand('source = idx | where a = 1')).toBe('source = idx'); expect(getSourceCommand('source = idx')).toBe('source = idx'); @@ -187,6 +195,24 @@ describe('session_utils', () => { }); }); + describe('assembleSessionRows trace ids', () => { + it('keeps traces without a root span, ordered by root start where known', () => { + const stats = parseSessionStats([ + { [FIELD]: 's1', total_traces: 3, start_time: '2026-09-28 22:00:00', end_time: '' }, + ]); + const traceToSession = new Map([ + ['t-noroot', 's1'], + ['t2', 's1'], + ['t1', 's1'], + ]); + const rows = assembleSessionRows(stats, traceToSession, [ + root('t2', '2026-09-28 22:00:05', {}), + root('t1', '2026-09-28 22:00:01', {}), + ]); + expect(rows[0].traceIds).toEqual(['t1', 't2', 't-noroot']); + }); + }); + describe('formatting', () => { it('formats session durations', () => { expect(formatSessionDuration(0)).toBe('—'); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts index 60f3620be3a5..01d0a04ea641 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts @@ -16,8 +16,12 @@ export const SESSION_FIELD_PPL = `\`${AGENT_TRACES_SESSION_ID_FIELD}\``; /** Max sessions listed in the Sessions tab. */ export const SESSIONS_PAGE_LIMIT = 100; -/** Max traces fetched for the root-span lookup behind the sessions list. */ -export const SESSION_ROOTS_LIMIT = 2000; +/** + * Max traces looked up for the listed sessions (trace map and root spans). Above this the + * list still shows every session, but per-session details come from a subset and the tab + * says so, instead of silently dropping traces. + */ +export const SESSION_TRACES_LIMIT = 5000; /** Max spans fetched when opening a single session. */ export const SESSION_SPANS_LIMIT = 5000; @@ -50,25 +54,38 @@ interface SessionStats { * Convert a PPL response (JDBC `datarows` or `data_frame` fields format) to an * array of records keyed by column name. */ -export const pplResponseToRecords = (response: any): Array> => { - if (response?.datarows && response?.schema) { - const schema = response.schema as Array<{ name: string }>; - return (response.datarows as any[][]).map((row) => { - const record: Record = {}; - schema.forEach((col, idx) => { - record[col.name] = row[idx]; +/** A PPL query response row, keyed by column name. */ +export type PplRecord = Record; + +const isObject = (v: unknown): v is Record => typeof v === 'object' && v !== null; + +/** + * Rows of a PPL response as records keyed by column name. Accepts both the raw PPL shape + * (`schema` + `datarows`) and the data-frame shape returned by the search strategy. + */ +export const pplResponseToRecords = (response: unknown): PplRecord[] => { + if (!isObject(response)) return []; + const { schema, datarows } = response; + if (Array.isArray(schema) && Array.isArray(datarows)) { + const names = schema.map((col) => (isObject(col) ? String(col.name) : '')); + return datarows.map((row) => { + const record: PplRecord = {}; + names.forEach((name, idx) => { + record[name] = Array.isArray(row) ? row[idx] : undefined; }); return record; }); } - const data = response?.type === 'data_frame' && response?.body ? response.body : response; - if (data?.fields && data?.size > 0) { - const records: Array> = []; - for (let i = 0; i < data.size; i++) { - const record: Record = {}; - data.fields.forEach((field: { name: string; values: any[] }) => { - record[field.name] = field.values?.[i]; + const data = response.type === 'data_frame' && isObject(response.body) ? response.body : response; + const size = Number(data.size); + if (Array.isArray(data.fields) && size > 0) { + const records: PplRecord[] = []; + for (let i = 0; i < size; i++) { + const record: PplRecord = {}; + data.fields.forEach((field) => { + if (!isObject(field)) return; + record[String(field.name)] = Array.isArray(field.values) ? field.values[i] : undefined; }); records.push(record); } @@ -106,7 +123,7 @@ export const buildSessionFacetQuery = ( `${whereQuery} | where isnotnull(${SESSION_FIELD_PPL}) and isnotnull(\`${field}\`) | stats distinct_count(${SESSION_FIELD_PPL}) as sessions by \`${field}\` | sort - sessions | head ${limit}`; /** Convert facet stats records into fields-panel buckets. */ -export const parseFacetBuckets = (records: Array>, field: string): Bucket[] => { +export const parseFacetBuckets = (records: PplRecord[], field: string): Bucket[] => { const rows = records .map((r) => ({ value: r[field], count: Number(r.sessions ?? 0) })) .filter((r) => r.value !== null && r.value !== undefined && r.count > 0); @@ -139,17 +156,25 @@ export const buildSessionStatsQuery = (source: string, sessionIds: string[]): st sessionIds )}) | stats distinct_count(traceId) as total_traces, min(startTime) as start_time, max(endTime) as end_time by ${SESSION_FIELD_PPL} | sort - start_time`; -/** Maps each trace id to its session id. Any span in a trace may carry the session id. */ -export const buildTraceSessionMapQuery = (source: string, sessionIds: string[]): string => +/** + * Maps each trace id to its session id: one row per (trace, session) pair. Any span in a + * trace may carry the session id. `limit` is sized by the caller from the sessions' trace + * counts, so no trace is dropped by an arbitrary cap. + */ +export const buildTraceSessionMapQuery = ( + source: string, + sessionIds: string[], + limit = SESSION_TRACES_LIMIT +): string => `${source} | where ${SESSION_FIELD_PPL} in (${inList( sessionIds - )}) | dedup traceId | fields traceId, ${SESSION_FIELD_PPL} | head ${SESSION_ROOTS_LIMIT}`; + )}) | stats count() as spans by traceId, ${SESSION_FIELD_PPL} | head ${limit}`; /** Root spans of the given traces (they carry per-trace input, output and token totals). */ export const buildRootSpansQuery = (source: string, traceIds: string[]): string => `${source} | where parentSpanId = "" and traceId in (${inList( traceIds - )}) | sort startTime | head ${SESSION_ROOTS_LIMIT}`; + )}) | sort startTime | head ${Math.max(1, traceIds.length)}`; /** Every span in the given traces, for the session detail flyout. */ export const buildSessionSpansQuery = ( @@ -169,7 +194,7 @@ const toMs = (ts: string): number => { return m.isValid() ? m.valueOf() : NaN; }; -export const parseSessionStats = (records: Array>): SessionStats[] => +export const parseSessionStats = (records: PplRecord[]): SessionStats[] => records .map((r) => ({ sessionId: String(r[AGENT_TRACES_SESSION_ID_FIELD] ?? ''), @@ -180,14 +205,17 @@ export const parseSessionStats = (records: Array>): SessionS .filter((s) => s.sessionId !== ''); /** Read a span attribute that may be stored as a flat dotted key or a nested object. */ -export const getSpanAttribute = (doc: Record | undefined, key: string): unknown => { +export const getSpanAttribute = ( + doc: Record | undefined, + key: string +): unknown => { if (!doc) return undefined; const attrs = doc.attributes; - if (attrs && typeof attrs === 'object') { + if (isObject(attrs)) { if (key in attrs) return attrs[key]; - let cur: any = attrs; + let cur: unknown = attrs; for (const part of key.split('.')) { - if (cur === null || cur === undefined || typeof cur !== 'object') return undefined; + if (!isObject(cur)) return undefined; cur = cur[part]; } if (cur !== undefined) return cur; @@ -217,6 +245,14 @@ export const assembleSessionRows = ( rootsBySession.set(sessionId, list); } + const traceIdsBySession = new Map(); + for (const [traceId, sessionId] of traceToSession) { + const list = traceIdsBySession.get(sessionId) ?? []; + list.push(traceId); + traceIdsBySession.set(sessionId, list); + } + const rootIds = new Set(rootRows.map((r) => r.traceId)); + return stats.map((s) => { const roots = (rootsBySession.get(s.sessionId) ?? []).sort( (a, b) => toMs(rawStart(a)) - toMs(rawStart(b)) @@ -240,14 +276,20 @@ export const assembleSessionRows = ( ? previewOutputMessages(withOutput[withOutput.length - 1].output) : '', userId: userRoot ? String(getSpanAttribute(userRoot.rawDocument, 'user.id')) : null, - traceIds: roots.map((r) => r.traceId), + // Every trace mapped to the session, not just those whose root span was found; + // traces with a root come first in start order. + traceIds: [ + ...new Set([ + ...roots.map((r) => r.traceId), + ...(traceIdsBySession.get(s.sessionId) ?? []).filter((id) => !rootIds.has(id)), + ]), + ], }; }); }; /** Raw (unformatted) start time from a row's source document. */ -export const rawStart = (row: BaseRow): string => - String((row.rawDocument as Record | undefined)?.startTime ?? ''); +export const rawStart = (row: BaseRow): string => String(row.rawDocument?.startTime ?? ''); /** Session duration in the mock's `2h:48m` style; short sessions use `42m:05s` or `3.2s`. */ export const formatSessionDuration = (ms: number): string => { diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.test.tsx b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.test.tsx new file mode 100644 index 000000000000..35371a28f648 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { SessionsTab } from './sessions_tab'; +import { SessionRow } from './session_utils'; + +jest.mock('../../../../../opensearch_dashboards_react/public', () => ({ + ...jest.requireActual('../../../../../opensearch_dashboards_react/public'), + useOpenSearchDashboards: () => ({ services: { uiSettings: { get: () => 'UTC' } } }), +})); + +// Stable references: a new array per render would re-run effects on every render. +const mockSessionsResult = { + sessions: [{ sessionId: 's1' }, { sessionId: 's2' }] as SessionRow[], + loading: false, + error: null, + elapsedMs: 1, + refresh: () => {}, + ignoredCommands: [], + hasFilter: false, + totalSessions: 2, + partial: false, +}; +jest.mock('./hooks/use_sessions', () => ({ + useSessions: () => mockSessionsResult, +})); + +jest.mock('./sessions_table', () => ({ + SessionsTable: ({ + sessions, + onSessionClick, + }: { + sessions: SessionRow[]; + onSessionClick: (s: SessionRow) => void; + }) => ( +
+ {sessions.map((s: SessionRow) => ( + + ))} +
+ ), +})); + +const mockMounts = jest.fn(); +jest.mock('./session_details_flyout', () => ({ + SessionDetailsFlyout: ({ session: s }: { session: SessionRow }) => { + jest.requireActual('react').useEffect(() => { + mockMounts(s.sessionId); + }, []); + return
{s.sessionId}
; + }, +})); + +describe('SessionsTab', () => { + it('opens a fresh flyout when another session is selected', () => { + render(); + fireEvent.click(screen.getByText('s1')); + fireEvent.click(screen.getByText('s2')); + // Without a key the instance (and its focused trace / view state) would be reused. + expect(mockMounts).toHaveBeenCalledTimes(2); + expect(mockMounts).toHaveBeenLastCalledWith('s2'); + expect(screen.getByTestId('flyout')).toHaveTextContent('s2'); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx index a032d3d1f364..9d1966c63dfe 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_tab.tsx @@ -30,8 +30,16 @@ export const SessionsTab = () => { }, [uiSettings]); const formatTs = useCallback((ts: string) => formatTimestamp(ts, timezone), [timezone]); - const { sessions, loading, error, elapsedMs, ignoredCommands, hasFilter, totalSessions } = - useSessions(formatTs); + const { + sessions, + loading, + error, + elapsedMs, + ignoredCommands, + hasFilter, + totalSessions, + partial, + } = useSessions(formatTs); const [wrapCellText, setWrapCellText] = useState(false); const [selected, setSelected] = useState(null); @@ -143,10 +151,25 @@ export const SessionsTab = () => { })} /> )} + {partial && !error && ( + + )} {body}
{selected && ( setSelected(null)} diff --git a/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts b/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts index 9be247f439c9..ed574382e863 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts +++ b/src/plugins/agent_traces/public/application/pages/traces/hooks/use_trace_metrics.ts @@ -110,7 +110,7 @@ const doFetchMetrics = async ( const response = await pplService.executeQuery(datasetParam, query); return parseStatsResponse(response); } catch { - return {} as Record; + return {} as ReturnType; } })(), ]); diff --git a/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx b/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx index 700f29085ed7..b46bdb78e046 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx @@ -347,6 +347,29 @@ describe('extractSpanFilterQuery', () => { }); }); + it('stops at the first reshaping command so later filters never reference missing fields', () => { + // Fields made by stats/rename do not exist on span rows. + expect(extractSpanFilterQuery('source=t | stats count() as c by x | where c > 5')).toEqual({ + filterQuery: 'source=t', + ignoredCommands: ['stats', 'where'], + }); + expect(extractSpanFilterQuery('source=t | rename a as b | where b = 1')).toEqual({ + filterQuery: 'source=t', + ignoredCommands: ['rename', 'where'], + }); + expect(extractSpanFilterQuery('source=t | where a = 1 | fields a | where a = 2')).toEqual({ + filterQuery: 'source=t | where a = 1', + ignoredCommands: ['fields', 'where'], + }); + }); + + it('keeps filters across sort', () => { + expect(extractSpanFilterQuery('source=t | where a = 1 | sort - b | where c = 2')).toEqual({ + filterQuery: 'source=t | where a = 1 | where c = 2', + ignoredCommands: [], + }); + }); + it('reports reshaping commands and drops sort silently', () => { expect( extractSpanFilterQuery('source = t | where a = 1 | sort - b | stats count() by c | head 5') diff --git a/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx b/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx index 222fe14285dd..7cd44ebe8a3e 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx @@ -119,21 +119,27 @@ export const splitPplCommands = (queryString: string): string[] => { }; /** - * The span-level filter in a PPL query: the source plus every row-level command - * (where, eval, parse, ...), in order. Commands that reshape rows (stats, head, fields, - * dedup, ...) are returned separately so callers can tell the user they were not applied. - * `sort` is dropped silently since it never changes which rows match. + * The span-level filter in a PPL query: the source plus the leading row-level commands + * (where, eval, parse, ...), in order. Scanning stops at the first command that reshapes + * rows (stats, rename, fields, dedup, head, ...): later filters may reference fields that + * only exist after it, so they cannot run against span rows. Commands from that point on + * are returned so callers can tell the user they were not applied. `sort` never changes + * which rows match, so it is skipped and scanning continues. */ export const extractSpanFilterQuery = ( queryString: string ): { filterQuery: string; ignoredCommands: string[] } => { const kept: string[] = []; const ignored: string[] = []; + let reshaped = false; splitPplCommands(queryString).forEach((part, index) => { const command = part.split(/\s+/)[0].toLowerCase(); - if ((index === 0 && command.startsWith('source')) || ROW_LEVEL_COMMANDS.has(command)) { + if (reshaped) { + ignored.push(command); + } else if ((index === 0 && command.startsWith('source')) || ROW_LEVEL_COMMANDS.has(command)) { kept.push(part); } else if (command !== 'sort') { + reshaped = true; ignored.push(command); } }); diff --git a/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.test.tsx b/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.test.tsx index 9083f74d38ad..9039d8a1d95e 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.test.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.test.tsx @@ -179,7 +179,13 @@ describe('ppl_request_helpers', () => { it('escapes string values', () => { expect(escapePPLValue('test')).toBe('"test"'); expect(escapePPLValue('test"quote')).toBe('"test\\"quote"'); - expect(escapePPLValue('test\\backslash')).toBe('"test\\backslash"'); + // PPL string literals treat backslash as an escape, so it is escaped too. + expect(escapePPLValue('test\\backslash')).toBe('"test\\\\backslash"'); + }); + + it('cannot be closed early by a trailing backslash or an embedded quote', () => { + expect(escapePPLValue('abc\\')).toBe('"abc\\\\"'); + expect(escapePPLValue('x" or 1=1')).toBe('"x\\" or 1=1"'); }); it('handles number values', () => { diff --git a/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.tsx b/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.tsx index a0523168d550..49cf7198012a 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.tsx @@ -100,9 +100,13 @@ export const executePPLQuery = async ( return response; }; +/** Escape backslashes first, then quotes, so a value cannot close the PPL string literal. */ +const escapePPLString = (value: string): string => + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + export const escapePPLValue = (value: any): string => { if (typeof value === 'string') { - return `"${value.replace(/"/g, '\\"')}"`; + return `"${escapePPLString(value)}"`; } else if (typeof value === 'number') { return value.toString(); } else if (typeof value === 'boolean') { @@ -110,7 +114,7 @@ export const escapePPLValue = (value: any): string => { } else if (value === null || value === undefined) { return `"${value}"`; } else { - return `"${JSON.stringify(value).replace(/"/g, '\\"')}"`; + return `"${escapePPLString(JSON.stringify(value))}"`; } }; diff --git a/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.test.tsx b/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.test.tsx index 1255cf82b27d..41a85b86e910 100644 --- a/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.test.tsx +++ b/src/plugins/agent_traces/public/components/fields_selector/discover_sidebar.test.tsx @@ -98,8 +98,10 @@ jest.mock('./facet_list', () => ({ getDetailsByField, }: { title: string; - fields: any[]; - getDetailsByField: (field: any) => { buckets: Array<{ value: string; count: number }> }; + fields: Array<{ name: string }>; + getDetailsByField: (field: { name: string }) => { + buckets: Array<{ value: string; count: number }>; + }; }) => (

{title}

diff --git a/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx b/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx index 6c843ce4d673..25b2f72335e0 100644 --- a/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx +++ b/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx @@ -45,10 +45,14 @@ import { setStateToOsdUrl } from '../../../opensearch_dashboards_utils/public'; import { PLUGIN_ID, AGENT_TRACES_SESSION_ID_FIELD } from '../../common'; import { formatTimestamp, TraceRow } from '../application/pages/traces/hooks/tree_utils'; import { hitToBaseRow } from '../application/pages/traces/table_shared'; -import { PPLService } from '../application/pages/traces/trace_details/data_fetching/ppl_request_helpers'; +import { + PPLService, + escapePPLValue, +} from '../application/pages/traces/trace_details/data_fetching/ppl_request_helpers'; import { fetchSessions } from '../application/pages/sessions/fetch_sessions'; import { SessionRow } from '../application/pages/sessions/session_utils'; import { AgentView, isAgentView } from './agent_view_panel'; +import { OpenSearchSearchHit } from '../types/doc_views_types'; import { prepareAgentSpansQuery, prepareRootSpansQuery } from '../application/tab_queries'; export interface SearchProps { @@ -215,11 +219,12 @@ export class AgentTracesEmbeddable title: this.savedAgentTraces.title, visualizationBuilder: this.visualizationBuilder, formatTs: this.formatTs, - onOpenTrace: (row) => this.openInAgentTraces('traces', `| where traceId = "${row.traceId}"`), + onOpenTrace: (row) => + this.openInAgentTraces('traces', `| where traceId = ${escapePPLValue(row.traceId)}`), onOpenSession: (session) => this.openInAgentTraces( 'sessions', - `| where \`${AGENT_TRACES_SESSION_ID_FIELD}\` = "${session.sessionId.replace(/"/g, '\\"')}"` + `| where \`${AGENT_TRACES_SESSION_ID_FIELD}\` = ${escapePPLValue(session.sessionId)}` ), }; const timeRangeSearchSource = searchSource.create(); @@ -408,9 +413,9 @@ export class AgentTracesEmbeddable this.searchProps.agentError = undefined; this.searchProps.rows = result.sessions; this.searchProps.hits = result.sessions.length; - } catch (error: any) { + } catch (error: unknown) { this.searchProps.sessions = []; - this.searchProps.agentError = error?.message || String(error); + this.searchProps.agentError = error instanceof Error ? error.message : String(error); } this.searchProps.isLoading = false; this.updateOutput({ loading: false, error: undefined }); @@ -473,7 +478,8 @@ export class AgentTracesEmbeddable this.searchProps.rows = rows; if (this.agentView) { this.searchProps.agentRows = rows.map( - (hit: any) => hitToBaseRow(hit, this.formatTs) as TraceRow + (hit: OpenSearchSearchHit>) => + hitToBaseRow(hit, this.formatTs) as TraceRow ); } // NOTE: PPL response is not the same as OpenSearch response, resp.hits.total here is 0. From 220281be3e824b733ed15cb551eb9935f2375ee7 Mon Sep 17 00:00:00 2001 From: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:27:11 -0700 Subject: [PATCH 15/15] [Agent Traces] Address review: panel time range, escaped quotes, shared helpers - Dashboard Sessions panels query the panel's time range (dashboard time or a per-panel override) instead of the global timefilter. PPLService takes an optional time range and abort signal; the sessions fetch shares the embeddable's abort controller, so a newer refresh cancels an older one and a slow response cannot overwrite it. - splitPplCommands honors backslash escapes inside string literals, so `where msg = "a\"| b"` stays one command. - Sessions reuse readAttribute from genai_message_preview instead of a copy (getSpanAttribute removed). - The two sessions tables share useLazyRows and setTitleIfTruncated. Tested: agent_traces jest (191 suites, 2009 tests), eslint, scoped tsc; live, a Sessions panel with a 24h per-panel range on a 15m dashboard lists 1,037 sessions (PPL: 1,037 in 24h, 11 in 15m). Signed-off-by: Shenoy Pratik Gurudatt <4348487+ps48@users.noreply.github.com> --- .../pages/sessions/lazy_rows.test.tsx | 67 +++++++++++++++++++ .../application/pages/sessions/lazy_rows.ts | 43 ++++++++++++ .../pages/sessions/session_spans_table.tsx | 28 +------- .../pages/sessions/session_utils.test.ts | 12 ++-- .../pages/sessions/session_utils.ts | 29 ++------ .../pages/sessions/sessions_table.tsx | 30 +-------- .../traces/hooks/genai_message_preview.ts | 3 +- .../pages/traces/table_shared.test.tsx | 19 ++++++ .../application/pages/traces/table_shared.tsx | 11 ++- .../ppl_request_helpers.test.tsx | 17 +++++ .../data_fetching/ppl_request_helpers.tsx | 25 +++++-- .../embeddable/agent_traces_embeddable.tsx | 12 +++- 12 files changed, 207 insertions(+), 89 deletions(-) create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/lazy_rows.test.tsx create mode 100644 src/plugins/agent_traces/public/application/pages/sessions/lazy_rows.ts diff --git a/src/plugins/agent_traces/public/application/pages/sessions/lazy_rows.test.tsx b/src/plugins/agent_traces/public/application/pages/sessions/lazy_rows.test.tsx new file mode 100644 index 000000000000..49e23f96f364 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/lazy_rows.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, renderHook } from '@testing-library/react'; +import { LAZY_LOAD_BATCH_SIZE, setTitleIfTruncated, useLazyRows } from './lazy_rows'; + +describe('useLazyRows', () => { + let observed: IntersectionObserverCallback | undefined; + const disconnect = jest.fn(); + + beforeEach(() => { + observed = undefined; + disconnect.mockClear(); + (global as { IntersectionObserver?: unknown }).IntersectionObserver = jest.fn( + (callback: IntersectionObserverCallback) => { + observed = callback; + return { observe: jest.fn(), disconnect }; + } + ); + }); + + const scrollToEnd = () => + act(() => { + observed?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + }); + + it('renders the next batch when the sentinel scrolls into view, and resets on new rows', () => { + const { result, rerender } = renderHook(({ rows }) => useLazyRows(rows), { + initialProps: { rows: [1] as unknown[] }, + }); + expect(result.current.renderedCount).toBe(LAZY_LOAD_BATCH_SIZE); + + act(() => result.current.sentinelRef(document.createElement('div'))); + scrollToEnd(); + expect(result.current.renderedCount).toBe(2 * LAZY_LOAD_BATCH_SIZE); + + rerender({ rows: [2] }); + expect(result.current.renderedCount).toBe(LAZY_LOAD_BATCH_SIZE); + }); + + it('disconnects the observer when the sentinel goes away', () => { + const { result } = renderHook(() => useLazyRows([])); + act(() => result.current.sentinelRef(document.createElement('div'))); + act(() => result.current.sentinelRef(null)); + expect(disconnect).toHaveBeenCalled(); + }); +}); + +describe('setTitleIfTruncated', () => { + const hover = (scrollWidth: number, clientWidth: number) => { + const el = document.createElement('span'); + Object.defineProperty(el, 'scrollWidth', { value: scrollWidth }); + Object.defineProperty(el, 'clientWidth', { value: clientWidth }); + setTitleIfTruncated('full text')({ currentTarget: el } as never); + return el.title; + }; + + it('sets the title only when the text is truncated', () => { + expect(hover(200, 100)).toBe('full text'); + expect(hover(100, 100)).toBe(''); + }); +}); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/lazy_rows.ts b/src/plugins/agent_traces/public/application/pages/sessions/lazy_rows.ts new file mode 100644 index 000000000000..7a2876c3a826 --- /dev/null +++ b/src/plugins/agent_traces/public/application/pages/sessions/lazy_rows.ts @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +/** Rows added each time the end of a sessions table scrolls into view. */ +export const LAZY_LOAD_BATCH_SIZE = 50; + +/** + * Render a long table in batches: returns how many rows to render and a ref for a sentinel + * element after the last row, which renders the next batch when it scrolls into view. + * Back to the first batch whenever `resetKey` changes (new rows, new sort order). + */ +export const useLazyRows = (resetKey: unknown) => { + const [renderedCount, setRenderedCount] = useState(LAZY_LOAD_BATCH_SIZE); + useEffect(() => setRenderedCount(LAZY_LOAD_BATCH_SIZE), [resetKey]); + + const observerRef = useRef(null); + const sentinelRef = useCallback((node: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + if (node && typeof IntersectionObserver !== 'undefined') { + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) setRenderedCount((c) => c + LAZY_LOAD_BATCH_SIZE); + }, + { threshold: 0.1 } + ); + observerRef.current.observe(node); + } + }, []); + useEffect(() => () => observerRef.current?.disconnect(), []); + + return { renderedCount, sentinelRef }; +}; + +/** Show the full text as a native tooltip only when the cell is truncated (like DataTable). */ +export const setTitleIfTruncated = (text: string) => (e: React.MouseEvent) => { + const el = e.currentTarget; + el.title = el.scrollWidth > el.clientWidth ? text : ''; +}; diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.tsx b/src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.tsx index 641ad476e630..c7272c700c8d 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_spans_table.tsx @@ -3,15 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React from 'react'; import { i18n } from '@osd/i18n'; import { EuiBadge, EuiHealth, EuiProgress } from '@elastic/eui'; import { TableHeaderColumn } from '../../../components/data_table/table_header/table_header_column'; import { getCategoryMeta, getSpanCategory } from '../../../services/span_categorization'; import { TraceRow } from '../traces/hooks/tree_utils'; import { previewSpanInput, previewSpanOutput } from '../traces/hooks/genai_message_preview'; - -const LAZY_LOAD_BATCH_SIZE = 50; +import { setTitleIfTruncated, useLazyRows } from './lazy_rows'; export const KindBadge: React.FC<{ row: TraceRow }> = ({ row }) => { const meta = getCategoryMeta(getSpanCategory(row)); @@ -26,11 +25,6 @@ export const KindBadge: React.FC<{ row: TraceRow }> = ({ row }) => { ); }; -const setTitleIfTruncated = (text: string) => (e: React.MouseEvent) => { - const el = e.currentTarget; - el.title = el.scrollWidth > el.clientWidth ? text : ''; -}; - interface Column { key: 'status' | 'kind' | 'name' | 'input' | 'output' | 'latency'; label: string; @@ -73,23 +67,7 @@ interface SessionSpansTableProps { /** Session-scoped traces/spans table, using the same markup and styles as the Traces tab. */ export const SessionSpansTable: React.FC = ({ rows, onRowClick }) => { - const [renderedCount, setRenderedCount] = useState(LAZY_LOAD_BATCH_SIZE); - useEffect(() => setRenderedCount(LAZY_LOAD_BATCH_SIZE), [rows]); - const observerRef = useRef(null); - const sentinelRef = useCallback((node: HTMLDivElement | null) => { - observerRef.current?.disconnect(); - observerRef.current = null; - if (node && typeof IntersectionObserver !== 'undefined') { - observerRef.current = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) setRenderedCount((c) => c + LAZY_LOAD_BATCH_SIZE); - }, - { threshold: 0.1 } - ); - observerRef.current.observe(node); - } - }, []); - useEffect(() => () => observerRef.current?.disconnect(), []); + const { renderedCount, sentinelRef } = useLazyRows(rows); const renderCell = (row: TraceRow, key: Column['key']) => { switch (key) { diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts index a19b3401e66c..5598c62da9bd 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.test.ts @@ -12,7 +12,6 @@ import { buildTraceSessionMapQuery, formatSessionDuration, getSourceCommand, - getSpanAttribute, parseSessionStats, pplResponseToRecords, shortenId, @@ -22,6 +21,7 @@ import { parseFacetBuckets, } from './session_utils'; import { BaseRow } from '../traces/hooks/tree_utils'; +import { readAttribute } from '../traces/hooks/genai_message_preview'; const FIELD = 'attributes.gen_ai.conversation.id'; @@ -136,12 +136,12 @@ describe('session_utils', () => { }); }); - describe('getSpanAttribute', () => { + describe('readAttribute (user id lookup)', () => { it('reads flat dotted, nested and top-level flattened keys', () => { - expect(getSpanAttribute({ attributes: { 'user.id': 'u1' } }, 'user.id')).toBe('u1'); - expect(getSpanAttribute({ attributes: { user: { id: 'u2' } } }, 'user.id')).toBe('u2'); - expect(getSpanAttribute({ 'attributes.user.id': 'u3' }, 'user.id')).toBe('u3'); - expect(getSpanAttribute({ attributes: {} }, 'user.id')).toBeUndefined(); + expect(readAttribute({ attributes: { 'user.id': 'u1' } }, 'user.id')).toBe('u1'); + expect(readAttribute({ attributes: { user: { id: 'u2' } } }, 'user.id')).toBe('u2'); + expect(readAttribute({ 'attributes.user.id': 'u3' }, 'user.id')).toBe('u3'); + expect(readAttribute({ attributes: {} }, 'user.id')).toBeUndefined(); }); }); diff --git a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts index 01d0a04ea641..db89fe03aa96 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts +++ b/src/plugins/agent_traces/public/application/pages/sessions/session_utils.ts @@ -8,7 +8,11 @@ import { AGENT_TRACES_SESSION_ID_FIELD } from '../../../../common'; import { escapePPLValue } from '../traces/trace_details/data_fetching/ppl_request_helpers'; import { BaseRow } from '../traces/hooks/tree_utils'; import { Bucket } from '../../../components/fields_selector/types'; -import { previewInputMessages, previewOutputMessages } from '../traces/hooks/genai_message_preview'; +import { + previewInputMessages, + previewOutputMessages, + readAttribute, +} from '../traces/hooks/genai_message_preview'; /** Backtick-quoted session id field for use in PPL. */ export const SESSION_FIELD_PPL = `\`${AGENT_TRACES_SESSION_ID_FIELD}\``; @@ -204,25 +208,6 @@ export const parseSessionStats = (records: PplRecord[]): SessionStats[] => })) .filter((s) => s.sessionId !== ''); -/** Read a span attribute that may be stored as a flat dotted key or a nested object. */ -export const getSpanAttribute = ( - doc: Record | undefined, - key: string -): unknown => { - if (!doc) return undefined; - const attrs = doc.attributes; - if (isObject(attrs)) { - if (key in attrs) return attrs[key]; - let cur: unknown = attrs; - for (const part of key.split('.')) { - if (!isObject(cur)) return undefined; - cur = cur[part]; - } - if (cur !== undefined) return cur; - } - return doc[`attributes.${key}`]; -}; - const rowTokens = (row: BaseRow): number | null => typeof row.totalTokens === 'number' ? row.totalTokens : null; @@ -260,7 +245,7 @@ export const assembleSessionRows = ( const withInput = roots.filter((r) => previewInputMessages(r.input) !== ''); const withOutput = roots.filter((r) => previewOutputMessages(r.output) !== ''); const tokenValues = roots.map(rowTokens).filter((t): t is number => t !== null); - const userRoot = roots.find((r) => getSpanAttribute(r.rawDocument, 'user.id') != null); + const userRoot = roots.find((r) => readAttribute(r.rawDocument, 'user.id') != null); const start = toMs(s.startTime); const end = toMs(s.endTime); @@ -275,7 +260,7 @@ export const assembleSessionRows = ( lastMessage: withOutput.length ? previewOutputMessages(withOutput[withOutput.length - 1].output) : '', - userId: userRoot ? String(getSpanAttribute(userRoot.rawDocument, 'user.id')) : null, + userId: userRoot ? String(readAttribute(userRoot.rawDocument, 'user.id')) : null, // Every trace mapped to the session, not just those whose root span was found; // traces with a root come first in start order. traceIds: [ diff --git a/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx b/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx index 888cb9cdb047..0cd23e2dec48 100644 --- a/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx +++ b/src/plugins/agent_traces/public/application/pages/sessions/sessions_table.tsx @@ -3,16 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { i18n } from '@osd/i18n'; import { EuiBadge, EuiLink, EuiProgress } from '@elastic/eui'; import { TableHeaderColumn } from '../../../components/data_table/table_header/table_header_column'; import { TokenIcon } from '../../../components/data_table/table_cell/trace_utils/trace_utils'; import { SortOrder } from '../../../helpers/data_table_helper'; import { SessionRow, formatSessionDuration } from './session_utils'; - -/** Rows rendered per batch as the user scrolls (same as the Traces/Spans DataTable). */ -const LAZY_LOAD_BATCH_SIZE = 50; +import { setTitleIfTruncated, useLazyRows } from './lazy_rows'; const DEFAULT_SORT: SortOrder[] = [['startTime', 'desc']]; @@ -51,12 +49,6 @@ export const TokensBadge: React.FC<{ tokens: number | null }> = ({ tokens }) => ); -/** Show the full text as a native tooltip only when the cell is truncated (like DataTable). */ -const setTitleIfTruncated = (text: string) => (e: React.MouseEvent) => { - const el = e.currentTarget; - el.title = el.scrollWidth > el.clientWidth ? text : ''; -}; - const compareSessions = (a: SessionRow, b: SessionRow, key: SessionColumnKey): number => { const va = a[key]; const vb = b[key]; @@ -150,23 +142,7 @@ export const SessionsTable: React.FC = ({ }, [sessions, sortOrder]); // Infinite-scroll lazy loading, mirroring the Traces/Spans DataTable. - const [renderedCount, setRenderedCount] = useState(LAZY_LOAD_BATCH_SIZE); - useEffect(() => setRenderedCount(LAZY_LOAD_BATCH_SIZE), [sessions, sortOrder]); - const observerRef = useRef(null); - const sentinelRef = useCallback((node: HTMLDivElement | null) => { - observerRef.current?.disconnect(); - observerRef.current = null; - if (node && typeof IntersectionObserver !== 'undefined') { - observerRef.current = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) setRenderedCount((c) => c + LAZY_LOAD_BATCH_SIZE); - }, - { threshold: 0.1 } - ); - observerRef.current.observe(node); - } - }, []); - useEffect(() => () => observerRef.current?.disconnect(), []); + const { renderedCount, sentinelRef } = useLazyRows(sortedSessions); const visible = sortedSessions.slice(0, renderedCount); diff --git a/src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.ts b/src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.ts index d50a415a4390..0883b509159b 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.ts +++ b/src/plugins/agent_traces/public/application/pages/traces/hooks/genai_message_preview.ts @@ -145,7 +145,8 @@ export const previewOutputMessages = (value: unknown): string => { }; /** Read a span attribute stored either as a flat dotted key or as nested objects. */ -const readAttribute = (doc: Record | undefined, key: string): unknown => { +/** A span attribute from nested (`attributes.a.b`), dotted-key or flattened documents. */ +export const readAttribute = (doc: Record | undefined, key: string): unknown => { if (!doc) return undefined; const attrs = doc.attributes; if (isObject(attrs)) { diff --git a/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx b/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx index b46bdb78e046..b63336274524 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/table_shared.test.tsx @@ -337,6 +337,25 @@ describe('splitPplCommands', () => { 'where `x|y` = 1', ]); }); + + it('keeps escaped quotes inside string literals', () => { + expect(splitPplCommands('source = t | where msg = "a\\"| b" | head 5')).toEqual([ + 'source = t', + 'where msg = "a\\"| b"', + 'head 5', + ]); + expect(splitPplCommands("source = t | where msg = 'it\\'s | ok' | head 5")).toEqual([ + 'source = t', + "where msg = 'it\\'s | ok'", + 'head 5', + ]); + // An escaped backslash does not escape the closing quote. + expect(splitPplCommands('source = t | where p = "c:\\\\" | head 5')).toEqual([ + 'source = t', + 'where p = "c:\\\\"', + 'head 5', + ]); + }); }); describe('extractSpanFilterQuery', () => { diff --git a/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx b/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx index 7cd44ebe8a3e..d2902a110de9 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/table_shared.tsx @@ -95,14 +95,21 @@ export const splitPplWhereAndTail = ( /** PPL commands that filter or annotate span rows without changing their shape. */ const ROW_LEVEL_COMMANDS = new Set(['where', 'eval', 'parse', 'grok', 'regex', 'fillnull']); -/** Split a PPL query on top-level pipes, ignoring pipes inside quotes or backticks. */ +/** + * Split a PPL query on top-level pipes, ignoring pipes inside quotes or backticks. Inside a + * string literal a backslash escapes the next character, so `"a\"| b"` stays one literal. + */ export const splitPplCommands = (queryString: string): string[] => { const parts: string[] = []; let current = ''; let quote: string | null = null; + let escaped = false; for (const ch of queryString) { if (quote) { - if (ch === quote) quote = null; + if (escaped) escaped = false; + // Backticks quote identifiers, which have no escapes. + else if (ch === '\\' && quote !== '`') escaped = true; + else if (ch === quote) quote = null; current += ch; } else if (ch === "'" || ch === '"' || ch === '`') { quote = ch; diff --git a/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.test.tsx b/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.test.tsx index 9039d8a1d95e..3e1c6f2fde29 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.test.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.test.tsx @@ -273,5 +273,22 @@ describe('ppl_request_helpers', () => { const dataset = createMockDataset(); await expect(pplService.executeQuery(dataset, 'source = test-index')).rejects.toThrow(error); }); + + it('uses the global timefilter unless given a time range', async () => { + await pplService.executeQuery(createMockDataset(), 'source = test-index'); + const [request, options] = (mockDataService.search.search as jest.Mock).mock.calls[0]; + expect(request.params.body.timeRange).toBeUndefined(); + expect(options.abortSignal).toBeUndefined(); + }); + + it('sends its time range and abort signal with every query', async () => { + const timeRange = { from: '2026-10-01T00:00:00.000Z', to: '2026-10-01T01:00:00.000Z' }; + const controller = new AbortController(); + const scoped = new PPLService(mockDataService, { timeRange, signal: controller.signal }); + await scoped.executeQuery(createMockDataset(), 'source = test-index'); + const [request, options] = (mockDataService.search.search as jest.Mock).mock.calls[0]; + expect(request.params.body.timeRange).toEqual(timeRange); + expect(options.abortSignal).toBe(controller.signal); + }); }); }); diff --git a/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.tsx b/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.tsx index 49cf7198012a..ab059b439c8a 100644 --- a/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.tsx +++ b/src/plugins/agent_traces/public/application/pages/traces/trace_details/data_fetching/ppl_request_helpers.tsx @@ -4,7 +4,7 @@ */ import { DataPublicPluginStart } from '../../../../../../../data/public'; -import { Dataset } from '../../../../../../../data/common'; +import { Dataset, TimeRange } from '../../../../../../../data/common'; export interface PPLQueryParams { traceId: string; @@ -34,6 +34,8 @@ export interface PPLQueryRequest { }>; }; aggConfig?: any; // For external data source aggregations + /** Time range for the dataset's time field; the global timefilter when omitted. */ + timeRange?: TimeRange; }; }; } @@ -60,7 +62,8 @@ export const buildPPLDataset = (dataset: Dataset) => { export const buildPPLQueryRequest = ( dataset: Dataset, pplQuery: string, - aggConfig?: any + aggConfig?: any, + timeRange?: TimeRange ): PPLQueryRequest => { const request: PPLQueryRequest = { params: { @@ -82,6 +85,9 @@ export const buildPPLQueryRequest = ( if (aggConfig) { request.params.body.aggConfig = aggConfig; } + if (timeRange) { + request.params.body.timeRange = timeRange; + } return request; }; @@ -118,11 +124,20 @@ export const escapePPLValue = (value: any): string => { } }; +export interface PPLServiceOptions { + /** Time range to query instead of the global timefilter (e.g. a dashboard panel's own range). */ + timeRange?: TimeRange; + /** Aborts the service's in-flight queries. */ + signal?: AbortSignal; +} + export class PPLService { protected dataService: DataPublicPluginStart; + protected options: PPLServiceOptions; - constructor(dataService: DataPublicPluginStart) { + constructor(dataService: DataPublicPluginStart, options: PPLServiceOptions = {}) { this.dataService = dataService; + this.options = options; } async executeQuery(dataset: Dataset, pplQuery: string): Promise { @@ -131,8 +146,8 @@ export class PPLService { } try { - const request = buildPPLQueryRequest(dataset, pplQuery); - return await executePPLQuery(this.dataService, request); + const request = buildPPLQueryRequest(dataset, pplQuery, undefined, this.options.timeRange); + return await executePPLQuery(this.dataService, request, this.options.signal); } catch (error) { // eslint-disable-next-line no-console console.error('PPL Query Error:', error); diff --git a/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx b/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx index 25b2f72335e0..d16ca7cfedcf 100644 --- a/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx +++ b/src/plugins/agent_traces/public/embeddable/agent_traces_embeddable.tsx @@ -398,22 +398,32 @@ export class AgentTracesEmbeddable private fetchSessionsView = async () => { if (!this.searchProps) return; const dataset = this.savedQuery?.dataset; + // Like fetch(): a newer refresh cancels this one, so a slow response cannot overwrite it. + if (this.abortController) this.abortController.abort(); + const abortController = new AbortController(); + this.abortController = abortController; this.updateOutput({ loading: true, error: undefined }); this.searchProps.isLoading = true; try { if (!dataset || !this.sessionsBaseQuery) throw new Error('Saved search has no dataset'); const result = await fetchSessions( - new PPLService(this.services.data), + // The panel's time range (dashboard time or a per-panel override), as fetch() uses. + new PPLService(this.services.data, { + timeRange: this.input.timeRange, + signal: abortController.signal, + }), dataset, this.sessionsBaseQuery, this.formatTs ); + if (abortController.signal.aborted) return; this.searchProps.sessions = result.sessions; this.searchProps.agentTotal = result.totalSessions; this.searchProps.agentError = undefined; this.searchProps.rows = result.sessions; this.searchProps.hits = result.sessions.length; } catch (error: unknown) { + if (abortController.signal.aborted) return; this.searchProps.sessions = []; this.searchProps.agentError = error instanceof Error ? error.message : String(error); }