Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ export const SessionDetailView: FC<SessionDetailViewProps> = ({
sessionId={sessionId}
parentBreadcrumbs={traceParentBreadcrumbs}
traceSummary={trajectories.find(({ trace }) => trace.id === traceId)?.trace}
traceSummaryStatus={
explorer.spansLoaded ? 'resolved' : explorer.spansError ? 'error' : 'loading'
}
traceSummaryErrorMessage={explorer.spansError?.message}
>
{(trace) => (
<TraceSpanAccordions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { IntakeAccordion } from '@nemo/common/src/components/IntakeAccordion';
import { useGetTrace } from '@nemo/sdk/generated/platform/api';
import type { Trace } from '@nemo/sdk/generated/platform/schema';
import { Stack, StatusMessage, Text } from '@nvidia/foundations-react-core';
import { Banner, Stack, StatusMessage, Text } from '@nvidia/foundations-react-core';
import { KeyValueRows } from '@studio/components/IntakeDetail/IntakeComponents/KeyValueRows';
import { RawJsonDebug } from '@studio/components/IntakeDetail/IntakeComponents/RawJsonDebug';
import {
Expand All @@ -29,6 +29,8 @@ interface TraceDetailViewProps {
traceId: string;
sessionId: string;
traceSummary?: Trace;
traceSummaryStatus: 'loading' | 'resolved' | 'error';
traceSummaryErrorMessage?: string;
parentBreadcrumbs: BreadcrumbsItemProps[];
children: (trace: Trace) => ReactNode;
}
Expand All @@ -39,6 +41,8 @@ export const TraceDetailView: FC<TraceDetailViewProps> = ({
traceId,
sessionId,
traceSummary,
traceSummaryStatus,
traceSummaryErrorMessage,
parentBreadcrumbs,
children,
}) => {
Expand All @@ -53,6 +57,7 @@ export const TraceDetailView: FC<TraceDetailViewProps> = ({
// The session already owns enough trace data to keep its explorer mounted
// while the selected trace's full payload hydrates in the background.
const resolvedTrace = trace ?? traceSummary;
const traceNotFound = error?.response?.status === 404;

const { setBreadcrumbs } = useBreadcrumbs();
const traceBreadcrumbLabel = resolvedTrace ? getTraceDisplayName(resolvedTrace) : traceId;
Expand All @@ -70,7 +75,23 @@ export const TraceDetailView: FC<TraceDetailViewProps> = ({
setBreadcrumbs([...parentBreadcrumbs, { slotLabel: `Trace ${traceBreadcrumbLabel}` }]);
}, [parentBreadcrumbs, setBreadcrumbs, traceBreadcrumbLabel]);

if (error?.response?.status === 404) {
if (!resolvedTrace && (isLoading || (traceNotFound && traceSummaryStatus === 'loading'))) {
return <Loading description="Loading trace..." />;
}

if (traceNotFound && !resolvedTrace && traceSummaryStatus === 'error') {
return (
<StatusMessage
className="mx-auto mt-density-2xl"
size="medium"
slotMedia={<CircleAlert width={65} height={65} />}
slotHeading="Error loading trace activity"
slotSubheading={traceSummaryErrorMessage}
/>
);
}

if (traceNotFound && !resolvedTrace) {
return (
<NotFound
subheader="Trace Not Found"
Expand All @@ -79,10 +100,6 @@ export const TraceDetailView: FC<TraceDetailViewProps> = ({
);
}

if (isLoading && !resolvedTrace) {
return <Loading description="Loading trace..." />;
}

if (error && !resolvedTrace) {
return (
<StatusMessage
Expand Down Expand Up @@ -110,6 +127,11 @@ export const TraceDetailView: FC<TraceDetailViewProps> = ({

return (
<>
{traceNotFound && (
<Banner kind="inline" status="info">
Trace details are still arriving. The activity received so far is shown below.
</Banner>
)}
{children(resolvedTrace)}
<IntakeAccordion
variant="section"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
// SPDX-License-Identifier: Apache-2.0

import { useGetSession, useListSpans, useListTraces } from '@nemo/sdk/generated/platform/api';
import type { Span } from '@nemo/sdk/generated/platform/schema';
import { type Span, SpanStatus, type Trace } from '@nemo/sdk/generated/platform/schema';
import { type SessionExplorerData } from '@studio/components/IntakeDetail/TraceSpanAccordions';
import { buildSpanTree, type SessionTrajectory } from '@studio/util/intakeTelemetry';
import {
buildSpanTree,
compareSpansByStartedAt,
type SessionTrajectory,
} from '@studio/util/intakeTelemetry';
import { useMemo } from 'react';

const SESSION_TRACES_PAGE_SIZE = 1000;
Expand Down Expand Up @@ -57,11 +61,33 @@ export function useSessionTrajectories(workspace: string, sessionId: string) {
traceSpans.push(span);
groupedSpans.set(span.trace_id, traceSpans);
}
return traces.map((trace) => {

const traceSummaries: Trace[] = [...traces];
const knownTraceIds = new Set(traces.map((trace) => trace.id));
for (const [traceId, spans] of groupedSpans) {
if (knownTraceIds.has(traceId)) continue;
const earliestSpan = spans.reduce((earliest, span) =>
compareSpansByStartedAt(span, earliest) < 0 ? span : earliest
);
traceSummaries.push({
id: traceId,
session_id: sessionId,
workspace,
started_at: earliestSpan.started_at,
status: SpanStatus.unknown,
span_count: spans.length,
});
}

traceSummaries.sort((a, b) => {
const startedAtDifference = Date.parse(a.started_at) - Date.parse(b.started_at);
return startedAtDifference || a.id.localeCompare(b.id);
});
return traceSummaries.map((trace) => {
const spans = groupedSpans.get(trace.id) ?? [];
return { trace, spans, spanTree: buildSpanTree(spans) };
});
}, [sessionSpansResponse?.data, traces]);
}, [sessionId, sessionSpansResponse?.data, traces, workspace]);

const explorer = useMemo<SessionExplorerData>(
() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,126 @@ describe('IntakeSessionDetailRoute', () => {
expect((await screen.findAllByText('Outside page span')).length).toBeGreaterThan(0);
});

it('renders received spans while trace details are still arriving', async () => {
const traceId = 'trace-still-arriving';
const receivedSpan = {
...mockSpanById('span-llm-001')!,
span_id: 'span-received-before-trace',
parent_span_id: 'span-root-not-received',
trace_id: traceId,
name: 'Generate response from received activity',
};

server.use(
http.get('*/apis/intake/v2/workspaces/:workspace/traces', () =>
HttpResponse.json({
...mockTracesPage,
data: [],
pagination: {
...mockTracesPage.pagination,
current_page_size: 0,
total_results: 0,
},
})
),
http.get('*/apis/intake/v2/workspaces/:workspace/traces/:traceId', ({ params }) =>
params['traceId'] === traceId
? new HttpResponse(null, { status: 404 })
: new HttpResponse(null, { status: 500 })
),
http.get('*/apis/intake/v2/workspaces/:workspace/spans', () =>
HttpResponse.json({
...mockSpansPage,
data: [receivedSpan],
pagination: {
...mockSpansPage.pagination,
current_page_size: 1,
total_results: 1,
},
})
)
);

renderSessionDetail('session-agent-run-001', `?traceId=${traceId}`);

expect(
await screen.findByText(
'Trace details are still arriving. The activity received so far is shown below.'
)
).toBeInTheDocument();
expect(screen.queryByText('Trace Not Found')).not.toBeInTheDocument();
expect(
(await screen.findAllByText('Generate response from received activity')).length
).toBeGreaterThan(0);
});

it('shows not found when neither trace details nor matching spans exist', async () => {
const traceId = 'trace-never-received';
server.use(
http.get('*/apis/intake/v2/workspaces/:workspace/traces', () =>
HttpResponse.json({
...mockTracesPage,
data: [],
pagination: {
...mockTracesPage.pagination,
current_page_size: 0,
total_results: 0,
},
})
),
http.get(
'*/apis/intake/v2/workspaces/:workspace/traces/:traceId',
() => new HttpResponse(null, { status: 404 })
),
http.get('*/apis/intake/v2/workspaces/:workspace/spans', () =>
HttpResponse.json({
...mockSpansPage,
data: [],
pagination: {
...mockSpansPage.pagination,
current_page_size: 0,
total_results: 0,
},
})
)
);

renderSessionDetail('session-agent-run-001', `?traceId=${traceId}`);

expect(await screen.findByText('Trace Not Found')).toBeInTheDocument();
expect(screen.queryByText(/Trace details are still arriving/)).not.toBeInTheDocument();
});

it('shows an error when trace details are missing and session spans fail to load', async () => {
const traceId = 'trace-with-failed-activity-request';
server.use(
http.get('*/apis/intake/v2/workspaces/:workspace/traces', () =>
HttpResponse.json({
...mockTracesPage,
data: [],
pagination: {
...mockTracesPage.pagination,
current_page_size: 0,
total_results: 0,
},
})
),
http.get(
'*/apis/intake/v2/workspaces/:workspace/traces/:traceId',
() => new HttpResponse(null, { status: 404 })
),
http.get(
'*/apis/intake/v2/workspaces/:workspace/spans',
() => new HttpResponse(null, { status: 500 })
)
);

renderSessionDetail('session-agent-run-001', `?traceId=${traceId}`);

expect(await screen.findByText('Error loading trace activity')).toBeInTheDocument();
expect(screen.queryByText('Trace Not Found')).not.toBeInTheDocument();
});

it('rejects a trace deep link that belongs to another session', async () => {
renderSessionDetail('session-agent-run-001', '?traceId=trace-agent-run-002');

Expand Down
Loading