diff --git a/frontend/README.md b/frontend/README.md
index df555fd72..df2909ffa 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -67,7 +67,8 @@ This creates `build.zip` with a top-level `dist/` directory, including `dist/ind
- Dashboards: server-backed lists and detail routes, search/tags/favourites/sort/pagination, ownership, full-document saves with conflict resolution, SQL and PromQL tiles, six variable types, URL time ranges and selections, classic import/export, and explicit migration of old browser-local dashboards. Demo dashboards use independent in-memory state per client.
- Team: native users and one-time passwords, role and privilege management, default OIDC role configuration, provider group mappings and role provenance, and API key creation/copy/deletion. Search and 25-row pagination apply to every tab; authorization stays with the backend.
- Agents: schema-discovered GenAI telemetry datasets, bounded recent records, time ranges, local search, record inspection and trace handoff.
-- Cluster: registered node reachability and resource metrics with independent request states; read-only. Errors: paginated OTLP error spans and trace handoff; classic grouped issue analytics remain unavailable.
+- Cluster: registered node reachability and resource metrics with independent request states; read-only.
+- Errors: grouped issues, full-range frequency analytics, and occurrence inspection for OTLP traces and recognized log severity schemas, plus paginated OTLP error spans and trace handoff. Groups match exact service, error type and message values; issue assignment and resolution are not persisted.
- Workspace setup: `/onboarding` discovers accessible datasets and opens the matching explorer; `/new` explains server scope and provides connection/setup actions. Hosted workspace creation is not exposed by this server.
- Network access: edit the server’s outbound alert-target policy, including domain/CIDR rules, private IP access and TLS verification.
- Keystone and LLM settings: native capability pages with explicit limitations and deployment-dependent classic links. See [native screen scope](docs/prism-parity.md#native-missing-screen-scope-2026-10-12).
diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md
index 17a92c17f..1f37228d6 100644
--- a/frontend/docs/api-contracts.md
+++ b/frontend/docs/api-contracts.md
@@ -213,7 +213,11 @@ Agents uses existing dataset list, schema, metadata and SQL requests. Discovery
Cluster reads `GET /api/v1/cluster/info` and `/api/v1/cluster/metrics` through the shared authenticated client. The first returns node identity, storage/staging paths, reachability and nullable status/error text. Metrics are independent: a denied metrics request does not hide node health. Nonfinite Rust metric values serialized as null display as unavailable per field. Missing cluster routes (404/405) show a deployment-mode explanation; 403 and 401 retain shared permission/session handling. No node deletion is exposed.
-Errors uses the existing trace-list contract with `options: "errorSpans"`, selectable UTC ranges, server sorting and pagination. The selected dataset must advertise `otel-traces`. Failed metadata inspection is distinguished from confirmed incompatibility and can be retried. Dataset/range changes reset pagination and cancel obsolete requests. Trace handoff preserves the captured query bounds. Classic grouped-issue and frequency endpoints are absent and are not called.
+Errors aggregates grouped issues and frequency through the existing `POST /api/v1/query` contract. Dataset metadata and schema determine the trace or log interpretation and the time partition. OTLP spans are deduplicated before aggregation; log occurrences count severity-matched records. Exact service, error type and message values identify a group, with SQL NULL kept distinct from literal display placeholders. Aggregates cover the full captured interval independently of paginated issue and occurrence lists. SQL enforces precise half-open bounds inside a minute-expanded transport envelope.
+
+Trace type and message come from one deterministic representative row, preferring populated exception events, so different events cannot form an invented pair. Logs recognize textual severity aliases, exact OTLP severity codes 17–24 and Pino/Bunyan `level` codes 50/60; `msg` is a recognized message alias. The log SQL handoff returns full stored records, with internal query metadata retained only in sample mode. See [the Errors runbook](error-analytics.md) for alias behavior, counting rules and real-server tests.
+
+The Error spans view retains the native trace-list contract with `options: "errorSpans"`, server sorting and pagination for datasets advertising `otel-traces`. Failed metadata or schema requests remain explicit and retryable. Dataset/range changes cancel obsolete requests and reset pagination. Trace and SQL investigation links preserve captured query bounds. No classic grouped-issue endpoint or persisted issue lifecycle API is required.
Network access reads and replaces `GET/PUT /api/v1/alert-target-policy`. The complete object contains `allowPrivate`, `allowInvalidTls`, `allowedDomains`, `allowedCidrs`, `deniedDomains`, and `deniedCidrs`. All fields are required; the server is authoritative for authorization, CIDR validation and allow/deny overlaps. This policy governs outbound alert delivery, not ingress access to Parseable. The editor retains failed-save drafts and protects unsaved navigation. Demo writes are isolated within each client. The backend caches policy per node and loads it on startup; cross-node propagation is not guaranteed by a successful save.
diff --git a/frontend/docs/error-analytics.md b/frontend/docs/error-analytics.md
new file mode 100644
index 000000000..72a6493a4
--- /dev/null
+++ b/frontend/docs/error-analytics.md
@@ -0,0 +1,53 @@
+# Errors: grouping, frequency, and regression tests
+
+The Errors page queries the existing SQL API for grouped issues and frequency. It also retains the native OTLP error-span list. The [Mayfly implementation record](https://mayfly-celld.tail835e0d.ts.net/w/46ncdGypknzAj7dnRkJ8bQ?page=1983eae1-fba9-4616-9643-1de4e746162c) records the scope and validation evidence.
+
+## What the counts mean
+
+An issue is an exact match of service, error type, and message. Empty strings and missing values are treated as absent; absent values remain distinct from literal text such as `(unknown)`. Variable IDs or numbers in messages create separate issues. There is no fuzzy grouping, saved resolution state, assignment, or cross-dataset correlation.
+
+For OTLP traces, an occurrence is a distinct error span. The query deduplicates expanded event/link rows and retries by service, trace ID, and span ID before aggregating. A span must have error status `2`; an exception event alone does not make a successful span an error. When rows disagree, type and message come together from one representative row. A populated exception event takes priority over direct span attributes; ties use type then message in ascending order, with absent values last. This keeps groups deterministic without inventing a type/message combination that never occurred. The occurrence timestamp remains the earliest row in the selected interval.
+
+For logs, an occurrence is one stored record. Repeated identical logs remain separate occurrences. Recognized textual severity fields are checked case-insensitively for error/fatal equivalents. OTLP numeric severity must be an integer code from 17 through 24; numeric `level` supports the standard Pino/Bunyan error (50) and fatal (60) codes. Fractional codes do not match. Message text is not searched for the word “error” to classify a record. The derived `p_log_category` field is excluded because OTLP ingestion can infer it from body text when severity is unspecified. A dataset with only that category and no explicit severity field is unsupported. Schema inspection selects available service, type and message aliases; unsupported schemas have an explicit explanation and a Log explorer link.
+
+Each view covers one dataset and one captured time interval. The dataset's configured time partition defines that interval. SQL applies `start <= timestamp < end` precisely, while the request envelope expands to minute boundaries for the server planner. Frequency buckets divide the selected interval into at most 48 equal elapsed-time intervals, including zero-count buckets. Summary totals and frequency are independent of the current issue page.
+
+Issue and occurrence pages request one extra row to decide whether another page exists. Sorting happens on the server with deterministic group tie-breakers. Dataset/range changes and refresh discard obsolete requests and selections. Native trace links preserve the captured bounds. Log investigation opens SQL with the same classification and exact issue predicates, returning full stored records so stack traces and other fields remain available. Live handoffs start with readable SQL; only sample mode retains the internal query marker needed by its restricted evaluator. A log trace ID does not imply a compatible trace dataset.
+
+## Code map
+
+- `src/lib/errorAnalytics.ts`: schema interpretation, safely quoted SQL, strict result decoding, loaders and group identity.
+- `src/lib/errorAnalyticsDemo.ts`: deterministic sample-data evaluation, restricted to exact queries rebuilt from their metadata.
+- `src/features/errors/`: page orchestration, grouped issues, frequency and occurrence inspection.
+- `src/lib/errorAnalytics.test.ts`: fast data-contract and sample-data regressions.
+- `src/lib/errorAnalytics.live.test.ts`: opt-in generated SQL checks against a real Parseable server.
+- `e2e/errors.spec.ts`: browser state, navigation, pagination and failure behavior. API responses use fixture records; these tests do not establish SQL engine compatibility.
+
+## Running the tests
+
+From `frontend`, install dependencies with `npm ci`, then run focused unit tests:
+
+```sh
+npx vitest run src/lib/errorAnalytics.test.ts
+```
+
+The real-server suite is deliberately opt-in. It creates uniquely named disposable JSON-log, OTLP-log and OTLP-trace datasets and removes those datasets afterward. Use an isolated development server and an account allowed to ingest, query, and delete these fixtures:
+
+```sh
+ERRORS_REFERENCE_URL=http://127.0.0.1:8450 \
+ERRORS_REFERENCE_USERNAME=fixture \
+ERRORS_REFERENCE_PASSWORD=fixture-throwaway \
+npx vitest run src/lib/errorAnalytics.live.test.ts
+```
+
+The example credentials belong to the local disposable fixture server. Without `ERRORS_REFERENCE_URL`, the suite skips its live tests. It derives bounds from persisted timestamps and polls for readiness instead of assuming ingestion is immediately queryable. Assertions use explicit fixture counts: they check retries and event expansion, nonerror exclusion, severity aliases, escaped message values, NULL identity, more than one page of groups, and agreement between frequency and summary counts.
+
+Browser tests use the repository's Playwright configuration:
+
+```sh
+npx playwright test e2e/errors.spec.ts e2e/operations.spec.ts --workers=1
+```
+
+Ensure the configured server serves this checkout. A pre-existing server on the default port can belong to another worktree. Production build/type checking and the complete frontend unit suite are `npm run build` and `npm test`.
+
+The Errors stories cover trace and log issues, loading, empty, permission, query failure, unsupported schema and unavailable metadata states. After building Storybook, run `native-screens.spec.ts --grep operations-errors` with `playwright.storybook.config.ts`. Detail stories exercise paging, trace links, keyboard dismissal and focus restoration; the suite also runs axe and a narrow dark viewport. Follow the shared Playwright lock instructions in the frontend README on shared hosts.
diff --git a/frontend/docs/prism-parity.md b/frontend/docs/prism-parity.md
index bbdaa2b6a..f97bcdf0a 100644
--- a/frontend/docs/prism-parity.md
+++ b/frontend/docs/prism-parity.md
@@ -126,15 +126,15 @@ The [observability validation record](observability-validation.md) documents ori
The previously classic-only routes below now resolve to native pages. `classicUi.ts` marks them `limited`, distinguishing a usable native subset or capability explanation from full classic parity. The existing Traces, APM, Add data and telemetry-aware dataset routes retain their behavior.
-| Routes | Native behavior | Deliberate limits |
-| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
-| `/agents`, `/agents/:dataset` | Discover flattened GenAI fields through dataset schemas; query the latest 100 telemetry records in a selected time range; search loaded records; inspect full records; open compatible trace IDs | Individual events/spans, not classic run aggregation, evaluations, pricing or token-cost analytics. Demo uses explicit synthetic fixtures. |
-| `/cluster` | Show registered distributed nodes, reachability, status, storage/staging locations and resource metrics from the existing cluster endpoints | Read-only. Standalone servers can report cluster administration unavailable. Node and metrics failures remain separate. |
-| `/errors`, `/errors/:dataset` | Discover accessible OTLP trace datasets; list, sort and paginate error spans; open their trace waterfall | Classic grouped error issues and frequency endpoints are absent from this backend; arbitrary log issue aggregation is not fabricated. |
-| `/onboarding` | Discover accessible datasets, link each to its confirmed explorer, and guide ingestion and team setup | Dataset presence is not presented as proof of event arrival. Existing Add data owns ingestion configuration and verification. |
-| `/new` | Explain server workspace scope; open connection settings or native setup | No API in this checkout provisions a separate hosted workspace. |
-| `/keystone`, `/keystone/:id` | Native availability page and classic investigation handoff where available | No supported investigation/conversation API in this checkout. No claim that an ID exists or is accessible. |
-| `/settings/llm-key` | Native availability pages and deployment-dependent classic handoff | No provider-key management API. No credentials are read, stored or submitted. |
+| Routes | Native behavior | Deliberate limits |
+| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `/agents`, `/agents/:dataset` | Discover flattened GenAI fields through dataset schemas; query the latest 100 telemetry records in a selected time range; search loaded records; inspect full records; open compatible trace IDs | Individual events/spans, not classic run aggregation, evaluations, pricing or token-cost analytics. Demo uses explicit synthetic fixtures. |
+| `/cluster` | Show registered distributed nodes, reachability, status, storage/staging locations and resource metrics from the existing cluster endpoints | Read-only. Standalone servers can report cluster administration unavailable. Node and metrics failures remain separate. |
+| `/errors`, `/errors/:dataset` | Discover trace and log error datasets; group issues, query full-range frequency, inspect occurrences, sort/page issues and OTLP spans, and open trace or SQL investigations | Exact service/type/message grouping with recognized severity schemas; persistent issue resolution, fuzzy grouping and cross-dataset correlation remain unavailable. See [Errors contracts and tests](error-analytics.md). |
+| `/onboarding` | Discover accessible datasets, link each to its confirmed explorer, and guide ingestion and team setup | Dataset presence is not presented as proof of event arrival. Existing Add data owns ingestion configuration and verification. |
+| `/new` | Explain server workspace scope; open connection settings or native setup | No API in this checkout provisions a separate hosted workspace. |
+| `/keystone`, `/keystone/:id` | Native availability page and classic investigation handoff where available | No supported investigation/conversation API in this checkout. No claim that an ID exists or is accessible. |
+| `/settings/llm-key` | Native availability pages and deployment-dependent classic handoff | No provider-key management API. No credentials are read, stored or submitted. |
Network access (`/settings/network-access`) edits the real outbound alert-target policy via `GET` and `PUT /api/v1/alert-target-policy`. It is distinct from ingress access control. The full policy includes private-IP and invalid-TLS switches plus allowed/denied domains and CIDRs. Backend authorization and validation remain authoritative; failed saves retain the draft. The policy cache is per server node and loaded at startup; cross-node propagation is a backend limitation, not asserted by the UI.
diff --git a/frontend/docs/validation-review.md b/frontend/docs/validation-review.md
index 35176c918..416f76ba4 100644
--- a/frontend/docs/validation-review.md
+++ b/frontend/docs/validation-review.md
@@ -655,3 +655,22 @@ Validation of the final implementation:
- Opus findings addressed include network API scope, full-policy payloads, domain rules that could never match the backend, authorization-header allowlist help, permission-safe discard, unique field descriptions, missing metric values, failed dataset verification, unnecessary discovery requests, duplicate refresh requests and native URL preservation. An independent regression caught and fixed synthetic demo data appearing under unknown dataset names.
Broader suites are **not clean** on this host. The 493-test development-server run was stopped after repeated `net::ERR_INSUFFICIENT_RESOURCES`, blank lazy pages and Chromium failures: 238 passed, 45 failed, 3 interrupted, 1 skipped and 206 not run. The full 221-test Storybook run had 162 passes and 59 failures (38 existing screenshot assertions and 21 runtime/resource failures). A representative grouped-bars screenshot was rebuilt and tested from an isolated `origin/main` archive: it fails by the same 1,093 pixels, and its actual PNG is byte-identical to this branch (`c21e1b584291ac5ee37e020c75832fb14dbec1566610613dc06c792f9249b06d`). No baselines were updated. The obsolete sidebar expectation was updated for Agents/Errors and passed on retry; the reported APM 24-hour test also passed independently. The final focused run used a production bundle to avoid the development server's large module graph.
+
+## Grouped Errors analytics (2026-10-12)
+
+Implemented on `feat/error-issue-analytics` from `origin/main` (`bd882956`) in an isolated worktree. Two Sol 6.1 agents covered wiki research and real-server validation; Opus 5.5 agents covered architecture, SQL/demo implementation, UI, and independent review. The [implementation runbook](error-analytics.md) explains grouping, counting and reproducible test commands. The [Mayfly record](https://mayfly-celld.tail835e0d.ts.net/w/46ncdGypknzAj7dnRkJ8bQ?page=1983eae1-fba9-4616-9643-1de4e746162c) records completion evidence.
+
+The default Errors view now groups exact service/type/message values, shows full-range counts and frequency, and opens paginated occurrences. General log severity schemas and OTLP traces are supported. The original paginated, sortable error-span view and trace links remain available. Live SQL investigation returns complete stored log records with exact issue predicates; the same handoff also executes in sample mode.
+
+Review regressions cover coherent exception pairs when a span has multiple different exception events, numeric Pino/Bunyan levels, exact numeric status/severity comparisons, direct versus event exception fields, permission versus unsupported-schema states, and readable live SQL handoffs. Frequency and summary counts remain independent of table pagination. No fuzzy grouping or persisted assignment/resolution state is claimed.
+
+The final independent Opus review confirmed the earlier findings resolved and reported no required fixes. Optional follow-ups include measuring large-range trace window-query cost and improving errors for hand-edited malformed demo markers.
+
+Validation passed:
+
+- Production build and TypeScript; all **2,193 frontend unit tests**, with ten opt-in live tests skipped in that default run.
+- **27 Errors unit tests and nine real-server tests** against the disposable Parseable server at `127.0.0.1:8450`. Fixtures cover JSON/OTLP logs and traces, severity aliases and numeric levels, exact grouping, differing exception events, retries, SQL quoting, raw log handoff, pagination, fractional bounds, and frequency totals. Created datasets are removed afterward.
+- **12 application browser tests** (`errors.spec.ts` and `operations.spec.ts`) and **12 Errors Storybook checks**, including axe, occurrence paging, keyboard dismissal/focus restoration, and a narrow dark viewport. Final browser runs held the shared Playwright lock and used this worktree's isolated ports 5191/6191 instead of other checkouts' servers on the defaults.
+- Formatting and `git diff --check`.
+
+The browser transport fixtures establish UI behavior; the opt-in live suite separately establishes generated SQL compatibility. Large-production-dataset performance, cross-browser coverage, and deployment are outside this validation. The full unrelated browser and screenshot suites were not rerun; the earlier baseline limitations above still apply.
diff --git a/frontend/e2e/errors.spec.ts b/frontend/e2e/errors.spec.ts
new file mode 100644
index 000000000..4d3ed8c12
--- /dev/null
+++ b/frontend/e2e/errors.spec.ts
@@ -0,0 +1,278 @@
+import { expect, test, type Page } from '@playwright/test';
+import { handleErrorDemoQuery } from '../src/lib/errorAnalyticsDemo';
+import type { LogRecord, QueryRequest } from '../src/lib/types';
+
+// Browser tests exercise request/state/navigation behavior. The separate
+// errorAnalytics.live.test.ts executes the generated SQL on a real server.
+const bounds = {
+ startTime: '2026-10-12T15:00:00.123Z',
+ endTime: '2026-10-12T16:00:00.123Z',
+};
+const range = new URLSearchParams({ start: bounds.startTime, end: bounds.endTime });
+const logRows: LogRecord[] = [
+ // Identical logs are separate occurrences: six retries form one issue.
+ ...Array.from({ length: 6 }, (_, index) => ({
+ p_timestamp: `2026-10-12T15:0${index}:30.000Z`,
+ level: 'ERROR',
+ service: 'checkout',
+ 'exception.type': 'TimeoutError',
+ message: "Payment gateway's connection timed out",
+ })),
+ // More groups than one page prove that the overview is not a page subtotal.
+ ...Array.from({ length: 31 }, (_, index) => ({
+ p_timestamp: '2026-10-12T15:20:00.000Z',
+ level: 'ERROR',
+ service: 'worker',
+ 'exception.type': 'JobError',
+ message: `Job failure ${String(index).padStart(2, '0')}`,
+ })),
+ {
+ p_timestamp: '2026-10-12T15:30:00.000Z',
+ level: 'INFO',
+ service: 'checkout',
+ message: 'No error occurred',
+ },
+];
+
+const traceBase = {
+ p_timestamp: '2026-10-12T15:10:00.000Z',
+ 'service.name': 'checkout',
+ span_trace_id: 'a'.repeat(32),
+ span_span_id: '1'.repeat(16),
+ span_name: 'charge-card',
+ span_status_code: 2,
+ event_name: 'exception',
+ 'event_exception.type': 'TimeoutError',
+ 'event_exception.message': 'Payment gateway timeout',
+};
+const traceRows: LogRecord[] = [
+ traceBase,
+ { ...traceBase }, // Expanded event/retry: still just one span.
+ { ...traceBase, span_trace_id: 'b'.repeat(32), span_span_id: '2'.repeat(16) },
+ { ...traceBase, span_span_id: '3'.repeat(16), span_status_code: 1 }, // Exception alone is insufficient.
+];
+
+type CapturedQuery = QueryRequest & {
+ kind: string;
+ offset?: number;
+ sort?: string;
+ issue?: unknown;
+};
+
+async function serveLogs(
+ page: Page,
+ options: { unsupported?: boolean; denied?: boolean; traces?: boolean } = {},
+) {
+ const requests: CapturedQuery[] = [];
+ let denied = options.denied ?? false;
+ await page.route('**/api/**', async (route) => {
+ const path = new URL(route.request().url()).pathname;
+ if (path === '/api/v1/logstream')
+ return route.fulfill({
+ json: [{ name: options.traces ? 'orders_traces' : 'application_logs' }],
+ });
+ if (path.endsWith('/info'))
+ return route.fulfill({
+ json: {
+ telemetryType: options.traces ? 'traces' : 'logs',
+ logSource: [{ log_source_format: options.traces ? 'otel-traces' : 'json' }],
+ },
+ });
+ if (path.endsWith('/schema'))
+ return route.fulfill({
+ json: {
+ fields: (options.traces
+ ? Object.keys(traceBase)
+ : options.unsupported
+ ? ['p_timestamp', 'message']
+ : ['p_timestamp', 'level', 'service', 'exception.type', 'message']
+ ).map((name) => ({ name, data_type: 'Utf8' })),
+ },
+ });
+ if (path === '/api/v1/query') {
+ const body = route.request().postDataJSON();
+ const request: QueryRequest = {
+ sql: body.query,
+ startTime: body.startTime,
+ endTime: body.endTime,
+ };
+ const match = /^\/\* parseable-errors:([^\n]+) \*\//.exec(request.sql);
+ if (match) {
+ requests.push({ ...request, ...JSON.parse(decodeURIComponent(match[1])) });
+ if (denied) return route.fulfill({ status: 403, body: 'Error analytics access denied' });
+ return route.fulfill({
+ json: handleErrorDemoQuery(request, options.traces ? traceRows : logRows),
+ });
+ }
+ return route.fulfill({ json: [] });
+ }
+ return route.fulfill({ json: {} });
+ });
+ return { requests, allowQueries: () => (denied = false) };
+}
+
+test('log issues show full-range totals while paging and sorting groups on the server', async ({
+ page,
+}) => {
+ const fixture = await serveLogs(page);
+ await page.goto(`/errors/application_logs?${range}`);
+ await expect(page.getByRole('heading', { name: 'Errors', exact: true })).toBeVisible();
+ await expect(
+ page.getByText("Payment gateway's connection timed out", { exact: true }),
+ ).toBeVisible();
+ await expect(page.getByRole('img', { name: /Error log records over time/ })).toBeVisible();
+ await expect(
+ page
+ .locator('dt')
+ .filter({ hasText: /^Error log records$/ })
+ .locator('+ dd'),
+ ).toHaveText('37');
+ await expect(
+ page
+ .locator('dt')
+ .filter({ hasText: /^Issues$/ })
+ .locator('+ dd'),
+ ).toHaveText('32');
+ await page.getByRole('button', { name: 'Next page', exact: true }).click();
+ await expect
+ .poll(() =>
+ fixture.requests.some((request) => request.kind === 'issues' && request.offset === 25),
+ )
+ .toBe(true);
+ await expect(
+ page
+ .locator('dt')
+ .filter({ hasText: /^Error log records$/ })
+ .locator('+ dd'),
+ ).toHaveText('37');
+ await page.getByRole('button', { name: 'Sort by Last seen (UTC)', exact: true }).click();
+ await expect
+ .poll(() =>
+ fixture.requests.some(
+ (request) =>
+ request.kind === 'issues' && request.sort === 'latest' && (request.offset ?? 0) === 0,
+ ),
+ )
+ .toBe(true);
+});
+
+test('a log issue opens its matching occurrences and hands the exact query to SQL', async ({
+ page,
+}) => {
+ const fixture = await serveLogs(page);
+ await page.goto(`/errors/application_logs?${range}`);
+ await page.getByRole('button', { name: /Payment gateway's connection timed out/ }).click();
+ const detail = page.getByRole('dialog');
+ await expect(detail).toBeVisible();
+ await expect
+ .poll(() => fixture.requests.some((request) => request.kind === 'occurrences'))
+ .toBe(true);
+ const link = detail.getByRole('link', { name: /SQL/ });
+ const href = new URL((await link.getAttribute('href'))!, 'http://localhost');
+ expect(href.pathname).toBe('/sql-editor');
+ expect(href.searchParams.get('start')).toBe('2026-10-12T15:00:00.000Z');
+ expect(href.searchParams.get('end')).toBe('2026-10-12T16:01:00.000Z');
+ expect(href.searchParams.get('query')).toContain(bounds.startTime);
+ expect(href.searchParams.get('query')).toContain(bounds.endTime);
+ expect(href.searchParams.get('query')).toContain("Payment gateway''s connection timed out");
+ expect(href.searchParams.get('query')).toMatch(/^SELECT \* FROM /);
+ expect(href.searchParams.get('query')).not.toContain('parseable-errors:');
+ // A trace id in a log is not evidence of a compatible trace dataset.
+ await expect(detail.getByRole('link', { name: /Open trace/ })).toHaveCount(0);
+ await page.keyboard.press('Escape');
+ await expect(detail).toHaveCount(0);
+});
+
+test('permission failures stay errors and retry restores real analytics', async ({ page }) => {
+ const fixture = await serveLogs(page, { denied: true });
+ await page.goto(`/errors/application_logs?${range}`);
+ await expect(page.getByText('Permission denied', { exact: true }).first()).toBeVisible();
+ await expect(page.getByText('No errors', { exact: true })).toHaveCount(0);
+ fixture.allowQueries();
+ await page
+ .getByRole('region', { name: 'Issues', exact: true })
+ .getByRole('button', { name: 'Try again', exact: true })
+ .click();
+ await expect(
+ page.getByText("Payment gateway's connection timed out", { exact: true }),
+ ).toBeVisible();
+});
+
+test('logs without a recognized severity field explain eligibility without guessing errors from text', async ({
+ page,
+}) => {
+ const fixture = await serveLogs(page, { unsupported: true });
+ await page.goto(`/errors/application_logs?${range}`);
+ await expect(page.getByRole('link', { name: 'Explore logs' })).toHaveAttribute(
+ 'href',
+ '/logs/explore/application_logs',
+ );
+ expect(fixture.requests).toHaveLength(0);
+});
+
+test('trace issue drill-down preserves exact bounds and links each deduplicated occurrence', async ({
+ page,
+}) => {
+ await serveLogs(page, { traces: true });
+ await page.goto(`/errors/orders_traces?${range}`);
+ await expect(
+ page
+ .locator('dt')
+ .filter({ hasText: /^Error spans$/ })
+ .locator('+ dd'),
+ ).toHaveText('2');
+ await page.getByRole('button', { name: /Payment gateway timeout/ }).click();
+ const detail = page.getByRole('dialog');
+ await expect(detail.getByTestId('error-occurrence-table').locator('tbody tr')).toHaveCount(2);
+ const links = detail.getByRole('link', { name: /Open trace/ });
+ await expect(links).toHaveCount(2);
+ const href = new URL((await links.first().getAttribute('href'))!, 'http://localhost');
+ expect(href.pathname).toBe('/traces/explore/orders_traces');
+ expect(href.searchParams.get('start')).toBe(bounds.startTime);
+ expect(href.searchParams.get('end')).toBe(bounds.endTime);
+ expect(href.searchParams.get('traceId')).toBe('a'.repeat(32));
+ await expect(detail.getByRole('link', { name: /SQL/ })).toHaveCount(0);
+});
+
+test('changing the range resets the issue page and uses one captured interval for all panels', async ({
+ page,
+}) => {
+ const fixture = await serveLogs(page);
+ await page.goto(`/errors/application_logs?${range}`);
+ await page.getByRole('button', { name: 'Next page', exact: true }).click();
+ await expect.poll(() => fixture.requests.some((request) => request.offset === 25)).toBe(true);
+ const previousRequests = fixture.requests.length;
+ await page.getByLabel('Time range', { exact: true }).selectOption('6h');
+ await expect(page).toHaveURL(/range=6h/);
+ await expect
+ .poll(
+ () => new Set(fixture.requests.slice(previousRequests).map((request) => request.kind)).size,
+ )
+ .toBe(3);
+ const refreshed = fixture.requests.slice(previousRequests);
+ expect(refreshed.find((request) => request.kind === 'issues')?.offset).toBe(0);
+ expect(new Set(refreshed.map((request) => `${request.startTime}/${request.endTime}`)).size).toBe(
+ 1,
+ );
+ expect(Date.parse(refreshed[0].endTime) - Date.parse(refreshed[0].startTime)).toBe(6 * 3_600_000);
+});
+
+test('sample log investigation runs in the SQL editor instead of handing off an unsupported query', async ({
+ page,
+}) => {
+ await page.addInitScript(() => sessionStorage.setItem('parseable-mode', 'demo'));
+ await page.goto('/errors/application_logs');
+ await page
+ .getByRole('button', { name: /Upstream request timed out/ })
+ .first()
+ .click();
+ await page
+ .getByRole('dialog')
+ .getByRole('link', { name: /SQL editor/ })
+ .click();
+ await expect(page).toHaveURL(/\/sql-editor/);
+ await page.getByRole('button', { name: 'Run query', exact: true }).click();
+ await expect(page.getByRole('table')).toBeVisible();
+ await expect(page.getByRole('table')).toContainText('Upstream request timed out');
+ await expect(page.getByText(/Demo SQL supports SELECT/)).toHaveCount(0);
+});
diff --git a/frontend/e2e/operations.spec.ts b/frontend/e2e/operations.spec.ts
index 7a7ab4f64..3caee7fa3 100644
--- a/frontend/e2e/operations.spec.ts
+++ b/frontend/e2e/operations.spec.ts
@@ -22,6 +22,18 @@ test('errors uses supported OTLP error span endpoint and opens trace detail', as
const requests: unknown[] = [];
await page.route('**/api/**', async (route) => {
const path = new URL(route.request().url()).pathname;
+ if (path.endsWith('/schema'))
+ return route.fulfill({
+ json: {
+ fields: [
+ 'p_timestamp',
+ 'service.name',
+ 'span_trace_id',
+ 'span_span_id',
+ 'span_status_code',
+ ].map((name) => ({ name })),
+ },
+ });
if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'orders_traces' }] });
if (path.endsWith('/info'))
return route.fulfill({
@@ -40,7 +52,7 @@ test('errors uses supported OTLP error span endpoint and opens trace detail', as
}
return route.fulfill({ json: {} });
});
- await page.goto('/errors/orders_traces');
+ await page.goto('/errors/orders_traces?view=spans');
await expect(page.getByRole('heading', { name: 'Errors', exact: true })).toBeVisible();
await expect(page.getByTestId('trace-table')).toBeVisible();
expect(requests[0]).toMatchObject({
@@ -86,6 +98,18 @@ test('errors explains ineligible datasets without querying fabricated error APIs
let unsupportedRequests = 0;
await page.route('**/api/**', async (route) => {
const path = new URL(route.request().url()).pathname;
+ if (path.endsWith('/schema'))
+ return route.fulfill({
+ json: {
+ fields: [
+ 'p_timestamp',
+ 'service.name',
+ 'span_trace_id',
+ 'span_span_id',
+ 'span_status_code',
+ ].map((name) => ({ name })),
+ },
+ });
if (path.includes('/errors/')) unsupportedRequests++;
if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'logs' }] });
if (path.endsWith('/info'))
@@ -108,6 +132,18 @@ test('errors retries unverified dataset metadata instead of declaring it ineligi
let metadataUnavailable = true;
await page.route('**/api/**', async (route) => {
const path = new URL(route.request().url()).pathname;
+ if (path.endsWith('/schema'))
+ return route.fulfill({
+ json: {
+ fields: [
+ 'p_timestamp',
+ 'service.name',
+ 'span_trace_id',
+ 'span_span_id',
+ 'span_status_code',
+ ].map((name) => ({ name })),
+ },
+ });
if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'orders_traces' }] });
if (path.endsWith('/info'))
return metadataUnavailable
@@ -119,7 +155,7 @@ test('errors retries unverified dataset metadata instead of declaring it ineligi
return route.fulfill({ json: { count: 0, offset: 0, limit: 25, records: [] } });
return route.fulfill({ json: {} });
});
- await page.goto('/errors/orders_traces');
+ await page.goto('/errors/orders_traces?view=spans');
await expect(page.getByText('Could not verify dataset', { exact: true })).toBeVisible();
await expect(page.getByText('No eligible error dataset')).toHaveCount(0);
metadataUnavailable = false;
diff --git a/frontend/src/features/errors/ErrorIssues.tsx b/frontend/src/features/errors/ErrorIssues.tsx
new file mode 100644
index 000000000..ca85f7e88
--- /dev/null
+++ b/frontend/src/features/errors/ErrorIssues.tsx
@@ -0,0 +1,520 @@
+import { useCallback, useMemo, useState, type ReactNode } from 'react';
+import { Link } from 'react-router-dom';
+import { ArrowDown, ArrowUpDown, ArrowUpRight, ChevronLeft, ChevronRight } from 'lucide-react';
+import { useApp } from '../../app/AppProvider';
+import { useAsync } from '../../hooks/useAsync';
+import { Button, EmptyState, Sheet } from '../../components/ui';
+import { QueryState } from '../../components/explorer/QueryState';
+import { formatTimestamp } from '../../components/explorer/timezone';
+import { TimeSeriesChart } from '../../components/charts/TimeSeriesChart';
+import {
+ buildErrorExplorerSql,
+ ERROR_PAGE_SIZE,
+ loadErrorIssues,
+ loadErrorOccurrences,
+ loadErrorSeries,
+ loadErrorSummary,
+ type ErrorBounds,
+ type ErrorBucket,
+ type ErrorIssue,
+ type ErrorSchema,
+ type ErrorSort,
+} from '../../lib/errorAnalytics';
+import { apmTransportBounds } from '../../lib/apm';
+
+type Props = { dataset: string; schema: ErrorSchema; bounds: ErrorBounds };
+
+/** Source-specific wording; a log record and an error span are different things. */
+export function occurrenceNoun(schema: ErrorSchema, count = 2): string {
+ const plural = count !== 1;
+ return schema.source === 'traces'
+ ? plural
+ ? 'error spans'
+ : 'error span'
+ : plural
+ ? 'error log records'
+ : 'error log record';
+}
+
+function utc(value: string | null): string {
+ return value ? `${formatTimestamp(value, 'UTC')} UTC` : '—';
+}
+
+function minutes(bounds: ErrorBounds): number {
+ return (Date.parse(bounds.endTime) - Date.parse(bounds.startTime)) / 60_000;
+}
+
+function perMinute(count: number, bounds: ErrorBounds): string {
+ const rate = count / minutes(bounds);
+ return rate > 0 && rate < 0.01
+ ? '<0.01'
+ : rate.toLocaleString(undefined, { maximumFractionDigits: 2 });
+}
+
+/** A missing value is shown as a placeholder that cannot be mistaken for a literal value. */
+function Value({ value, missing }: { value: string | null; missing: string }) {
+ return value === null ? {missing} : <>{value}>;
+}
+
+function fieldList(fields: string[]): string {
+ return fields.length ? fields.join(', ') : 'none in this dataset';
+}
+
+export function ErrorCountingNote({ schema }: { schema: ErrorSchema }) {
+ const severity = [
+ schema.severity.text.length
+ ? `${schema.severity.text.join(' or ')} is an error level such as error, fatal or critical`
+ : '',
+ schema.severity.number.length
+ ? `${schema.severity.number.join(' or ')} is between 17 and 24 (OpenTelemetry error and fatal)`
+ : '',
+ schema.severity.text.includes('level') ? 'level is 50 or 60 (Pino/Bunyan error and fatal)' : '',
+ ].filter(Boolean);
+ return (
+
+ How errors are counted and grouped
+
+ {schema.source === 'traces'
+ ? 'Each span with error status counts once, even if the same span was received more than once.'
+ : `Each log record counts once when ${severity.join(', or when ')}.`}{' '}
+ Times use{' '}
+ {schema.timeColumn === 'p_timestamp' ? 'ingestion time' : `the ${schema.timeColumn} field`}.
+
+
+ Issues group occurrences that have exactly the same service, error type and message.
+ Messages that differ in any way, such as a request ID or number, are separate issues.
+
- This link includes options that are not applied here:{' '}
- {[...new Set(params.keys())]
- .filter((key) => !['start', 'end', 'range'].includes(key))
- .join(', ')}
- . Use the native time range and sort controls to refine these error spans.
+