[OPIK-8021] [QA] Proposed e2e specs for the virtualized traces table (from #7947 exploration) - #7970
Conversation
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>
Addresses PR review comment on columnWindow.ts.
…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.
Addresses PR review: DataTableSkeletonBody recomputed getVisibleLeafColumns()/sliceColumnWindow() every render; memoize it. Also adds empty-input and out-of-range-index test cases for sliceColumnWindow.
…lumn windows Two specs proposed by the release QA side flow from exploratory testing of #7947, which virtualizes the traces table's rows and columns. - Row window: 60 seeded traces, swept top to bottom, asserting the rendered rows are a contiguous slice of the API's ordering at every offset, no id in the DOM twice, and every trace seen exactly once across the sweep. - Column window: 45 distinct feedback-score names put the table past 60 columns; with every column turned on from the Columns picker, each rendered row's cell sequence and the colgroup must match the header position for position at five horizontal offsets, and the pinned select column must stay flush with the container's left edge. Adds a `windowedTraces` / `wideTracesTable` fixture pair (batch REST seeding, explicit trace teardown), the window-reading helpers on LogsPage, and batch trace/feedback-score writes on the backend client. Taxonomy updated: traces.configure-columns flips to covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📋 PR Linter Failed❌ Missing Section. The description is missing the ❌ Missing Section. The description is missing the ❌ Missing Section. The description is missing the ❌ Missing Section. The description is missing the ❌ Missing Section. The description is missing the |
⏱️ pre-commit per-hook timingNo linted files changed — nothing to run. ⏭️ 43 skipped (no matching files changed)
|
| 'Scrolling a windowed traces table renders every seeded trace exactly once, in the API order', | ||
| { tag: ['@cap:traces.list-traces'] }, |
There was a problem hiding this comment.
Misleading test title overstates coverage
The test title claims each seeded trace renders exactly once, but its assertions only enforce per-window uniqueness and seen.keys() coverage, so overlapping sampled windows can still render a trace multiple times — should we rename it to reflect coverage/order or add a cross-window count assertion?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`tests_end_to_end/e2e/tests/trace-explore/traces-table-virtualization.spec.ts` around
lines 26-27, rename the traces row virtualization test to describe that scrolling covers
every seeded trace in API order. The current “exactly once” wording is inaccurate
because overlapping windows are allowed and the assertions never require each trace’s
cross-window count to equal one; preserve the existing coverage, uniqueness-per-window,
and ordering checks.
| const header = await logs.readHeaderColumnSequence(); | ||
| const rows = await logs.readRenderedRowColumnSequences(); | ||
| const colgroup = await logs.countColgroupColumns(); | ||
|
|
||
| expect(rows.length, `scrollLeft=${left}: no rows rendered`).toBeGreaterThan(0); | ||
| expect( | ||
| colgroup, | ||
| `scrollLeft=${left}: the colgroup declares a different number of columns than the header renders`, | ||
| ).toBe(header.length); |
There was a problem hiding this comment.
Wrong spacer widths pass alignment test
The column-window assertion ignores spacer and rendered-column widths across the header, body, and colgroup, so a nonzero-width aria-hidden spacer can preserve the __spacer position while shifting cells under the wrong headers and still pass — should we expose and assert each width at every sampled offset?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`tests_end_to_end/e2e/tests/trace-explore/traces-table-virtualization.spec.ts` around
lines 151-159, strengthen the `Check header, cell and colgroup alignment across the
width` assertions: comparing normalized sequences and column counts does not detect
incorrect spacer or rendered-column widths. Expose the rendered spacer/column widths
through the relevant `LogsPage` helpers, then assert at every sampled scroll offset that
corresponding header, body-cell, spacer, and `<col>` widths match, including the
`aria-hidden` spacer’s width.
| async readHeaderColumnSequence(): Promise<string[]> { | ||
| return this.page | ||
| .locator('thead tr th') | ||
| .evaluateAll( | ||
| (cells, spacer) => cells.map((cell) => cell.getAttribute('data-header-id') ?? spacer), | ||
| LogsPage.COLUMN_SPACER, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Every rendered row's column sequence, in the same encoding as | ||
| * `readHeaderColumnSequence` — so the two are directly comparable, which is |
There was a problem hiding this comment.
Repeated table checks lack trace steps
readHeaderColumnSequence() and readRenderedRowColumnSequences() call locator evaluateAll directly, so failures from repeated offset checks or scrollTableTo()'s expect.poll samples are unlabeled — should we wrap each body in a descriptive test.step(...) and return that callback, as .agents/skills/writing-e2e-tests/SKILL.md and conventions.md require?
Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
tests_end_to_end/e2e/pom/logs.page.ts around lines 519-525, and in the adjacent
readRenderedRowColumnSequences method through line 549, update both table-sequence
helpers to follow the page-object test-step convention. Wrap each locator evaluateAll
operation in a descriptive test.step callback and return the callback, using labels that
identify whether the header or rendered-row column sequence check failed.
| rows: Array.from(document.querySelectorAll('tr[data-row-id]')).flatMap((row) => { | ||
| const traceId = row.getAttribute('data-row-id') ?? ''; | ||
| const cell = row.querySelector(`[data-cell-id="${traceId}_select"]`); | ||
| return cell ? [{ traceId, ...describe(cell) }] : []; | ||
| }), |
There was a problem hiding this comment.
Missing pinned cells pass silently
readPinnedSelectColumnGeometry uses flatMap to drop rendered tr[data-row-id] rows when [data-cell-id="${traceId}_select"] is missing, so geometry.rows.length > 0 can pass while the maximum-scroll pinned-column check skips the missing cell — should we return a record/error for every rendered row and assert its cardinality against the rendered trace rows?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`tests_end_to_end/e2e/pom/logs.page.ts` around lines 600-604, fix
`readPinnedSelectColumnGeometry` so it does not use `flatMap` to silently discard
rendered rows whose `select` cell is missing. Return a geometry record or explicit
missing-cell error for every `tr[data-row-id]`, and update the associated validation to
assert that the returned row count matches the rendered trace-row count before checking
pinned geometry.
| if (!shouldLeaveArtifacts(testInfo)) { | ||
| try { | ||
| await backendClient.deleteTraces(orderedIds); | ||
| } catch (err) { | ||
| console.warn(`[windowedTraces fixture] delete warning for ${project.name}:`, err); | ||
| } | ||
| } |
There was a problem hiding this comment.
Setup failures leak persisted traces
When createTracesBatch partially succeeds or waitForTracesListed fails, execution skips use(ref) and leaves accepted trace IDs persisted — should we use a failure-safe try/finally to track partial batches, catch/log cleanup errors, and add an E2E regression test for both paths?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
tests_end_to_end/e2e/fixtures/windowed-traces.fixture.ts around lines 159-165, refactor
the `windowedTraces` fixture setup, `await use(ref)`, and teardown into a failure-safe
`try/finally`. Track every trace ID successfully submitted, including IDs from partially
accepted batch chunks, and delete those IDs in the `finally` block while catching and
logging cleanup errors so the original setup or test failure remains reported. Add or
update an E2E regression test to force batch or polling setup failure and verify
accepted traces are removed.
| renderRow={renderRow} | ||
| renderNoData={renderNoData} | ||
| showLoadingOverlay={showLoadingOverlay} | ||
| rowVirtualization={rowVirtualization} |
There was a problem hiding this comment.
Default tables ignore row virtualization
When TableBody is the default DataTableBody, it accepts rowVirtualization but always renders table.getRowModel().rows.map(renderRow), so DataTable callers using rowVirtualization={{ enabled: true }} without DataTableVirtualBody render every row — should we select DataTableVirtualBody automatically?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-frontend/src/shared/DataTable/DataTable.tsx` around lines 521-527, fix the
`showSkeleton`/`TableBody` rendering logic so `rowVirtualization.enabled` actually
virtualizes rows when the default body is used. Automatically select the
virtualization-capable body (such as `DataTableVirtualBody`) or otherwise route
rendering through it, while preserving any explicitly supplied custom `TableBody`
behavior.
| export const isColumnSpacer = (item: unknown): item is ColumnSpacer => | ||
| (item as ColumnSpacer | null)?.isColumnSpacer === true; |
There was a problem hiding this comment.
Malformed spacers produce invalid table widths
isColumnSpacer returns true for any non-null value with isColumnSpacer: true, so malformed values reach DataTable and ColumnSpacerCell as invalid cell.id keys or spacer.size widths such as NaNpx/Infinitypx — should we verify an object with a string id and finite numeric size, and add malformed-shape tests?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-frontend/src/shared/DataTable/columnVirtualization/columnWindow.ts` around
lines 27-28, harden the `isColumnSpacer` type guard so it only returns true for non-null
objects with `isColumnSpacer === true`, a string `id`, and a finite numeric `size`. Add
malformed-shape tests for missing or non-string ids, missing/non-numeric/NaN/Infinity
sizes, and primitive or null inputs, while preserving valid spacer behavior.
| const wrapper = document.querySelector('[data-table-wrapper]'); | ||
| if (!wrapper) { | ||
| throw new Error('LogsPage: no [data-table-wrapper] on the page'); | ||
| } | ||
| let element = wrapper.parentElement; | ||
| while (element) { | ||
| const { overflowY } = getComputedStyle(element); | ||
| if (overflowY === 'auto' || overflowY === 'scroll') return element; |
There was a problem hiding this comment.
Layout changes break virtualization E2E coverage
The virtualization helpers rely on computed overflowY and raw table selectors, so layout or markup changes can select the wrong element or return no sections while specs miss the intended behavior. Should we add descriptive scroll-container/table data-testids and use scoped getByTestId() queries, as .agents/skills/writing-e2e-tests/SKILL.md and .agents/skills/playwright-pom-discovery/SKILL.md require?
Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`tests_end_to_end/e2e/pom/logs.page.ts` around lines 445-452, update
`scrollContainerHandle` and the related virtualization helpers to use stable frontend
test IDs instead of `[data-table-wrapper]`, computed `overflowY`, and raw
document-structure selectors. Add descriptive `data-testid` hooks to
`PageBodyScrollContainer` and the virtualized table/sections in the frontend change,
then use `getByTestId()` and scope header, row, colgroup, and spacer queries from the
table hook so the checks cannot silently target the wrong element or return empty data
after a markup restyle.
| const rows: Array<Span | Trace> = useMemo( | ||
| () => data?.content ?? [], | ||
| () => uniqBy(data?.content ?? [], "id"), | ||
| [data?.content], |
There was a problem hiding this comment.
Logs rows keep arbitrary experiment payload
uniqBy(data?.content ?? [], "id") keeps the first row even though TraceDAO can return multiple experiment rows per trace, so the rendered experiment reference is arbitrary — should we make the backend return one canonical row per trace or deduplicate by an explicit authoritative/version rule before building rows?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/v2/pages/LogsPage/TracesSpansTab/TracesSpansTab.tsx around lines
932-934, the `rows` construction uses `uniqBy` to discard duplicate trace IDs, which can
silently select an arbitrary experiment payload. Fix the underlying `TraceDAO` list
query so its experiments join returns one deterministic, authoritative row per trace
(using an explicit version/order rule or equivalent `LIMIT 1 BY trace_id` logic), then
remove or retain frontend deduplication only as a defensive measure that cannot
determine experiment data. Add or update coverage for traces associated with multiple
experiments to verify the canonical experiment reference is consistently rendered.
| const virtualization = useMemo( | ||
| () => ({ | ||
| enabled: | ||
| (columns.length >= 15 || rows.length >= 15) && | ||
| (columns.length > 50 || rows.length > 25 || cellCount > 3000), | ||
| }), | ||
| [columns.length, rows.length, cellCount], |
There was a problem hiding this comment.
The virtualization gate hardcodes thresholds inline, so its performance policy is hard to discover and tune consistently — should we extract them into named constants or a virtualizationConfig object with brief rationale for each limit?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/v2/pages/LogsPage/TracesSpansTab/TracesSpansTab.tsx around lines
1353-1359, the table virtualization policy uses magic thresholds for axis sizes, column
count, row count, and total cell count (15, 50, 25, 3000). Extract these into
descriptive module-level constants (e.g. MIN_VIRTUALIZATION_AXIS_SIZE,
COLUMN_VIRTUALIZATION_THRESHOLD, ROW_VIRTUALIZATION_THRESHOLD,
MAX_VIRTUALIZED_CELL_COUNT) or a single virtualizationConfig object, with brief comments
explaining the rationale for each limit. Update the virtualization condition(s)
controlling columnVirtualization and rowVirtualization to reference these constants
without changing current behavior.
Where these came from
Exploratory testing of #7947 — "OPIK-8021 [FE] perf: virtualize
the traces table's columns and rows" — against that PR's own deployed
environment (
https://pr-7947.dev.comet.com, serving2.2.37-7947-merge-3045,OSS install, workspace
default). A human worked the flows by hand there first;these specs automate the two that held up.
This PR targets
andriid/OPIK-8021-traces-table-columns-perf, notmain.The behaviour asserted here — a traces table that renders a moving window of
rows and columns instead of all of them — only exists on that branch. On
mainthe table still renders every row, so the specs' own precondition checks
(fewer rows in the DOM than were seeded, spacer cells present in the header
row) would fail. Rebase this onto
mainonce #7947 lands.Written and verified against
4b66cdd4c1d0583a08ffd574fb983371c40bba8b, whichwas #7947's head at the time.
The specs
Both live in
tests_end_to_end/e2e/tests/trace-explore/traces-table-virtualization.spec.ts.1.
@cap:traces.list-traces— row window renders every trace exactly onceSeeds 60 traces, reads the API's ordering for the same project, then sweeps the
page body from top to bottom in 250px steps. At every offset it asserts the
rendered
data-row-ids are a contiguous slice of the API's ordering startingwhere they claim to, and that no id is in the DOM twice at once. Across the whole
sweep, every seeded trace must have been seen, and the final window must be the
tail of the ordering.
The failure this is for is a row silently dropped or repeated at a window seam —
which renders as a plausible-looking table, not as an error.
traces.list-tracesis nominally covered by
trace-explore-smoke.spec.ts, but that spec seeds threetraces and so never crosses the 25-row threshold; this is the only spec that
reaches the windowed code path at all.
Verification: passed.
npx playwright test tests/trace-explore/traces-table-virtualization.spec.tsagainst
https://pr-7947.dev.comet.com, green on 4 consecutive runs (~24s each).2.
@cap:traces.configure-columns— column window keeps everything alignedGives the same 60 traces 45 distinct feedback-score names, which the Logs table
offers as 45 extra columns (67 in total), then turns every column on from the
Columns picker. At five horizontal scroll offsets it reads the header's column
sequence — including the
aria-hiddenspacer cells, which are positions in therow like any other — and asserts every rendered row's cell sequence and the
<colgroup>match it position for position, and that the sampled sequencesare not all identical (i.e. the window actually moved). Finally, at maximum
scrollLeft, the pinned
selectcolumn must beposition: stickyatleft: 0and geometrically flush with the scroll container's left edge, on the header and
on every rendered row.
The failure this is for is a spacer of the wrong width shifting every cell one
column across: correct data under the wrong header, which looks like correct
data.
traces.configure-columnshad no spec at all before this.Verification: passed. Same command and environment, green on 4 consecutive
runs (~16s each).
What this change also touches
fixtures/windowed-traces.fixture.ts(new) —windowedTracesseeds 60traces in one batch write with caller-minted UUIDv7 ids one second apart, then
polls until the project lists exactly those ids and returns them in the API's
order.
wideTracesTablechains from it and adds the 45 score names. Sixtyrather than the forty the exploration used, so "fewer rows in the DOM than were
seeded" has ~20 rows of headroom rather than 2 and does not depend on the
runner's viewport height. Teardown deletes the traces explicitly — deleting a
project does not cascade to them, and
global-teardown's prefix sweep onlyknows about experiments, datasets and projects.
pom/logs.page.ts— window-reading helpers (header/cell/colgroupsequences, scroll extent, a scroll-and-settle step, pinned-column geometry) and
the Columns picker. No raw selectors leak into the spec.
core/backend/client.ts—createTracesBatchandscoreTracesBatch. ThePython bridge creates one trace per call and flushes each time, which is right
for a three-trace fixture and not for sixty traces carrying 2,700 scores.
coverage/taxonomy.yaml— spec added to thetracesarea'sspecs:list;configure-columnsflipped tocovered: true, tier: t2-cuj.list-traceswasalready
covered: trueatt1-smokeand is left as-is.Two things a reviewer should look at
data-testid.PageBodyScrollContainer.tsxrenders the element the table virtualizes against, and its only distinguishing
marks are Tailwind classes. The POM resolves it as the nearest scrollable
ancestor of
[data-table-wrapper]— semantic rather than class-coupled, andthe same lookup the virtualizer makes through React context. The house
conventions say to add a
data-testidin the same change; that is deliberatelynot done here, because the target environment serves a prebuilt frontend
image, so a testid added in this PR could not have been exercised by the run
above. A
data-testid="page-body-scroll-container"would be a strictimprovement and the POM helper should switch to it.
assert.
TracesSpansTab.tsxbuilds onevirtualizationobject for bothaxes and the comment says one decision drives both, but the column axis was
observed not to engage on a wide table with few columns. That is an
optimisation gap, not a correctness bug — nothing renders wrongly — so no
assertion here pins the current behaviour. Worth an author sanity check
independently of this PR.
What was deliberately not written
One candidate of three was dropped: select-all and bulk delete covering rows
outside the rendered window (
traces.delete-traces). It verified by hand, buttraces.delete-tracesis already covered bytrace-delete.spec.ts, and theright shape is probably to raise that spec's seed count past the windowing
threshold rather than add a second delete spec — a call for whoever owns that
file, not for a generated PR.
Verification, in full
Run from
tests_end_to_end/e2e/, againstOPIK_BASE_URL=https://pr-7947.dev.comet.com(
OPIK_DEPLOYMENT=oss, workspacedefault):The whole
trace-explore/directory was run because this change touches a sharedPOM, a shared fixture chain and the shared backend client.