Skip to content

[OPIK-7249] [BE][FE] fix: cheap existence probe for Logs empty state#7378

Merged
thiagohora merged 3 commits into
mainfrom
YarivHashaiComet/OPIK-7249-logs-existence-probe-scan
Jul 9, 2026
Merged

[OPIK-7249] [BE][FE] fix: cheap existence probe for Logs empty state#7378
thiagohora merged 3 commits into
mainfrom
YarivHashaiComet/OPIK-7249-logs-existence-probe-scan

Conversation

@YarivHashaiComet

@YarivHashaiComet YarivHashaiComet commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Details

Fixes the full-project ClickHouse scan reported in #7298 (same class as #6148 / #6949). The project Logs page decides the empty state ("show Log your first trace onboarding, or the table?") with an auxiliary size=1 list probe (useTracesOrSpansList / useThreadList) that has no time bounds — and even for one row the backend builds the full trace/span/cost/feedback aggregation over the entire project before LIMIT 1. It runs on every Logs page load (Traces, Spans and Threads tabs), ignores the selected time range, and on large projects is a multi-hundred-MiB to multi-GiB, multi-second scan.

Fix — add a minimal existence endpoint per entity and use it for the probe:

  • BE: GET /v1/private/traces/exists (+ a thread_only flag → AND thread_id != '' for the Threads empty state) and GET /v1/private/spans/exists, each a project-scoped SELECT 1 … LIMIT 1. Deliberately minimal — no filters/search/from_time/to_time surface — so the query is always a primary-key-prunable LIMIT 1 with no full-project COUNT fallback path (per review: those params have no caller today and would only add a latent full-O(project) fallback). Shared ExistenceResponse DTO.
  • FE: new useTracesExist / useSpansExist / useTracesOrSpansExist hooks; the Traces, Spans and Threads tabs derive hasProjectData from the existence endpoint (threads via thread_only) instead of a size=1 list query.
  • Turns a whole-project scan into a single-row LIMIT 1 lookup.
  • Not done by forwarding from_time/to_time into the probe: that breaks the empty state (data older than the window would show onboarding) and revives the id↔event-time mismatch that got [OPIK-6385] [FE] Pass thread time range to ThreadDetailsPanel traces query #6949 reverted in [OPIK-6385] [FE] revert: thread time range in ThreadDetailsPanel traces query #7007.

Change checklist

  • User facing
  • Documentation update

Issues

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 4.8 (1M context)
  • Scope: Root-cause + prod query-log benchmark, full implementation (BE existence endpoints + DAO/service, FE hooks + tab wiring), and backend integration tests.
  • Human verification: Author directed the investigation and approved the approach; reviewed the diff; integration tests pass under testcontainers and the query cost/result-equivalence was validated against prod ClickHouse.

Testing

  • Backend mvn compile — clean.
  • New backend integration tests (green under testcontainers, run individually):
    • mvn test -Dtest='TracesResourceTest$TracesExistence' — existence true, empty-project false, and thread_only distinguishing thread projects from trace-only projects.
    • mvn test -Dtest='SpansResourceTest$SpansExistence' — span existence true, and false for a project that only has traces (span existence ≠ trace existence).
  • eslint (changed FE files, --max-warnings=0) and tsc --noEmit — clean, no new type errors.
  • Query cost + result-equivalence validated on a large real project: single-row LIMIT 1 read (~24–42K rows / ~8 ms) vs. the previous whole-project scan (millions of rows / hundreds of ms – multiple seconds).

Documentation

No user-facing docs needed (internal empty-state/perf fix).

Follow-up: regenerate the OpenAPI spec + Fern SDKs for the two new operations (tracesExist, spansExist) via scripts/generate_openapi.sh (needs the fern CLI) — not committed here to avoid a large partial/desynced generated diff.

@YarivHashaiComet
YarivHashaiComet requested a review from a team as a code owner July 7, 2026 09:17
@github-actions github-actions Bot added java Pull requests that update Java code Frontend Backend tests Including test files, or tests related like configuration. typescript *.ts *.tsx labels Jul 7, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🌐 typecheck — frontend Whole-project tsc type check 43.93s
☕ spotless — java backend Format Java code 5.97s
🌐 eslint — frontend Lint + autofix JS/TS 4.74s
Total (3 ran) 54.64s
⏭️ 38 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 ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
📘 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 ⏭️

Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java Outdated
Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java Outdated
Comment on lines +405 to +409
@GET
@Path("/exists")
@Operation(operationId = "spansExist", summary = "Check whether a project has spans", description = "Returns whether the project has at least one span matching the given scope. Cheap existence probe (LIMIT 1) used to drive empty-state decisions without scanning or aggregating the whole project.", responses = {
@ApiResponse(responseCode = "200", description = "Span existence", content = @Content(schema = @Schema(implementation = ExistenceResponse.class)))
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing API docs for endpoint

According to the PR description, the new GET /v1/private/spans/exists and GET /v1/private/traces/exists routes weren't regenerated into the OpenAPI/Fern outputs, so the shared API contract and published SDKs/docs don't expose them to clients — should we rerun scripts/generate_openapi.sh and commit the generated files?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/priv/SpansResource.java
around lines 405-409 (the new `@GET @Path("/exists")` operation), note that this
endpoint is implemented but not included in the published OpenAPI/SDK contract because
`scripts/generate_openapi.sh` was not run. Regenerate the OpenAPI spec and Fern/SDK
artifacts using the repo’s script(s), then commit the updated generated outputs (the
files under the OpenAPI/SDK output directories) so the new `/v1/private/spans/exists`
operation appears in the shared API contract. Also verify the corresponding `GET
/v1/private/traces/exists` endpoint is present in the generated contract and adjust
generation config if needed.

Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/SpanDAO.java Outdated
@YarivHashaiComet
YarivHashaiComet force-pushed the YarivHashaiComet/OPIK-7249-logs-existence-probe-scan branch from ccfb663 to 8a99471 Compare July 7, 2026 11:49
@YarivHashaiComet
YarivHashaiComet requested review from a team as code owners July 7, 2026 17:10
@github-actions github-actions Bot added documentation Improvements or additions to documentation Infrastructure labels Jul 7, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Backend Tests - Integration Group 11

310 tests  +29   310 ✅ +29   10m 49s ⏱️ +58s
 26 suites  -  1     0 💤 ± 0 
 26 files    -  1     0 ❌ ± 0 

Results for commit 5b71cdd. ± Comparison against base commit 8428b81.

This pull request removes 16 and adds 45 tests. Note that renamed tests count towards both.
com.comet.opik.api.resources.v1.priv.DatasetExperimentE2ETest$FilterDatasetsByExperimentWith ‑ when__filteringByDatasetsWithExperimentsAfterAnExperimentIsDeleted__thenShouldReturnTheDatasetWithExperiments
com.comet.opik.api.resources.v1.priv.DatasetExperimentE2ETest$FilterDatasetsByExperimentWith ‑ when__filteringByDatasetsWithExperimentsAfterDeletingExperimentsButDatasetHasMore__thenShouldReturnTheDatasetWithExperiments
com.comet.opik.api.resources.v1.priv.DatasetExperimentE2ETest$FilterDatasetsByExperimentWith ‑ when__filteringByDatasetsWithExperiments__thenShouldReturnTheDatasetWithExperiments
com.comet.opik.api.resources.v1.priv.DatasetsResourceProjectScopedTest ‑ createDatasetWithExistingProjectName
com.comet.opik.api.resources.v1.priv.DatasetsResourceProjectScopedTest ‑ createDatasetWithNonExistingProjectId
com.comet.opik.api.resources.v1.priv.DatasetsResourceProjectScopedTest ‑ createDatasetWithNonExistingProjectName
com.comet.opik.api.resources.v1.priv.DatasetsResourceProjectScopedTest ‑ createDatasetWithProjectId
com.comet.opik.api.resources.v1.priv.DatasetsResourceProjectScopedTest ‑ findDatasetsByProjectId
com.comet.opik.api.resources.v1.priv.DatasetsResourceProjectScopedTest ‑ putDatasetItemsWithProjectNameScopesDatasetToProject
com.comet.opik.api.resources.v1.priv.DatasetsResourceProjectScopedTest ‑ putDatasetItems__datasetCreatedWithoutProjectName__itemsReturnedOnGet
…
com.comet.opik.api.resources.v1.priv.DashboardsResourceFindProjectDashboardsTest ‑ findProjectDashboardsByNameSearch
com.comet.opik.api.resources.v1.priv.DashboardsResourceFindProjectDashboardsTest ‑ findProjectDashboardsNoFiltersReturnsAll
com.comet.opik.api.resources.v1.priv.DashboardsResourceFindProjectDashboardsTest ‑ findProjectDashboardsWithDefaultParameters
com.comet.opik.api.resources.v1.priv.DashboardsResourceFindProjectDashboardsTest ‑ findProjectDashboardsWithEmptyResult
com.comet.opik.api.resources.v1.priv.DashboardsResourceFindProjectDashboardsTest ‑ findProjectDashboardsWithFilters(Function, Function)[1]
com.comet.opik.api.resources.v1.priv.DashboardsResourceFindProjectDashboardsTest ‑ findProjectDashboardsWithFilters(Function, Function)[2]
com.comet.opik.api.resources.v1.priv.DashboardsResourceFindProjectDashboardsTest ‑ findProjectDashboardsWithFilters(Function, Function)[3]
com.comet.opik.api.resources.v1.priv.DashboardsResourceFindProjectDashboardsTest ‑ findProjectDashboardsWithPagination
com.comet.opik.api.resources.v1.priv.DashboardsResourceFindProjectDashboardsTest ‑ findProjectDashboards__whenSortingByValidFields__thenReturnDashboardsSorted(Comparator, SortingField)[1]
com.comet.opik.api.resources.v1.priv.DashboardsResourceFindProjectDashboardsTest ‑ findProjectDashboards__whenSortingByValidFields__thenReturnDashboardsSorted(Comparator, SortingField)[2]
…

♻️ This comment has been updated with latest results.

@thiagohora thiagohora left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could /traces/exists and /spans/exists ship as a minimal probeproject_id/project_name + thread_only only — instead of mirroring the full find contract (filters, search, from_time/to_time)?

The two current consumers (ThreadsTab, TracesSpansTab empty-state) only ever pass project scope + thread_only + source/visibility; there's no backend-internal or SDK caller. So the filter/search/time-range params have zero callers today, which means:

  • Correctness: they only exist to feed the canUseCheapExists gate + COUNT fallback — the source of the two issues here (experiment_filters silently dropped on the cheap path; the fallback COUNT has no thread_only clause, so thread_only=true + an aggregate filter counts across all threads). Both are unreachable in prod today but latent for any future/direct caller.
  • Performance: the cheap win is entirely from LIMIT 1 on project scope. As soon as a filter trips the fallback you hit a full O(project) dedup COUNT — ~15s / ~2.5 GiB on a ~19M-trace project vs ~10ms for the probe.

Trimming to the minimal probe can't break any existing consumer and removes both the risk and the dead surface. Add filters back only when a concrete caller needs them?

AI-WATERMARK: yes

@YarivHashaiComet
YarivHashaiComet force-pushed the YarivHashaiComet/OPIK-7249-logs-existence-probe-scan branch from 5b71cdd to 3e95fc0 Compare July 8, 2026 09:29
@github-actions github-actions Bot removed documentation Improvements or additions to documentation Infrastructure labels Jul 8, 2026
Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java Outdated
Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/SpanDAO.java
The project Logs page decided whether to show the "log your first trace"
onboarding by firing an auxiliary size=1 list probe (useTracesOrSpansList /
useThreadList). Even for a single row the backend builds the full
trace/span/cost/feedback aggregation across the entire project before
LIMIT 1 -- a multi-GiB, multi-second ClickHouse scan on large projects,
fired on every Logs page load (Traces, Spans and Threads tabs) and ignoring
the selected time range. Same class as #6148 / #6949.

Add a minimal existence endpoint per entity and use it for the probe:

- BE: GET /v1/private/traces/exists (+thread_only) and /v1/private/spans/exists,
  each a project-scoped SELECT 1 ... LIMIT 1. Deliberately minimal -- no
  filters/search/time-range surface -- so the query is always a
  primary-key-prunable LIMIT 1 with no full-project COUNT fallback.
- FE: useTracesExist / useSpansExist / useTracesOrSpansExist hooks; the
  Traces, Spans and Threads tabs derive hasProjectData from the existence
  endpoint (threads via thread_only) instead of a size=1 list query.

Turns a whole-project scan into a single-row LIMIT 1 lookup. Not done by
forwarding from_time/to_time into the probe: that breaks the empty-state
(data older than the window would show onboarding) and revives the
id<->event-time mismatch that got #6949 reverted in #7007.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@YarivHashaiComet
YarivHashaiComet force-pushed the YarivHashaiComet/OPIK-7249-logs-existence-probe-scan branch from 3e95fc0 to 3a95a38 Compare July 8, 2026 13:45
Comment thread apps/opik-frontend/src/api/traces/useSpansExist.ts
Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/SpanDAO.java
thiagohora
thiagohora previously approved these changes Jul 8, 2026

@thiagohora thiagohora left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving — the design feedback was fully addressed: /traces/exists and /spans/exists are now minimal probes (project_id/project_name + thread_only), canUseCheapExists and the COUNT fallback are gone, and both EXISTS templates are stripped to project scope. That structurally removes the two correctness bugs from the previous round.

Leaving a few non-blocking follow-ups inline — the rate-limit gap (both probes are currently unthrottled) and the dropped source=sdk scope are the two worth a look before merge.

AI-WATERMARK: yes

Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java Outdated
- back the thread probe with trace_threads (PK-prunable) instead of
  traces WHERE thread_id != '', which the sort key + bloom index can't prune
- define tracesExist/spansExist rate-limit buckets in config.yml so the
  @ratelimited probes are actually throttled
- restore the source=sdk scope on the probes (with the ENUM_LEGACY unknown
  fallback) so the Logs empty state matches the sdk-scoped list again
- add project_id-form and source-scoping tests for traces and spans

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java
Comment thread apps/opik-frontend/src/v2/pages/LogsPage/TracesSpansTab/TracesSpansTab.tsx Outdated
The round-2 source=sdk restoration left the round-1 'source-agnostic' comment in TracesSpansTab, contradicting the code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thiagohora
thiagohora merged commit 345ee93 into main Jul 9, 2026
79 of 81 checks passed
@thiagohora
thiagohora deleted the YarivHashaiComet/OPIK-7249-logs-existence-probe-scan branch July 9, 2026 14:52
AndreiCautisanu added a commit that referenced this pull request Jul 10, 2026
Generic regression gating the Logs empty-vs-populated decision the
existence probe (#7378) governs: empty project shows the onboarding
empty-state, a project with one seeded SDK trace shows the traces table.
No-behavior-delta perf fix, so the gate is deliberately generic, not
repro-specific. Adds emptyState accessors + waitForEmptyOrTable to the
Logs POM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend Frontend java Pull requests that update Java code tests Including test files, or tests related like configuration. typescript *.ts *.tsx

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: MetricsTab trace existence probe omits time range and causes full project scan

3 participants