Skip to content

[OPIK-8021] [FE] perf: virtualize the traces table's columns and rows - #7947

Merged
andriidudar merged 5 commits into
mainfrom
andriid/OPIK-8021-traces-table-columns-perf
Aug 24, 2026
Merged

[OPIK-8021] [FE] perf: virtualize the traces table's columns and rows#7947
andriidudar merged 5 commits into
mainfrom
andriid/OPIK-8021-traces-table-columns-perf

Conversation

@andriidudar

@andriidudar andriidudar commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Details

A project with a large feedback-score taxonomy gives the traces table one column per score name, and the resulting cell count blocks the main thread for seconds on every interaction. DataTable now renders only the horizontally visible center columns plus both pinned blocks, with leading/trailing spacer cells carrying the summed width of everything skipped, so total width, minWidth and the scrollbar are unchanged. Row virtualization gets the same treatment for the vertical axis.

  • Column windowing is opt-in per table via a columnVirtualization prop on DataTable: { enabled?, overscan?, getScrollElement? }. getScrollElement is for tables that scroll in their own container instead of the page body. All windowing logic lives in shared/DataTable/columnVirtualization/ (TanStack Virtual useVirtualizer, horizontal: true). DataTable gains three calls to one generic index-based slicer — for the colgroup, the header row and each body row — which is what keeps column-to-cell alignment correct. Nothing else in DataTable branches on virtualization.
  • Row windowing gets a matching rowVirtualization prop ({ enabled? }) on DataTableVirtualBody, so a table can toggle it on/off without swapping the TableBody component — the virtualizer is constructed once with enabled passed straight to useVirtualizer, and falls back to rows.map(renderRow) when off.
  • TracesSpansTab decides both axes independently rather than gating one on the other: each axis windows once its own count crosses a threshold (50 columns / 25 rows), or once total cell count (columns × rows) crosses the combined budget those thresholds imply (1250). A floor of 15 keeps a trivially small axis from being windowed for no benefit. This fixes a real gap: a table with e.g. 1000 columns and only 10 rows previously rendered every column uncapped, because the old logic required both axes to cross their threshold together.
  • DataTableSkeletonBody is windowed too, and switched from getAllLeafColumns() to getVisibleLeafColumns(); it previously rendered one cell per defined column per skeleton row, hidden columns included, which mismatched the colgroup.
  • A single shared axis guard (observeOwnAxisOffset in virtualizerOptions.ts) wraps TanStack's own scroll observer and drops repeated offsets, so a sideways scroll doesn't wake the row virtualizer and a vertical scroll doesn't wake the column virtualizer — this also removes wasted work in the five other tables already using row virtualization.
  • Guards that refuse to activate regardless of config: more than one header group, active grouping, renderCustomRow, or no resolvable scroll container — each renders every column as before, since those paths emit cell counts a windowed colgroup cannot match.
  • A table that has not opted in pays nothing: the virtualizer is constructed with enabled: false, so it resolves no scroll element, attaches no listeners and takes no measurements, and the slicer returns the original array untouched.
  • Works around a backend bug (OPIK_8056, filed separately): the traces list endpoint joins experiment membership and returns a duplicate row per experiment a trace belongs to, overflowing the requested page size and producing React duplicate-key warnings. TracesSpansTab de-dupes data.content by id until the endpoint is fixed.

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves #
  • OPIK-8021

AI-WATERMARK

AI-WATERMARK: yes

  • If yes:
    • Tools: Claude Code
    • Model(s): Claude Opus 5
    • Scope: full implementation
    • Human verification: code review + manual browser testing, with before/after measurements taken interactively in Chrome DevTools

Testing

Commands, from apps/opik-frontend:

  • npm run lint — clean
  • npm run typecheck — clean

Manual verification in Chrome against a local dev server on a reproduction project, toggling the props and column/row counts directly.

Scenarios validated:

  • Column windowing at ~330 selected columns / 100 rows: table renders ~10-20 header/body columns instead of all of them, with correct leading/trailing spacer widths that sum to the unwindowed table width, at every horizontal scroll offset (start, mid-scroll, right edge).
  • The lopsided case this PR's second commit fixes: ~330 columns with only 10 rows (size=10) — column axis windows down to 12 rendered header cells (DOM 30,000+ → ~3,500) while the row axis correctly stays unwindowed (only 10 rows, nothing to gain). Confirms the two axes no longer block each other.
  • Crossing the enabled threshold in both directions at runtime through the column picker while scrolled horizontally and vertically, sampled every animation frame — scroll position, widths and cell counts hold (this is what the initialOffset/initialRect seeding on the virtualizer fixes: without it, a disabled virtualizer keeps no offset or viewport, so the first enabled render started at offset 0 with a zero-width viewport and the container lost its horizontal scroll position).
  • Row height switched between Compact/Medium/Detailed with windowing both on and off — row heights and scrollHeight update correctly, row window resizes.
  • Skeleton body under CPU throttling: skeleton cell count matches the windowed colgroup, not the full column count.
  • Duplicate-row workaround: confirmed the traces list can return the same trace id twice when it belongs to 2+ experiments (isolated via the exclude query param — omitting exclude=["experiment"] reproduces it); uniqBy on id removes the duplicate before render and the key warning disappears.
  • Tab and page transitions (cold and hot) measured for regressions: no meaningful blocking-time regression from the base case.

Not run: no automated tests were added — the repo has no DataTable test suite. Row virtualization on the other five tables that use DataTableVirtualBody was not re-verified by hand beyond compiling and the rowVirtualization prop defaulting to enabled: true (unchanged behavior when the prop is omitted).

Documentation

N/A — no user-facing documentation changes; the new props and their guards are documented by types and comments in shared/DataTable/.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🌐 typecheck — frontend Whole-project tsc type check 30.25s
🌐 eslint — frontend Lint + autofix JS/TS 4.57s
Total (2 ran) 34.82s
⏭️ 41 skipped (no matching files changed)
Hook Description Result
🐍 trim trailing whitespace — python sdk Strip trailing whitespace ⏭️
🐍 fix end of files — python sdk Ensure files end in a newline ⏭️
🐍 ruff — python sdk Lint + autofix Python (ruff) ⏭️
🐍 ruff-format — python sdk Format Python code (ruff) ⏭️
🐍 mypy — python sdk Static type check ⏭️
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
⚓ helm-docs Regenerate Helm chart README ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
☕ spotless — java backend Format Java code ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows ⏭️

Comment thread apps/opik-frontend/src/shared/DataTable/virtualizerOptions.ts Outdated
Comment thread apps/opik-frontend/src/shared/DataTable/virtualizerOptions.ts Outdated
Comment thread apps/opik-frontend/src/shared/DataTable/virtualizerOptions.ts Outdated
Comment thread apps/opik-frontend/src/shared/DataTable/DataTable.tsx Outdated
Comment thread apps/opik-frontend/src/shared/DataTable/DataTable.tsx
andriidudar and others added 2 commits August 24, 2026 09:09
A project with a large feedback-score taxonomy gives the traces table one
column per score name; with enough columns and rows this blocks the main
thread for seconds on every interaction. DataTable now renders only the
horizontally visible center columns plus both pinned blocks, with leading and
trailing spacer cells carrying the summed width of what was skipped, so total
width, minWidth and the scrollbar stay unchanged. Row virtualization is
similarly capped so it only activates once a table is actually large.

Both axes are opt-in per table via columnVirtualization / rowVirtualization
props on DataTable, following the same pattern as TableBody={DataTableVirtualBody}.
TracesSpansTab enables both together once the table exceeds 50 columns and 25
rows.

Also works around a backend issue (OPIK_8056) where the traces list endpoint
returns duplicate rows for traces that belong to more than one experiment,
which otherwise produced React key warnings; rows are de-duplicated by id
until the endpoint is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both axes shared one enabled flag gated by columns > 50 AND rows >= 25. A
table with 1000 columns and 10 rows never crossed the row floor, so the whole
combined condition stayed off and rendered every column uncapped — the axis
that actually needed windowing was blocked by the other axis's count.

Each axis now decides on its own count, or on total cell count (columns ×
rows) crossing the same budget the two thresholds implied together, so a
lopsided table windows the axis that needs it regardless of the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andriidudar
andriidudar force-pushed the andriid/OPIK-8021-traces-table-columns-perf branch from 60c03c1 to e37a75f Compare August 24, 2026 07:18
Addresses PR review comment on columnWindow.ts.
@andriidudar
andriidudar marked this pull request as ready for review August 24, 2026 07:34
@andriidudar
andriidudar requested a review from a team as a code owner August 24, 2026 07:34
@CometActions

CometActions commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

This change looks worth a test.

The Logs traces table now renders a window of rows and columns once it passes 25 rows / 50 columns / 3000 cells, with spacer cells standing in for what is skipped. Every existing trace-explore spec seeds at most three traces (smoke seeds 3, the filterable-traces fixture 3, trace-delete 1-2), so none of them ever crosses a threshold and none exercises the windowed path at all; traces.configure-columns has no spec either. A window that dropped or misaligned a row or column would only show up above ~26 traces, which nothing in the estate currently renders.

Would target traces.list-traces, traces.configure-columns.

What it would check
  1. Seed ~40 traces in one project, open Logs, and scroll the table top to bottom confirming every seeded trace id appears exactly once - no gaps or repeats at the window seams
  2. Enable enough extra columns (metadata / feedback-score columns) to force horizontal windowing, scroll right, and confirm each header still lines up with its own cells and the pinned left column stays put
  3. With ~40 traces, select all and check the selection count and a bulk delete apply to rows outside the rendered window, not just the visible ones
  4. Compare a table just under the threshold (25 traces) against one just over (26): same rows, same ordering, same column content in both
  5. Sort and filter while scrolled part-way down and confirm the table re-renders from the top with the correct rows rather than a stale window

Deploying a test environment for this PR and exploring it — results will follow in a comment.

areas: threads, traces
Run

Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review.

Re-checked after a push on 24 Aug 08:30 UTC.

@CometActions CometActions added the test-environment Deploy Opik adhoc environment label Aug 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.2.37-6395 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch andriid/OPIK-8021-traces-table-columns-perf
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

Comment thread apps/opik-frontend/src/shared/DataTable/DataTable.tsx
Comment thread apps/opik-frontend/src/shared/DataTable/DataTableSkeletonBody.tsx Outdated
Comment thread apps/opik-frontend/src/v2/pages/LogsPage/TracesSpansTab/TracesSpansTab.tsx Outdated
…tion

Raise the cell-count budget from 1250 to 3000 so the default view
(~15 columns x 100 rows = 1500) stays fully rendered, and collapse the
two independently-computed axis flags into one shared enabled decision
so the table is windowed or not as a whole, rather than deciding each
axis separately.
@CometActions

Copy link
Copy Markdown
Collaborator

Test environment deployment failed

The deployment encountered an error. Please check the deployment logs for details.

Addresses PR review: DataTableSkeletonBody recomputed
getVisibleLeafColumns()/sliceColumnWindow() every render; memoize it.
Also adds empty-input and out-of-range-index test cases for
sliceColumnWindow.
Comment thread apps/opik-frontend/src/shared/DataTable/DataTableSkeletonBody.tsx

@aadereiko aadereiko left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, LGTM!

@andriidudar
andriidudar merged commit 3594a70 into main Aug 24, 2026
15 checks passed
@andriidudar
andriidudar deleted the andriid/OPIK-8021-traces-table-columns-perf branch August 24, 2026 10:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants