From f9e6daa48ef24c122a8ee8d5cc6de5c1b1ab24a6 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Tue, 24 Feb 2026 13:11:13 -0800 Subject: [PATCH 1/5] [web-shared] Fix trace viewer pagination issue --- .../src/components/run-trace-view.tsx | 9 + .../src/components/workflow-trace-view.tsx | 51 ++- .../web/app/components/run-detail-view.tsx | 6 + packages/web/app/lib/workflow-api-client.ts | 357 +++++++++++++----- .../workflows/96_many_steps.ts | 22 ++ 5 files changed, 344 insertions(+), 101 deletions(-) create mode 100644 workbench/nextjs-turbopack/workflows/96_many_steps.ts diff --git a/packages/web-shared/src/components/run-trace-view.tsx b/packages/web-shared/src/components/run-trace-view.tsx index 0c643e1522..e9ff44896d 100644 --- a/packages/web-shared/src/components/run-trace-view.tsx +++ b/packages/web-shared/src/components/run-trace-view.tsx @@ -27,6 +27,9 @@ interface RunTraceViewProps { onCancelRun?: (runId: string) => Promise; onStreamClick?: (streamId: string) => void; onSpanSelect?: (info: SpanSelectionInfo) => void; + onLoadMoreSpans?: () => void | Promise; + hasMoreSpans?: boolean; + isLoadingMoreSpans?: boolean; } export function RunTraceView({ @@ -44,6 +47,9 @@ export function RunTraceView({ onCancelRun, onStreamClick, onSpanSelect, + onLoadMoreSpans, + hasMoreSpans, + isLoadingMoreSpans, }: RunTraceViewProps) { if (error && !run) { return ( @@ -72,6 +78,9 @@ export function RunTraceView({ onCancelRun={onCancelRun} onStreamClick={onStreamClick} onSpanSelect={onSpanSelect} + onLoadMoreSpans={onLoadMoreSpans} + hasMoreSpans={hasMoreSpans} + isLoadingMoreSpans={isLoadingMoreSpans} /> ); diff --git a/packages/web-shared/src/components/workflow-trace-view.tsx b/packages/web-shared/src/components/workflow-trace-view.tsx index 496dc20e28..ea3b84a836 100644 --- a/packages/web-shared/src/components/workflow-trace-view.tsx +++ b/packages/web-shared/src/components/workflow-trace-view.tsx @@ -278,6 +278,9 @@ function TraceViewerWithContextMenu({ onWakeUpSleep, onCancelRun, onResolveHook, + onLoadMoreSpans, + hasMoreSpans = false, + isLoadingMoreSpans = false, children, }: { trace: { spans: Span[] }; @@ -294,9 +297,12 @@ function TraceViewerWithContextMenu({ payload: unknown, hook?: Hook ) => Promise; + onLoadMoreSpans?: () => void | Promise; + hasMoreSpans?: boolean; + isLoadingMoreSpans?: boolean; children: ReactNode; }): ReactNode { - const { dispatch } = useTraceViewer(); + const { state, dispatch } = useTraceViewer(); // Drive active span widths at 60fps without React re-renders useLiveTick(isLive); @@ -413,6 +419,37 @@ function TraceViewerWithContextMenu({ }; }, [handleContextMenu]); + const loadingMoreRef = useRef(false); + useEffect(() => { + const timeline = state.timelineRef.current; + if (!timeline || !onLoadMoreSpans || !hasMoreSpans) { + return; + } + + const thresholdPx = 200; + const maybeLoadMore = () => { + if (loadingMoreRef.current || isLoadingMoreSpans || !hasMoreSpans) { + return; + } + const remaining = + timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight; + if (remaining > thresholdPx) { + return; + } + + loadingMoreRef.current = true; + Promise.resolve(onLoadMoreSpans()).finally(() => { + loadingMoreRef.current = false; + }); + }; + + timeline.addEventListener('scroll', maybeLoadMore); + maybeLoadMore(); + return () => { + timeline.removeEventListener('scroll', maybeLoadMore); + }; + }, [state.timelineRef, onLoadMoreSpans, hasMoreSpans, isLoadingMoreSpans]); + const closeMenu = useCallback(() => { setContextMenu(null); }, []); @@ -853,6 +890,9 @@ export const WorkflowTraceViewer = ({ onStreamClick, onSpanSelect, onLoadEventData, + onLoadMoreSpans, + hasMoreSpans = false, + isLoadingMoreSpans = false, }: { run: WorkflowRun; steps: Step[]; @@ -883,6 +923,12 @@ export const WorkflowTraceViewer = ({ correlationId: string, eventId: string ) => Promise; + /** Load next trace page when vertical scroll reaches bottom. */ + onLoadMoreSpans?: () => void | Promise; + /** Whether trace pagination has more data to load. */ + hasMoreSpans?: boolean; + /** Whether trace pagination is currently fetching another page. */ + isLoadingMoreSpans?: boolean; }) => { const [selectedSpan, setSelectedSpan] = useState( null @@ -1049,6 +1095,9 @@ export const WorkflowTraceViewer = ({ onWakeUpSleep={onWakeUpSleep} onCancelRun={onCancelRun} onResolveHook={onResolveHook} + onLoadMoreSpans={onLoadMoreSpans} + hasMoreSpans={hasMoreSpans} + isLoadingMoreSpans={isLoadingMoreSpans} > diff --git a/packages/web/app/lib/workflow-api-client.ts b/packages/web/app/lib/workflow-api-client.ts index ce17c9eca4..301d3753e6 100644 --- a/packages/web/app/lib/workflow-api-client.ts +++ b/packages/web/app/lib/workflow-api-client.ts @@ -37,6 +37,7 @@ import type { import { getPaginationDisplay } from './utils'; const MAX_ITEMS = 1000; +const TRACE_VIEWER_BATCH_SIZE = 50; const LIVE_POLL_LIMIT = 10; const LIVE_STEP_UPDATE_INTERVAL_MS = 2000; const LIVE_UPDATE_INTERVAL_MS = 5000; @@ -616,94 +617,48 @@ export function useWorkflowHooks( }; } -// Helper function to exhaustively fetch steps -async function fetchAllSteps( +async function fetchAllEventsForCorrelationId( env: EnvMap, - runId: string -): Promise<{ data: Step[]; cursor?: string }> { - let stepsData: Step[] = []; - let stepsCursor: string | undefined; - while (true) { - const { error, result } = await unwrapServerActionResult( - fetchSteps(env, runId, { - cursor: stepsCursor, - sortOrder: 'asc', - limit: 100, - }) - ); - // TODO: We're not handling errors well for infinite fetches - if (error) { - break; - } - - stepsData = [...stepsData, ...result.data]; - if (!result.hasMore || !result.cursor || stepsData.length >= MAX_ITEMS) { - break; - } - stepsCursor = result.cursor; - } - - return { data: stepsData, cursor: stepsCursor }; -} + correlationId: string +): Promise { + let eventsData: Event[] = []; + let cursor: string | undefined; -// Helper function to exhaustively fetch hooks -async function fetchAllHooks( - env: EnvMap, - runId: string -): Promise<{ data: Hook[]; cursor?: string }> { - let hooksData: Hook[] = []; - let hooksCursor: string | undefined; while (true) { const { error, result } = await unwrapServerActionResult( - fetchHooks(env, { - runId, - cursor: hooksCursor, + fetchEventsByCorrelationId(env, correlationId, { + cursor, sortOrder: 'asc', - limit: 100, + limit: 1000, }) ); if (error) { break; } - hooksData = [...hooksData, ...result.data]; - if (!result.hasMore || !result.cursor || hooksData.length >= MAX_ITEMS) { + eventsData = [...eventsData, ...result.data]; + if (!result.hasMore || !result.cursor || eventsData.length >= MAX_ITEMS) { break; } - hooksCursor = result.cursor; + cursor = result.cursor; } - return { data: hooksData, cursor: hooksCursor }; + return eventsData; } -// Helper function to exhaustively fetch events -async function fetchAllEvents( +async function fetchEventsForCorrelationIds( env: EnvMap, - runId: string -): Promise<{ data: Event[]; cursor?: string }> { - let eventsData: Event[] = []; - let eventsCursor: string | undefined; - while (true) { - const { error, result } = await unwrapServerActionResult( - fetchEvents(env, runId, { - cursor: eventsCursor, - sortOrder: 'asc', - limit: 1000, - }) - ); - - if (error) { - break; - } - - eventsData = [...eventsData, ...result.data]; - if (!result.hasMore || !result.cursor || eventsData.length >= MAX_ITEMS) { - break; - } - eventsCursor = result.cursor; + correlationIds: string[] +): Promise { + if (correlationIds.length === 0) { + return []; } - - return { data: eventsData, cursor: eventsCursor }; + const results = await Promise.all( + correlationIds.map((correlationId) => + fetchAllEventsForCorrelationId(env, correlationId) + ) + ); + return results.flat(); } /** @@ -728,11 +683,15 @@ export function useWorkflowTraceViewerData( const [stepsCursor, setStepsCursor] = useState(); const [hooksCursor, setHooksCursor] = useState(); const [eventsCursor, setEventsCursor] = useState(); + const [stepsHasMore, setStepsHasMore] = useState(false); + const [hooksHasMore, setHooksHasMore] = useState(false); + const [eventsHasMore, setEventsHasMore] = useState(false); + const [isLoadingMoreTraceData, setIsLoadingMoreTraceData] = useState(false); const isFetchingRef = useRef(false); const [initialLoadCompleted, setInitialLoadCompleted] = useState(false); - // Fetch all data for a run + // Fetch first trace page and related events for visible spans. const fetchAllData = useCallback(async () => { if (isFetchingRef.current) { return; @@ -743,42 +702,99 @@ export function useWorkflowTraceViewerData( setAuxiliaryDataLoading(true); setError(null); - const promises = [ - unwrapServerActionResult(fetchRun(env, runId)).then( - ({ error, result }) => { - if (error) { - setError(error); - return; - } - setRun(hydrateResourceIO(result)); - return result; - } - ), - fetchAllSteps(env, runId).then((result) => { - setSteps(result.data.map(hydrateResourceIO)); - setStepsCursor(result.cursor); - }), - fetchAllHooks(env, runId).then((result) => { - setHooks(result.data.map(hydrateResourceIO)); - setHooksCursor(result.cursor); - }), - fetchAllEvents(env, runId).then((result) => { - setEvents(result.data.map(hydrateResourceIO)); - setEventsCursor(result.cursor); - }), + const [runResult, stepsResult, hooksResult, eventsResult] = + await Promise.all([ + unwrapServerActionResult(fetchRun(env, runId)), + unwrapServerActionResult( + fetchSteps(env, runId, { + sortOrder: 'asc', + limit: TRACE_VIEWER_BATCH_SIZE, + }) + ), + unwrapServerActionResult( + fetchHooks(env, { + runId, + sortOrder: 'asc', + limit: TRACE_VIEWER_BATCH_SIZE, + }) + ), + unwrapServerActionResult( + fetchEvents(env, runId, { + sortOrder: 'asc', + limit: TRACE_VIEWER_BATCH_SIZE, + }) + ), + ]); + + if (runResult.error) { + setError(runResult.error); + } else { + setRun(hydrateResourceIO(runResult.result)); + } + + const nextSteps = stepsResult.error + ? [] + : stepsResult.result.data.map(hydrateResourceIO); + const nextHooks = hooksResult.error + ? [] + : hooksResult.result.data.map(hydrateResourceIO); + const initialEvents = eventsResult.error + ? [] + : eventsResult.result.data.map(hydrateResourceIO); + + const correlationIds = [ + ...nextSteps.map((step) => step.stepId), + ...nextHooks.map((hook) => hook.hookId), ]; + const correlationEventsRaw = await fetchEventsForCorrelationIds( + env, + correlationIds + ); + const correlationEvents = correlationEventsRaw.map(hydrateResourceIO); + + setSteps(nextSteps); + setHooks(nextHooks); + const initialCombinedEvents = [...initialEvents, ...correlationEvents]; + setEvents( + Array.from( + new Map( + initialCombinedEvents.map((event) => [String(event.eventId), event]) + ).values() + ) + ); - const results = await Promise.allSettled(promises); + setStepsCursor( + stepsResult.error || !stepsResult.result.hasMore + ? undefined + : stepsResult.result.cursor + ); + setHooksCursor( + hooksResult.error || !hooksResult.result.hasMore + ? undefined + : hooksResult.result.cursor + ); + setEventsCursor( + eventsResult.error || !eventsResult.result.hasMore + ? undefined + : eventsResult.result.cursor + ); + setStepsHasMore(Boolean(!stepsResult.error && stepsResult.result.hasMore)); + setHooksHasMore(Boolean(!hooksResult.error && hooksResult.result.hasMore)); + setEventsHasMore( + Boolean(!eventsResult.error && eventsResult.result.hasMore) + ); + + const settledResults = [runResult, stepsResult, hooksResult, eventsResult]; setLoading(false); setAuxiliaryDataLoading(false); setInitialLoadCompleted(true); isFetchingRef.current = false; - // Just doing the first error, but would be nice to show multiple - const error = results.find((result) => result.status === 'rejected') - ?.reason as Error; - if (error) { - setError(error); - return; + + if (!runResult.error) { + const firstError = settledResults.find((result) => result.error)?.error; + if (firstError) { + setError(firstError); + } } }, [env, runId]); @@ -806,6 +822,144 @@ export function useWorkflowTraceViewerData( [] ); + const loadMoreTraceData = useCallback(async () => { + if ( + isFetchingRef.current || + !initialLoadCompleted || + isLoadingMoreTraceData + ) { + return; + } + if (!stepsHasMore && !hooksHasMore && !eventsHasMore) { + return; + } + + setIsLoadingMoreTraceData(true); + try { + const [nextStepsResult, nextHooksResult, nextEventsResult] = + await Promise.all([ + stepsHasMore + ? unwrapServerActionResult( + fetchSteps(env, runId, { + cursor: stepsCursor, + sortOrder: 'asc', + limit: TRACE_VIEWER_BATCH_SIZE, + }) + ) + : Promise.resolve({ error: null, result: null }), + hooksHasMore + ? unwrapServerActionResult( + fetchHooks(env, { + runId, + cursor: hooksCursor, + sortOrder: 'asc', + limit: TRACE_VIEWER_BATCH_SIZE, + }) + ) + : Promise.resolve({ error: null, result: null }), + eventsHasMore + ? unwrapServerActionResult( + fetchEvents(env, runId, { + cursor: eventsCursor, + sortOrder: 'asc', + limit: TRACE_VIEWER_BATCH_SIZE, + }) + ) + : Promise.resolve({ error: null, result: null }), + ]); + + if (nextStepsResult.error) { + setError(nextStepsResult.error); + } + if (nextHooksResult.error) { + setError(nextHooksResult.error); + } + if (nextEventsResult.error) { + setError(nextEventsResult.error); + } + + const nextSteps = + nextStepsResult.result?.data.map(hydrateResourceIO) ?? []; + const nextHooks = + nextHooksResult.result?.data.map(hydrateResourceIO) ?? []; + const nextEvents = + nextEventsResult.result?.data.map(hydrateResourceIO) ?? []; + + if (nextSteps.length > 0) { + setSteps((prev) => mergeSteps(prev, nextSteps)); + } + if (nextHooks.length > 0) { + setHooks((prev) => mergeHooks(prev, nextHooks)); + } + + const newCorrelationIds = [ + ...nextSteps.map((step) => step.stepId), + ...nextHooks.map((hook) => hook.hookId), + ]; + const correlationEventsRaw = await fetchEventsForCorrelationIds( + env, + newCorrelationIds + ); + const correlationEvents = correlationEventsRaw.map(hydrateResourceIO); + const allNewEvents = [...nextEvents, ...correlationEvents]; + if (allNewEvents.length > 0) { + setEvents((prev) => mergeEvents(prev, allNewEvents)); + } + + const nextStepsHasMore = nextStepsResult.error + ? stepsHasMore + : Boolean(nextStepsResult.result && nextStepsResult.result.hasMore); + const nextHooksHasMore = nextHooksResult.error + ? hooksHasMore + : Boolean(nextHooksResult.result && nextHooksResult.result.hasMore); + const nextEventsHasMore = nextEventsResult.error + ? eventsHasMore + : Boolean(nextEventsResult.result && nextEventsResult.result.hasMore); + + setStepsHasMore(nextStepsHasMore); + setHooksHasMore(nextHooksHasMore); + setEventsHasMore(nextEventsHasMore); + + if (!nextStepsResult.error) { + setStepsCursor( + nextStepsResult.result?.hasMore + ? nextStepsResult.result.cursor + : undefined + ); + } + if (!nextHooksResult.error) { + setHooksCursor( + nextHooksResult.result?.hasMore + ? nextHooksResult.result.cursor + : undefined + ); + } + if (!nextEventsResult.error) { + setEventsCursor( + nextEventsResult.result?.hasMore + ? nextEventsResult.result.cursor + : undefined + ); + } + } finally { + setIsLoadingMoreTraceData(false); + } + }, [ + env, + runId, + initialLoadCompleted, + isLoadingMoreTraceData, + stepsHasMore, + hooksHasMore, + eventsHasMore, + stepsCursor, + hooksCursor, + eventsCursor, + mergeEvents, + mergeHooks, + mergeSteps, + ]); + const pollRun = useCallback(async (): Promise => { if (run?.completedAt) { return false; @@ -959,6 +1113,9 @@ export function useWorkflowTraceViewerData( auxiliaryDataLoading, error, update, + loadMoreTraceData, + hasMoreTraceData: stepsHasMore || hooksHasMore || eventsHasMore, + isLoadingMoreTraceData, }; } diff --git a/workbench/nextjs-turbopack/workflows/96_many_steps.ts b/workbench/nextjs-turbopack/workflows/96_many_steps.ts new file mode 100644 index 0000000000..52ec5d50d9 --- /dev/null +++ b/workbench/nextjs-turbopack/workflows/96_many_steps.ts @@ -0,0 +1,22 @@ +async function runIndexedStep(index: number): Promise<{ index: number }> { + 'use step'; + return { index }; +} + +export async function twoHundredStepsWorkflow() { + 'use workflow'; + + const totalSteps = 200; + const results: number[] = []; + + for (let i = 0; i < totalSteps; i++) { + const result = await runIndexedStep(i); + results.push(result.index); + } + + return { + totalSteps, + firstStep: results[0], + lastStep: results[results.length - 1], + }; +} From ca10e1f4252c992b102ae69d81a09be746aff8e4 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Tue, 24 Feb 2026 13:11:52 -0800 Subject: [PATCH 2/5] [web-shared] Fix trace viewer pagination issue --- .changeset/moody-ghosts-decide.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/moody-ghosts-decide.md diff --git a/.changeset/moody-ghosts-decide.md b/.changeset/moody-ghosts-decide.md new file mode 100644 index 0000000000..f34f9e4573 --- /dev/null +++ b/.changeset/moody-ghosts-decide.md @@ -0,0 +1,5 @@ +--- +"@workflow/web-shared": patch +--- + +Fix traceviewer pagination issues From 614f197874672cb1506c8a61e23769ca5d35e37a Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Tue, 24 Feb 2026 13:13:00 -0800 Subject: [PATCH 3/5] [web-shared] Fix trace viewer pagination issue --- .changeset/moody-ghosts-decide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/moody-ghosts-decide.md b/.changeset/moody-ghosts-decide.md index f34f9e4573..81bac1be6c 100644 --- a/.changeset/moody-ghosts-decide.md +++ b/.changeset/moody-ghosts-decide.md @@ -1,5 +1,6 @@ --- "@workflow/web-shared": patch +"@workflow/web": patch --- Fix traceviewer pagination issues From f820c7aaa8a865128817b784decd94fe40f618a2 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Tue, 24 Feb 2026 14:40:00 -0800 Subject: [PATCH 4/5] [workflow o11y] bump package and fix trace viewer pagination issue --- packages/world-vercel/src/encryption.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index f836d38792..cfaf082cc9 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -106,7 +106,7 @@ export async function fetchRunKey( ); } - const params = new URLSearchParams({ projectId, runId }); + const params = new URLSearchParams({ projectId, runId, deploymentId }); const response = await fetch( `https://api.vercel.com/v1/workflow/run-key/${deploymentId}?${params}`, { From 0bf1d09e6cf28e401265f5c3bc0c4a44ecb57407 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Tue, 24 Feb 2026 14:45:20 -0800 Subject: [PATCH 5/5] [workflow o11y] bump package and fix trace viewer pagination issue --- packages/world-vercel/src/encryption.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index cfaf082cc9..f836d38792 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -106,7 +106,7 @@ export async function fetchRunKey( ); } - const params = new URLSearchParams({ projectId, runId, deploymentId }); + const params = new URLSearchParams({ projectId, runId }); const response = await fetch( `https://api.vercel.com/v1/workflow/run-key/${deploymentId}?${params}`, {