diff --git a/frontend/README.md b/frontend/README.md index 39f8084a5..33fce92ff 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -66,7 +66,7 @@ This creates `build.zip` with a top-level `dist/` directory, including `dist/ind - Datasets: search, create logs/metrics/traces with an optional custom partition, manage information and ingestion/storage statistics, inspect schema, explore, set/clear retention, configure/disable hot tier when the server enables it, and delete with typed name confirmation. Time partitions cannot be set at creation. Static schema upload, tags/labels, existing partition editing and index management are not supported. - 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 with configurable expiration from 30 minutes to 90 days or no expiration. New UI-created keys default to 90 days; existing keys keep their original lifetime. 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. +- Agents: schema-discovered GenAI datasets, related spans grouped into runs, successful/failed/incomplete status, observed duration, models and recorded token usage. Run details include the span tree, model and tool calls, errors, captured conversation and raw fields. Search/status/model filters and captured time bounds survive navigation between Agents, Traces and Logs. Missing telemetry and bounded samples are explicit; evaluations and cost estimates remain deferred. - 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. diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md index c562fbcc8..0ae0cf18d 100644 --- a/frontend/docs/api-contracts.md +++ b/frontend/docs/api-contracts.md @@ -215,7 +215,7 @@ See [observability validation](observability-validation.md) for bounds, caps, er ## Native operations and setup screens -Agents uses existing dataset list, schema, metadata and SQL requests. Discovery recognizes flattened `gen_ai.agent.name`, `gen_ai.agent.id`, `gen_ai.operation.name`, `gen_ai.request.model` and `gen_ai.tool.name`; direct dataset links defer the full schema scan until the selector is focused or rescanned. Discovery is limited to four concurrent schema requests and skips forbidden/missing datasets. Queries quote dataset/field identifiers, use the dataset time partition, and return at most 100 recent non-null GenAI records in captured UTC bounds. Search applies only to those loaded records. Run aggregation, evaluations and pricing are not inferred. Unknown demo dataset names cannot return synthetic records. +Agents uses existing dataset list, schema, metadata and SQL requests. Discovery recognizes flattened `gen_ai.agent.name`, `gen_ai.agent.id`, `gen_ai.operation.name`, `gen_ai.request.model`, `gen_ai.response.model` and `gen_ai.tool.name`; direct dataset links defer the full schema scan until the selector is focused or rescanned. Discovery is limited to four concurrent schema requests and skips forbidden/missing datasets. Queries quote dataset/field identifiers and use the dataset time partition. Up to 100 recent trace IDs containing GenAI telemetry seed a query for related spans, including children without GenAI attributes; the outer response is bounded to 1,000 flattened records. Explicit half-open UTC predicates preserve exact time bounds inside the minute-expanded query envelope. Search, status and model filters apply to the loaded runs. Run normalization deduplicates span identities before counting usage, preserves event content and displays absent values distinctly from zero. A unique outer agent span can certify recovery within its descendants: explicit completed OK permits recovered child errors, which remain visible. Failed or incomplete ancestors and separate roots retain their outcome. Completed UNSET spans are accepted; missing status or completion remains incomplete. Missing identity, parents, clipped time coverage and the separate 100-trace / 1,000-record caps are disclosed. Evaluations and cost estimates remain deferred until telemetry and pricing inputs are reliable. Unknown demo dataset names cannot return synthetic records. 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. diff --git a/frontend/docs/parity-screenshots/agents-runs/run-detail-desktop.png b/frontend/docs/parity-screenshots/agents-runs/run-detail-desktop.png new file mode 100644 index 000000000..52d4b01a7 Binary files /dev/null and b/frontend/docs/parity-screenshots/agents-runs/run-detail-desktop.png differ diff --git a/frontend/docs/parity-screenshots/agents-runs/run-detail-mobile.png b/frontend/docs/parity-screenshots/agents-runs/run-detail-mobile.png new file mode 100644 index 000000000..0b3fd8fb8 Binary files /dev/null and b/frontend/docs/parity-screenshots/agents-runs/run-detail-mobile.png differ diff --git a/frontend/docs/parity-screenshots/agents-runs/run-list-desktop.png b/frontend/docs/parity-screenshots/agents-runs/run-list-desktop.png new file mode 100644 index 000000000..534a04c06 Binary files /dev/null and b/frontend/docs/parity-screenshots/agents-runs/run-list-desktop.png differ diff --git a/frontend/docs/prism-parity.md b/frontend/docs/prism-parity.md index f97bcdf0a..7f6fc6623 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 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. | +| Routes | Native behavior | Deliberate limits | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/agents`, `/agents/:dataset` | Discover GenAI datasets; group related spans into runs; inspect status, duration, models, tokens, span trees, calls, errors, captured conversation and raw fields; preserve time/filter context across Agents, Traces and Logs | Bounded telemetry sample, with incomplete/missing fields disclosed. Evaluations and cost estimates are deferred. 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 d4884d96d..c6bcded21 100644 --- a/frontend/docs/validation-review.md +++ b/frontend/docs/validation-review.md @@ -1,5 +1,51 @@ # Frontend validation and independent review +## PR #32 review fixes (2026-10-12) + +The seven review findings are addressed. Trace-focused Logs share links retain their encoded time window, and Traces Refresh reloads fixed windows while advancing preset windows with one request per active list/detail. Agents search keeps typing and caret updates synchronous with URL navigation, and searches literal recorded values, including backslashes and quotes. + +Captured conversation includes backend datatype-suffixed fields and orders events by their available epoch precision; exact duplicates remain collapsed. Token totals include independently reported sibling branches without repeating model-call rollups, and identify ambiguous disconnected usage as partial. + +Each finding received a Sol 6.1 implementation pass, a separate Sol regression review and a dedicated Opus 5.5 review. Follow-up reviews caught and corrected duplicate preset refreshes and loss of event precision from backend timestamp text. Regression coverage includes actual Share links/reload/handoffs, exact refresh request counts, caret/rapid typing/history/dataset changes, literal nested search, typed content, event ordering/deduplication and branch-level token accounting. + +Final validation through the Swamp `blacksmith-test` workflow passed formatting, TypeScript/production build, **2,314 unit tests** (14 skipped), **95 relevant application browser tests**, and **24 production-build browser tests**. Browser tests ran with four workers and zero retries. Earlier runs exposed two test synchronization assumptions around modal closure and development StrictMode mount requests; the corrected tests retain the original behavior and exact Refresh assertions. The full application suite, Storybook and live exporter/ingestion checks were not repeated for these fixes. + +The subsequent full GitHub PR check passed, but the duplicate push check exposed a rapid-navigation race: an obsolete Logs handoff cleanup could replace a newer Traces route. The captured browser trace showed Traces opening, then returning to the consumed Logs URL without another click. Logs now checks the current browser pathname and search before consuming a handoff; browser-history detection also works when the component first mounts after navigation has moved on. A separate rapid-handoff test checks the final Traces route after requests settle. Sol 6.1 and Opus 5.5 independently confirmed the cause and reviewed the guard. + +Swamp Blacksmith validation of that follow-up passed formatting, TypeScript/build, the unit suite, **80 navigation executions** (four tests repeated 20 times), **96 relevant application browser tests**, and **24 production browser tests**, with zero browser retries. + +## In-app agent telemetry setup guide (2026-10-12) + +Agents now has a **Set up telemetry** panel, also linked from the **Get started** connection step and the Agents empty state. It generates copyable Claude Code JSON, Codex TOML and a coding-agent brief using an editable collector base URL. Content flags default off and reset when changing providers; the guide explains that metadata and some tool snippets may still be exported. It links to collector ingestion setup, documents required span mappings and distinguishes receiving telemetry from complete run compatibility. + +Claude configuration uses per-signal logs/traces destinations without disabling existing metrics. Existing configuration must be merged in place, including Codex inline exporters and tables. The panel remains available on discovery errors, preserves filters and frozen time bounds, and uses `setup=1` for direct links/reload. This transient parameter is excluded from remembered observability filters. + +Final follow-up checks pass: TypeScript/production build, **62 focused unit tests**, **28 application tests**, **8 production mount tests** and **22 relevant Storybook tests**. New cases cover copying both configurations/agent brief, content opt-in, invalid URLs, clipboard failure, empty/error states, stable query counts and filters, keyboard/mobile accessibility, and `/` + `/next` mounts. All four generated JSON/TOML variants also pass real parsers. A host build initially encountered root-owned generated files from previous container runs; the pinned Playwright container completed the final build and tests. The full application suite was not repeated for this follow-up, and no live Claude Code/Codex exporter integration is claimed. + +The [Mayfly UI decision and screenshot demo](https://mayfly-celld.tail835e0d.ts.net/w/46ncdGypknzAj7dnRkJ8bQ?page=6345efc5-183e-4dce-8f81-dbd4cd83ee1d) records the three placement options, selected design, setup flow, provider references and four screenshots. Sol 6.1 implemented the component and unit coverage; Two Opus 5.5 reviews checked provider correctness and follow-up corrections; the final review confirmed all five initial findings fixed with no remaining regressions or blockers. Evaluations and cost estimates remain deferred. + +## Agents run dashboard (2026-10-12) + +Implemented on `feat/agent-runs-dashboard` from `origin/main` `06030daa`. The [Mayfly Agents record](https://mayfly-celld.tail835e0d.ts.net/w/46ncdGypknzAj7dnRkJ8bQ?page=b3577b4a-031d-46d3-827a-df813856231a) lives beneath the existing native-screens page. Sol 6.1 implementation and integration reviewers, Opus 5.5 telemetry/browser/final reviewers, and Luna wiki discovery contributed to this change. + +The list now groups observed spans by trace ID, falling back to explicit run IDs or isolated span/record identities when trace identity is absent. A trace is the run boundary; multiple top-level invocations within one trace remain one observed run. It shows successful, failed and incomplete status, observed duration, model names, input/output usage and loaded span counts. Search/status/model filters operate on loaded runs. Details provide the parent/child tree, model calls, tool calls, error messages/exception events, captured conversation and each span's raw records. + +The query selects up to 100 recent trace IDs with GenAI fields, then fetches up to 1,000 related flattened records, including children without GenAI attributes. Exact half-open predicates apply to both scans using the dataset time partition, with a minute-expanded transport envelope. Event/link rows are deduplicated by span identity before usage aggregation; span-level conversation duplicated across event rows is shown once. Usage prefers model calls and avoids ancestor rollup duplication. Missing values stay distinct from zero. No evaluation scores, pricing data or cost estimates are introduced. + +A valid ended span with OTel UNSET status is complete. An explicitly successful outer agent can recover from errors in its own descendants; failed ancestors or separate roots still fail the observed run, and unfinished ones keep it incomplete. Child errors remain inspectable and are disclosed. Missing status/completion, absent identities/parents, malformed hierarchy and incomplete query coverage are identified. Missing end epoch zero is authoritative even when ingestion supplied a misleading timestamp or saturated zero duration. The 100-trace and 1,000-record limits have separate visible notices. Query limits conservatively mark affected result coverage as partial, and runs starting before the selected ingestion window are flagged; the dashboard does not claim a complete run inventory or full-range aggregate totals. + +The versioned `observe` URL context preserves captured UTC bounds, the preset label and each view's native filters across Agents, Traces and Logs. Logs keeps focused trace criteria separate from saved grouped filters; changing focus replaces the previous trace without losing AND/OR groups. Browser back/reload and dataset changes are covered. Bare trace-focused Logs bookmarks are supported. Run-specific links require a valid trace identifier. Demo rows remain synthetic and do not link to unrelated demo Logs/Traces datasets. + +Captured demo screenshots: [run list](parity-screenshots/agents-runs/run-list-desktop.png), [desktop details](parity-screenshots/agents-runs/run-detail-desktop.png), and [390px mobile details](parity-screenshots/agents-runs/run-detail-mobile.png). + +Final validation passed across the broad and focused runs: + +- Formatting, TypeScript and the production build pass; **2,283 unit tests across 92 files** pass (14 opt-in/existing tests skipped). +- **537 distinct application tests** pass, with two optional screenshot-capture tests skipped. The broad run passed 535 cases; two legacy Logs URL assertions were updated to allow only the new `observe` parameter and passed in the focused rerun. The final affected Agents/navigation/handoff run passed 35 cases before exposing an intermittent rapid-filter race; that race was fixed and all 13 navigation cases then passed five consecutive repetitions (**65 executions, no retries**), followed by **16 Traces/Logs handoff tests**. The three added run-status unit regressions cover unfinished ancestors, separate roots and agent-owned descendants. +- **22 production-build browser tests** and **232 Storybook tests** pass, including accessibility and unchanged visual baselines. Browser suites ran under the shared lock in isolated `mcr.microsoft.com/playwright:v1.63.0-noble` containers. + +The monolithic `npm run check` was not rerun after the final targeted corrections; its stages were verified through the full and focused runs described above. Three opt-in live tests also passed against a disposable `quay.io/parseablehq/parseable:v3.2.4` server (not rebuilt Rust): real OTLP event/link expansion and generic children, successful/recovered/failed/incomplete telemetry, missing usage, exact fractional time boundaries and exclusion of later same-trace records. The fixture dataset and container were removed. Reproduce with a disposable loopback server and `PARSEABLE_AGENTS_LIVE_URL=http://127.0.0.1:8393 npx vitest run src/lib/agents.live.test.ts`; credentials default to `fixture` / `fixture-throwaway` and can be supplied using the matching `PARSEABLE_AGENTS_LIVE_USERNAME` / `PARSEABLE_AGENTS_LIVE_PASSWORD` variables. + This record distinguishes the current worktree's completed checks from the supplied earlier research. Original Prism source was not recovered; visual comparisons use actual rendered v3.2.4 assets and an independently implemented frontend. No deployed service or the reference checkout was modified. ## API key expiration (2026-10-12) diff --git a/frontend/e2e-prod/mounts.spec.ts b/frontend/e2e-prod/mounts.spec.ts index b8057312c..3d9b4a7ae 100644 --- a/frontend/e2e-prod/mounts.spec.ts +++ b/frontend/e2e-prod/mounts.spec.ts @@ -13,6 +13,30 @@ function problems(page: Page): string[] { for (const mount of ['', '/next']) { test.describe(`the production build mounted at ${mount || '/'}`, () => { + test('opens the shared agent setup guide from Get started under its mount', async ({ + page, + }) => { + const failed = problems(page); + await page.addInitScript(() => sessionStorage.setItem('parseable-mode', 'demo')); + await page.goto(`${mount}/onboarding`); + await page.getByRole('link', { name: 'Set up Claude Code or Codex' }).click(); + await expect(page).toHaveURL(`${mount}/agents?setup=1`); + const guide = page.getByRole('dialog', { name: 'Set up agent telemetry' }); + await expect(guide).toBeVisible(); + await guide.getByRole('tab', { name: 'Codex', exact: true }).click(); + await expect(guide.getByLabel('Codex settings', { exact: true })).toContainText( + '[otel.trace_exporter.otlp-http]', + ); + await expect( + guide.getByRole('link', { name: 'View collector and ingestion setup' }), + ).toHaveAttribute('href', `${mount}/add-data`); + await page.reload(); + await expect(guide).toBeVisible(); + await guide.getByRole('button', { name: 'Close dialog' }).click(); + await expect(page).toHaveURL(`${mount}/agents`); + expect(failed).toEqual([]); + }); + test('loads lazy pages on deep routes from its own mount', async ({ page }) => { const failed = problems(page); const paths: string[] = []; diff --git a/frontend/e2e/agent-setup.spec.ts b/frontend/e2e/agent-setup.spec.ts new file mode 100644 index 000000000..db8e4cb55 --- /dev/null +++ b/frontend/e2e/agent-setup.spec.ts @@ -0,0 +1,182 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; +import { agentDemoFields, agentDemoRows } from '../src/lib/agents'; + +async function serve(page: Page, options: { empty?: boolean; error?: boolean } = {}) { + const mutations: string[] = []; + const queries: unknown[] = []; + await page.route('**/api/**', async (route) => { + const req = route.request(); + const path = new URL(req.url()).pathname; + if (req.method() !== 'GET' && path !== '/api/v1/query') mutations.push(path); + if (path === '/api/v1/logstream') + return route.fulfill( + options.error + ? { status: 503, body: 'Discovery unavailable' } + : { json: options.empty ? [] : [{ name: 'agent_telemetry' }] }, + ); + if (path.endsWith('/schema')) + return route.fulfill({ json: { fields: agentDemoFields.map((name) => ({ name })) } }); + if (path.endsWith('/info')) + return route.fulfill({ + json: { telemetryType: 'traces', logSource: [{ log_source_format: 'otel-traces' }] }, + }); + if (path === '/api/v1/query') { + queries.push(req.postDataJSON()); + return route.fulfill({ json: agentDemoRows(Date.parse('2026-10-12T16:00:00.000Z')) }); + } + if (path === '/api/v1/about') return route.fulfill({ json: { capabilities: {} } }); + return route.fulfill({ status: 404, body: 'No fixture' }); + }); + await page.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: async (value: string) => { + (window as unknown as { copied: string }).copied = value; + }, + }, + }); + }); + return { mutations, queries }; +} +const guide = (page: Page) => page.getByRole('dialog', { name: 'Set up agent telemetry' }); +const copied = (page: Page) => + page.evaluate(() => (window as unknown as { copied: string }).copied); + +test('Get started opens the shared guide and copies provider configs with explicit content choice', async ({ + page, +}) => { + const { mutations } = await serve(page, { empty: true }); + await page.goto('/onboarding'); + await page.getByRole('link', { name: 'Set up Claude Code or Codex' }).click(); + await expect(page).toHaveURL('/agents?setup=1'); + const panel = guide(page); + await expect(panel).toBeVisible(); + await expect(panel.getByRole('checkbox')).not.toBeChecked(); + await panel.getByLabel('Collector base URL').fill('https://collector.example/otel/'); + await panel.getByRole('button', { name: 'Copy Claude Code settings' }).click(); + const config = JSON.parse(await copied(page)); + expect(config.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT).toBe( + 'https://collector.example/otel/v1/traces', + ); + expect(config.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT).toBe( + 'https://collector.example/otel/v1/logs', + ); + expect(config.env.OTEL_LOG_USER_PROMPTS).toBe('0'); + expect(config.env.OTEL_LOG_ASSISTANT_RESPONSES).toBe('0'); + await panel.getByRole('checkbox').check(); + await panel.getByRole('button', { name: 'Copy Claude Code settings' }).click(); + expect(JSON.parse(await copied(page)).env.OTEL_LOG_TOOL_CONTENT).toBe('1'); + await panel.getByRole('tab', { name: 'Codex', exact: true }).click(); + await expect(panel.getByRole('checkbox')).not.toBeChecked(); + await panel.getByRole('button', { name: 'Copy Codex settings' }).click(); + expect(await copied(page)).toContain('endpoint = "https://collector.example/otel/v1/traces"'); + expect(await copied(page)).toContain('endpoint = "https://collector.example/otel/v1/logs"'); + expect(await copied(page)).toContain('log_user_prompt = false'); + await panel.getByRole('checkbox').check(); + await panel.getByText('Copy instructions for your coding agent', { exact: true }).click(); + await panel.getByRole('button', { name: 'Copy Telemetry setup agent brief' }).click(); + expect(await copied(page)).toContain('Set up Codex telemetry'); + expect(await copied(page)).toContain('log_user_prompt = true'); + expect(await copied(page)).toContain('Requested content flags are explicitly enabled'); + expect(mutations).toEqual([]); +}); + +test('invalid endpoints withhold copy actions and clipboard failure leaves selectable config', async ({ + page, +}) => { + await serve(page, { empty: true }); + await page.goto('/agents?setup=1'); + const panel = guide(page); + await panel.getByLabel('Collector base URL').fill('https://user:secret@example.com?token=x'); + await expect(panel.getByLabel('Collector base URL')).toHaveAttribute('aria-invalid', 'true'); + await expect(panel.getByRole('button', { name: 'Copy Claude Code settings' })).toHaveCount(0); + await panel.getByLabel('Collector base URL').fill('http://127.0.0.1:4318'); + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: async () => { + throw new Error('unavailable'); + }, + }, + }); + }); + await panel.getByRole('button', { name: 'Copy Claude Code settings' }).click(); + await expect( + panel.getByText('Clipboard is unavailable. Select and copy the configuration below.'), + ).toBeVisible(); + await expect(panel.getByLabel('Claude Code settings', { exact: true })).toContainText( + 'CLAUDE_CODE_ENABLE_TELEMETRY', + ); +}); + +test('guide preserves dataset, filters, frozen range and query count on close and reload', async ({ + page, +}) => { + const { queries } = await serve(page); + const params = new URLSearchParams({ + start: '2026-10-12T15:00:00.000Z', + end: '2026-10-12T16:00:00.000Z', + search: 'Support', + status: 'success', + model: 'example-model', + }); + await page.goto(`/agents/agent_telemetry?${params}`); + await expect(page.getByRole('button', { name: 'Inspect run Support assistant' })).toBeVisible(); + const count = queries.length; + await page.getByRole('button', { name: 'Set up telemetry', exact: true }).click(); + await expect(guide(page)).toBeVisible(); + await guide(page).getByRole('button', { name: 'Close dialog' }).click(); + await expect(guide(page)).toHaveCount(0); + for (const [key, value] of params) expect(new URL(page.url()).searchParams.get(key)).toBe(value); + expect(queries.length).toBe(count); + await expect(page.getByRole('button', { name: 'Set up telemetry', exact: true })).toBeFocused(); + await page.getByRole('button', { name: 'Set up telemetry', exact: true }).click(); + await page.reload(); + await expect(guide(page)).toBeVisible(); + await page.keyboard.press('Escape'); + for (const [key, value] of params) expect(new URL(page.url()).searchParams.get(key)).toBe(value); + await expect(page.getByRole('button', { name: 'Inspect run Support assistant' })).toBeVisible(); +}); + +for (const state of ['empty', 'error'] as const) { + test(`guide is available with ${state} discovery`, async ({ page }) => { + const { mutations } = await serve(page, { [state]: true }); + await page.goto('/agents'); + await page + .getByRole('button', { + name: state === 'empty' ? 'Configure an agent' : 'Set up telemetry', + exact: true, + }) + .click(); + await expect(guide(page)).toBeVisible(); + await expect( + guide(page).getByRole('button', { name: 'Copy Claude Code settings' }), + ).toBeVisible(); + expect(mutations).toEqual([]); + }); +} + +test('guide supports narrow screens, keyboard tabs and accessible controls', async ({ page }) => { + await serve(page, { empty: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/agents?setup=1'); + const panel = guide(page); + await expect(panel).toBeVisible(); + await panel.getByRole('tab', { name: 'Claude Code' }).focus(); + await page.keyboard.press('ArrowRight'); + await expect(panel.getByRole('tab', { name: 'Codex', exact: true })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await panel + .getByLabel('Collector base URL') + .fill('https://collector.example/some/long/path/prefix'); + await panel.getByText('Mapping and validation checklist', { exact: true }).click(); + await expect(panel.getByText('Map agent and operation', { exact: false })).toBeVisible(); + expect(await panel.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true); + expect((await new AxeBuilder({ page }).include('[role="dialog"]').analyze()).violations).toEqual( + [], + ); +}); diff --git a/frontend/e2e/agents-navigation.spec.ts b/frontend/e2e/agents-navigation.spec.ts new file mode 100644 index 000000000..fd3020366 --- /dev/null +++ b/frontend/e2e/agents-navigation.spec.ts @@ -0,0 +1,394 @@ +import { expect, test, type Page } from '@playwright/test'; +import { agentDemoFields, agentDemoRows } from '../src/lib/agents'; +import { storyTraceDetail, storyTraceList } from '../src/features/traces/fixtures'; +import { encodeLogView } from '../src/features/logs/logViewState'; +import { initialLogView } from '../src/features/logs/logViewReducer'; + +const bounds = { startTime: '2026-10-12T09:00:00.000Z', endTime: '2026-10-12T10:00:00.000Z' }; +async function mockNavigation(page: Page) { + const queries: Array<{ query: string; startTime: string; endTime: string }> = []; + const details: Array<{ traceId: string; startTime: string; endTime: string }> = []; + const lists: Array<{ + serviceName?: string; + options?: string; + startTime: string; + endTime: string; + }> = []; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'agent_telemetry' }] }); + if (path.endsWith('/schema')) + return route.fulfill({ + json: { fields: [...agentDemoFields, 'message', 'level'].map((name) => ({ name })) }, + }); + if (path.endsWith('/info')) + return route.fulfill({ + json: { telemetryType: 'traces', logSource: [{ log_source_format: 'otel-traces' }] }, + }); + if (path === '/api/v1/about') return route.fulfill({ json: { capabilities: {} } }); + if (path.endsWith('/services/traces/list')) { + lists.push(route.request().postDataJSON()); + return route.fulfill({ + json: { + count: storyTraceList.length, + records: storyTraceList.slice(0, 25), + offset: 0, + limit: 25, + }, + }); + } + if (path.endsWith('/services/traces/detail')) { + details.push(route.request().postDataJSON()); + return route.fulfill({ json: storyTraceDetail }); + } + if (path === '/api/v1/query') { + queries.push(route.request().postDataJSON()); + return route.fulfill({ + json: agentDemoRows(Date.parse(bounds.endTime)).map((row) => ({ + ...row, + message: 'Support request', + level: 'error', + })), + }); + } + return route.fulfill({ status: 404, body: 'No fixture' }); + }); + return { queries, details, lists }; +} + +test('Agents trace drilldown and browser back preserve filters, selected run and captured window', async ({ + page, +}) => { + const calls = await mockNavigation(page); + await page.goto( + `/agents/agent_telemetry?${new URLSearchParams({ start: bounds.startTime, end: bounds.endTime, range: '1h', search: 'Support', status: 'success', model: 'example-model' })}`, + ); + await expect(page.getByLabel('Search agent runs')).toHaveValue('Support'); + await expect(page.getByRole('button', { name: 'Inspect run Support assistant' })).toBeVisible(); + await page.getByRole('button', { name: 'Inspect run Support assistant' }).click(); + await page.getByRole('link', { name: 'Open trace', exact: true }).click(); + await expect(page.getByRole('dialog', { name: 'Trace detail' })).toBeVisible(); + await expect.poll(() => calls.details.length).toBeGreaterThan(0); + expect(calls.details.at(-1)).toMatchObject({ + ...bounds, + traceId: '00000000000000000000000000000001', + }); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('1h'); + expect(new URL(page.url()).searchParams.has('model')).toBe(false); + await page.goBack(); + await expect(page.getByRole('dialog', { name: 'Agent run details' })).toBeVisible(); + await expect(page.getByLabel('Search agent runs')).toHaveValue('Support'); + await expect(page.getByLabel('Run status')).toHaveValue('success'); + await expect(page.getByLabel('Run model')).toHaveValue('example-model'); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('1h'); +}); + +test('sidebar travel restores independent grouped log filters, trace filters and Agent filters', async ({ + page, +}) => { + const calls = await mockNavigation(page); + const logView = { + ...initialLogView('agent_telemetry'), + range: bounds, + search: 'Support', + expression: { + combinator: 'or' as const, + groups: [ + { + id: 'g', + combinator: 'and' as const, + rules: [{ id: 'r', field: 'level', operator: '=' as const, value: 'error' }], + }, + ], + }, + }; + await page.goto( + `/logs/explore/agent_telemetry?${new URLSearchParams({ logView: encodeLogView(logView) })}`, + ); + await expect(page.getByLabel('Search messages')).toHaveValue('Support'); + await expect(page.getByLabel('Remove filter level = error')).toBeVisible(); + await expect.poll(() => new URL(page.url()).searchParams.has('observe')).toBe(true); + await page.getByTestId('sidebar-agents').click(); + await expect(page.getByRole('button', { name: 'Inspect run Support assistant' })).toBeVisible(); + await page.getByLabel('Search agent runs').fill('Support'); + await page.getByLabel('Run status').selectOption('success'); + await page.getByLabel('Run model').selectOption('example-model'); + await page.getByTestId('sidebar-traces').click(); + await expect(page.getByTestId('trace-table')).toBeVisible(); + await page.getByLabel('Service', { exact: true }).fill('worker'); + await page.getByRole('button', { name: 'Apply service' }).click(); + await page.getByLabel('Status', { exact: true }).selectOption('error'); + await page.getByLabel('Sort by').selectOption('longestFirst'); + await expect.poll(() => calls.lists.at(-1)?.serviceName).toBe('worker'); + await page.getByTestId('sidebar-logs').click(); + await expect(page.getByLabel('Search messages')).toHaveValue('Support'); + await expect(page.getByLabel('Remove filter level = error')).toBeVisible(); + await expect.poll(() => calls.queries.at(-1)?.query).toContain('"level" = \'error\''); + await expect.poll(() => new URL(page.url()).searchParams.has('logView')).toBe(false); + await page.getByTestId('sidebar-agents').click(); + await expect(page.getByLabel('Search agent runs')).toHaveValue('Support'); + await expect(page.getByLabel('Run status')).toHaveValue('success'); + await expect(page.getByLabel('Run model')).toHaveValue('example-model'); + await page.getByTestId('sidebar-traces').click(); + await expect(page.getByLabel('Service', { exact: true })).toHaveValue('worker'); + await expect(page.getByLabel('Status', { exact: true })).toHaveValue('error'); + await expect(page.getByLabel('Sort by')).toHaveValue('longestFirst'); + expect(calls.lists.at(-1)).toMatchObject(bounds); +}); + +test('opening run logs combines trace focus with saved log criteria and retains absolute bounds', async ({ + page, +}) => { + const calls = await mockNavigation(page); + await page.goto( + `/agents/agent_telemetry?${new URLSearchParams({ start: bounds.startTime, end: bounds.endTime })}`, + ); + await page.getByRole('button', { name: 'Inspect run Support assistant' }).click(); + await page + .getByRole('dialog', { name: 'Agent run details' }) + .getByRole('link', { name: 'Open logs', exact: true }) + .click(); + await expect(page.getByRole('button', { name: 'Clear trace filter' })).toBeVisible(); + await expect + .poll(() => calls.queries.at(-1)?.query) + .toContain('"span_trace_id" = \'00000000000000000000000000000001\''); + expect(calls.queries.at(-1)).toMatchObject(bounds); + await page.getByTestId('sidebar-agents').click(); + await expect(page.getByRole('dialog', { name: 'Agent run details' })).toBeVisible(); +}); + +test('consumed Logs navigation restores filters on reload and browser back', async ({ page }) => { + const calls = await mockNavigation(page); + const logView = { + ...initialLogView('agent_telemetry'), + range: bounds, + search: 'Support', + expression: { + combinator: 'and' as const, + groups: [ + { + id: 'g', + combinator: 'and' as const, + rules: [{ id: 'r', field: 'level', operator: '=' as const, value: 'error' }], + }, + ], + }, + }; + await page.goto( + `/logs/explore/agent_telemetry?${new URLSearchParams({ logView: encodeLogView(logView) })}`, + ); + await expect.poll(() => new URL(page.url()).searchParams.has('observe')).toBe(true); + await page.reload(); + await expect(page.getByLabel('Search messages')).toHaveValue('Support'); + await expect(page.getByLabel('Remove filter level = error')).toBeVisible(); + await expect.poll(() => calls.queries.at(-1)?.query).toContain('"level" = \'error\''); + expect(calls.queries.at(-1)).toMatchObject(bounds); + await page.getByTestId('sidebar-agents').click(); + await expect(page.getByRole('button', { name: 'Inspect run Support assistant' })).toBeVisible(); + await page.goBack(); + await expect(page.getByLabel('Search messages')).toHaveValue('Support'); + await expect(page.getByLabel('Remove filter level = error')).toBeVisible(); + expect(calls.queries.at(-1)).toMatchObject(bounds); +}); + +test('switching Logs dataset during a native handoff resets its filters and retains the window', async ({ + page, +}) => { + const calls = await mockNavigation(page); + await page.route('**/api/v1/logstream', (route) => + route.fulfill({ json: [{ name: 'agent_telemetry' }, { name: 'other_dataset' }] }), + ); + const logView = { ...initialLogView('agent_telemetry'), range: bounds, search: 'Support' }; + await page.goto( + `/logs/explore/agent_telemetry?${new URLSearchParams({ logView: encodeLogView(logView) })}`, + ); + await expect.poll(() => new URL(page.url()).searchParams.has('observe')).toBe(true); + await page.getByLabel('Dataset', { exact: true }).selectOption('other_dataset'); + await expect(page.getByLabel('Search messages')).toHaveValue(''); + await expect.poll(() => calls.queries.at(-1)?.query).toContain('"other_dataset"'); + expect(calls.queries.at(-1)).toMatchObject(bounds); + await expect(page.getByText('The shared view dataset does not match this page.')).toHaveCount(0); +}); + +test('trace focus narrows OR groups and replacing the focused run never retains an old trace predicate', async ({ + page, +}) => { + const calls = await mockNavigation(page); + const logView = { + ...initialLogView('agent_telemetry'), + range: bounds, + expression: { + combinator: 'or' as const, + groups: [ + { + id: 'g1', + combinator: 'and' as const, + rules: [{ id: 'r1', field: 'level', operator: '=' as const, value: 'error' }], + }, + { + id: 'g2', + combinator: 'and' as const, + rules: [{ id: 'r2', field: 'message', operator: '=' as const, value: 'Support request' }], + }, + ], + }, + }; + await page.goto( + `/logs/explore/agent_telemetry?${new URLSearchParams({ logView: encodeLogView(logView) })}`, + ); + await expect.poll(() => new URL(page.url()).searchParams.has('observe')).toBe(true); + await page.getByTestId('sidebar-agents').click(); + await page.getByRole('button', { name: 'Inspect run Support assistant' }).click(); + await page + .getByRole('dialog', { name: 'Agent run details' }) + .getByRole('link', { name: 'Open logs', exact: true }) + .click(); + await expect(page.getByRole('button', { name: 'Clear trace filter' })).toBeVisible(); + await expect + .poll(() => calls.queries.at(-1)?.query) + .toContain('"span_trace_id" = \'00000000000000000000000000000001\''); + expect(calls.queries.at(-1)?.query).toContain(' OR '); + await expect(page.getByLabel('Match groups')).toHaveValue('or'); + await page.getByTestId('sidebar-agents').click(); + await expect(page.getByRole('dialog', { name: 'Agent run details' })).toBeVisible(); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Inspect run Research assistant' }).click(); + await page + .getByRole('dialog', { name: 'Agent run details' }) + .getByRole('link', { name: 'Open logs', exact: true }) + .click(); + await expect + .poll(() => calls.queries.at(-1)?.query) + .toContain('"span_trace_id" = \'00000000000000000000000000000002\''); + expect(calls.queries.at(-1)?.query).not.toContain('00000000000000000000000000000001'); + expect(calls.queries.at(-1)?.query).toContain(' OR '); + await page.getByRole('button', { name: 'Clear trace filter' }).click(); + await expect.poll(() => calls.queries.at(-1)?.query).not.toContain('"span_trace_id"'); + await expect(page.getByLabel('Remove filter level = error')).toBeVisible(); +}); + +test('sidebar travel preserves trace filter edits while their list request is pending', async ({ + page, +}) => { + await mockNavigation(page); + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + let started = false; + await page.route('**/services/traces/list', async (route) => { + if (route.request().postDataJSON().serviceName === 'slow-worker') { + started = true; + await pending; + } + await route.fallback().catch(() => {}); + }); + try { + await page.goto( + `/traces/explore/agent_telemetry?${new URLSearchParams({ start: bounds.startTime, end: bounds.endTime })}`, + ); + await expect(page.getByTestId('trace-table')).toBeVisible(); + await page.getByLabel('Service', { exact: true }).fill('slow-worker'); + await page.getByRole('button', { name: 'Apply service' }).click(); + await expect.poll(() => started).toBe(true); + await page.getByTestId('sidebar-agents').click(); + await expect(page.getByRole('button', { name: 'Inspect run Support assistant' })).toBeVisible(); + await page.getByTestId('sidebar-traces').click(); + await expect(page.getByLabel('Service', { exact: true })).toHaveValue('slow-worker'); + } finally { + release(); + } +}); + +test('run log handoffs use trace_id aliases and report absent trace fields without invalid SQL', async ({ + page, +}) => { + const calls = await mockNavigation(page); + let hasTraceField = true; + await page.route('**/schema', (route) => + route.fulfill({ + json: { + fields: [ + ...agentDemoFields.filter((field) => field !== 'span_trace_id'), + ...(hasTraceField ? ['trace_id'] : []), + 'message', + 'level', + ].map((name) => ({ name })), + }, + }), + ); + await page.goto( + `/agents/agent_telemetry?${new URLSearchParams({ start: bounds.startTime, end: bounds.endTime })}`, + ); + await page.getByRole('button', { name: 'Inspect run Support assistant' }).click(); + await page + .getByRole('dialog', { name: 'Agent run details' }) + .getByRole('link', { name: 'Open logs', exact: true }) + .click(); + await expect + .poll(() => calls.queries.at(-1)?.query) + .toContain('"trace_id" = \'00000000000000000000000000000001\''); + await page.getByTestId('sidebar-agents').click(); + await expect(page.getByRole('dialog', { name: 'Agent run details' })).toBeVisible(); + hasTraceField = false; + const before = calls.queries.length; + await page + .getByRole('dialog', { name: 'Agent run details' }) + .getByRole('link', { name: 'Open logs', exact: true }) + .click(); + await expect( + page.getByText('This dataset has no trace ID field. Trace-focused logs are unavailable.'), + ).toBeVisible(); + expect(calls.queries).toHaveLength(before); +}); + +for (const parameter of ['traceId', 'logTraceId']) { + test(`standalone ${parameter} links apply trace focus without navigation context`, async ({ + page, + }) => { + const calls = await mockNavigation(page); + await page.goto( + `/logs/explore/agent_telemetry?${new URLSearchParams({ [parameter]: '00000000000000000000000000000001' })}`, + ); + await expect(page.getByRole('button', { name: 'Clear trace filter' })).toBeVisible(); + await expect + .poll(() => calls.queries.at(-1)?.query) + .toContain('"span_trace_id" = \'00000000000000000000000000000001\''); + await expect.poll(() => new URL(page.url()).searchParams.has(parameter)).toBe(false); + await page.reload(); + await expect(page.getByRole('button', { name: 'Clear trace filter' })).toBeVisible(); + }); + + test(`standalone ${parameter} links recover when the dataset has no trace field`, async ({ + page, + }) => { + const calls = await mockNavigation(page); + await page.route('**/schema', (route) => + route.fulfill({ json: { fields: [{ name: 'p_timestamp' }, { name: 'message' }] } }), + ); + await page.goto( + `/logs/explore/agent_telemetry?${new URLSearchParams({ [parameter]: '00000000000000000000000000000001' })}`, + ); + await expect( + page.getByText('This dataset has no trace ID field. Trace-focused logs are unavailable.'), + ).toBeVisible(); + expect(calls.queries).toHaveLength(0); + await page.getByRole('button', { name: 'Clear invalid shared view' }).click(); + await expect.poll(() => calls.queries.length).toBeGreaterThan(0); + expect(calls.queries.at(-1)?.query).not.toContain('"span_trace_id"'); + await expect(page.getByRole('button', { name: 'Clear invalid shared view' })).toHaveCount(0); + }); +} + +test('malformed standalone trace links show a notice and continue loading Logs', async ({ + page, +}) => { + const calls = await mockNavigation(page); + await page.goto('/logs/explore/agent_telemetry?traceId=not-a-trace'); + await expect( + page.getByText('The trace ID in this link is invalid and was not applied.'), + ).toBeVisible(); + await expect.poll(() => calls.queries.length).toBeGreaterThan(0); + expect(calls.queries.at(-1)?.query).not.toContain('"span_trace_id"'); + await expect.poll(() => new URL(page.url()).searchParams.has('traceId')).toBe(false); +}); diff --git a/frontend/e2e/agents-review-navigation.spec.ts b/frontend/e2e/agents-review-navigation.spec.ts new file mode 100644 index 000000000..c9e7704d5 --- /dev/null +++ b/frontend/e2e/agents-review-navigation.spec.ts @@ -0,0 +1,243 @@ +import { expect, test, type Page } from '@playwright/test'; +import { STORY_TRACE_ID, storyTraceDetail, storyTraceList } from '../src/features/traces/fixtures'; +import { encodeLogView, decodeLogView } from '../src/features/logs/logViewState'; +import { initialLogView } from '../src/features/logs/logViewReducer'; + +const bounds = { startTime: '2026-10-01T09:00:00.000Z', endTime: '2026-10-01T10:00:00.000Z' }; +type Window = typeof bounds; +async function mockNavigation(page: Page) { + const queries: Array = []; + const lists: Array = []; + const details: Array = []; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/logstream')) return route.fulfill({ json: [{ name: 'events' }] }); + if (path.endsWith('/schema')) + return route.fulfill({ + json: { fields: ['p_timestamp', 'span_trace_id', 'message'].map((name) => ({ name })) }, + }); + if (path.endsWith('/info')) + return route.fulfill({ + json: { telemetryType: 'traces', logSource: [{ log_source_format: 'otel-traces' }] }, + }); + if (path.endsWith('/services/traces/list')) { + lists.push(route.request().postDataJSON()); + return route.fulfill({ + json: { count: 1, records: storyTraceList.slice(0, 1), offset: 0, limit: 25 }, + }); + } + if (path.endsWith('/services/traces/detail')) { + details.push(route.request().postDataJSON()); + return route.fulfill({ json: storyTraceDetail }); + } + if (path.endsWith('/query')) { + const query = route.request().postDataJSON(); + queries.push(query); + return route.fulfill({ + json: query.query.startsWith('SELECT *') + ? [ + { + p_timestamp: '2026-10-01T09:30:00.000Z', + span_trace_id: STORY_TRACE_ID, + message: 'historical event', + }, + ] + : [], + }); + } + return route.fulfill({ json: {} }); + }); + return { queries, lists, details }; +} + +test('trace-focused Share retains its frozen window on open, reload and explorer handoffs', async ({ + page, +}) => { + const calls = await mockNavigation(page); + const rows = () => calls.queries.filter((query) => query.query.startsWith('SELECT *')); + await page.goto( + `/logs/explore/events?${new URLSearchParams({ traceId: STORY_TRACE_ID, start: bounds.startTime, end: bounds.endTime })}`, + ); + await expect(page.getByRole('button', { name: 'Clear trace filter' })).toBeVisible(); + await expect.poll(() => rows().at(-1)).toMatchObject(bounds); + await expect.poll(() => new URL(page.url()).searchParams.has('observe')).toBe(true); + await page.getByRole('button', { name: 'Share / copy link' }).click(); + const shared = await page.getByLabel('View link').inputValue(); + const params = new URL(shared).searchParams; + expect(decodeLogView(params.get('logView')!).range).toEqual(bounds); + expect(params.get('logTraceId')).toBe(STORY_TRACE_ID); + calls.queries.length = 0; + await page.goto(shared); + await expect.poll(() => rows().at(-1)).toMatchObject(bounds); + expect(rows().at(-1)?.query).toContain(`"span_trace_id" = '${STORY_TRACE_ID}'`); + await expect.poll(() => new URL(page.url()).searchParams.has('logView')).toBe(false); + calls.queries.length = 0; + await page.reload(); + await expect.poll(() => rows().at(-1)).toMatchObject(bounds); + await expect(page.getByRole('button', { name: 'Clear trace filter' })).toBeVisible(); + await page.getByTestId('sidebar-traces').click(); + await expect.poll(() => calls.lists.at(-1)).toMatchObject(bounds); + calls.queries.length = 0; + await page.getByTestId('sidebar-logs').click(); + await expect.poll(() => rows().at(-1)).toMatchObject(bounds); + expect(rows().at(-1)?.query).toContain(`"span_trace_id" = '${STORY_TRACE_ID}'`); +}); + +test('explicit preset handoffs override a shared view while preserving trace focus', async ({ + page, +}) => { + const calls = await mockNavigation(page); + const params = new URLSearchParams({ + logView: encodeLogView({ ...initialLogView('events'), range: bounds, search: 'historical' }), + logTraceId: STORY_TRACE_ID, + range: '6h', + start: '2026-10-02T04:00:00.000Z', + end: '2026-10-02T10:00:00.000Z', + }); + await page.goto(`/logs/explore/events?${params}`); + await expect + .poll(() => calls.queries.filter((query) => query.query.startsWith('SELECT *')).at(-1)) + .toMatchObject({ + startTime: params.get('start'), + endTime: params.get('end'), + }); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('6h'); + const query = calls.queries.filter((query) => query.query.startsWith('SELECT *')).at(-1)?.query; + expect(query).toContain(STORY_TRACE_ID); + expect(query).toContain('historical'); +}); + +test('Traces Refresh reloads fixed list and open detail, and advances preset bounds', async ({ + page, +}) => { + const calls = await mockNavigation(page); + await page.goto( + `/traces/explore/events?${new URLSearchParams({ start: bounds.startTime, end: bounds.endTime, service: 'api-gateway', sort: 'longestFirst' })}`, + ); + await expect(page.getByTestId('trace-table')).toBeVisible(); + await expect.poll(() => new URL(page.url()).searchParams.has('observe')).toBe(true); + await page.waitForLoadState('networkidle'); + const before = calls.lists.length; + const beforeClosedDetail = calls.details.length; + await page.getByRole('button', { name: 'Refresh traces' }).click(); + await expect.poll(() => calls.lists.length).toBe(before + 1); + await page.waitForLoadState('networkidle'); + expect(calls.lists).toHaveLength(before + 1); + expect(calls.details).toHaveLength(beforeClosedDetail); + expect(calls.lists.at(-1)).toMatchObject({ + ...bounds, + serviceName: 'api-gateway', + sortBy: 'longestFirst', + }); + await page.getByRole('button', { name: `Open trace ${STORY_TRACE_ID}, POST /orders` }).click(); + await expect(page.getByRole('dialog', { name: 'Trace detail', exact: true })).toBeVisible(); + await expect.poll(() => calls.details.length).toBeGreaterThan(0); + await page.waitForLoadState('networkidle'); + const beforeDetail = calls.details.length; + const beforeList = calls.lists.length; + // The modal hides the toolbar; exercise its handler with the detail request retained. + await page + .getByRole('button', { name: 'Refresh traces', includeHidden: true }) + .evaluate((button: HTMLButtonElement) => button.click()); + await expect.poll(() => calls.lists.length).toBe(beforeList + 1); + await expect.poll(() => calls.details.length).toBe(beforeDetail + 1); + await page.waitForLoadState('networkidle'); + expect(calls.lists).toHaveLength(beforeList + 1); + expect(calls.details).toHaveLength(beforeDetail + 1); + expect(calls.lists.at(-1)).toMatchObject(bounds); + expect(calls.details.at(-1)).toMatchObject({ ...bounds, traceId: STORY_TRACE_ID }); + await page.getByRole('button', { name: 'Close dialog' }).click(); + await expect(page.getByRole('dialog', { name: 'Trace detail', exact: true })).toHaveCount(0); + await expect.poll(() => new URL(page.url()).searchParams.has('traceId')).toBe(false); + // Wait for the closed detail to reach the remembered explorer snapshot before + // testing a handoff; a pending close must not be mistaken for a refresh failure. + await expect + .poll(() => { + const observe = new URL(page.url()).searchParams.get('observe'); + if (!observe) return true; + const query = JSON.parse(observe).views.traces?.query; + return query === undefined || new URLSearchParams(query).has('traceId'); + }) + .toBe(false); + await page.getByLabel('Time range', { exact: true }).selectOption('1h'); + await expect.poll(() => new URL(page.url()).searchParams.get('range')).toBe('1h'); + await expect.poll(() => calls.lists.at(-1)?.startTime).not.toBe(bounds.startTime); + await page.getByTestId('sidebar-logs').click(); + await expect(page.locator('.logs-page')).toBeVisible(); + await expect(page.locator('.logs-page').getByLabel('Time range', { exact: true })).toHaveValue( + '1h', + ); + await page.getByTestId('sidebar-traces').click(); + await expect(page).toHaveURL(/\/traces\/explore\/events\?/); + await expect(page.getByTestId('trace-table')).toBeVisible(); + await expect(page.getByRole('dialog', { name: 'Trace detail', exact: true })).toHaveCount(0); + await expect(page.getByLabel('Time range', { exact: true })).toHaveValue('1h'); + await page.waitForLoadState('networkidle'); + expect(calls.lists.at(-1)).toMatchObject({ serviceName: 'api-gateway', sortBy: 'longestFirst' }); + const presetBefore = calls.lists.length; + const presetClosedDetail = calls.details.length; + const previousEnd = calls.lists.at(-1)!.endTime; + await page.getByRole('button', { name: 'Refresh traces' }).click(); + await expect.poll(() => calls.lists.length).toBe(presetBefore + 1); + await page.waitForLoadState('networkidle'); + expect(calls.lists).toHaveLength(presetBefore + 1); + expect(calls.details).toHaveLength(presetClosedDetail); + expect(Date.parse(calls.lists.at(-1)!.endTime)).toBeGreaterThan(Date.parse(previousEnd)); + expect(Date.parse(calls.lists.at(-1)!.endTime) - Date.parse(calls.lists.at(-1)!.startTime)).toBe( + 3_600_000, + ); + await page.getByRole('button', { name: `Open trace ${STORY_TRACE_ID}, POST /orders` }).click(); + await expect(page.getByRole('dialog', { name: 'Trace detail', exact: true })).toBeVisible(); + // Opening mounts a new Sheet: development StrictMode replays its loading + // effect. Establish a settled baseline; Refresh below must still fetch once. + await expect.poll(() => calls.details.length).toBeGreaterThan(presetClosedDetail); + await page.waitForLoadState('networkidle'); + expect(calls.details.at(-1)).toMatchObject({ + startTime: calls.lists.at(-1)!.startTime, + endTime: calls.lists.at(-1)!.endTime, + traceId: STORY_TRACE_ID, + }); + const presetDetailBefore = calls.details.length; + const presetListBefore = calls.lists.length; + const previousDetailEnd = calls.details.at(-1)!.endTime; + await page + .getByRole('button', { name: 'Refresh traces', includeHidden: true }) + .evaluate((button: HTMLButtonElement) => button.click()); + await expect.poll(() => calls.lists.length).toBe(presetListBefore + 1); + await expect.poll(() => calls.details.length).toBe(presetDetailBefore + 1); + await page.waitForLoadState('networkidle'); + expect(calls.lists).toHaveLength(presetListBefore + 1); + expect(calls.details).toHaveLength(presetDetailBefore + 1); + for (const request of [ + ...calls.lists.slice(presetListBefore), + ...calls.details.slice(presetDetailBefore), + ]) { + expect(Date.parse(request.endTime)).toBeGreaterThan(Date.parse(previousDetailEnd)); + expect(Date.parse(request.endTime) - Date.parse(request.startTime)).toBe(3_600_000); + } + expect(calls.details.at(-1)).toMatchObject({ + startTime: calls.lists.at(-1)!.startTime, + endTime: calls.lists.at(-1)!.endTime, + traceId: STORY_TRACE_ID, + }); +}); + +test('rapid Logs handoffs cannot replace a newer Traces navigation', async ({ page }) => { + await mockNavigation(page); + await page.goto( + `/traces/explore/events?${new URLSearchParams({ start: bounds.startTime, end: bounds.endTime, service: 'api-gateway' })}`, + ); + await expect(page.getByTestId('trace-table')).toBeVisible(); + await expect.poll(() => new URL(page.url()).searchParams.has('observe')).toBe(true); + // Browser history changes before the outgoing Traces DOM necessarily commits. + // Return immediately, while Logs can still have a handoff effect pending. + await page.getByTestId('sidebar-logs').click(); + await expect(page).toHaveURL(/\/logs\/explore\/events\?/); + await page.getByTestId('sidebar-traces').click(); + await expect(page).toHaveURL(/\/traces\/explore\/events\?/); + await expect(page.getByTestId('trace-table')).toBeVisible(); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(/\/traces\/explore\/events\?/); + await expect(page.getByRole('button', { name: 'Refresh traces' })).toBeVisible(); + await expect(page.locator('.logs-page')).toHaveCount(0); +}); diff --git a/frontend/e2e/agents-review-search.spec.ts b/frontend/e2e/agents-review-search.spec.ts new file mode 100644 index 000000000..6fb7f7197 --- /dev/null +++ b/frontend/e2e/agents-review-search.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, type Page } from '@playwright/test'; +import { agentDemoFields, agentDemoRows } from '../src/lib/agents'; + +const bounds = { + start: '2026-10-01T09:00:00.000Z', + end: '2026-10-01T10:00:00.000Z', +}; + +async function mockAgents(page: Page) { + let queries = 0; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/logstream')) + return route.fulfill({ json: [{ name: 'agent_telemetry' }, { name: 'other_agents' }] }); + if (path.endsWith('/schema')) + return route.fulfill({ + json: { fields: [...agentDemoFields, 'message', 'captured'].map((name) => ({ name })) }, + }); + if (path.endsWith('/query')) { + queries++; + return route.fulfill({ + json: agentDemoRows(Date.parse(bounds.end)).map((row) => ({ + ...row, + message: 'C:\\repo', + captured: { messages: [{ content: 'Say "hello"' }] }, + })), + }); + } + return route.fulfill({ json: {} }); + }); + return () => queries; +} + +async function openAgents(page: Page, search = '') { + await page.goto(`/agents/agent_telemetry?${new URLSearchParams({ ...bounds, search })}`); + await expect(page.getByRole('button', { name: 'Inspect run Support assistant' })).toBeVisible(); + await expect.poll(() => new URL(page.url()).searchParams.has('observe')).toBe(true); +} + +test('search preserves the caret and every rapidly typed character without refetching', async ({ + page, +}) => { + const queryCount = await mockAgents(page); + await openAgents(page, 'Support'); + const before = queryCount(); + const input = page.getByLabel('Search agent runs'); + await input.focus(); + await input.evaluate((node: HTMLInputElement) => node.setSelectionRange(3, 3)); + await input.pressSequentially('X'); + await expect(input).toHaveValue('SupXport'); + await expect.poll(() => input.evaluate((node: HTMLInputElement) => node.selectionStart)).toBe(4); + await input.pressSequentially('Y'); + await expect(input).toHaveValue('SupXYport'); + await input.fill(''); + await input.pressSequentially('Support', { delay: 0 }); + await expect(input).toHaveValue('Support'); + await expect.poll(() => new URL(page.url()).searchParams.get('search')).toBe('Support'); + await expect(page.getByRole('button', { name: 'Inspect run Support assistant' })).toBeVisible(); + expect(queryCount()).toBe(before); + expect(new URL(page.url()).searchParams.get('start')).toBe(bounds.start); + expect(new URL(page.url()).searchParams.get('end')).toBe(bounds.end); +}); + +test('search matches literal nested values without matching column names', async ({ page }) => { + await mockAgents(page); + await openAgents(page); + const input = page.getByLabel('Search agent runs'); + for (const value of ['C:\\repo', 'say "HELLO"']) { + await input.fill(value); + await expect(page.getByRole('button', { name: 'Inspect run Support assistant' })).toBeVisible(); + } + await input.fill('span_parent_span_id'); + await expect(page.getByText('No matching runs', { exact: true })).toBeVisible(); +}); + +test('Back and Forward restore search, and dataset navigation retains the latest edit', async ({ + page, +}) => { + const queryCount = await mockAgents(page); + await openAgents(page, 'Support'); + const input = page.getByLabel('Search agent runs'); + await page.getByRole('button', { name: 'Inspect run Support assistant' }).click(); + await expect(page.getByRole('dialog', { name: 'Agent run details' })).toBeVisible(); + await page + .getByRole('dialog', { name: 'Agent run details' }) + .getByRole('button', { name: 'Close dialog', exact: true }) + .click(); + // Closing navigates in a transition. Until that commits, Radix still traps + // focus in the sheet; fill() alone can target the background input too early. + await expect(page.getByRole('dialog', { name: 'Agent run details' })).toBeHidden(); + await expect.poll(() => new URL(page.url()).searchParams.has('run')).toBe(false); + await input.click(); + await expect(input).toBeFocused(); + await input.fill('Research'); + await expect.poll(() => new URL(page.url()).searchParams.get('search')).toBe('Research'); + const before = queryCount(); + await page.goBack(); + await expect(input).toHaveValue('Support'); + await page.goForward(); + await expect(input).toHaveValue('Research'); + expect(queryCount()).toBe(before); + await input.fill('Support'); + await page.getByLabel('Agents dataset').focus(); + await expect( + page.getByLabel('Agents dataset').getByRole('option', { name: 'other_agents' }), + ).toBeAttached(); + await page.getByLabel('Agents dataset').selectOption('other_agents'); + await expect(page).toHaveURL(/\/agents\/other_agents\?/); + await expect(input).toHaveValue('Support'); + await expect(page.getByRole('button', { name: 'Inspect run Support assistant' })).toBeVisible(); + expect(new URL(page.url()).searchParams.get('search')).toBe('Support'); + expect(new URL(page.url()).searchParams.get('start')).toBe(bounds.start); + expect(new URL(page.url()).searchParams.get('end')).toBe(bounds.end); +}); diff --git a/frontend/e2e/agents.spec.ts b/frontend/e2e/agents.spec.ts index fa4ce4b6e..d8878ac4a 100644 --- a/frontend/e2e/agents.spec.ts +++ b/frontend/e2e/agents.spec.ts @@ -1,12 +1,311 @@ -import { expect, test, type Page } from '@playwright/test'; -import { agentDemoFields, agentDemoRows } from '../src/lib/agents'; -async function mockAgents(page: Page, status = 200, empty = false) { - const captured: Array<{ query: string; startTime: string; endTime: string }> = []; +import { expect, test, type Locator, type Page } from '@playwright/test'; +import type { LogRecord } from '../src/lib/types'; + +// Fixtures mirror rows produced by src/otel/traces.rs: one row per span, or one +// row per span event (and link) with every span, scope and resource field +// copied onto it. Timestamps are nanosecond text with *_epoch companions; an +// OTLP end time of zero is flattened as epoch 0 with a duration of 0. +const bounds = { + startTime: '2026-10-12T15:00:00.000Z', + endTime: '2026-10-12T16:00:00.000Z', +}; +const range = new URLSearchParams({ start: bounds.startTime, end: bounds.endTime }); +const base = Date.parse(bounds.startTime); +const traceIds = { + support: 'a1'.repeat(16), + research: 'b2'.repeat(16), + billing: 'c3'.repeat(16), + triage: 'd4'.repeat(16), +}; + +function nanoText(ms: number) { + return new Date(ms).toISOString().replace('Z', '000000Z'); +} +function ingested(ms: number) { + return new Date(ms).toISOString().replace('Z', ''); +} + +type SpanFixture = { + trace: string; + id: string; + parent?: string; + name: string; + start: number; + /** Omitted when the exporter sent no end time. */ + duration?: number; + status?: 0 | 1 | 2; + message?: string; + attributes?: LogRecord; + events?: LogRecord[]; +}; + +function spanRows(span: SpanFixture): LogRecord[] { + const end = span.duration === undefined ? undefined : span.start + span.duration; + const status = span.status ?? 0; + const row: LogRecord = { + p_timestamp: ingested((end ?? span.start) + 250), + 'service.name': 'agent-gateway', + 'telemetry.sdk.language': 'python', + resource_dropped_attributes_count: 0, + resource_schema_url: '', + scope_name: 'opentelemetry.instrumentation.openai_agents', + scope_version: '0.1.0', + scope_dropped_attributes_count: 0, + scope_schema_url: '', + span_trace_id: span.trace, + span_span_id: span.id, + span_parent_span_id: span.parent ?? '', + span_trace_state: '', + span_flags: 1, + span_flags_description: '', + span_name: span.name, + span_kind: span.attributes?.['gen_ai.operation.name'] === 'chat' ? 3 : 1, + span_kind_description: + span.attributes?.['gen_ai.operation.name'] === 'chat' ? 'CLIENT' : 'INTERNAL', + span_start_time_unix_nano: nanoText(span.start), + span_start_time_unix_nano_epoch: span.start * 1e6, + // The backend writes ingestion time as text when OTLP end time is zero. + span_end_time_unix_nano: nanoText(end ?? span.start + 90_000), + span_end_time_unix_nano_epoch: end === undefined ? 0 : end * 1e6, + span_duration_ns: end === undefined ? 0 : (end - span.start) * 1e6, + span_dropped_attributes_count: 0, + span_dropped_events_count: 0, + span_dropped_links_count: 0, + span_status_code: status, + span_status_description: ['UNSET', 'OK', 'ERROR'][status], + span_status_message: span.message ?? '', + ...span.attributes, + }; + if (!span.events?.length) return [row]; + return span.events.map((event) => ({ ...row, event_dropped_attributes_count: 0, ...event })); +} + +function event(name: string, at: number, start: number, attributes: LogRecord = {}): LogRecord { + return { + event_name: name, + event_time_unix_nano: nanoText(at), + event_time_unix_nano_epoch: at * 1e6, + event_duration_ns: (at - start) * 1e6, + ...Object.fromEntries( + Object.entries(attributes).map(([key, value]) => [`event_${key}`, value]), + ), + }; +} + +const agent = (name: string) => ({ + 'gen_ai.operation.name': 'invoke_agent', + 'gen_ai.agent.name': name, + 'gen_ai.agent.id': `asst_${name.split(' ')[0].toLowerCase()}`, +}); + +// Successful: all spans completed with OTel's default UNSET status. The first +// chat span streams two chunk events, so it arrives as two duplicated rows. +const supportStart = base + 10 * 60_000; +const support: SpanFixture[] = [ + { + trace: traceIds.support, + id: 'a100000000000001', + name: 'invoke_agent Support assistant', + start: supportStart, + duration: 4200, + attributes: agent('Support assistant'), + }, + { + trace: traceIds.support, + id: 'a100000000000002', + parent: 'a100000000000001', + name: 'chat gpt-4o', + start: supportStart + 100, + duration: 1800, + attributes: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4o', + 'gen_ai.response.model': 'gpt-4o-2024-08-06', + 'gen_ai.usage.input_tokens': 1200, + 'gen_ai.usage.output_tokens': 300, + 'gen_ai.prompt.0.role': 'user', + 'gen_ai.prompt.0.content': 'Where is order 8812?', + 'gen_ai.completion.0.role': 'assistant', + 'gen_ai.completion.0.content': 'Let me look that order up for you.', + }, + events: [ + event('llm.content.completion.chunk', supportStart + 900, supportStart + 100), + event('llm.content.completion.chunk', supportStart + 1700, supportStart + 100), + ], + }, + { + trace: traceIds.support, + id: 'a100000000000003', + parent: 'a100000000000001', + name: 'execute_tool lookup_order', + start: supportStart + 2000, + duration: 650, + attributes: { + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'lookup_order', + 'gen_ai.tool.call.id': 'call_Q1x8', + 'gen_ai.tool.call.arguments': '{"order_id":"8812"}', + }, + }, + { + trace: traceIds.support, + id: 'a100000000000004', + parent: 'a100000000000001', + name: 'chat gpt-4o-mini', + start: supportStart + 2800, + duration: 1300, + attributes: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4o-mini', + 'gen_ai.response.model': 'gpt-4o-mini-2024-07-18', + 'gen_ai.usage.input_tokens': 300, + 'gen_ai.usage.output_tokens': 120, + }, + }, +]; + +// Failed: the root reports ERROR after a tool timeout recorded as an exception. +const researchStart = base + 20 * 60_000; +const research: SpanFixture[] = [ + { + trace: traceIds.research, + id: 'b200000000000001', + name: 'invoke_agent Research assistant', + start: researchStart, + duration: 31_500, + status: 2, + message: 'Tool call failed: upstream timeout', + attributes: agent('Research assistant'), + }, + { + trace: traceIds.research, + id: 'b200000000000002', + parent: 'b200000000000001', + name: 'chat claude-sonnet-4-5', + start: researchStart + 100, + duration: 900, + status: 1, + attributes: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-sonnet-4-5', + 'gen_ai.usage.input_tokens': 800, + 'gen_ai.usage.output_tokens': 90, + }, + }, + { + trace: traceIds.research, + id: 'b200000000000003', + parent: 'b200000000000001', + name: 'execute_tool fetch_url', + start: researchStart + 1100, + duration: 30_000, + status: 2, + message: 'TimeoutError', + attributes: { 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': 'fetch_url' }, + events: [ + event('exception', researchStart + 31_100, researchStart + 1100, { + 'exception.type': 'TimeoutError', + 'exception.message': 'GET https://docs.example.com/guide timed out after 30s', + }), + ], + }, +]; + +// Incomplete: the root was exported without an end time, and its model call +// records neither model names nor token usage. +const billingStart = base + 30 * 60_000; +const billing: SpanFixture[] = [ + { + trace: traceIds.billing, + id: 'c300000000000001', + name: 'invoke_agent Billing assistant', + start: billingStart, + attributes: agent('Billing assistant'), + }, + { + trace: traceIds.billing, + id: 'c300000000000002', + parent: 'c300000000000001', + name: 'chat', + start: billingStart + 100, + duration: 700, + attributes: { 'gen_ai.operation.name': 'chat' }, + }, +]; + +// Recovered: a failed tool call is retried and the root reports explicit OK. +const triageStart = base + 40 * 60_000; +const triage: SpanFixture[] = [ + { + trace: traceIds.triage, + id: 'd400000000000001', + name: 'invoke_agent Triage assistant', + start: triageStart, + duration: 2600, + status: 1, + attributes: agent('Triage assistant'), + }, + { + trace: traceIds.triage, + id: 'd400000000000002', + parent: 'd400000000000001', + name: 'execute_tool lookup_customer', + start: triageStart + 100, + duration: 400, + status: 2, + message: 'CustomerNotFound', + attributes: { 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': 'lookup_customer' }, + events: [ + event('exception', triageStart + 480, triageStart + 100, { + 'exception.type': 'CustomerNotFound', + 'exception.message': 'No customer with email jo@example.com', + }), + ], + }, + { + trace: traceIds.triage, + id: 'd400000000000003', + parent: 'd400000000000001', + name: 'chat gpt-4o-mini', + start: triageStart + 600, + duration: 1900, + status: 1, + attributes: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4o-mini', + 'gen_ai.response.model': 'gpt-4o-mini-2024-07-18', + 'gen_ai.usage.input_tokens': 410, + 'gen_ai.usage.output_tokens': 60, + }, + }, +]; + +const allRows = (spans: SpanFixture[]) => spans.flatMap(spanRows); +// Query results are newest first, interleaving runs as the SQL endpoint would. +const agentRows = [...triage, ...billing, ...research, ...support] + .flatMap(spanRows) + .sort((a, b) => String(b.p_timestamp).localeCompare(String(a.p_timestamp))); +const agentFields = [...new Set(agentRows.flatMap((row) => Object.keys(row)))]; + +type Captured = { query: string; startTime: string; endTime: string }; +async function serveAgents( + page: Page, + options: { status?: number; rows?: LogRecord[]; datasets?: string[] } = {}, +) { + const captured: Captured[] = []; + const state = { status: options.status ?? 200, rows: options.rows ?? agentRows }; await page.route('**/api/**', async (route) => { const path = new URL(route.request().url()).pathname; - if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'agent_telemetry' }] }); + if (path === '/api/v1/logstream') + return route.fulfill({ + json: (options.datasets ?? ['agent_telemetry']).map((name) => ({ name })), + }); if (path.endsWith('/schema')) - return route.fulfill({ json: { fields: agentDemoFields.map((name) => ({ name })) } }); + return route.fulfill({ json: { fields: agentFields.map((name) => ({ name })) } }); if (path.endsWith('/info')) return route.fulfill({ json: { telemetryType: 'traces', logSource: [{ log_source_format: 'otel-traces' }] }, @@ -14,43 +313,315 @@ async function mockAgents(page: Page, status = 200, empty = false) { if (path === '/api/v1/about') return route.fulfill({ json: { capabilities: {} } }); if (path === '/api/v1/query') { captured.push(route.request().postDataJSON()); - return status === 200 - ? route.fulfill({ json: empty ? [] : agentDemoRows() }) - : route.fulfill({ status, body: 'Access denied' }); + return state.status === 200 + ? route.fulfill({ json: state.rows }) + : route.fulfill({ status: state.status, body: 'Access denied' }); } return route.fulfill({ status: 404, body: 'No fixture' }); }); - return captured; + return { captured, state }; } -test('discovers agent datasets, filters loaded records and exposes raw fields', async ({ - page, -}) => { - const captured = await mockAgents(page); - await page.goto('/agents'); + +const columns = ['Run', 'Status', 'Started (UTC)', 'Duration', 'Model', 'Tokens', 'Spans']; +function runRow(page: Page, name: string) { + return page + .getByRole('region', { name: 'Agent runs' }) + .getByRole('row') + .filter({ has: page.getByText(name, { exact: true }) }); +} +function cell(row: Locator, column: string) { + return row.getByRole('cell').nth(columns.indexOf(column)); +} +function summaryCount(page: Page, label: string) { + return page + .getByLabel('Loaded run summary') + .locator('div') + .filter({ has: page.getByText(label, { exact: true }) }); +} +function sqlBounds(query: string) { + // The run query repeats its bounds in the trace-seeding subquery. + return [...new Set([...query.matchAll(/TIMESTAMP '([^']+)'/g)].map((match) => match[1]))]; +} +function fact(scope: Locator, label: string) { + return scope + .locator('dt', { hasText: new RegExp(`^${label}$`) }) + .first() + .locator('xpath=following-sibling::dd[1]'); +} +async function openRun(page: Page, name: string) { + await page.getByRole('button', { name: `Inspect run ${name}`, exact: true }).click(); + const details = page.getByRole('dialog', { name: 'Agent run details' }); + await expect(details.getByRole('heading', { name, level: 2 })).toBeVisible(); + return details; +} + +test('discovers agent datasets and queries the exact selected bounds', async ({ page }) => { + const { captured } = await serveAgents(page); + await page.goto(`/agents?${range}`); await expect(page.getByRole('heading', { name: 'Choose an agent dataset' })).toBeVisible(); await page.getByLabel('Agents dataset').selectOption('agent_telemetry'); - await expect(page.getByRole('table')).toBeVisible(); - expect(captured[0].query).toContain('LIMIT 100'); - expect(Date.parse(captured[0].endTime) - Date.parse(captured[0].startTime)).toBe(3600000); - await page.getByLabel('Search agent telemetry').fill('Research'); - await expect(page.getByRole('row')).toHaveCount(2); - await page.getByRole('button', { name: 'Inspect telemetry record 1' }).click(); - await expect(page.getByRole('dialog')).toContainText('gen_ai.usage.input_tokens'); - await expect(page.getByRole('link', { name: 'Open trace' })).toHaveAttribute( + await expect(page).toHaveURL(/\/agents\/agent_telemetry\?/); + await expect(runRow(page, 'Support assistant')).toBeVisible(); + expect(captured).toHaveLength(1); + expect(captured[0].query).toContain('agent_telemetry'); + expect(sqlBounds(captured[0].query)).toEqual([bounds.startTime, bounds.endTime]); +}); + +test('groups event-expanded spans into runs with honest status, duration, model and tokens', async ({ + page, +}) => { + await serveAgents(page); + await page.goto(`/agents/agent_telemetry?${range}`); + await expect(page.getByRole('region', { name: 'Agent runs' }).getByRole('row')).toHaveCount(5); + await expect(summaryCount(page, 'Runs')).toContainText('4'); + await expect(summaryCount(page, 'Successful')).toContainText('2'); + await expect(summaryCount(page, 'Failed')).toContainText('1'); + await expect(summaryCount(page, 'Incomplete')).toContainText('1'); + await expect(page.getByText('4 of 4 runs')).toBeVisible(); + + // Completed UNSET spans are successful. Streamed chunk events duplicate the + // first model span's row, but it is one span and its usage is counted once. + const supportRow = runRow(page, 'Support assistant'); + await expect(cell(supportRow, 'Status')).toHaveText('Successful'); + await expect(cell(supportRow, 'Duration')).toHaveText('4.20 s'); + await expect(cell(supportRow, 'Model')).toContainText('gpt-4o-2024-08-06'); + await expect(cell(supportRow, 'Model')).toContainText('gpt-4o-mini-2024-07-18'); + await expect(cell(supportRow, 'Tokens')).toContainText('1,500 in'); + await expect(cell(supportRow, 'Tokens')).toContainText('420 out'); + await expect(cell(supportRow, 'Spans')).toHaveText('4'); + + const researchRow = runRow(page, 'Research assistant'); + await expect(cell(researchRow, 'Status')).toHaveText('Failed'); + await expect(cell(researchRow, 'Spans')).toHaveText('3'); + + // Missing completion, model and usage are reported, never shown as zero. + const billingRow = runRow(page, 'Billing assistant'); + await expect(cell(billingRow, 'Status')).toHaveText('Incomplete'); + await expect(cell(billingRow, 'Duration')).toHaveText('Not recorded'); + await expect(cell(billingRow, 'Model')).toHaveText('Not recorded'); + await expect(cell(billingRow, 'Tokens')).toHaveText('Not recorded'); + await expect(billingRow).not.toContainText(/\b0 (in|out)\b|0 ms/); + + // An explicit OK root is authoritative after a recovered tool failure. + await expect(cell(runRow(page, 'Triage assistant'), 'Status')).toHaveText('Successful'); +}); + +test('filters loaded runs by status, model and span content', async ({ page }) => { + const { captured } = await serveAgents(page); + await page.goto(`/agents/agent_telemetry?${range}`); + const table = page.getByRole('region', { name: 'Agent runs' }); + await expect(runRow(page, 'Support assistant')).toBeVisible(); + + await page.getByLabel('Run status').selectOption({ label: 'Failed' }); + await expect(table.getByRole('row')).toHaveCount(2); + await expect(runRow(page, 'Research assistant')).toBeVisible(); + await expect(page).toHaveURL(/status=failed/); + await page.getByLabel('Run status').selectOption({ label: 'All statuses' }); + + await page.getByLabel('Run model').selectOption('gpt-4o-mini-2024-07-18'); + await expect(page.getByText('2 of 4 runs')).toBeVisible(); + await expect(runRow(page, 'Support assistant')).toBeVisible(); + await expect(runRow(page, 'Triage assistant')).toBeVisible(); + await page.getByLabel('Run model').selectOption({ label: 'All models' }); + + // Search reaches child span and event fields, not just run names. + await page.getByLabel('Search agent runs').fill('docs.example.com'); + await expect(table.getByRole('row')).toHaveCount(2); + await expect(runRow(page, 'Research assistant')).toBeVisible(); + await page.getByLabel('Search agent runs').fill('no-such-agent'); + await expect(page.getByRole('heading', { name: 'No matching runs' })).toBeVisible(); + // Filters work on loaded runs and never resubmit the query. + expect(captured).toHaveLength(1); +}); + +test('run details show the span tree, selected span facts and deduplicated raw records', async ({ + page, +}) => { + await serveAgents(page); + await page.goto(`/agents/agent_telemetry?${range}`); + const details = await openRun(page, 'Support assistant'); + await expect(page).toHaveURL(new RegExp(`run=[^&]*${traceIds.support}`)); + await expect(details).toContainText(traceIds.support); + await expect(details.getByRole('link', { name: 'Open trace' })).toHaveAttribute( 'href', - /traceId=00000000000000000000000000000002/, + new RegExp(traceIds.support), + ); + + await expect(details.getByRole('tab', { name: 'Span tree (4)' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + const tree = details.getByRole('list', { name: 'Run span tree' }); + const root = tree.getByRole('listitem').filter({ + has: page.getByRole('button', { name: /invoke_agent Support assistant/ }), + }); + const children = root.getByRole('list', { name: 'Child spans' }); + await expect(children.getByRole('button')).toHaveCount(3); + await expect(children.getByRole('button', { name: /^chat gpt-4o(?!-)/ })).toBeVisible(); + await expect(children.getByRole('button', { name: /execute_tool lookup_order/ })).toBeVisible(); + await expect(children.getByRole('button', { name: /chat gpt-4o-mini/ })).toBeVisible(); + + await children.getByRole('button', { name: /^chat gpt-4o(?!-)/ }).click(); + const span = details.getByRole('region', { name: 'Selected span' }); + await expect(span.getByRole('heading', { name: 'chat gpt-4o', exact: true })).toBeVisible(); + await expect(span).toContainText('a100000000000002'); + await expect(span).toContainText('a100000000000001'); + await expect(span).toContainText('gpt-4o-2024-08-06'); + await expect(span).toContainText('1,200 in'); + await expect(span).toContainText('1.80 s'); + // Both event rows are retained as raw records for the one span. + await span.getByText('Recorded fields (2 records)').click(); + await expect(span.getByText(/llm\.content\.completion\.chunk/)).toHaveCount(2); + await expect(span).toContainText('"span_kind_description": "CLIENT"'); +}); + +test('run details list model calls, tool calls and captured conversation once', async ({ + page, +}) => { + await serveAgents(page); + await page.goto(`/agents/agent_telemetry?${range}`); + const details = await openRun(page, 'Support assistant'); + + await details.getByRole('tab', { name: 'Model calls (2)' }).click(); + const models = details.getByRole('list', { name: 'Model calls' }); + await expect(models.getByRole('listitem')).toHaveCount(2); + await expect(models).toContainText('gpt-4o-2024-08-06'); + await expect(models).toContainText('300 in'); + await expect(models).toContainText('120 out'); + + await details.getByRole('tab', { name: 'Tool calls (1)' }).click(); + const tools = details.getByRole('list', { name: 'Tool calls' }); + await expect(tools.getByRole('listitem')).toHaveCount(1); + await expect(tools).toContainText('lookup_order'); + + await details.getByRole('tab', { name: 'Errors (0)' }).click(); + await expect(details.getByText('No errors reported in the loaded spans.')).toBeVisible(); + + // Span attributes are copied onto each streamed event row; content must not repeat. + await details.getByRole('tab', { name: 'Conversation' }).click(); + const conversation = details.getByRole('region', { + name: 'Conversation from chat gpt-4o', + exact: true, + }); + await expect(conversation.getByText('Where is order 8812?', { exact: true })).toHaveCount(1); + await expect( + conversation.getByText('Let me look that order up for you.', { exact: true }), + ).toHaveCount(1); + await expect( + details.getByRole('region', { name: 'Conversation from execute_tool lookup_order' }), + ).toContainText(/"order_id"\s*:\s*"8812"/); +}); + +test('failed run details expose span errors and exception events', async ({ page }) => { + await serveAgents(page); + await page.goto(`/agents/agent_telemetry?${range}`); + const details = await openRun(page, 'Research assistant'); + await expect(details.getByText('Failed', { exact: true }).first()).toBeVisible(); + await details.getByRole('tab', { name: 'Errors (2)' }).click(); + const errors = details.getByRole('tabpanel'); + await expect(errors).toContainText('Tool call failed: upstream timeout'); + await expect(errors).toContainText('GET https://docs.example.com/guide timed out after 30s'); + await expect(errors).toContainText('TimeoutError'); + + await errors.getByRole('button', { name: 'execute_tool fetch_url' }).click(); + const span = details.getByRole('region', { name: 'Selected span' }); + await expect(span.getByRole('heading', { name: 'execute_tool fetch_url' })).toBeVisible(); + await expect(span).toContainText('fetch_url'); + await expect(span).toContainText('Not recorded'); // tools report no token usage + await details.getByRole('tab', { name: 'Conversation' }).click(); + await expect(details.getByText(/No conversation content captured/)).toBeVisible(); +}); + +test('recovered and incomplete runs keep their gaps visible in details', async ({ page }) => { + await serveAgents(page); + await page.goto(`/agents/agent_telemetry?${range}`); + + let details = await openRun(page, 'Triage assistant'); + await expect(details.getByText('Successful', { exact: true }).first()).toBeVisible(); + await expect(details.getByRole('tab', { name: 'Errors (1)' })).toBeVisible(); + await expect(details.getByRole('status').first()).toContainText(/error|fail/i); + await details.getByRole('tab', { name: 'Errors (1)' }).click(); + await expect(details.getByRole('tabpanel')).toContainText( + 'No customer with email jo@example.com', + ); + await page.keyboard.press('Escape'); + await expect(details).toBeHidden(); + await expect(page).not.toHaveURL(/run=/); + + details = await openRun(page, 'Billing assistant'); + await expect(details.getByText('Incomplete', { exact: true }).first()).toBeVisible(); + for (const label of ['Duration', 'Models', 'Token usage']) + await expect(fact(details, label)).toHaveText('Not recorded'); + await expect(details.getByRole('status').first()).toContainText(/duration/i); + await details.getByRole('tab', { name: 'Model calls (1)' }).click(); + await expect(details.getByRole('list', { name: 'Model calls' })).toContainText( + 'Name not recorded', ); }); -test('permission failure renders recovery without stale telemetry', async ({ page }) => { - await mockAgents(page, 403); - await page.goto('/agents/agent_telemetry'); + +test('selection is cleared when reloaded data no longer contains the run', async ({ page }) => { + const { captured, state } = await serveAgents(page); + await page.goto(`/agents/agent_telemetry?${range}`); + const details = await openRun(page, 'Support assistant'); + + state.rows = [...allRows(research), ...allRows(triage)]; + await page.goBack(); + await expect(details).toBeHidden(); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect.poll(() => captured.length).toBe(2); + await expect(runRow(page, 'Support assistant')).toHaveCount(0); + await expect(runRow(page, 'Research assistant')).toBeVisible(); + + // Returning to the old run link must not resurrect details from earlier data. + await page.goForward(); + await expect(page).toHaveURL(/run=/); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByText('invoke_agent Support assistant')).toHaveCount(0); +}); + +test('a run link outside the loaded data opens no stale details', async ({ page }) => { + await serveAgents(page, { rows: allRows(research) }); + await page.goto(`/agents/agent_telemetry?${range}&run=trace:${traceIds.support}`); + await expect(runRow(page, 'Research assistant')).toBeVisible(); + await expect(page.getByRole('dialog')).toHaveCount(0); +}); + +test('permission failure renders recovery without stale runs or details', async ({ page }) => { + await serveAgents(page, { status: 403 }); + await page.goto(`/agents/agent_telemetry?${range}&run=trace:${traceIds.support}`); await expect(page.getByRole('heading', { name: 'Permission denied' })).toBeVisible(); await expect(page.getByRole('table')).toHaveCount(0); + await expect(page.getByLabel('Loaded run summary')).toHaveCount(0); + await expect(page.getByRole('dialog')).toHaveCount(0); +}); + +test('run table and details stay within a mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await serveAgents(page); + await page.goto(`/agents/agent_telemetry?${range}`); + await expect(runRow(page, 'Support assistant')).toBeVisible(); + const fits = () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth); + expect(await fits()).toBe(true); + + const details = await openRun(page, 'Support assistant'); + await expect + .poll(async () => { + const box = await details.boundingBox(); + return !!box && box.x >= 0 && box.x + box.width <= 390; + }) + .toBe(true); + await details.getByRole('tab', { name: 'Conversation' }).click(); + const message = details.getByText('Where is order 8812?', { exact: true }).first(); + await message.scrollIntoViewIfNeeded(); + const messageBox = await message.boundingBox(); + expect(messageBox && messageBox.x >= 0 && messageBox.x + messageBox.width <= 390).toBe(true); + expect(await fits()).toBe(true); }); + test('empty telemetry on mobile stays within viewport', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); - await mockAgents(page, 200, true); - await page.goto('/agents/agent_telemetry'); + await serveAgents(page, { rows: [] }); + await page.goto(`/agents/agent_telemetry?${range}`); await expect( page.getByRole('heading', { name: 'No agent telemetry in this range' }), ).toBeVisible(); @@ -66,11 +637,8 @@ test('direct agent links defer schema discovery until the dataset selector is fo page.on('request', (request) => { if (new URL(request.url()).pathname.endsWith('/schema')) schemaRequests.push(request.url()); }); - await mockAgents(page); - await page.route('**/api/v1/logstream', (route) => - route.fulfill({ json: [{ name: 'agent_telemetry' }, { name: 'another_dataset' }] }), - ); - await page.goto('/agents/agent_telemetry'); + await serveAgents(page, { datasets: ['agent_telemetry', 'another_dataset'] }); + await page.goto(`/agents/agent_telemetry?${range}`); await expect(page.getByRole('table')).toBeVisible(); expect(schemaRequests.filter((url) => url.includes('another_dataset'))).toHaveLength(0); await page.getByLabel('Agents dataset').focus(); @@ -82,7 +650,7 @@ test('direct agent links defer schema discovery until the dataset selector is fo test('direct nonagent dataset link rejects its schema before submitting a query', async ({ page, }) => { - const captured = await mockAgents(page); + const { captured } = await serveAgents(page); await page.route('**/api/v1/logstream/*/schema', (route) => route.fulfill({ json: { fields: [{ name: 'p_timestamp' }, { name: 'message' }] } }), ); @@ -93,14 +661,65 @@ test('direct nonagent dataset link rejects its schema before submitting a query' }); test('refreshing a preset submits one query with newly captured bounds', async ({ page }) => { - const captured = await mockAgents(page); + const { captured } = await serveAgents(page); await page.goto('/agents/agent_telemetry?range=24h'); await expect(page.getByRole('table')).toBeVisible(); expect(captured).toHaveLength(1); - const firstEnd = captured[0].endTime; + const [firstStart, firstEnd] = sqlBounds(captured[0].query); + expect(Date.parse(firstEnd) - Date.parse(firstStart)).toBe(86_400_000); await page.getByRole('button', { name: 'Refresh', exact: true }).click(); - await expect(page.getByRole('table')).toBeVisible(); await expect.poll(() => captured.length).toBe(2); - expect(captured[1].endTime).not.toBe(firstEnd); - expect(Date.parse(captured[1].endTime) - Date.parse(captured[1].startTime)).toBe(86400000); + await expect(page.getByRole('table')).toBeVisible(); + const [start, end] = sqlBounds(captured[1].query); + expect(end).not.toBe(firstEnd); + expect(Date.parse(end) - Date.parse(start)).toBe(86_400_000); + expect(new URL(page.url()).searchParams.get('end')).toBe(end); }); + +test('discloses the trace limit even when fewer than 1000 records are loaded', async ({ page }) => { + const rows = Array.from({ length: 100 }, (_, index) => + spanRows({ + trace: (index + 1).toString(16).padStart(32, '0'), + id: '0101010101010101', + name: `Agent ${index}`, + start: base + 1000, + duration: 100, + status: 1, + attributes: { + 'gen_ai.agent.name': `Agent ${index}`, + 'gen_ai.operation.name': 'invoke_agent', + }, + }), + ).flat(); + await serveAgents(page, { rows }); + await page.goto(`/agents/agent_telemetry?${range}`); + await expect(page.getByRole('status').filter({ hasText: '100-trace limit' })).toContainText( + 'Older runs may be omitted', + ); + await expect(page.getByRole('table').getByRole('row')).toHaveCount(101); +}); + +for (const trace of ['trace-with-dashes', '']) { + test(`run details do not silently open unfiltered logs for unsupported identity: ${trace || 'missing'}`, async ({ + page, + }) => { + const rows = spanRows({ + trace, + id: '0101010101010101', + name: 'Unlinked agent', + start: base + 1000, + duration: 100, + status: 1, + attributes: { + 'gen_ai.agent.name': 'Unlinked agent', + 'gen_ai.operation.name': 'invoke_agent', + }, + }); + await serveAgents(page, { rows }); + await page.goto(`/agents/agent_telemetry?${range}`); + const details = await openRun(page, 'Unlinked agent'); + await expect(details.getByRole('link', { name: 'Open logs', exact: true })).toHaveCount(0); + await expect(details.getByRole('link', { name: 'Open trace', exact: true })).toHaveCount(0); + await expect(details.getByRole('tab', { name: 'Span tree (1)' })).toBeVisible(); + }); +} diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts index 408838a16..a7dfff59f 100644 --- a/frontend/e2e/app.spec.ts +++ b/frontend/e2e/app.spec.ts @@ -216,9 +216,10 @@ test('native login switches from demo to live and sends backend query contracts' expect(request.headers().authorization).toBe( `Basic ${Buffer.from('reader:test-password').toString('base64')}`, ); - expect(new URL(request.url()).searchParams.get('redirect')).toBe( - 'http://127.0.0.1:5173/logs', - ); + const redirect = new URL(new URL(request.url()).searchParams.get('redirect')!); + // The current Logs view may carry its preserved navigation context. + redirect.searchParams.delete('observe'); + expect(redirect.href).toBe('http://127.0.0.1:5173/logs'); authenticated = true; return route.fulfill({ status: 200, body: 'ok' }); } diff --git a/frontend/e2e/classic-links.spec.ts b/frontend/e2e/classic-links.spec.ts index 6c0562781..048c5a4f6 100644 --- a/frontend/e2e/classic-links.spec.ts +++ b/frontend/e2e/classic-links.spec.ts @@ -1,5 +1,14 @@ import { expect, test, type Page } from '@playwright/test'; +/** Only the preserved navigation context may remain after consuming a Logs link. */ +async function expectLogsURL(page: Page, target: string) { + await expect(page).toHaveURL((url) => { + const remaining = new URL(url); + remaining.searchParams.delete('observe'); + return `${remaining.pathname}${remaining.search}${remaining.hash}` === target; + }); +} + /** A live server with two datasets; returns the query requests it receives. */ async function server(page: Page) { const queries: { query: string; startTime: string; endTime: string }[] = []; @@ -30,7 +39,7 @@ test('a classic Logs bookmark opens with its range and filters', async ({ page } await expect(page.getByRole('status').filter({ hasText: 'Not carried over' })).toHaveText( /time zone Asia\/Kolkata \(times are shown in UTC\), 1 filter\./, ); - await expect(page).toHaveURL('/logs/explore/app?keep=1'); + await expectLogsURL(page, '/logs/explore/app?keep=1'); await expect.poll(() => queries.length).toBeGreaterThan(0); expect(queries[0]).toMatchObject({ startTime: '2026-10-09T08:00:00.000Z', @@ -49,7 +58,7 @@ test('a classic quick range shows as that range', async ({ page }) => { await expect(page.getByLabel('Time range', { exact: true }).locator('option:checked')).toHaveText( 'Last 10 minutes', ); - await expect(page).toHaveURL('/logs/explore/app'); + await expectLogsURL(page, '/logs/explore/app'); await expect(page.getByText('Not carried over')).toHaveCount(0); await expect.poll(() => queries.length).toBeGreaterThan(0); const { startTime, endTime } = queries[0]; @@ -82,7 +91,7 @@ test('each classic Logs link opened while Logs is open is applied', async ({ pag await page.goto(link('app', '10m', 'error')); await expect(timeRange).toHaveValue('10m'); await expect(pills).toHaveText(['error']); - await expect(page).toHaveURL('/logs/explore/app'); + await expectLogsURL(page, '/logs/explore/app'); await navigateInApp(page, link('app', '3d', 'warn', '&tz=Asia%2FKolkata&keep=1#row-7'), { from: 'test', @@ -93,7 +102,7 @@ test('each classic Logs link opened while Logs is open is applied', async ({ pag /time zone Asia\/Kolkata/, ); // The parameters come out, and the rest of the address and the history state stay. - await expect(page).toHaveURL('/logs/explore/app?keep=1#row-7'); + await expectLogsURL(page, '/logs/explore/app?keep=1#row-7'); expect(await page.evaluate(() => window.history.state.usr)).toEqual({ from: 'test' }); await expect.poll(async () => (await lastQuery()).query).toContain(`"level" = 'warn'`); const warn = await lastQuery(); @@ -105,7 +114,7 @@ test('each classic Logs link opened while Logs is open is applied', async ({ pag await expect(timeRange).toHaveValue('30m'); await expect(pills).toHaveText(['info']); await expect(page.getByText('Not carried over')).toHaveCount(0); - await expect(page).toHaveURL('/logs/explore/other'); + await expectLogsURL(page, '/logs/explore/other'); await expect.poll(async () => (await lastQuery()).query).toContain('FROM "other"'); const info = await lastQuery(); expect(info.query).toContain(`"level" = 'info'`); @@ -140,12 +149,12 @@ test('a classic Logs link replaces a search typed before it', async ({ page }) = test('classic paths that moved open their page here', async ({ page }) => { await server(page); await page.goto('/logs/search/app?start=1h&end=now&type=fixed'); - await expect(page).toHaveURL('/logs/explore/app'); + await expectLogsURL(page, '/logs/explore/app'); await page.goto('/settings'); await expect(page).toHaveURL('/alerts/targets'); // A redirect keeps the query, hash and history state of the link. await navigateInApp(page, '/logs/search/app?keep=1#row-3', { from: 'test' }); - await expect(page).toHaveURL('/logs/explore/app?keep=1#row-3'); + await expectLogsURL(page, '/logs/explore/app?keep=1#row-3'); expect(await page.evaluate(() => window.history.state.usr)).toEqual({ from: 'test' }); await navigateInApp(page, `/metrics/explore/x/metric/cpu?keep=1#m`, { from: 'test' }); await expect(page).toHaveURL(/\/metrics\/explore\/x\?keep=1&query=cpu#m$/); @@ -158,7 +167,8 @@ test('classic paths that moved open their page here', async ({ page }) => { ['/metrics/explore/x/metric/%E0%A4%A', '/metrics/explore/x/metric/%E0%A4%A'], ]) { await navigateInApp(page, path); - await expect(page).toHaveURL(target); + if (target.startsWith('/logs/')) await expectLogsURL(page, target); + else await expect(page).toHaveURL(target); await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); } diff --git a/frontend/e2e/logs-depth-handoffs.spec.ts b/frontend/e2e/logs-depth-handoffs.spec.ts index 970076d80..6be84ef27 100644 --- a/frontend/e2e/logs-depth-handoffs.spec.ts +++ b/frontend/e2e/logs-depth-handoffs.spec.ts @@ -58,6 +58,13 @@ async function navigate(page: Page, path: string) { window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state })); }, path); } +async function expectLogsURL(page: Page, path: string) { + await expect(page).toHaveURL((url) => { + const current = new URL(url); + current.searchParams.delete('observe'); + return current.pathname + current.search + current.hash === path; + }); +} test('legacy saved view identifiers preserve filters without blocking queries', async ({ page, }) => { @@ -71,7 +78,7 @@ test('legacy saved view identifiers preserve filters without blocking queries', 'saved view', ); await expect(page.getByRole('button', { name: 'Clear invalid shared view' })).toHaveCount(0); - await expect(page).toHaveURL('/logs/explore/events'); + await expectLogsURL(page, '/logs/explore/events'); expect(queries.some((q) => q.query.includes('"level" = \'ERROR\''))).toBe(true); }); test('native state wins over classic cleanup and is consumed once', async ({ page }) => { @@ -87,14 +94,14 @@ test('native state wins over classic cleanup and is consumed once', async ({ pag 'America/Chicago', ); await expect(page.getByRole('heading', { name: /1 records/ })).toBeVisible(); - await expect(page).toHaveURL('/logs/explore/events?keep=yes'); + await expectLogsURL(page, '/logs/explore/events?keep=yes'); expect(queries.every((q) => q.startTime === startTime && q.endTime === endTime)).toBe(true); await page.getByRole('textbox', { name: 'Search messages' }).fill('edited'); await expect.poll(() => queries.some((q) => q.query.includes("'%edited%'"))).toBe(true); await navigate(page, '/logs/explore/events?keep=another'); await page.goBack(); await expect(page.getByRole('textbox', { name: 'Search messages' })).toHaveValue('edited'); - await expect(page).toHaveURL('/logs/explore/events?keep=yes'); + await expectLogsURL(page, '/logs/explore/events?keep=yes'); }); test('classic links reset timezone and close event details', async ({ page }) => { await fixture(page); diff --git a/frontend/e2e/native-regressions.spec.ts b/frontend/e2e/native-regressions.spec.ts index 40e99ec09..f29733ced 100644 --- a/frontend/e2e/native-regressions.spec.ts +++ b/frontend/e2e/native-regressions.spec.ts @@ -32,17 +32,24 @@ test('agent dataset changes clear inspected records while the new query is pendi return route.fulfill({ json: {} }); }); await page.goto('/agents/first'); - await expect(page.getByRole('cell', { name: 'First agent' })).toBeVisible(); - await page.getByRole('button', { name: 'Inspect telemetry record 1' }).click(); + await expect( + page.getByRole('cell', { name: 'First agent Partial telemetry', exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Inspect run First agent' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); await page.getByLabel('Agents dataset').focus(); await page.getByLabel('Agents dataset').selectOption('second'); - await expect(page.getByRole('cell', { name: 'First agent' })).toHaveCount(0); + await expect( + page.getByRole('cell', { name: 'First agent Partial telemetry', exact: true }), + ).toHaveCount(0); await expect(page.getByRole('table')).toHaveCount(0); await expect(page.getByRole('dialog')).toHaveCount(0); release(); - await expect(page.getByRole('cell', { name: 'Second agent' })).toBeVisible(); + await expect( + page.getByRole('cell', { name: 'Second agent Partial telemetry', exact: true }), + ).toBeVisible(); }); test('cluster refresh removes obsolete nodes on permission failure', async ({ page }) => { diff --git a/frontend/src/app/Sidebar.tsx b/frontend/src/app/Sidebar.tsx index 980c6a33d..9c2e3a7dd 100644 --- a/frontend/src/app/Sidebar.tsx +++ b/frontend/src/app/Sidebar.tsx @@ -1,5 +1,10 @@ import { useState } from 'react'; -import { NavLink } from 'react-router-dom'; +import { NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { + currentObservabilitySearch, + observabilityHref, + observabilityView, +} from '../lib/observabilityNavigation'; import { Activity, Bell, @@ -82,6 +87,8 @@ export function Sidebar({ onConnect: () => void; }) { const { mode, demoEnabled } = useApp(); + const location = useLocation(); + const navigate = useNavigate(); const [search, setSearch] = useState(''); return (