Commit Graph
6914 Commits
Author SHA1 Message Date
Hengfei Yang 6ca0eec107 fix: MCP tool calls return 404 when ZO_BASE_URI is set (#13844)
Fixes #13845

## What changed

The MCP shared HTTP client (used to execute tool calls against the local
REST API) now includes `ZO_BASE_URI` in its base URL:
`http://localhost:{port}{base_uri}` instead of
`http://localhost:{port}`.

## Why

When `ZO_BASE_URI` is set (e.g. `/abc`), all HTTP routes are mounted
under that prefix. The MCP endpoint itself was reachable (`POST
/abc/api/default/mcp` → 200), but every tool execution failed: the
internal client sent requests to the unprefixed path and got a 404.

```
INFO rmcp_openapi::http_client: Final URL: http://localhost:5080/api/default/streams
INFO config::axum::middlewares::access_log: 127.0.0.1 "GET /api/default/streams HTTP/1.1" 404 ...
```

## Notes for reviewers

- `cfg.common.base_uri` is already normalized at config load to have no
trailing slash (empty when unset), so the empty-prefix behavior is
unchanged.
- The join semantics are safe for path-prefixed base URLs:
rmcp-openapi's `with_base_url` appends a trailing slash and `build_url`
strips the tool path's leading slash before `Url::join`, covered by the
crate's `test_build_url_with_base_url_containing_path`. So `/abc` +
`/api/{org}/streams` → `/abc/api/{org}/streams`.
- The other `localhost:5080` occurrence in `tools.rs` is a test-only
mock client; the OAuth protected-resource metadata handler already
includes `base_uri`.
- `cargo check -p openobserve-mcp` and `cargo test -p openobserve-mcp`
pass.
2026-08-17 11:44:34 +00:00
Hengfei Yang 9d76505882 feat(traces): resolve session time ranges through the trace time index (#13837)
Design at:
[https://github.com/openobserve/designs/blob/main/infrastructure/file-list/session-time-index.md](https://github.com/openobserve/designs/blob/main/infrastructure/file-list/session-time-index.md)

## Summary

- carry an optional `session_id` attribute on trace time index records,
resolved from span attributes by the existing `SESSION_ID_COLUMNS`
precedence; the first non-empty value in the request wins for the whole
trace, stamped onto all of its hour-bucket records
- keep the aggregation key `(hour, trace_id)` unchanged; hourly
compaction picks the attribute up with `MAX(session_id)`, which ignores
NULLs
- reuse the locate/expand timer for sessions with a 7-day expand window
(traces keep 24h); a UUID v7 session id doubles as a probe anchor and
tightens the scan's left bound to the session's lifetime neighborhood
- aggregate session lookups per trace, so a lookup resolves the
session's range plus every member trace's real range
- reshape the time-range endpoint to query parameters so one endpoint
serves both lookup kinds
- make the session-details caller range optional, union it with the
indexed range (never narrowed), take trace ids from the index, and keep
the legacy source-stream scan for pre-coverage data
- add `session_id` to the default bloom filter fields (gated by the
existing `ZO_FEATURE_DEFAULT_INDEX_FIELDS_ENABLED`, folds into the
secondary-index field set like `trace_id`); streams without the column
build nothing

## Why

Session detail lookup depends on a caller-provided time range: the
sessions list passes its own picker window into the details request, so
an LLM session that started before the window silently loses its earlier
turns, and a deep link without a range is a hard 400. The trace time
index already turns a `trace_id` into a time range at bounded cost; a
session is the same problem one level up — `session_id` → range plus
member traces.

## Environment variables

No new variables. The existing `ZO_TRACE_TIME_INDEX_ENABLED` (default
`true`) gates the feature, session column included. The existing
coverage marker is reused; there is no historical backfill.

This change must ship in the same release as #13827: reusing the
coverage marker is valid only while no release separates the two.

## API changes

All timestamp parameters and returned ranges use microseconds.

### Time-range endpoint reshaped

```http
GET /api/{org_id}/{stream_name}/traces/time_range?trace_id=xx&hint_ts=...
GET /api/{org_id}/{stream_name}/traces/time_range?session_id=xx&hint_ts=...
```

Exactly one of `trace_id` / `session_id` is required; zero or both is
`400`. This replaces `GET /{stream_name}/traces/{trace_id}/time_range`;
the old path form is removed without a compatibility alias since #13827
has not shipped in a release.

### Session details: range becomes optional

`GET /api/{org_id}/{stream_name}/traces/session/details` now accepts
`start_time` / `end_time` as an optional pair plus an optional `hint_ts`
(defaulting to the caller range's midpoint). The effective range is the
union of the caller range and the indexed session range, so it never
narrows what the caller asked for; `400` only when neither side yields a
range. When the caller range lies entirely inside index coverage the
source-stream trace-id scan is skipped; otherwise it still runs over the
caller range and its results are merged, so behavior is never worse than
before. No frontend changes are required — the frontend keeps sending
its list window and the union completes the session.

## Validation

- `cargo test -p openobserve-core traces::time_index --lib` — 13 passed
- `cargo test -p search datafusion::merge --lib` — 5 passed, including
NULL-mixed session aggregation and pre-column file compatibility
- `cargo test -p openobserve-api-search --lib` — 149 passed
- `cargo test -p openobserve-api-http --lib` — 53 passed, including
OpenAPI/MCP initialization
- `cargo test -p config router:: --lib` — 8 passed
- `cargo test -p infra db::trace_time_index --lib` — passed
- `cargo clippy` on all touched crates (`--all-targets`) and `cargo
check --workspace` — clean
- the enterprise repository has no references to any changed API and no
enterprise-gated code was modified
2026-08-17 10:10:42 +00:00
Abhay Padamaniandgithub-actions[bot] f112ac01a5 fix: dashboard layout improvements (#13815)
<h2 style="unicode-bidi: plaintext; color: rgb(204, 204, 204);
font-family: -apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;,
Roboto, sans-serif; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; letter-spacing: normal; orphans: 2;
text-align: start; text-indent: 0px; text-transform: none; widows: 2;
word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal;
background-color: rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;">Dashboard layout &amp; UX improvements</h2><p
style="white-space: pre-wrap; margin-top: 0.1em; margin-bottom: 0.2em;
unicode-bidi: plaintext; color: rgb(204, 204, 204); font-family:
-apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;, Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">Polish
pass on the dashboard experience: settings layout, drag/resize grid
feedback, empty states, and per-tab panel counts.</p><h3
style="unicode-bidi: plaintext; color: rgb(204, 204, 204); font-family:
-apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;, Roboto,
sans-serif; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; letter-spacing: normal; orphans: 2;
text-align: start; text-indent: 0px; text-transform: none; widows: 2;
word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal;
background-color: rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">✨
Highlights</h3><p style="white-space: pre-wrap; margin-top: 0.1em;
margin-bottom: 0.2em; unicode-bidi: plaintext; color: rgb(204, 204,
204); font-family: -apple-system, &quot;system-ui&quot;, &quot;Segoe
UI&quot;, Roboto, sans-serif; font-size: 13px; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal; font-weight:
400; letter-spacing: normal; orphans: 2; text-align: start; text-indent:
0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; background-color: rgb(24, 24, 24);
text-decoration-thickness: initial; text-decoration-style: initial;
text-decoration-color: initial;"><strong>Grid feedback while
dragging/resizing panels</strong> (<a
href="vscode-webview://0vkpvjgb486eanj04qjfqcnqmnatevjacbbbqdp3ecrs4efo5krm/web/src/views/Dashboards/RenderDashboardCharts.vue"
target="_blank" rel="noopener noreferrer" style="color: rgb(77, 170,
252); text-decoration: rgb(77, 170,
252);">RenderDashboardCharts.vue</a>)</p><ul
style="padding-inline-start: 2em; color: rgb(204, 204, 204);
font-family: -apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;,
Roboto, sans-serif; font-size: 13px; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal; font-weight:
400; letter-spacing: normal; orphans: 2; text-align: start; text-indent:
0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;"><li
style="unicode-bidi: plaintext;">Added a live alignment grid that
appears only during a drag or resize, drawn from GridStack's real cell
metrics (192 columns × row height).</li><li style="unicode-bidi:
plaintext;">The grid is<span> </span><strong>operation-aware</strong>:
lines snap to the top/left edges
while<span> </span><em>moving</em><span> </span>and the bottom/right
edges while<span> </span><em>resizing</em>, so the edge you're dragging
always lands on a line and every line is reachable (no skipped
positions).</li><li style="unicode-bidi: plaintext;">Panels are made
opaque during interaction (<code style="font-family: monospace; color:
rgb(208, 208, 208); background-color: rgb(60, 60, 60); padding: 2px 4px;
border-radius: 3px; word-break: break-word; font-size:
0.9em;">--color-surface-base</code>) so the grid only shows in the
gutters/empty cells, not through chart or table panels.</li></ul><p
style="white-space: pre-wrap; margin-top: 0.1em; margin-bottom: 0.2em;
unicode-bidi: plaintext; color: rgb(204, 204, 204); font-family:
-apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;, Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><strong>Empty states</strong> (<a
href="vscode-webview://0vkpvjgb486eanj04qjfqcnqmnatevjacbbbqdp3ecrs4efo5krm/web/src/lib/core/EmptyState/presets.ts"
target="_blank" rel="noopener noreferrer" style="color: rgb(77, 170,
252); text-decoration: rgb(77, 170, 252);">presets.ts</a>, <a
href="vscode-webview://0vkpvjgb486eanj04qjfqcnqmnatevjacbbbqdp3ecrs4efo5krm/web/src/components/dashboards/settings/VariableSettings.vue"
target="_blank" rel="noopener noreferrer" style="color: rgb(77, 170,
252); text-decoration: rgb(77, 170, 252);">VariableSettings.vue</a>, <a
href="vscode-webview://0vkpvjgb486eanj04qjfqcnqmnatevjacbbbqdp3ecrs4efo5krm/web/src/views/Dashboards/ScheduledDashboards.vue"
target="_blank" rel="noopener noreferrer" style="color: rgb(77, 170,
252); text-decoration: rgb(77, 170,
252);">ScheduledDashboards.vue</a>)</p><ul style="padding-inline-start:
2em; color: rgb(204, 204, 204); font-family: -apple-system,
&quot;system-ui&quot;, &quot;Segoe UI&quot;, Roboto, sans-serif;
font-size: 13px; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; font-weight: 400; letter-spacing: normal;
orphans: 2; text-align: start; text-indent: 0px; text-transform: none;
widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
white-space: normal; background-color: rgb(24, 24, 24);
text-decoration-thickness: initial; text-decoration-style: initial;
text-decoration-color: initial;"><li style="unicode-bidi:
plaintext;">Replaced the generic<span> </span><code style="font-family:
monospace; color: rgb(208, 208, 208); background-color: rgb(60, 60, 60);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">NoData</code><span> </span>placeholder with<span> </span><code
style="font-family: monospace; color: rgb(208, 208, 208);
background-color: rgb(60, 60, 60); padding: 2px 4px; border-radius: 3px;
word-break: break-word; font-size:
0.9em;">OEmptyState</code><span> </span>for the Variables settings table
and the Scheduled Dashboards list.</li><li style="unicode-bidi:
plaintext;">Added a new<span> </span><code style="font-family:
monospace; color: rgb(208, 208, 208); background-color: rgb(60, 60, 60);
padding: 2px 4px; border-radius: 3px; word-break: break-word; font-size:
0.9em;">no-variables</code><span> </span>preset (illustration + copy +
"Add variable" action); reused the existing<span> </span><code
style="font-family: monospace; color: rgb(208, 208, 208);
background-color: rgb(60, 60, 60); padding: 2px 4px; border-radius: 3px;
word-break: break-word; font-size:
0.9em;">no-reports</code><span> </span>preset for scheduled
dashboards.</li><li style="unicode-bidi: plaintext;">Fixed the Variables
table so a long
list<span> </span><strong>scrolls</strong><span> </span>instead of
clipping, and made the Scheduled Dashboards table fill the drawer height
so the empty state centers.</li></ul><p style="white-space: pre-wrap;
margin-top: 0.1em; margin-bottom: 0.2em; unicode-bidi: plaintext; color:
rgb(204, 204, 204); font-family: -apple-system, &quot;system-ui&quot;,
&quot;Segoe UI&quot;, Roboto, sans-serif; font-size: 13px; font-style:
normal; font-variant-ligatures: normal; font-variant-caps: normal;
font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start;
text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; background-color: rgb(24, 24, 24);
text-decoration-thickness: initial; text-decoration-style: initial;
text-decoration-color: initial;"><strong>Dashboard settings
layout</strong> (<a
href="vscode-webview://0vkpvjgb486eanj04qjfqcnqmnatevjacbbbqdp3ecrs4efo5krm/web/src/components/dashboards/settings/GeneralSettings.vue"
target="_blank" rel="noopener noreferrer" style="color: rgb(77, 170,
252); text-decoration: rgb(77, 170,
252);">GeneralSettings.vue</a>)</p><ul style="padding-inline-start: 2em;
color: rgb(204, 204, 204); font-family: -apple-system,
&quot;system-ui&quot;, &quot;Segoe UI&quot;, Roboto, sans-serif;
font-size: 13px; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; font-weight: 400; letter-spacing: normal;
orphans: 2; text-align: start; text-indent: 0px; text-transform: none;
widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
white-space: normal; background-color: rgb(24, 24, 24);
text-decoration-thickness: initial; text-decoration-style: initial;
text-decoration-color: initial;"><li style="unicode-bidi:
plaintext;">Reworked the General settings layout.</li></ul><p
style="white-space: pre-wrap; margin-top: 0.1em; margin-bottom: 0.2em;
unicode-bidi: plaintext; color: rgb(204, 204, 204); font-family:
-apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;, Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><strong>Tab panel-count badge</strong> (<a
href="vscode-webview://0vkpvjgb486eanj04qjfqcnqmnatevjacbbbqdp3ecrs4efo5krm/web/src/components/dashboards/tabs/TabList.vue"
target="_blank" rel="noopener noreferrer" style="color: rgb(77, 170,
252); text-decoration: rgb(77, 170, 252);">TabList.vue</a>)</p><ul
style="padding-inline-start: 2em; color: rgb(204, 204, 204);
font-family: -apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;,
Roboto, sans-serif; font-size: 13px; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal; font-weight:
400; letter-spacing: normal; orphans: 2; text-align: start; text-indent:
0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;"><li
style="unicode-bidi: plaintext;">Each dashboard tab now shows a badge
with its panel count (section headers excluded), so tab density is
visible without opening each tab.</li></ul><p style="white-space:
pre-wrap; margin-top: 0.1em; margin-bottom: 0.2em; unicode-bidi:
plaintext; color: rgb(204, 204, 204); font-family: -apple-system,
&quot;system-ui&quot;, &quot;Segoe UI&quot;, Roboto, sans-serif;
font-size: 13px; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; font-weight: 400; letter-spacing: normal;
orphans: 2; text-align: start; text-indent: 0px; text-transform: none;
widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><strong>Variable dependency indicator</strong> (<a
href="vscode-webview://0vkpvjgb486eanj04qjfqcnqmnatevjacbbbqdp3ecrs4efo5krm/web/src/components/dashboards/settings/VariableSettings.vue"
target="_blank" rel="noopener noreferrer" style="color: rgb(77, 170,
252); text-decoration: rgb(77, 170,
252);">VariableSettings.vue</a>)</p><ul style="padding-inline-start:
2em; color: rgb(204, 204, 204); font-family: -apple-system,
&quot;system-ui&quot;, &quot;Segoe UI&quot;, Roboto, sans-serif;
font-size: 13px; font-style: normal; font-variant-ligatures: normal;
font-variant-caps: normal; font-weight: 400; letter-spacing: normal;
orphans: 2; text-align: start; text-indent: 0px; text-transform: none;
widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
white-space: normal; background-color: rgb(24, 24, 24);
text-decoration-thickness: initial; text-decoration-style: initial;
text-decoration-color: initial;"><li style="unicode-bidi:
plaintext;">Variables that depend on others show a "Depends on: …" chip
(tree icon + count) in the settings table.</li></ul><p
style="white-space: pre-wrap; margin-top: 0.1em; margin-bottom: 0.2em;
unicode-bidi: plaintext; color: rgb(204, 204, 204); font-family:
-apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;, Roboto,
sans-serif; font-size: 13px; font-style: normal; font-variant-ligatures:
normal; font-variant-caps: normal; font-weight: 400; letter-spacing:
normal; orphans: 2; text-align: start; text-indent: 0px; text-transform:
none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px;
background-color: rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color:
initial;"><strong>Panel header token</strong> (<a
href="vscode-webview://0vkpvjgb486eanj04qjfqcnqmnatevjacbbbqdp3ecrs4efo5krm/web/src/lib/styles/tokens/component.css"
target="_blank" rel="noopener noreferrer" style="color: rgb(77, 170,
252); text-decoration: rgb(77, 170, 252);">component.css</a>)</p><ul
style="padding-inline-start: 2em; color: rgb(204, 204, 204);
font-family: -apple-system, &quot;system-ui&quot;, &quot;Segoe UI&quot;,
Roboto, sans-serif; font-size: 13px; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal; font-weight:
400; letter-spacing: normal; orphans: 2; text-align: start; text-indent:
0px; text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;"><li
style="unicode-bidi: plaintext;">Lightened<span> </span><code
style="font-family: monospace; color: rgb(208, 208, 208);
background-color: rgb(60, 60, 60); padding: 2px 4px; border-radius: 3px;
word-break: break-word; font-size:
0.9em;">--color-panel-bar-bg</code><span> </span>(grey-200 → grey-100)
for a softer chart-header band; the grey-300 divider still carries the
header/body edge.</li></ul><h3 style="unicode-bidi: plaintext; color:
rgb(204, 204, 204); font-family: -apple-system, &quot;system-ui&quot;,
&quot;Segoe UI&quot;, Roboto, sans-serif; font-style: normal;
font-variant-ligatures: normal; font-variant-caps: normal;
letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px;
text-transform: none; widows: 2; word-spacing: 0px;
-webkit-text-stroke-width: 0px; white-space: normal; background-color:
rgb(24, 24, 24); text-decoration-thickness: initial;
text-decoration-style: initial; text-decoration-color: initial;">

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-17 10:02:32 +00:00
ktx-akshay 33c6e9e0e1 test(dashboards): cover CTE/subquery match_all queries through the panel search path (#13838)
## What

First test coverage for the dashboard **panel** endpoints and the
**dashboard
search path**, running CTE/subquery `match_all` queries as real
dashboard panels.

Nothing in the repo previously touched either:
`grep -rn "/panels\|search_type=dashboards" tests/api-testing/` returned
zero hits.

## Why

Panels query `_search?type=logs&search_type=dashboards&dashboard_id=…` —
a
different route into the engine than the Logs page (`search_type=ui`).
The
`match_all` + FST projection rewrite fixed in #13808 runs inside it, so
a query
that passes via Logs can still regress on a dashboard.

The rewrite has to preserve the projection of the block the predicate
sits in.
When that block is the whole statement, the projection is obvious. When
it's a
CTE body feeding a JOIN, a window, or a four-branch UNION ALL, it has to
preserve
the *CTE's* output schema while the outer query reads different columns
from it —
which is where it went wrong.

## Coverage

**71 distinct queries across 29 combination families**, all with
`match_all`
inside a `WITH …` body or a derived table:

| Axis | Variants |
|---|---|
| Nesting depth | 1, 2, 3, and 5-level CTE chains |
| Nesting kind | CTEs and derived tables (and one query using both) |
| UNION ALL | 2, 3, and 4 separate `match_all` branches |
| JOIN | CTE⋈CTE, derived⋈derived, cross-stream |
| Window | `ROW_NUMBER`, `RANK`, `DENSE_RANK`, partitioned aggregates |
| Negation | `NOT match_all`, and mixed with positive `match_all` |
| Combined with | `COUNT(DISTINCT)`, `CASE`, `HAVING`, `histogram()` |

Each case creates a real panel, runs it via the dashboard search path,
then
asserts row count against the DuckDB oracle and that no expected column
was
dropped from the projection. A further 30 cases run the same SQL through
**both**
Logs and dashboard and diff the results — that's what catches a rewrite
firing on
only one path.

Queries are reused from the existing query-agent corpus rather than
duplicated,
so the two suites can't drift: query-agent verifies the values via the
Logs path,
this verifies the same SQL survives the dashboard path.

Cases are **discovered by classifying the SQL**, not from a hand-written
ID list,
so new combinations added to the corpus are picked up automatically.
Test ids
name the shape (`Q1022-cte1_union_cdist_4ma`), so a failure identifies
the
combination without opening the corpus.

Plus 14 tests for the panel endpoints themselves — AddPanel /
UpdatePanel /
DeletePanel, contract + CRUD lifecycle + 7 error paths derived from
`DashboardError` in `src/core/src/http.rs`.

## Changes

| File | |
|---|---|
| `tests/dashboards/test_dashboard_cte_panels.py` | new — 103 tests |
| `tests/dashboards/test_dashboard_panels.py` | new — 14 tests |
| `tests/dashboards/conftest.py` | new — shared fixtures |
| `support/panel_queries.py` | new — discovery + panel helpers |
| `support/endpoints/dashboards.py` | +78, additive |
| `support/endpoints/search.py` | +36, additive |
| `support/factories.py` | +73, additive |

The three shared-file edits are **187 added lines, 0 modified** —
nothing
existing can break.

## Results

168 tests in tests/api-testing/tests/dashboards/ (117 new + 51
pre-existing)
166 passed, 1 skipped, 1 xfailed     # full nested-query corpus present
74 passed, 2 skipped, 1 xfailed     # current corpus on main


Repeated 2× and 4× respectively, no flakes.

Discovery scales with the corpus: on main it finds 8 nested `match_all`
cases,
and 71 once the larger sentinel set is present. Cases that aren't in the
corpus
skip with an explanatory message instead of failing, so this is safe to
merge in
either order.
2026-08-17 09:47:17 +00:00
Julián ValdésandHengfei Yang 06c6b515fb fix(stats): only read file_list_dump_stats when dumping is enabled (#13830)
Fixes #13829.

## Problem

`file_list_dump_stats` is created **conditionally** —
`handle_partitioned_tables` only adds it when
`ZO_COMPACT_FILE_LIST_DUMP_ENABLED` is set, and that flag defaults to
`false`. But `update_stats_from_file_list_for_stream` reads it
**unconditionally**.

On a PostgreSQL metastore where the table was never created:

```
[STATS] run update stream stats from file list error: SqlxError#
error returned from database: relation "file_list_dump_stats" does not exist
```

**The failure is wider than the dump.** The `?` propagates before
`set_stream_stats`, so the entire stats update is aborted — every stream
reports `doc_num: 0` while ingestion, search and compaction keep
working. It reads as a UI problem rather than a failing job, which is
what makes it expensive to track down.

SQLite creates the table unconditionally, so this only surfaces on
deployments with an external PostgreSQL metastore.

## Change

Gate the read on the same flag that gates the creation. One call site —
`query_dump_stats_by_date_range` has no other caller.

When dumping is off there is nothing to merge, so the stats are
unchanged: `merge` over an empty result was already a no-op. The
difference is that the surrounding update now completes instead of
aborting.

## Alternative considered

Always creating the table would also fix it, at the cost of an empty
partitioned table on every deployment. Gating the read seemed closer to
the existing intent — the table is deliberately optional — but I am
happy to flip it if you prefer the schema to be unconditional.

## Testing

Reproduced on v0.92.1 with `ZO_META_STORE=postgres` and the flag at its
default: stats fail every cycle and all counters sit at zero. Creating
the table by hand with the DDL from
`create_partitioned_dump_stats_table` makes the error and the zeros go
away, which confirms the missing table is the whole cause.

I have not been able to build the project locally to run the test suite
— no Rust toolchain on the machine I reproduced this on. The change is a
single conditional and the import follows the one already used in
`src/compaction/src/merge.rs` (`use config::{get_config, ...}`), but it
deserves a CI run before merging.

Signed-off-by: Julián Valdés <julianvaldes24@gmail.com>
Co-authored-by: Hengfei Yang <hengfei.yang@gmail.com>
2026-08-17 08:24:56 +00:00
Neha PandClaude Opus 4.7 8a4f7ea23b test(query-agent): add 70 match_all sentinel queries for FST rewrite path coverage (#13824)
## Summary

Adds Q1003–Q1072 (70 queries) to
`tests/test-data/query-agent/queries/full_text_search.json`. All
exercise the `match_all` + FST projection rewrite path fixed in #13808,
under diverse plan shapes.

Structural patterns mirror real production customer queries (fan-out
UNION ALL, multi-CTE, CSE-triggering scalar wrappers on the FTS field)
but use **only test-fixture fields and values** — no customer
identifiers.

## Shape coverage

| Family | Range | Count | What it exercises |
|---|---|---|---|
| A. Subquery + `match_all` + GROUP BY | Q1003–Q1012 | 10 | Simple
inline-view aggregations |
| B. CTE with fan-out UNION ALL, `match_all` per branch | Q1013–Q1027 |
15 | Dominant customer pattern (multi-branch UNION + per-branch FTS +
LIKE/regex + outer aggregate) |
| C. CTE + scalar wrapper on `log`
(trim/upper/split_part/regexp_replace/substring/concat/length) +
`match_all` | Q1028–Q1037 | 10 | **CSE-trigger sentinels** — the
wrappers cause DataFusion CSE to insert the intermediate
`ProjectionExec` that #13808 fixes |
| D. Deep multi-CTE + `match_all` + regex/LIKE/CASE combos | Q1038–Q1047
| 10 | Deep plan trees, many projection candidates |
| E. Edge cases (DISTINCT, HAVING, CASE, IN-list) | Q1048–Q1052 | 5 |
Corner combinations |
| F. `match_all` inside JOINs (self-join, LEFT JOIN, cross-stream) |
Q1053–Q1058 | 6 | JOIN builders inject their own projections |
| G. `match_all` + window functions (ROW_NUMBER, RANK, DENSE_RANK,
SUM/COUNT OVER) | Q1059–Q1063 | 5 | Window planning inserts projections
above filter |
| H. Negated `match_all` (NOT, AND NOT, UNION of pos/neg) | Q1064–Q1066
| 3 | Rewriter's negation code path |
| I. Multiple `match_all` in same query (AND, OR, nested) | Q1067–Q1069
| 3 | Two FST fields injected simultaneously |
| J. `histogram` + `match_all` time-series | Q1070–Q1072 | 3 | Common
customer pattern; histogram optimizer + FST rewriter together |

## Verification

- All 70 queries have DuckDB oracle results auto-populated via
`compute_counts.py`.
- Two initial cross-stream JOIN queries (Q1055, Q1058) were rewritten to
disambiguate the FTS field between streams — now they scope `match_all`
inside a per-stream CTE before joining.
- Audit for customer-token leakage (pharma, tomcat, o2_test, usgm,
storenumber, tagname, cash, credit_card, etc.) returns **zero hits**
across Q1003–Q1072.

## Implementation notes

- Added generator script
[tests/test-data/query-agent/gen_q1003_q1052.py](tests/test-data/query-agent/gen_q1003_q1052.py)
alongside existing `gen_q201_q400.py` / `gen_q511_q556.py` convention.
Deterministic regeneration.
- Wide JSON churn across the other 12 category files is
`compute_counts.py` re-baselining expected results against a fresh
`BASE_TS` (`base_ts_override.json` is untracked); tuple-order shuffles
are harmless under `results_mode: rowsort`.
- Coverage is most meaningful under the `remove_filter_with_index_false`
CI leg (added in #13810), where `match_all` actually reaches
`RewriteMatchPhysical`. Under the default flag (`true`), the index
optimizer removes `match_all` before the rewriter runs, masking the bug
path.

## Test plan

- [ ] `api_query_agent / single_node_opt_true` — green
- [ ] `api_query_agent / single_node_opt_false` — green
- [ ] `api_query_agent / remove_filter_with_index_false` — green (this
is the leg that actually validates the rewriter under all 70 shapes)
- [ ] Q1003–Q1072 all pass on both memtable and parquet phases

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-17 06:42:06 +00:00
udev 35860aff9d fix: route annotation queue score lookups to queriers (#13801)
## Summary

- route Discovery and annotation queue endpoints to queriers so score
lookups, item hydration, and review reads do not fall through to
ingesters
- make Discovery source_stream required at the API response boundary
- cover Discovery, queue item detail, and review routing with regression
tests

The QueueItem remains reference-only. Workbench and enqueue validation
derive source_stream from timestamp-bounded _llm_scores queries in the
enterprise implementation.

Depends on https://github.com/openobserve/o2-enterprise/pull/2423

## Testing

- 5 querier routing tests
- Discovery response serialization test with the enterprise feature
- 16 enterprise annotation queue tests
- 14 enterprise Discovery tests
2026-08-17 05:24:31 +00:00
udev d770d0245a fix: preserve online evaluation provider API keys (#13739)
## Summary

- preserve the stored provider API key when an edit submits a blank
value
- replace the stored key when a nonblank value is supplied
- show the create-time required marker only for providers that require
authentication
- retain keyless creation for self-hosted providers

## Testing

- ProviderFormPage Vitest suite passed: 8 tests
- Targeted ESLint passed
- cargo fmt check passed
- git diff check passed
- Focused Rust test compilation is blocked by unrelated existing
alert-state model errors

Related to #13655.

Companion enterprise PR:
https://github.com/openobserve/o2-enterprise/pull/2390
2026-08-17 05:08:15 +00:00
Bhargav b9e11bb885 fix(web): classify unquoted LIKE patterns wherever the parser stops (#13818)
`x LIKE E00%` parses as the identifier `E00` followed by the modulo
operator, so the PEG parser fails on a later token and the existing
`found === "%"` check never fires — the error fell through to the
allow-list suppression and the editor showed no diagnostic at all. Only
patterns whose first character terminates the identifier (`LIKE BIN-%`)
were caught.

Add hasUnquotedLikePattern(), which inspects the LIKE/ILIKE/RLIKE
operand nearest the error position and reports badLikePattern when it is
a bare wildcard token. It runs immediately before the suppression
return, so it can only reclassify errors that were previously dropped;
quoted patterns, parenthesised expressions and function calls never
match.

Detection on the unquote_like_pattern mutation set goes 6/13 -> 13/13,
so raise that threshold from 0.5 to 0.9. The old 0.5 sat exactly on the
sample boundary, which is why adding one query to the shared query-agent
corpus in #13808 turned the suite red.

Also stop the per-mutation tests from regenerating the whole mutation
pool (~1,000 queries x 16 mutations, each re-parsed) on every case:
memoize generateBrokenQueries() and give the heavy tests an explicit 30s
timeout. Spec runtime drops from ~112s to ~48s and the 5s-default
timeouts go away.
2026-08-17 04:48:23 +00:00
Omkar Kesarkhaneandgithub-actions[bot] ddb67f3efb fix(synthetics): record from a specific step, and stop silently downgrading recorded actions (#13799)
Supersedes #13777, which carried the record-from-step commits alone.
Closing that in favour of this, because the two changes land in the same
three files (`types/synthetics.ts`, `useSyntheticsRecorder.ts`,
`JourneySteps.vue`) and reviewing them apart means resolving the same
conflict twice.

## Problem

**A recorded hover was stored as a click.**

Playwright 1.56 added `hover` to the recorder's action model, reachable
from its "Choose action" picker. The extension's action mapper seeds
every step with `action: 'click'` as a placeholder for its switch to
overwrite, and returned that placeholder from `default:` — so any action
name the switch did not know became a click the author never performed.

On the usual hover target — a nav trigger, a dropdown opener — clicking
it can navigate or submit. The run still reports green.

There are three separate guards meant to stop exactly this, and none of
them fired, because the relabel happens upstream of all of them:

| Guard | Why it missed |
|---|---|
| `UNSUPPORTED_REPLAY_ACTIONS` (extension) | step already said `click` |
| `RETIRED_ACTIONS` → `isStorableAction` | step already said `click` |
| `V2_STEP_ACTIONS` (Rust validation) | `click` is valid |

The same picker offers **Right click** and **Double click** as explicit
choices. Both were flattened too: `click_count` was dropped by the
mapper and never existed downstream, and the probe's executor called
`locator.click({ timeout })` while reading neither `button` nor
`click_count`. A deliberately recorded right click replayed as a left
click; a double click as a single one.

**Recording could only start from scratch.** Fixing step 12 of a 15-step
journey meant re-recording all 15.

## Change

**Record from a specific step.** Each step gets a record action that
replays the prefix before it, then flips the recorder into insert mode
at that point. A restore that cannot reach the requested step reports
where it stopped (`prefixFailed`) rather than silently recording from
the wrong place.

**Every action Playwright can record is now stored and replayed as
itself.**

- `hover` is a first-class v2 action — mapped, stored, validated, and
executed by `Locator.hover` in the probe and `Frame.hover` in the
extension player.
- `button` and `click_count` travel to storage, so right click and
double click replay as recorded.
- An action name the mapper does not know now **throws** instead of
returning the `click` placeholder. This is the part that is not
hover-specific: upstream added one action in nine minors and will add
more, and the next one must be a deliberate decision rather than a
silent downgrade.

**The extension handshake reports capabilities, not just presence.**
`getStatus` now returns `extVersion` and a `capabilities` array; O2
gates affordances on `hasCapability()` with a defined pre-handshake
fallback, and warns when the installed build is older than the minimum.
Previously the protocol carried no version at all — Chrome updates the
extension independently of when O2 deploys, so the two could disagree
about the wire with no way to say so.

**Two dead message declarations, opposite problems.** `setSources` no
longer exists in playwright-core (sources arrive alongside the actions),
so its declared variant could never fire — while its `generatedCode`
field is exactly what an eject-to-code feature would reach for. Deleted.
`elementPicked` is the reverse: it fires on every "Pick locator" and
every click in inspecting mode, and was being discarded, which made
inspect mode a dead end. Now kept.

### Compatibility

`button` and `click_count` are optional with defined absent-behaviour —
absent means a single left click. Every journey stored today stays
valid, means the same thing, and serialises byte-identically. **No
schema version bump and no migration.** A test pins that rather than
leaving it as an intention.

`hover` is a new vocabulary entry with no default, so the probe must be
able to execute one before O2 is allowed to store one — the probe change
ships first.

## Implementation

| File | |
|---|---|
| `src/config/src/meta/synthetics.rs` | `hover` joins
`V2_STEP_ACTIONS`/`V2_ELEMENT_ACTIONS`; `button` and `click_count` added
to `BrowserStepV2` with validation (`left\|middle\|right`, `1..=3`) |
| `web/src/types/synthetics.ts` | `button`/`clickCount` on both step
types; `extVersion` + `capabilities` on `RecorderStatus`; `setSources`
variant deleted and `elementPicked` typed; `prefixFailed` and
insert-mode fields on `recordingStarted` |
| `web/src/utils/synthetics/mapRecordedStep.ts` | promotes
`button`/`clickCount` from the wire, like every other stored field |
| `web/src/utils/synthetics/buildV2Steps.ts` | `hover` in
`ACTION_TO_V2`; crosses camelCase → snake_case for the two click fields
|
| `web/src/constants/synthetics.ts` | `hover` out of `RETIRED_ACTIONS`;
`MIN_EXTENSION_VERSION` |
| `web/src/composables/useSyntheticsRecorder.ts` | `startRecordingFrom`,
`hasCapability`, `pickedSelector`, `extensionOutdated` |
|
`web/src/components/synthetics/journey/{BrowserJourney,JourneySteps}.vue`
| per-step record action and its wiring |

`click_count` is capped at 3: the recorder only ever emits 1 or 2, and
an unbounded count is a way to hold a page's event loop open.

The two click fields cross from camelCase to snake_case in `buildV2Step`
and nowhere else — reading `step.wire` directly inside it would have
worked and invented a second path into storage.

### Depends on a recorder extension build

`hover`, the click fields and the capability handshake all need the
matching extension build. O2 degrades honestly without it:
`hasCapability()` falls back to the pre-handshake set, and an older
build raises the outdated-extension warning instead of failing on a
message shape it does not recognise.

## Tests

- **Rust** — hover accepted as an element action; `button`/`click_count`
validated at both bounds; a step carrying neither still validates (the
compatibility guarantee). Two existing tests encoded "hover is rejected"
and now say the opposite. 101 pass.
- **Probe** — hover hovers rather than clicks; a right click sends the
recorded button; a double click sends two clicks; a journey stored
before these fields replays unchanged. The action union is asserted
against the Rust vocabulary, so the next divergence fails a test instead
of reaching an executor with no case for it. Also pinned: a selector
carrying a frame hop resolves into the iframe, which had no coverage
anywhere.
- **Web** — 1213 across 60 files. Nine existing tests encoded "hover is
retired" across constants, `buildV2Steps`, `buildPayload` and the
save-gate schema; each now states the opposite deliberately.
- **Extension** — the unmapped-action throw, hover round-tripping both
directions, and a double click surviving the round trip as two clicks.

`vue-tsc`, `cargo clippy -D warnings`, and `prettier --check` are clean.
The last commit is formatting only: the three record-from-step files
arrived failing `format:check`, which is a gate of its own and would not
have been caught by lint.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-16 17:50:46 +00:00
Hengfei Yang 192f11f7ea fix(i18n): correct mistranslations in zh-CN locale (#13835)
## Summary

Fixes machine-translation errors in the Simplified Chinese locale
(`web/src/locales/languages/zh-CN.json`), found by sweeping the file
against the English source. Three batches of the same class of error:

### Nonsense translations (wrong sense of the English word)
| Before | After | English source |
|---|---|---|
| 痕迹 / 踪迹 (physical traces) | 追踪 | Traces (nav, tabs, buttons — 8 keys)
|
| 朗姆酒 (the liquor) | RUM | RUM |
| 漂亮 / 崩溃了 (beautiful / crashed) | 格式化 / 折叠 | Pretty / Collapsed view
toggle |
| 汽车 (a car) | 自动 | Auto (layout mode) |
| 转 / 转弯 (road turns) | 回合 | Turn / Turns (conversation) |
| {n} 个电话 (phone calls) | {n} 次调用 | {n} calls (tool calls) |
| oTel 收藏家/代理人 (art collector) | OTel Collector / 代理 | OTel Collector /
Agent |
| 空缺评测任务 (job vacancy) | 打开评估任务 | Open Eval Jobs |
| 简历 (a CV) | 恢复 | Resume (pause/resume) |
| 出口 (trade export, ×3) | 导出 | Export |
| 逃脱了 (ran away) | 已转义 | Escaped (string escaping) |
| 测试赛 (sports match) | 测试匹配 | Test Match |
| 比分 (sports score) | 分数 | scores (eval scores) |
| 好吧 ("well, fine") | 正常 | Ok (status) |
| 领域 (domain/field) | 区域 | Region (bucket region) |
| 全球 (worldwide, ×4) | 全局 | Global (scope) |
| 商店 (a shop) | 存储 | Store (key store) |
| 尺寸 (physical size, ×5) | 维度 | Dimensions (metrics) |
| 用法 / 使用密钥 (how-to / secret key) | 用量 / 用量键 | Usage / Usage key |
| 滤镜 (photo filters) | 筛选条件 | Filters |
| 网络缓存影响 (network) | 缓存净影响 | Net Cache Impact |
| 申请并搜索 (apply for) | 应用并搜索 | Apply & Search |
| 压缩后的尺寸 | 压缩后大小 | Compressed Size |
| 等得紧,我们正在搜寻你的踪迹。 | 正在加载追踪... | Loading Traces... |

### Terminology unification
- **GenAI agents → 智能体** (agent graph, agent behavior, agent mapping,
sessions — ~40 strings). Infrastructure agents (host collectors,
synthetics runners) keep the conventional 代理.
- **Eval jobs → 评估任务** (was a mix of 评估作业 / 评测任务 / 作业). Batch jobs
elsewhere (backfill, search jobs) keep 作业.
- **Enrichment table → 富化表** (was a mix of 充实表 / 丰富表 / 扩充表).
- **Cipher Key → 加密密钥** (was mixed with 密码密钥); **Actions → 操作** (was 行动
in 4 places); **Running Queries → 运行中的查询**; synthetics **journey → 流程**
(was 旅程); assistant 助理 → 助手.
- `shortcuts.actions.tracesSearch` resynced with current English source
("Run query" → 运行查询).

### Cosmetic
- Shortcuts cheatsheet: corrupted ⌘ symbol (rendered as ▼) restored;
ungrammatical strings fixed (按?任何地方 → 在任意位置按 ?, 焦点搜索栏 → 聚焦搜索栏, 导入控制面板 →
导入仪表板); untranslated example values restored to `input`/`output`;
half-translated strings completed (Spans 表格视图 → 跨度表格视图, Stacktrace 已复制 →
堆栈跟踪已复制).

Notes:
- zh-TW.json was checked for the same error patterns and is clean.
- 跟踪 vs 追踪 for "trace" is still mixed in a few longer sentences; left
as-is since 跟踪 is not incorrect, only inconsistent.

## Test plan
- JSON validated with `python3 -c 'json.load(...)'` after each batch;
keys unchanged, only values edited (~200 lines).
- Grep confirms none of the wrong terms above remain in zh-CN.json.
2026-08-16 15:52:36 +00:00
Hengfei Yang 41ff3cd9b6 feat: replace distinct_values_* streams with tantivy TopK for the _values API (#13834)
Design at: #13822

## Summary

`distinct_values_*` metadata streams existed to serve one reader: the
`_values` API. They are a redundant — and lossier — materialization of
what the tantivy secondary index already holds, and the index has the
collectors needed to replace them outright (`SimpleTopN` /
`SimpleDistinct`). This PR removes the distinct streams from the write
and read paths and lets the `_values` API be served by the tantivy index
optimizer.

### 1. Treat distinct value fields as secondary index fields (read-time
union)

- `get_stream_setting_index_fields()` — the single accessor used by both
the index build side (ingester parquet/pack jobs, compactor merge) and
the query side (leader/follower index optimizers) — now unions
`distinct_value_fields` into the effective index field set, gated on
`enable_distinct_fields`. Fields that cannot be secondary-indexed (full
text search keys, partition keys, reserved columns) are skipped; the
values API falls back to a normal scan for those.
- The distinct list is deliberately **not merged into
`settings.index_fields`**: the two stay independent, so removing a
distinct value field (or disabling `enable_distinct_fields`) cleanly
stops its indexing with no residue, and the user-managed `index_fields`
list is never mutated behind their back.
- Correctness gate: a distinct-only field is considered indexed since
its `added_ts` (`get_stream_setting_index_updated_at_for_fields`), so
parquet files older than that are served by DataFusion scan instead of
the index (`split_file_list_by_time_range`). `added_ts` is set on add
and reset when `enable_distinct_fields` is re-enabled (existing
behavior). For streams configured before this release the fields only
start being indexed at upgrade time, so `added_ts` is additionally
clamped by a release-wide cutoff `/tantivy/distinct_fields/updated_at` —
a lazy marker initialized to the first access after upgrade, following
the existing `ttv_secondary_index_updated_at` pattern. No data migration
and no `DB_SCHEMA_VERSION` bump needed.

### 2. `_values` API always queries the original stream

- Removed `can_use_distinct_stream()` and the `distinct_values_*` /
`SUM(count)` / `StreamType::Metadata` rewrite from `values_inner` (GET
`/_values`, formerly `values_v1`) and `build_search_request_per_field`
(POST `/_values_stream`).
- The generated SQL shapes are exactly what the index optimizer detects:
- `SELECT f AS zo_sql_key, COUNT(*) AS zo_sql_num FROM s GROUP BY
zo_sql_key ORDER BY zo_sql_num DESC, zo_sql_key ASC` + limit →
`SimpleTopN`
- `no_count`: `SELECT f AS zo_sql_key FROM s GROUP BY zo_sql_key ORDER
BY zo_sql_key ASC` + limit → `SimpleDistinct`
- The `keyword` filter (`str_match_ignore_case`) is a valid index
condition, so keyword-filtered values queries stay on the index path
too.

### 3. Remove distinct values generation

- Dropped the per-record distinct value collection in logs and traces
ingestion and the whole `core::metadata` module (channel, mem-table,
flush job, shutdown hook).
- Removed the now-unused `DISTINCT_FIELDS` static and the
`ZO_FEATURE_DISTINCT_EXTRA_FIELDS` / `ZO_DISTINCT_VALUES_INTERVAL` /
`ZO_DISTINCT_VALUES_HOURLY` config knobs, including the `SUM(count)`
merge rollup the last one gated — legacy `distinct_values_*` streams now
compact as ordinary metadata streams. The default fields
`DISTINCT_FIELDS` carried (`service_name`, `operation_name`) are already
default secondary index fields via `SQL_SECONDARY_INDEX_SEARCH_FIELDS`,
so traces `_values` queries on them remain index-served.
- Renamed `values_v1` to `values_inner`: the `v2` variant was removed
long ago (#5087) and the distinct stream switch inside `v1` is gone now
too, so the version suffix was pure history.

### 4. Default fields after this change

- `service_name`, `operation_name`: default secondary index fields
(unchanged, via `SQL_SECONDARY_INDEX_SEARCH_FIELDS`).
- `trace_id`: now also a built-in default **bloom filter** field
(`BLOOM_FILTER_DEFAULT_FIELDS`, gated on
`ZO_FEATURE_DEFAULT_INDEX_FIELDS_ENABLED`), in addition to being a
default index field — bloom sits on top of the secondary index and
prunes files for high-cardinality equality lookups.
- There is no "default distinct fields" concept anymore.

### 5. Fix surfaced by e2e: SimpleDistinct returned null values as `""`

The tantivy index encodes null/missing field values as empty strings
(`index_builder` adds `""` for null docs), so the `SimpleDistinct`
term-dictionary walk returned `""` as a distinct value — sorted first in
ascending order, it surfaced as a blank first option in dashboard
variable dropdowns, which the DataFusion `GROUP BY` path never returns
(its null group sorts last). This was exposed once distinct fields
became index-served; `handle_simple_distinct` now skips empty terms
(with a unit test).

### 6. Drive-by: fix the failing `api_fuzz_tests` CI job

`CreateAlertRequestSchema` was an untagged documentation union with two
identical variants, producing `oneOf: [CreateAlertRequestBody,
CreateAlertRequestBody]` in the OpenAPI spec. Under `oneOf` semantics
(exactly one branch must match) no instance can ever validate, so
schemathesis filtered out every generated example and `api_fuzz_tests`
failed on `POST /api/v2/{org_id}/alerts` on every CI run since that
schema landed. The handler deserializes `CreateAlertRequestBody`
directly, so the annotation now documents exactly that type; runtime
behavior is unchanged.

## What stays (intentionally)

- `distinct_value_fields` / `enable_distinct_fields` in stream settings:
still the user-facing way to declare values-API fields; the list now
drives the read-time index-field union described above.
- The `distinct_value_fields` DB table that tracks dashboard/report
usage of these fields (still gates removal in the settings API).
- `DISTINCT_STREAM_PREFIX` helpers, retention sync and stream-deletion
cleanup for existing `distinct_values_*` streams — legacy stream data
keeps compacting (as ordinary metadata streams) and ages out via
retention; no new records are written to them.

## Testing

- `cargo check --workspace --all-targets` and `cargo clippy --workspace
--all-targets` clean.
- Enterprise build verified with `Cargo.toml.openobserve` (`cargo check
--all-targets`).
- New unit tests for the index-field union (skip rules for
FTS/partition/reserved fields, `enable_distinct_fields` gating) and the
`added_ts` / release-cutoff indexed-since resolution; schema, infra,
config and api-search unit tests pass. A few pre-existing failures on a
clean tree (config `ssrf_guard`, one flaky sqlite-backed destinations
test, one `.env`-dependent index-fields test) are local-environment
issues and unrelated.

Closes #13822
2026-08-16 15:19:31 +00:00
Harsh Mahajan 7f74925605 feat: banner as notification (#13667) 2026-08-16 13:42:35 +00:00
Hengfei Yang c246119809 fix(i18n): stop translated placeholder names crashing localized pages (#13833)
Fixes #13751

## The crash

vue-i18n compiles messages just-in-time (`__INTLIFY_JIT_COMPILATION__`),
and a compile error is **thrown**, not warned — it tears down whatever
component asked for the message. The translation pipeline had been
localizing placeholder *names*, which is exactly that kind of error:

```
en-US  "Are you sure you want to delete {identifier}?"
zh-CN  "您确定要删除 {标识符} 吗?"        -> SyntaxError: 2 (INVALID_TOKEN_IN_PLACEHOLDER)
```

IAM → Service Accounts rendered blank in Chinese for this reason. The
two zh-CN strings named in the issue had already been patched by hand in
#13707, but the *class* was never swept.

## What was actually broken

Compiling every message in every locale file with vue-i18n's own
compiler found three groups:

| | count | symptom |
|---|---|---|
| Does not compile | 132 | page blanks with `SyntaxError` — 8 locales:
es, fr, it, ja, ko, nl, pt, tr |
| Compiles, unknown name | 147 | `{Anzahl}` for `{count}` → value
renders as an empty string |
| Stale text, dropped placeholder | 5 | `{org}`, `{payer}`,
`{endpoint}`, `{service}` — English source no longer has them |

Worst of the first group are mangled vue-i18n literal escapes, e.g.
`{'{'}team{'}'}` coming back as `{'{'} '}` — an unbalanced brace.

All 279 are corrected here. The existing translated prose is kept
verbatim; only the tokens are restored. The 5 stale ones are
retranslated from the current English text, since restoring a token that
no longer exists is not an option.

## Why it can't come back

**`web/src/locales/enUsMessages.spec.ts` already did this — for en-US
only.** That left the machine-translated locales, the actual source of
the bug, uncovered. Renamed to `localeMessages.spec.ts` and extended to
every locale, plus a second assertion that a translation only
interpolates names en-US defines.

That second check is a **subset** rule, not equality: dropping a
placeholder the target language does not need stays legal (an English
plural suffix like `{plural}` has no Japanese equivalent). Introducing a
name en-US never passes a value for is always a bug.

Placeholder names are collected by *running* the compiled message
against a recording context rather than by pattern-matching the source,
because only the compiler knows which braces are placeholders and which
are literal escapes — `{'}'}` is one token whose body is the character a
`{...}` regex stops at. All plural branches are evaluated.

**`scripts/translations/translator.py` — `_validate` had the same regex
blind spot** (`{[^{}]*}`) and never checked that the output compiles at
all, which is why these landed. It now tokenizes the way vue-i18n does
and rejects anything vue-i18n would refuse, so the pipeline stops
emitting them rather than CI merely catching them afterwards. Escape
drift (`{'{'} rows {'} rows {'}`) is caught too, because literal escapes
are now part of the compared token multiset.

## Verification

- The Python scanner was cross-checked against vue-i18n's compiler over
**all 170,055 messages** in the 15 locale files — identical compile
verdicts and identical token sets, 0 divergences.
- Injecting the original `{标识符}` string back into zh-CN fails the new
spec; injecting a compiling-but-wrong `{bilv}` fails the second
assertion.
- `npx vitest run --environment jsdom --root src/ locales/` → 45 passed.
`prettier --check` and `tsc` clean on the spec.
- Only `en-US.json` is imported anywhere in `web/src`, so no existing
test depends on the changed locale data.

## Not in scope

A handful of translations are awkward but compile and bind correctly
(e.g. ko-KR renders "ingestion limit" with the eating sense of the
word). Left alone to keep this diff auditable as a mechanical token
restoration.
2026-08-16 09:27:40 +00:00
Prabhat Sharmaandgithub-actions[bot] 0dba67d638 feat: composite alerts (#13797)
Design at : alerts/composite_alerts/composite_alerts.md

## Summary
- add composite alert graph persistence, evaluation, scheduling,
lifecycle APIs, and deletion safeguards
- add composite alert UI forms, preview, references, list/detail
integration, and localization
- add Rust, API, Vue, and Playwright contracts

## Verification
- cargo fmt --all -- --check
- cargo check -p openobserve-core
- cargo check -p openobserve-api-management
- npm run type-check:composite-alerts
- npm run type-check:app
- npm run lint:ci -- --quiet
- design/token/style checks
- targeted Rust/Vitest suites
- browser QA against local alerts UI

## Follow-ups (intentionally deferred, documented in the spec)
- `RunOutcome::Error`/`NotifyFailed` outcome records on composite
delivery failure (§11.5)
- Incident detail live-definition resolver for composites + "definition
unavailable" retention (§9.6)
- `alert_composite_*` observability metrics (§16)
- SLO/stream delete-conflict 409 bodies with readable-vs-hidden parent
split (§10.3)
- Super-cluster startup preflight (§18)

<img width="1650" height="979" alt="image"
src="https://github.com/user-attachments/assets/3def5246-7bf7-48fe-9a42-c0ac08271ea9"
/>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-15 00:57:20 +00:00
TessaIO 30b938a1ed feat: introduce compactor ZO_COMPACT_DELETE_FILES_DELAY_MINUTES (#13828)
Resolves #13819

Signed-off-by: TessaIO <ahmedgrati1999@gmail.com>
2026-08-14 22:46:44 +00:00
Hengfei Yang 3fda083f51 feat(traces): add trace time index (#13827)
Design at:
[https://github.com/openobserve/designs/blob/main/infrastructure/file-list/trace-time-index.md](https://github.com/openobserve/designs/blob/main/infrastructure/file-list/trace-time-index.md)

## Summary

- add an hourly Metadata time index per trace stream to resolve a trace
ID to its full time range
- add external time-range and trace-details APIs, including bounded
locate/expand behavior and standard search responses
- integrate index generation into trace ingestion and preserve query
visibility through the synchronous Metadata write path
- synchronize retention and deletion for associated Metadata streams
- reuse the shared trace stream permission check across trace APIs

## Why

Trace detail lookup currently depends on a caller-provided time range,
which can omit spans outside that window. The time index provides a
bounded, best-effort range lookup for `stream + trace_id` without
scanning the full trace stream.

Closes #13823

## Environment variables

| Variable | Default | Effect |
| --- | --- | --- |
| `ZO_TRACE_TIME_INDEX_ENABLED` | `true` | Enables per-trace-stream
Metadata indexes, index writes during trace ingestion, and indexed
time-range lookup. Only traces ingested after the feature is enabled are
indexed; there is no historical backfill. |
| `ZO_QUERY_TIMEOUT` | 600 seconds | Existing setting reused as the
cumulative Trace Timer locate/expand deadline and as the default timeout
for the trace-details search. |

When enabled, each traces stream can create a related Metadata stream
named `trace_time_index_{stream}`. Its retention settings follow the
source stream, and it is removed with the source stream.

## API changes

All timestamp parameters and returned time ranges use microseconds.

### Get a trace time range

```http
GET /api/{org_id}/{stream_name}/traces/{trace_id}/time_range
```

Query parameters:

- `hint_ts` (optional): timestamp hint used to start the index lookup

Returns the source stream, trace ID, and the resolved `start_time` /
`end_time`. The `range` field is `null` when the index cannot resolve
the trace.

### Get trace details

```http
GET /api/{org_id}/{stream_name}/traces/{trace_id}/details
```

Query parameters:

- `start_time` and `end_time` (optional, but must be supplied together):
caller-provided range
- `hint_ts` (optional): timestamp hint for the time-index lookup
- `timeout` (optional): trace-details search timeout in seconds

The endpoint unions the caller range with the indexed range, so it never
narrows a caller-provided range. If neither the caller range nor the
index can provide a range, it returns `400`. The response uses the
standard search response and includes the effective range in
`new_start_time` and `new_end_time`.

## Validation

- `cargo test -p openobserve-api-search --lib` — 145 passed
- `cargo test -p openobserve-core traces::time_index::tests --lib` — 7
passed
- `cargo test -p infra
db::trace_time_index::tests::test_coverage_start_cache -- --exact
--nocapture` — passed
- `cargo check -p openobserve-api-search`
- `cd web && npm run type-check:app`
- end-to-end ingestion and query validation with the data generator

## Validation notes

- targeted ESLint could not start because the local web dependencies are
missing `@eslint/css`
- enterprise-only compilation could not be completed from this OSS
checkout because the local enterprise overlay is unavailable
2026-08-14 15:29:23 +00:00
ktx-akshay bd28872ddc test: improve stability of Alpha environment tests (#13817) 2026-08-14 12:25:41 +00:00
dbee572722 fix: slo bugs (#13784)
closes https://github.com/openobserve/o2-enterprise/issues/2397

---------

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-14 10:31:32 +00:00
Omkar Kesarkhane 6927c2f335 fix(synthetics): collapse the variables panel by default (#13825)
## What

The variables panel on the browser-test **Journey** step now starts
collapsed.

## Why

The journey is what that page is for, and the panel was taking a share
of the width before the author had asked for it. The toolbar toggle sits
right next to the journey and is labelled, so bringing the panel in is
one click — and the splitter position is still shared with Configure, so
nothing about the panel's own behaviour changes.

## Scope

One line plus a comment. `variablesPanelOpen` is journey-only by design
(the existing comment above it explains why it is not shared with
Configure), so this cannot affect the Configure step.

```diff
-const variablesPanelOpen = ref(true);
+const variablesPanelOpen = ref(false);
```

## Testing

`npx vitest run src/views/synthetics/CreateBrowserTest.spec.ts` — 44
passing.

Split out of a larger synthetics branch so it can land on its own.
2026-08-14 09:43:24 +00:00
Harsh Mahajan 0ed5336bf1 chore: adding HA mode for o2-ai agent (#13525) 2026-08-14 16:14:03 +08:00
Huaijin 3901c548a2 perf(promql): use Utf8View for metric labels (#13796)
## Summary

- read PromQL metric label columns as Arrow `Utf8View` when the existing
`ZO_UTF8_VIEW_ENABLED` setting is enabled
- preserve `__hash__` and exemplars in their original representation
while retaining schema metadata
- make PromQL equality, negative, and regex matchers type-aware for
`Utf8View`; cast regex operands to `Utf8` to avoid the DataFusion 54
mixed-type rewrite issue
- support `StringViewArray` in series-label loading and keep
compatibility with the process-wide series-label cache

No PromQL-specific environment variable is introduced, and metrics
continue to use the existing global `ZO_BATCH_SIZE`.

## Performance

For the filtered histogram query below, a controlled local A/B using the
same release binary and identical settings except Utf8View measured P50
latency improving from 0.964s to 0.677s, about 29.8%:

```promql
histogram_quantile(0.9, sum by(le, path) (rate(codelab_api_request_duration_seconds_bucket{path="/api/service-1"}[5m])))
```

Test range was one hour with `step=15` and `use_cache=false`.

## Testing

- `cargo fmt --all -- --check`
- `cargo test -p promql --lib utils::tests` (10 passed)
- `cargo test -p promql --lib series_labels::tests` (11 passed)
- `cargo test -p search --lib
datafusion::exec::tests::test_metrics_query_schema` (2 passed)
- `cargo check -p promql-service -p promql`
2026-08-14 03:24:49 +00:00
ktx-vaidehi e490a5c347 fix(logs): load the stream list on Visualize when no stream is selected (#13812)
Entering Visualize from the logs page with no stream selected left the
Stream dropdown on "No options found" — enabled, openable, and empty, so
there was no way to pick one.

PanelFieldList defers its initial fetch in edit mode until a stream is
set, exempting only "metrics". The Visualize tab ("build") needs the
same exemption: its select is the user's own, and the list is keyed on
stream_type alone, so waiting for a stream buys nothing.

Also arms the stream_type watcher at mount, so changing Stream Type with
a blank stream now reloads the list instead of doing nothing.

- https://github.com/openobserve/o2-enterprise/issues/2165
2026-08-13 13:01:21 +00:00
Hengfei Yang 6a2dfbb2b4 fix(compact): stop retention from deleting one extra day (#13804)
Fixes #13800

## Problem

With a 7 day retention the stream only kept ~6 days of data. From the
issue: retention is 7 days, but the stream data range starts at
`2026-08-07T00:00 UTC` while now is `2026-08-13T04:52 UTC` — the whole
`2026-08-06` partition is gone.

Retention deletes data at day granularity (`delete_by_date` wipes a full
`[day, day+1)` partition), but the job generator stepped through the
days with:

```rust
while start < time_range.end { ... }   // time_range.end == lifecycle_end
```

`lifecycle_end = now - retention_days` is a point in the middle of a
day, so the day it falls into also matched the condition and got a
delete job. Deleting that partition also removes everything between
`lifecycle_end` and the end of that day, which is still inside the
retention period. Effective retention became `(N-1, N]` days instead of
`[N, N+1)`.

This is a regression from #8058, which switched retention from one job
per range to one job per day. Before that change the range end was
formatted as `%Y-%m-%d`, so the boundary day was never included.

The day-aligned fast path in `generate_jobs()` already assumes the
boundary day is retained — it skips the stream when `doc_time_min >=
retention_day_start` — so the two paths disagreed today.

## Fix

Only generate delete jobs for days that lie fully before the retention
boundary. Extracted the day splitting into `generate_deletion_dates()`
so it can be unit tested without a database.

Retained window goes back to `[N, N+1)` days: with a 7 day retention the
stream now holds 7–8 days of data depending on the time of day, which is
the expected behaviour for day-granular deletion.

Day-aligned range ends (the ranges produced by extended retention
splitting) are unaffected — `start + 1day <= end` and `start < end`
agree there.

## Tests

Three new unit tests for `generate_deletion_dates`: a mid-day boundary
(the regression case), a day-aligned end, and ranges shorter than a day.

```
test retention::tests::test_generate_deletion_dates_keeps_the_day_of_the_retention_boundary ... ok
test retention::tests::test_generate_deletion_dates_with_day_aligned_end ... ok
test retention::tests::test_generate_deletion_dates_less_than_a_day ... ok
```

`cargo clippy -p compaction --tests` and `cargo fmt --check` are clean.
`test_generate_retention_job` / `test_delete_all` fail locally on `main`
as well (sqlite data dir missing in my checkout), unrelated to this
change.

## Note on the `time_range_start >= time_range_end` guard

The removed loop had a `continue` when the two formatted dates compared
equal, but it could never
fire: both dates come from the same `start` variable, formatted before
and after `start +=
day_micros(1)`, and in UTC adding 24h always advances the calendar date.
Simulating the old loop
with the numbers from the issue (`lifecycle_end = 2026-08-06T04:52 UTC`)
produces jobs for
`08-04`, `08-05` **and `08-06`** — the guard never triggers.

It was meaningful before #8058, where the two dates came from the two
ends of the range
(`format(range.start)` / `format(range.end)`) and could land on the same
day — and where
`format(lifecycle_end)` as the exclusive end is exactly what used to
keep the boundary day alive.
Second commit removes it as dead code.
2026-08-13 11:38:30 +00:00
Neha PandClaude Opus 4.7 7fbce61fd1 test: add Q1001 + Q1002 sentinels for match_all/CSE + widen CI matrix (#13810)
Two regression sentinels for the FST projection rewrite path fixed in
openobserve/openobserve#13808 (currently in the merge queue on
branch-v0.40.0 and awaiting the main-branch counterpart):

  Q1001 — CTE with trim(log) alias, match_all in WHERE, column-pruned
  outer SELECT. Mirrors the shape of the PR's own unit test
  (test_cse_projection_schema_propagation) — CSE inserts a
  ProjectionExec between filter and datasource; a regression in the
  rewriter's f_down handling would surface here.

  Q1002 — five-CTE pipeline (filtered → in_shop → scoped → normalized
  → cleaned) mixing match_all with LIKE, split_part, regexp_replace ID
  normalization, and GROUP BY aggregation. Higher structural pressure:
  more intermediate ProjectionExec nodes and more chances for a stale
  column index to leak through.

Uses only test-fixture fields (log, resource_path, page_url,
http_method); no customer identifiers.

Also adds a third leg to the query-agent CI matrix that runs with
ZO_FEATURE_QUERY_REMOVE_FILTER_WITH_INDEX=false — exercises the
non-index-filter path that the match_all projection rewriter also
touches.

The wide JSON churn across the other query files is compute_counts.py
re-baselining expected results against a fresh BASE_TS
(base_ts_override.json is untracked); tuple-order shuffles are harmless
under results_mode: rowsort.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-13 09:46:15 +00:00
Hengfei Yang 3d0085c43e refactor(traces): remove the trace_list_index feature (#13809)
## What

Removes the `trace_list_index` metadata stream and everything that fed
it.

## Why

On every trace ingestion, `write_traces` built one `TraceListItem` per
span (`_timestamp`, `stream_name`, `service_name`, `trace_id`) and wrote
the batch into a per-org `trace_list_index` metadata stream — creating
the stream, its schema, a bloom filter on `trace_id`, and (on
enterprise) an OpenFGA ownership tuple on first write.

Nothing ever read it back. A repo-wide search for the stream name and
every associated symbol turns up only the writer itself: no query path,
no API handler, no UI reference, and no reference in the enterprise
tree. The feature was pure write amplification on the trace ingestion
hot path, plus a stream per org that users had to see and pay retention
on.

## Changes

- Delete `src/core/src/metadata/trace_list_index.rs` (the
`TraceListIndex` writer, its schema/settings bootstrap, and its tests).
- Drop `MetadataItem::TraceListIndexer` and
`MetadataType::TraceListIndexer`, and their dispatch arms in
`metadata::write` / `MetadataManager::close`.
- Remove the per-record index build and the trailing write call in
`write_traces`. The `service_name` lookup there had no other consumer,
so it goes too.
- Remove the now-unused `ZO_TRACES_LIST_INDEX_ENABLED` config option.
- `MetadataItem` is now single-variant, so the refutable `if let` in
`distinct_values::write` becomes a plain destructuring `let` (an `if
let` there would warn as irrefutable).

## Notes

- **Existing data is untouched.** Already-ingested `trace_list_index`
streams stay on disk and remain visible under the Metadata stream type;
after this change nothing writes to them. They can be dropped through
normal stream deletion. Say the word if you'd rather this ship with a
cleanup migration.
- `ZO_TRACES_LIST_INDEX_ENABLED` is removed rather than deprecated.
Deployments that set it will simply see it ignored — `env_config` does
not error on unknown `ZO_*` vars.

## Testing

- `cargo check --workspace --all-targets` — clean, no warnings.
- `cargo test -p openobserve-core --lib metadata::` — 22 passed.
- `cargo test -p openobserve-core --lib traces::` — 378 passed.
- `cargo test -p config --lib` — 3148 passed, 2 failed:
`utils::ssrf_guard::test_async_validator_rejects_literal_private` and
`..._resolves_hostname_to_private`. Both are DNS-dependent and fail
identically on `main` at `d1f8941512`, so they are unrelated to this
change.
- The `enterprise` feature could not be compiled locally (the
`o2_enterprise` submodule is a stub here, and `openobserve-cipher` fails
to build against it independently of this change). The only
enterprise-gated code in scope lived inside the deleted file, and the
enterprise tree has no references to the feature.
2026-08-13 09:35:07 +00:00
Shrinath Rao 57e4e1085c ci(tests): fix flaky rye setup by reusing setup-python interpreter (#13813)
## Problem

The API/DB integration test jobs intermittently fail — often, and
retries don't reliably help. CI reports the failure on the **`Pin
cpython`** step:

```
rye: command not found
##[error]Process completed with exit code 127
```

But that step is a **victim, not the cause**. The real failure is the
preceding **`Setup rye`** step (`eifinger/setup-rye@v4`). It downloads
and extracts the `rye` binary fine, then runs `rye self install`, which
downloads a **python-build-standalone toolchain (3.11.7)** from GitHub
to bootstrap Rye's own venv:

```
Downloading Rye from ".../rye/releases/download/0.27.0/rye-x86_64-linux.gz" ...   ✓
Extracting downloaded archive...                                                  ✓
Determined RYE_TOOLCHAIN_VERSION: 3.11.7
Installing Rye into /opt/hostedtoolcache/setup-rye-.../0.27.0/x86_64
##[error]The process '.../_temp/...' failed with exit code 1                       ← real failure
```

That download flakes and exits 1, so the `rye` binary is never placed on
`PATH`, and the next step (`rye pin cpython@3.11.6`) dies with `command
not found` (exit 127). Retries only pass when the download happens to
succeed.

Example failing run:
https://github.com/openobserve/openobserve/actions/runs/31679986019/job/94383108990

## Fix

Every one of these jobs already provisions CPython 3.11.6 via
`actions/setup-python`. Point **`RYE_TOOLCHAIN`** at that interpreter so
`rye self install` reuses it instead of downloading a second toolchain —
removing the flaky network fetch entirely:

```yaml
- name: Setup Python
  uses: actions/setup-python@v7
  id: setup-python
  with:
    python-version: "3.11.6"

- name: Setup rye
  uses: eifinger/setup-rye@v4
  env:
    RYE_TOOLCHAIN: ${{ steps.setup-python.outputs.python-path }}
  with:
    ...
```

The `setup-python` step's official `python-path` output is the absolute
path to the interpreter it provisioned — used directly (no hardcoded
`/bin/python`), and present in every affected job (verified 1:1).

## Scope

- `.github/workflows/api-testing.yml` — 4 `Setup rye` blocks
- `.github/workflows/db-testing.yml` — 1 `Setup rye` block

Additive, low-risk: only redirects Rye's internal bootstrap; `rye pin` /
`rye sync` are untouched.


## Companion PR

Enterprise: openobserve/o2-enterprise#2424 — same fix on
`api-testing.yml` (8 blocks) + `api-testing_env.yml` (2 blocks).
2026-08-13 09:28:41 +00:00
7f647109fc fix: preserve projection schema when rewriting match_all (#13808)
## Summary

Forward-port of #13798 to `main`.

- rebuild `ProjectionExec` expressions against the rewritten child
schema before DataFusion validates the projection
- carry full-text search fields through intermediate projections and
preserve the original `FilterExec` output schema
- reject a full-text search field that a projection already exposes as
an alias for a different expression, instead of silently searching the
wrong data
- add a regression for a projection sitting between the filter and the
datasource

## Root cause

`RewriteMatchPhysical` expanded the `NewEmptyExec` projection with
full-text search fields. When an optimizer rule (CSE, for example) had
inserted an intermediate `ProjectionExec`, adding the FST field changed
the child column positions while the projection still referenced its
columns by their old indices, so DataFusion rejected the plan.

The old `filter_projection` was also computed as positions inside
`parquet_projection`, i.e. in the datasource output space. That is only
valid when the filter reads the datasource directly. It is now mapped by
name against the actual filter input schema, and the field order of
`filter.schema()` is preserved so the operators above the filter keep
reading the same columns.

Two latent issues are fixed along the way:

- a scan with `projection == None` (all columns) was narrowed down to
only the FST fields
- `schema.index_of(...).unwrap()` in the datasource rewrite panicked
instead of returning a plan error

## Differences from #13798

`main` is on DataFusion 54 and builds the filter through
`FilterExecBuilder`, so step 5 uses `apply_projection` and keeps
`with_fetch`.

The regression test also differs. The CTE query used in #13798 no longer
produces an intermediate projection on DataFusion 54 — CSE does not
extract `btrim(log)` there — so the test would be vacuous.
`test_projection_between_filter_and_scan` builds the offending plan
shape directly instead, which does not depend on optimizer internals.

## Verification

- `cargo test -p search --no-default-features --lib
datafusion::optimizer::physical_optimizer::rewrite_match` — 7 passed, 0
failed
- `test_projection_between_filter_and_scan` fails on `main` without the
fix (`SchemaError: Unable to get field named "__common_expr_1"`)

---------

Co-authored-by: Neha P <neha@openobserve.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-13 07:53:44 +00:00
ktx-akshay d1f8941512 test: stabilize test cases for the Alpha environment (#13551) 2026-08-13 06:41:03 +00:00
Shrinath Rao 496c0d5842 ci(tests): pure-API Playwright spec classifier + anti-drift guard (Phase 3) (#13794)
## What

**Phase 3** of the Playwright→pytest API-test migration — the anti-drift
guardrail. (Phase 1 = Correlation #13788 / o2-ent#2419; Phase 2 = Alerts
#13791.)

> 📚 **Stacked on #13791.** Base is the Phase 2 branch so the diff shows
only these two files; GitHub auto-retargets this PR to `main` once
#13791 merges.

## Changes

- **`tests/ui-testing/scripts/classify-specs.js`** — codifies the
migration classifier. Labels each `*.spec.js` as `UI_ONLY` / `UI+SETUP`
/ `PURE_API` / `REVIEW` from UI markers (`page.goto`, `.click`,
`locator`, `getByRole`, `toBeVisible`, page-object imports, nav
helpers…) vs API markers (`page.request`, `authedRequest`,
`getAuthHeaders`, `/_json`, api-helper imports). Modes:
  - `--all` — regenerate the inventory (summary + PURE_API/REVIEW lists)
  - `--check <files>` — exit 1 if any file is pure-API (the guard)
  - `--json`
- Conservative by design: a spec is `PURE_API` only when **not one** UI
marker appears, so genuine UI tests are never nagged.
- **`.github/workflows/pure-api-spec-guard.yml`** — runs `--check` on
the **newly-added** `*.spec.js` files in a PR and **fails** if any is
pure-API (backend/API assertions with no browser). Pushes new API tests
into pytest (`tests/api-testing/`) from the start. Only added files are
checked, so editing an existing UI spec never trips it.

## Why

The migration removed 9 pure-API specs from the browser harness
(Correlation ×5, Alerts ×4). Without a guard, new API tests get authored
as Playwright specs again and the drift returns. This makes the rule
enforceable in CI instead of relying on reviewer memory.

## Verification

- Re-ran the classifier over all **257** specs → **`PURE_API: 0`** (both
phases fully removed their targets; zero drift). Sole `REVIEW` =
`cleanup.spec.js`, a teardown utility (not a test).
- Self-tested the guard: ✅ flags a synthetic pure-API spec (exit 1 +
actionable message), ✅ passes a UI spec (exit 0).
- Resolved the 6 previously-uncertain "Category C" specs → all UI or
teardown; **0 additional migrations**.
2026-08-13 05:07:55 +00:00
Hengfei Yang 83d562325c fix(alerts): type the aggregation HAVING clause from the aggregate, not the column (#13790)
Fixes #13786

## Why

Aggregation alerts that worked before 0.92.0 now fail at query time:

```
SELECT host, COUNT("host") AS alert_agg_value, ...
GROUP BY host HAVING "alert_agg_value" < '1.0'

Optimizer rule 'simplify_expressions' failed
caused by Arrow error: Cast error: Cannot cast string '1.0' to value of Int64 type
```

## Root cause

Two defects in `build_sql`, both introduced by #13547 (first shipped in
0.92.0), compound:

1. `having_filter_value` returns an `f64`, so a threshold of `1` becomes
`json!(1.0_f64)`. The workspace enables serde_json's
`arbitrary_precision`, so the number is stored as the literal text
`"1.0"` — never `"1"`.
2. `build_expr` was handed the data type of the **source** column
(`agg.having.column`, i.e. `host`: Utf8) rather than the type of the
aggregate. `COUNT("host")` is Int64, but the Utf8 branch quoted the
literal as `'1.0'`, which DataFusion then failed to cast.

Before #13547 the un-widened `agg.having` value was passed straight
through, so the clause read `< '1'` — a string DataFusion casts to Int64
without complaint. Hence the reporter's "it worked before 0.92.0".

### The same change also silently zeroed thresholds on numeric columns

`build_expr`'s integer branch called `as_i64().unwrap_or_default()`, and
`as_i64()` returns `None` for a float-serialized number (`N::Float(_) =>
None`; under `arbitrary_precision`, `"100.0".parse::<i64>()` fails too).
So an alert on `AVG(response_time) > 100` quietly became:

```sql
HAVING "alert_agg_value" > 0
```

No error, no log line — just a threshold replaced by zero, firing on
every evaluation (or never, for `<`). This affects more alerts than the
reported cast error, and does so invisibly.

## Fix

- **`having_data_type()`** resolves the comparison type from the
aggregate's OUTPUT rather than the column: `COUNT` → Int64,
`AVG`/`MEDIAN`/`P50`–`P99` → Float64, `MIN`/`MAX`/`SUM` → the column
type (they do return their input type, so string min/max keeps its
string comparison).
- **`build_having_expr()`** extracts the widening out of `build_sql` so
it is unit-testable without an `infra::schema` fetch, and normalizes an
integral f64 threshold back to an integer `Number`. That keeps the Utf8
min/max path emitting `'1'`, byte-identical to pre-0.92.
- **`build_expr`'s integer branch** falls back to `as_f64()` instead of
defaulting to 0. This also covers plain WHERE conditions, which had the
same silent-zero hazard. A fractional value stays fractional rather than
being truncated; a non-numeric value errors instead of defaulting.

The multi-alert path was never affected — `severity_order_sql` formats
its f64 as a bare numeric literal.

The reporter's alert now generates `HAVING "alert_agg_value" < 1`.

## Tests

Nine unit tests, including the exact #13786 shape (COUNT over a Utf8
column), the silent-zero regression, the widened warning threshold, and
the full `having_data_type` mapping. `cargo clippy` and `cargo fmt`
clean.

> [!NOTE]
> One pre-existing failure in the same suite,
`alerts::destinations::tests::test_alert_destination_template_is_optional`,
fails identically on unmodified `main` (SSRF guard rejects `127.0.0.1`)
and is unrelated to this change.

## Note for the reporter

The alert in #13786 will now execute without error, but it still won't
fire: `GROUP BY host HAVING COUNT("host") < 1` cannot match, because any
group keyed by a non-null `host` has a count of at least 1. Detecting
"this host stopped logging" needs a different alert shape, not an
aggregation threshold.

---

## Second commit: comment cleanup (no behaviour change)

`alerts_2.md` does not exist anywhere in the tree, so every "see §4.4,
option B" pointer in this file sent the reader looking for a file they
cannot open. Same for the bare requirement tags — `M-6`, `M-9`, `D8`,
`T-9` — which name nothing a reader of this repo can resolve. Each
reference is replaced with the reasoning it was standing in for, in
plain terms:

| was | now |
| --- | --- |
| `alerts_2.md §4.4, option B` | what widening the HAVING clause does,
and why the critical threshold alone would lose the warning band |
| `the M-6 cap` | `the max-groups cap` |
| `only resolve via M-7's timeout` | `resolve later via the reaper
timeout` |
| `T-9: label the worst SERIES` | just the behaviour being described |

Kept as a separate commit from the fix so the fix stays reviewable on
its own.

> [!NOTE]
> 81 references to `alerts_2.md` remain in other files under `src/`
(mostly `src/config/src/meta/alerts/`). Happy to clean those up in a
follow-up.
2026-08-13 05:07:13 +00:00
Loakesh Indiran 4bb0908679 fix(synthetics): let the deployment ceilings survive a config reload (#13785)
Companion to o2-enterprise PR
[#2375](https://github.com/openobserve/o2-enterprise/pull/2375). **Merge
this first** — that branch does not compile without `set_limits`.

## The bug

`job_lease_secs`, `max_check_budget_secs` and `max_net_timeout_ms` are
declared in `o2_enterprise`'s `SyntheticsConfig` and pushed in here,
because this crate cannot depend on `o2_enterprise`. The holder was a
`OnceLock`, and `OnceLock::set` is a no-op after the first write.

Boot wrote it once. A config reload is the second write, so it was
silently discarded: an operator could edit
`O2_SYNTHETICS_MAX_CHECK_BUDGET_SECS`, hit `/config/reload`, see no
error, and still have every check validated against the boot-time
ceiling until the process restarted.

## The change

The holder is an `ArcSwap` and `set_limits()` overwrites. `ArcSwap`
rather than a lock because `limits()` is read on every check-config
validation — i.e. on request threads — while a reload writes it, and a
reader-preferring lock would let those readers starve the writer.

`init_limits` is unchanged for callers and now delegates to
`set_limits`.

## One behaviour change worth reviewing

**Rejection now keeps the last-good value rather than reverting to
`DEFAULT_*`.**

A deployment running a deliberately tight 540s/600s pair that reloads
with a typo must not have its ceiling widened back to 840s/900s by the
failure — that would silently accept checks it was configured to refuse,
the opposite of what the operator asked for. At boot there is no
last-good value, so the defaults stand and behaviour is unchanged from
before. The `init_limits` doc comment is updated to say so.

## Tests

12 new tests in their own integration binary. That placement is
deliberate: the holder is process-global and ~90 unit tests in this
module validate check configs against `limits()`, so sharing a binary
would make them fail intermittently whenever an overwrite test had a
different ceiling installed. A separate `tests/*.rs` is a separate
process.

Two of them assert across `Synthetic::validate` rather than just reading
`limits()` back — reading the holder proves the swap happened, not that
validation actually moved. Hoisting `limits()` into a `LazyLock` would
keep the holder tests green while freezing validation forever.

The concurrency test asserts readers observe at least two distinct
installed sets, so it cannot pass against a `set_limits` that silently
drops writes.

`cargo test -p config`: 12 new pass, 98 existing in `meta::synthetics`
still pass. `cargo fmt --check` and `cargo clippy -p config
--all-targets -- -D warnings` clean.

---

*Replaces #13771 — identical content. Renamed the branch to
`fix/lease-claimed-by-2270` to match the enterprise branch, because
o2-enterprise's `unit-tests.yml` resolves its OSS checkout by looking
for a branch of the same name and otherwise falls back to `main` (which
lacks `set_limits`, so enterprise CI could not compile).*
2026-08-13 04:27:18 +00:00
Shrinath Rao 673bedb468 test(alerts): migrate multi-alert API specs from Playwright to pytest (Phase 2) (#13791)
## What

**Phase 2** of the Playwright→pytest API-test migration (Phase 1 =
Correlation, #13788 / o2-enterprise#2419). Ports the 4 **pure-API**
Alerts 4.0 (multi-alert) specs into pytest and deletes the Playwright
versions — they launched a browser only to assert the raw API.

## Changes — `tests/api-testing/tests/alerts/`

| File | Ported from |
|---|---|
| `multialert_helpers.py` | `utils/alerts-api-helpers.js` (v1/v2
endpoints, canonical payloads, seed fixtures, ingest, scheduler-outcome
polling) |
| `conftest.py` | `alerts` fixture — seeds template + destination +
stream, auto-cleans created alerts |
| `test_multialert_api.py` | `alerts-multialert-api.spec.js` — 8 tests
(save-time guardrails, round-trip type contract, B1 group-endpoint RBAC
guard, tag/priority facets) |
| `test_multialert_regression.py` |
`alerts-multialert-regression.spec.js` — 4 tests (zero-behaviour-change
guards) |
| `test_multialert_firing.py` | `alerts-multialert-firing.spec.js` — 4
tests (fires at critical/warning, per-group fan-out, simple rollup) |
| `test_multialert_transitions.py` |
`alerts-multialert-transitions.spec.js` — 1 test (Ok→Warning→Critical
history) |

**17 tests, 1:1 port.** Deletes the 4 API specs and trims them from
`ci_matrix.json`'s Alerts `run_files`. Keeps
`alerts-multialert-ui.spec.js` (UI) and `alerts-api-helpers.js` (still
used by the UI + priority-tags specs).

## No workflow change needed

Unlike the Correlation pilot (which needed a dedicated server env), the
main `api_integration_tests` job **already** runs `tests/alerts/`, and
its server env **already** sets `ZO_ALERT_SCHEDULE_INTERVAL: 3` — the
exact scheduler cadence the firing/transitions tests need. So these
tests are picked up automatically with no CI edits.

## Verified locally

Full suite against a fresh single-node enterprise build: **17/17
passed** (~52s), including the timing-dependent firing
(critical/warning/per-group/rollup) and level-transition history tests.
2026-08-13 01:41:58 +00:00
Shrinath Rao c64a59865e test(correlation): remove 5 migrated API specs from Playwright (ported to pytest in ENT) (#13788)
## What

Removes the 5 **pure-API** Correlation Playwright specs now ported to
pytest. Part of the Playwright→pytest API-test migration (Phase 1
pilot).

Deleted (browser-launched only to make API assertions — no UI):

`correlation-{discovery,custom-groups,identity-sets,reset,edge-cases}.spec.js`

## The pytest port lives in the ENT repo

Correlation is **enterprise-only** (`service_streams` returns 403 on OSS
builds), so — like every other ENT-only api-testing suite
(cipher/dedup/rbac/…) — the pytest suite lives in **o2-enterprise** and
is merged into the OSS tree at CI time. Companion PR:
openobserve/o2-enterprise#2419 (adds
`tests/api-testing/tests/correlation/` + the `api_correlation_tests` job
+ matrix edit). Verified there: **14/14** green on the enterprise build.

## Kept in Playwright (not migrated)

The 4 UI Correlation specs — `settings`, `alert-grouping`, `cache`,
`drawer` (13–92 browser markers each) — render and assert on UI, so they
stay. `correlation-api-helpers.js` stays too (the UI specs still import
it for seeding).
2026-08-12 16:11:44 +00:00
Hengfei Yang c0d49810b3 perf: re-add process-wide promql series label cache (#13748)
## Summary

Restores the process-wide PromQL series label cache that was added in
#13109 and removed in #13148, ported onto the partitioned label-loading
pipeline introduced by #13148.

Labels are immutable per series hash, so a warm cache lets repeated
queries skip the second (label-column) scan entirely — the biggest wins
are on `by()`-grouped aggregations, where the label phase is a fixed
cost that the cache removes.

## Changes

- **`src/promql/src/label_cache.rs`** (ported from #13109): sharded LRU
keyed by `(context fingerprint, series hash)`, bounded by **memory
size** — `ZO_METRICS_LABEL_CACHE_MAX_SIZE` in MB, `0` (default) = auto:
5% of total memory clamped to [100 MB, 1 GiB].
- **Reads are always served; admission gates writes only.** A lookup
cannot grow the cache, so even a query too big to admit benefits from
entries other queries warmed. Admission is sized by the query's actual
**misses** (the only entries it could insert) and bypasses **writes**
when they exceed 50% of the budget, so one oversized query cannot evict
the shared cache. Hit/miss metrics therefore reflect real lookup
outcomes.
- **`load_series_labels`** attaches cached labels partition-parallel
(rayon) before the scan and collects per-partition missing hashes:
- when every series hits, the DataFusion label scan is **skipped
entirely**
- the miss scan is narrowed to missing series: a hash in-list filter for
up to 8192 misses, plus a per-missing-series max-timestamp filter (falls
back to the sample scan's timestamp set when everything misses)
- Partition tasks only attach/cache series in their missing set;
extracted label sets are stored back into the cache, sharing the label
interner's `Arc<Label>` values (hot label values are deduped inside the
cache too).
- Cached entries exclude the synthetic `__hash__` label; it is prepended
per query, so raw-data and regular queries share entries.
- Restores the `query_metrics_label_cache_hit_count` /
`query_metrics_label_cache_miss_count` metrics (per organization).

## Performance

Re-validated on the current partitioned pipeline: warm queries are
**~20% faster than main** on the high-cardinality benchmark dataset
below.

The detailed tables are from #13109 (same cache design and dataset,
measured on the pre-#13148 pipeline): single node (M-series Mac, 32GB),
1.34B rows / 3.17M series of a histogram bucket stream, result cache
disabled, warm = median of 3 runs after first.

**What the cache itself contributes** (cache on vs off on the same
binary, `ZO_METRICS_LABEL_CACHE_MAX_SIZE=1` as the off-switch):

| Query | cache on | cache off | cache contribution |
|---|---|---|---|
| `sum by (region) (rate(m[5m]))` | **3.62s** | 5.40s | **1.49x** |
| `histogram_quantile(0.9, sum by (le) (rate(m[5m])))` | **3.53s** |
4.93s | **1.40x** |
| `count by (path) (rate(m[5m]))` | **3.73s** | 5.56s | **1.49x** |
| no-modifier aggregations (skip path), `topk` (write-bypassed), small
filters | — | — | none (by design) |

The cache is precisely a **`by()`-grouped-query optimization**: 1.4–1.5x
there, zero effect (and zero overhead) everywhere else. Cold runs with
the cache are on par with cache-off (`sum by (region)` cold: 5.33s
cached vs 5.30s uncached).

**Admission control** (`topk(5, rate(m[5m]))` needs full labels for all
3.17M series ≈ 3GB working set, past the 1 GiB auto budget):

| Configuration | time |
|---|---|
| cache disabled (baseline behavior) | 10.4s |
| cache without admission, 1 GiB budget (thrash) | 12.0s ⚠️ |
| **cache with admission, 1 GiB budget (bypasses)** | **10.6s — parity
restored** |
| cache with `ZO_METRICS_LABEL_CACHE_MAX_SIZE=8192` (fits, admitted) |
**4.5s — 2.3x faster** |

The thrash row is what admission prevents — and it was caused by insert
+ eviction churn, which is why the current revision gates only writes
and keeps reads always on.

Notes on reproducing: this dataset is deliberately extreme, so its
`by()` working sets (~0.6–0.9GB) exceed the default budget's 50%
single-query share — set `ZO_METRICS_LABEL_CACHE_MAX_SIZE=2048` or
higher to admit them. At typical production cardinality (≤1M series),
`by()` working sets are admitted at defaults. Within a single query,
selectors share the cache (the second operand of a binary op loads
labels in 315ms vs the 2.57s the first paid).

## Operational notes

Re-introduces `ZO_METRICS_LABEL_CACHE_MAX_SIZE` and the two cache
metrics removed in #13148. Sizing guide: a typical series (~10 labels)
costs ~1KB, so the 1 GiB auto cap holds roughly 1M series; column-pruned
entries from `by()` queries are far smaller (~100B), so grouped queries
fit ~10M series. A tiny budget (e.g. `1`) acts as a de-facto off switch
for writes.

## Testing

- 4 label-cache unit tests (get/put, byte-bounded eviction, admission
control, fingerprint) ported from #13109
- New tests: partial-miss scan attaches only missing series and
populates the cache; cached entries exclude `__hash__`; cached-attach
hit/miss partitioning
- All 374 `promql` crate tests pass; full workspace `cargo check` and
clippy clean
- Output verified numerically identical (#13109 methodology, max
relative diff 1e-13 from float summation order)
2026-08-12 14:47:47 +00:00
Shrinath Rao 2834186b8a test(e2e/alpha1): unique per-run alert destination/template so Add Alert button enables (#13783)
## Why

After #13776 merged, the alpha1 **RegressionSet** shard still failed —
but on a **new, different** cause than #13776 fixed. All 4
`alerts-regression` tests died at the very first step
(`clickAddAlertButton`) because the button was **disabled**:

```
<button disabled aria-disabled="true"
        title="No destinations available"
        data-test="alert-list-add-alert-btn">
19 × locator resolved to disabled  → toBeEnabled() timed out after 15s
```

## Root cause (reproduced live on the RegressionSet org, auto_org_2)

[`AlertList.vue`](web/src/components/alerts/AlertList.vue#L51) disables
the Add Alert button while `destinations.length === 0`:
```vue
:disabled="!destinations.length || !templates.length"
:title="!destinations.length ? t('alerts.noDestinations') : ''"
```

On the **shared** cloud org the **fixed** names `e2e_promql_dest` /
`e2e_promql_template` were stuck ghost records — a concurrent run's
cleanup couldn't fully delete them (UI delete timed out /
`destinations_templates_fk` 500). So the beforeAll create returned `400
"already exists"`, yet the destinations list loaded **empty**, keeping
the button disabled through the 15s `toBeEnabled` wait. Not a product
regression — the org simply had no *loadable* destination.

This is the same **shared-org fixed-name collision** class fixed for
streams in #13776.

## Fix

- `e2e_promql_dest` / `e2e_promql_template` → `..._<RUN_TOKEN>` (unique
per run), so the beforeAll always creates a real, loadable destination.
Keeps the `e2e_promql_` prefix so `cleanup.spec.js` still reclaims them.
- Gate the beforeAll on the destination actually appearing in the
`/alerts/destinations` list API before tests run, so the button is
reliably enabled (`destinations.length > 0`) instead of racing the
fetch.

## Verification (alpha, RegressionSet org auto_org_2 — reproduced 100%
before)

- Before: `Add Alert` disabled → `toBeEnabled` 15s timeout → 4/4 fail.
- After: `Destination confirmed in list API | e2e_promql_dest_<token>` →
button enabled → **alerts-regression 5/5 pass** (#9967, #10899, #10872,
#10110, #10472); alerts saved for both `>=` and `<=`.

## Scope
OSS-only test file. Picked up by the ENT alpha1 workflow the same way as
#13776 (checks out opensource `main`, or dispatch with
`o2_opensource_repo=test/alerts-regression-destinations-gate`).
2026-08-12 11:57:01 +00:00
ktx-akshay fa3d294999 test(reports): e2e regression coverage for dashboard scheduled reports drawer (#13569) (#13781)
## What

Adds E2E regression coverage for the dashboard **Scheduled Reports
drawer**, verifying the two
bugs fixed in #13569.

No production code is touched — this is test-only.

## Why

#13569 (`fix: dashboard reports drawer duplicates entries and hides
reports in custom folders`)
shipped with unit-test updates only. The drawer had no E2E coverage at
all, so both regressions
could return unnoticed.

| Bug | Root cause | Fix under test |
|-----|-----------|----------------|
| **1. Rows duplicate on reopen** | `scheduledReports` was seeded with
the `reports` prop and `formatReports()` pushed onto it, mutating the
prop. The drawer stays mounted between opens, so each reopen multiplied
the rows. | `ScheduledDashboards.vue` now rebuilds the array with
`.map()` |
| **2. Custom-folder reports hidden** | `reports.list(org, folderId,
dashboardId)` passed the **dashboard's** folder, but `folder_id` filters
by the **report's own** folder | `ViewDashboard.vue` passes `""` so
`folder_id` is omitted |

## Tests added — 9, all passing


`tests/ui-testing/playwright-tests/Reports/scheduledReportsDrawer.spec.js`

| # | Test | Covers |
|---|------|--------|
| 1 | P0: Drawer opens and lists the dashboard's cached report |
baseline |
| 2 | P0: Bug 13569-1 — reopening the drawer does not duplicate rows |
**bug 1** — exact row count across 3 close/reopen cycles |
| 3 | P0: Bug 13569-2 — report saved in a custom report folder is listed
| **bug 2** — report in custom folder, dashboard in default folder |
| 4 | P1: Reports list request omits `folder_id` | **bug 2** — pins the
network contract the fix changed |
| 5 | P1: Switching tabs re-filters the list without duplicating rows |
`filterReports()`, the second path bug 1 corrupted |
| 6 | P1: Search filter does not survive a reopen or leave stale rows |
filter + rebuild interaction |
| 7 | P2: Dashboard with no reports shows the empty state on every open
| empty state across reopen |
| 8 | P2: A report bound to another dashboard is not listed | guards
against over-correcting bug 2 (`dashboard_id` scoping must survive) |
| 9 | Cleanup: Remove all test resources via API | teardown |
2026-08-12 11:01:49 +00:00
Bhargav e4503f7fcb fix: Fix/match all pipe query corruption (#13764) 2026-08-12 08:50:33 +00:00
Shrinath Rao a28dcda276 test(correlation): standardize e2e suite onto framework harness (phase 0 — reference pattern) (#13775)
## What & why

Begins consolidating the **correlation (service-streams) e2e suite**
onto the standard `tests/ui-testing` framework. Today those specs live
in a self-contained parallel harness (own configs, own auth, own `O2_*`
env vars, per-test orgs with no teardown, non-standard names) and are
**not registered in any CI matrix** — so they never run automatically.

This is **Phase 0**: land the shared standard-shaped API helper and
convert the first spec as the **reference pattern** for review, before
batch-converting the rest.

## Changes
- **`utils/correlation-api-helpers.js`** (new) — single source of truth
for correlation API plumbing. Auth via `authedRequest`/`getAuthHeaders`
(framework session + 401/403 self-heal), per-test **fresh-org create +
teardown**. No `test`/`expect`.
- **`Correlation/correlation-discovery.spec.js`** (new) — converted from
`journeyA`: `enhanced-baseFixtures`, `@correlation`/`@P0` tags,
Copyright + JSDoc header, `testLogger.testStart`, standard `ZO_*` env
and naming. Assertions ported **1:1** (DISC-01/02/03).
- Remove `Correlation/journeyA-discovery.api.spec.js` (superseded).

## Deliberate deviation (documented)
Correlation discovery / identity config / `_reset` are **org-global and
destructive**, so each test provisions a **fresh org** and tears it down
in `afterEach`. This keeps the isolation the feature genuinely needs
while fixing the real defect in the old suite: **orgs were created but
never deleted** (leak).

## Validation
- `node --check` clean; requires resolve.
- Collects cleanly under `playwright.config.js` (`--list` → 3 tests,
tagged).
- ⚠️ **Green run pending an enterprise env** (service_streams 403s on
OSS). Not wired into CI yet, so `main` stays green. **Draft** until
validated.

## Follow-ups (later phases)
Batch-convert remaining 4 API + 3 UI specs · revive
`correlationSettings.spec.js` against the new UI · register a dedicated
**Correlation** shard in the ENT `ci_matrix.ent.json` · delete
`corr-*.config.js`.
2026-08-12 07:43:41 +00:00
Huaijin fd2306ffaa fix(search): preserve indexed top-n aggregation results (#13779)
- close: https://github.com/openobserve/openobserve/issues/13363

## Summary

- Prevent `AggregateTopkExec` from truncating file-local indexed
aggregation rows before the final aggregate merges duplicate group keys.
- Keep the Tantivy `SimpleTopN` optimization enabled while removing only
the conflicting downstream top-k node.
- Add a regression test that constructs the conflicting physical plan,
verifies `AggregateTopkExec` is present before leader index
optimization, and verifies it is removed afterward while `SimpleTopN`
remains selected.

## Root cause

`SimpleTopN` emits partial count rows per `(file, group key)`.
`AggregateTopkExec` previously ranked and truncated those individual
rows before the final aggregate could combine identical keys across
files. With enough files, this silently undercounted results and removed
entire groups while `is_partial` remained false.

The rewrite removes the conflicting node bottom-up. This is important
because `AggregateTopkExec` is inside the follower plan already
serialized by `RemoteScanExec`; rebuilding the parent updates those
serialized plan bytes before attaching `SimpleTopN`.

## Verification

Using 121 Parquet files and 121 Tantivy index files containing 93,601
records across 12 groups:

| Result | Before | After |
|---|---:|---:|
| Groups returned | 9 | 12 |
| Sum of grouped counts | 83,201 | 93,601 |
| Baseline `COUNT(*)` | 93,601 | 93,601 |
| `is_partial` | false | false |

Tests run:

- `cargo fmt --all -- --check`
- `cargo test -p search
test_leader_rule_topn_removes_conflicting_aggregate_topk -- --nocapture`
- `cargo test -p search
datafusion::optimizer::physical_optimizer::index_optimizer::tests --
--nocapture`
- `cargo test -p search aggregate_topk -- --nocapture`
- `cargo check -p search`
2026-08-12 07:23:50 +00:00
Amey PawarandHengfei Yang 3d06ed0423 fix(logs): reject LIMIT in filter mode instead of building an invalid query (#13773)
Closes #6915.

Without SQL mode the search bar's contents become the WHERE body of a
generated `select [FIELD_LIST][QUERY_FUNCTIONS] from "[INDEX_NAME]"
[WHERE_CLAUSE]`, and `handleNonSqlMode` splices the input in verbatim
after stripping comments and quoting known field names. Nothing checks
that the input is actually a predicate, so `k8s_namespace_name =
'nginx-ingress' LIMIT 10` produces valid SQL. The results grid runs it
and returns rows, while the histogram query built from the same input is
rejected by `is_eligible_for_histogram` and surfaces
`SearchHistogramNotAvailable`. That is the split described in the issue
— the log list works, the histogram errors.

The server side of this already exists, so the guard belongs in the UI,
before either request goes out.

## What changed

| | |
|---|---|
| `utils/query/nonSqlLimit.ts` | new — `hasLimitClause()` |
| `useSearchQuery.ts` | the guard in `handleNonSqlMode`, using the
file's existing `notificationMsg` + `return null` path |

Placement note: `hasLimitClause` lives in `utils/query/` rather than in
the composable because it holds no state and touches no reactivity — the
same split `doubleQuoteWarnings.ts` and `colorizeQuery.ts` already use.

The check is deliberately narrow. `LIMIT` matches only when followed by
a number, so `limit = 5` and `rate_limit >= 100` pass through. String
literals are blanked first, so `msg = 'rate limit 500 exceeded'` is
unaffected. Line and block comments are stripped after that, so `code =
200 -- limit 10` — which works today — keeps working.

## Tests

- **17** on the derivation — the issue's own query, case variants,
whitespace and newlines before the number, a bare `LIMIT 100`, a field
named `limit`, `rate_limit >= 100`, the word inside a literal, a literal
with a doubled quote, `limit = 'none'`, leading / trailing / block
comments, a real LIMIT alongside a comment, and a dash sequence inside a
literal.
- **6** on `buildSearch` — rejects the reported query and sets the
message, still builds without a LIMIT, allows `limit = 5`, ignores
commented-out LIMITs, and does not fire in SQL mode.

Each rejection test was checked to fail when the guard is reverted. The
negative cases stay green either way, which is what separates them from
tests that merely restate the implementation.

## Verification

- `npx vitest run --root src/ composables/useLogs utils/query` — **1965
passed**, 33 files, 0 failed
- `npx vue-tsc --noEmit -p tsconfig.vitest.json` — clean, and identical
to `main`
- `npx eslint` on all four files — clean
- `npx prettier --check` — clean

## Caveats

- Not exercised in a running app; covered by unit tests only.
- `ORDER BY`, `GROUP BY` and `HAVING` reach the same splice and are
**not** handled here. Happy to extend if you want them, but the issue
asked about `LIMIT` and I didn't want to widen it uninvited.
- `plugins/logs/IndexList.vue` holds an independent copy of the same
`[WHERE_CLAUSE]` splice for field-value lookups. It is reached by
clicking a field rather than running a search, so it sits outside this
path — flagging it rather than changing it.

---------

Co-authored-by: Hengfei Yang <hengfei.yang@gmail.com>
2026-08-12 06:44:44 +00:00
Shrinath Rao 6650950a50 test(e2e/alpha1): fix share-link redirect origin and cloud metrics ingest auth (#13776)
## Why

The scheduled **Alpha1 (Cloud)** e2e run failed on three jobs
([o2-enterprise run
31547517666](https://github.com/openobserve/o2-enterprise/actions/runs/31547517666)):
**Logs-Features** (7), **Functions** (11), **RegressionSet** (4).
Root-caused each against a freshly-deployed alpha; two are genuine test
heals, one was pure environment skew.

## Root causes & fixes

### 1. Logs-Features — shareLink + monaco-query-prefill (7 tests) ✅
fixed
On cloud the app builds the share link from its **public** base URL
(`*.external.zinclabs.dev`), while the tests authenticate against the
ingress host (`*.internal.zinclabs.dev`). `page.goto(sharedUrl)`
navigated **cross-origin**, dropped the auth cookies, and bounced to Dex
— so every post-redirect state assertion read back `null`.

**Fix:** re-point the short URL's origin to `ZO_BASE_URL` in
`clickShareLinkAndGetUrl()` (right next to the existing HTTP→HTTPS
"maintain auth context" step). `/short/<id>` resolves identically on the
backend regardless of ingress host, so only the origin changes and the
session stays valid. Same-origin envs (localhost / matching deployed
host) are untouched.

### 2. RegressionSet — alerts Bug #9967 PromQL (1 test) ✅ fixed
`ingestMetricsData()` authenticated with `ZO_ROOT_USER_EMAIL:PASSWORD`,
but **cloud ingestion needs `email:passcode`**. The ingest silently
401'd → the `e2e_test_cpu_usage` metrics stream was never created → the
PromQL alert flow timed out selecting it.

**Fix:** build auth via `getAuthHeaders()` (passcode on cloud, root
creds on self-hosted), refresh the passcode from the live browser
session first, and poll until the stream is indexed so the wizard's
stream-select doesn't race WAL lag.

### 3. Functions — radio → segmented toggle (11 tests) — no change
needed
The page object was already migrated to the segmented toggle in #13766;
these failures were the **alpha frontend lagging the merged test code**.
Green after the redeploy with zero code change.

## Verification (against alpha, `auto_org_1`)
- `shareLink.spec.js` + `monaco-query-prefill.spec.js` → **11/11
passed**
- `alerts-regression.spec.js` (full) → **5/5 passed** (#9967, #10110,
#10899, #10872, #10472)
- `Functions/js-transform-type.spec.js` → **7/7**,
`row-expansion.spec.js` → green

## Scope
OSS-only (test files). The ENT `playwright_alpha1.yml` checks out
opensource at `main`, so merging makes the next scheduled run pick this
up. It can also be pre-verified by dispatching that workflow with
`o2_opensource_repo=test/alpha1-e2e-heals-0812`.
2026-08-12 06:35:48 +00:00
Prabhat Sharma fb50aaea7f fix(deps): resolve 39 Dependabot alerts (#13774)
## Summary

- update vulnerable web transitive dependencies (`axios`,
`brace-expansion`, `fast-uri`, `http-proxy-middleware`, `js-yaml`,
`uuid`, and `websocket-driver`)
- remove the unused `node-polyfill-webpack-plugin`, eliminating the
unpatched `elliptic` dependency chain
- update Playwright to 1.55.1 and `node-forge` to 1.4.0 in the UI test
lockfile
- adapt `BaseImport` to Axios 1.18's stricter response-header type

This is expected to resolve 39 of the 41 currently open Dependabot
alerts.

## Remaining alerts

- `rmcp` remains on the project's custom `rmcp-openapi` fork. Updating
it safely requires a coordinated change to the enterprise
`Cargo.toml.openobserve`, which currently replaces the OSS manifest
during enterprise builds and pins the old API. The compatible
intermediate upstream releases also lack the fork's required
`allOf`/`anyOf` support.
- `thrift` 0.17.0 remains through `parquet` 58.4.0. The patched Thrift
line (0.23+) is outside Parquet 58's version constraint. Parquet 59
removes the Thrift dependency, but adopting it requires a coordinated
Arrow/DataFusion 59 upgrade.

## Validation

- `npm ci` and `npm audit --package-lock-only` in `web/` (0
vulnerabilities)
- `npm ci` and `npm audit --package-lock-only` in `tests/ui-testing/` (0
vulnerabilities)
- `npm run type-check:app`
- `npm run lint:ci -- --quiet`
- `npm run lint:design:strict`
- `NODE_OPTIONS=--max-old-space-size=8192 npm run build`
- `npm run test:unit -- --run` (41,917 passed, 404 skipped)
- `npx vitest run src/components/common/BaseImport.spec.ts --environment
jsdom` (41 passed)
- Playwright test discovery under 1.55.1 (2,076 tests in 265 files)
- `cargo fmt --all -- --check`
- `cargo test -p openobserve-mcp` (94 passed, 1 ignored)
- `cargo audit` (0 vulnerabilities; 2 allowed unmaintained warnings)
2026-08-11 22:17:35 +00:00
Prabhat Sharma c451db0835 fix: tighten workflow permissions and CSS selector escaping (#13772)
## Summary

- set explicit least-privilege permissions for audit, JavaScript
license, and release workflows while preserving release-job write access
- grant the spam-cleaner workflow the issue and pull-request scopes
required to delete comments
- escape quotes and backslashes before interpolating log toast text into
CSS attribute selectors

## Validation

- parsed the four modified workflow files as YAML
- ran Node syntax checking on
tests/ui-testing/pages/logsPages/logsPage.js
- ran git diff whitespace validation
2026-08-11 18:57:58 +00:00
Prabhat Sharmaandgithub-actions[bot] 360b989272 feat(slos): PromQL support for count and time-sliced SLOs, plus SLO/alert regression fixes (#13761)
Supersedes #13738, whose commit is included here as the first of four.

Adds PromQL support to count and time-sliced SLOs, lets metrics streams
choose SQL or PromQL, and fixes three shipped bugs found along the way.

## Shipped bug: every SQL time-slice SLO recorded wrong history

`fetch_rows`' `SliQueryPlan::Single` arm normalized every hit with
`with_good_column: true`, but that arm serves **two** shapes and only
one projects `zo_slo_good`:

| shape | projects | result |
|---|---|---|
| count single-query | `zo_slo_good` + `zo_slo_value` | correct |
| time slice | `zo_slo_value` only | `good` defaulted to `0.0` |

So every SQL time-slice slice was classified from `0.0` instead of its
aggregate. With a positive threshold, `<`/`<=` (e.g. `p95 < 300ms`)
recorded every slice **good** — the SLO read ~100%, the budget never
burned, and no alert on it could fire. `>`/`>=` recorded every slice
**bad** and fired continuously. Shipped in v0.92.0 (`4772df1723`);
`to_row` had no time-slice test, which is how it got through.

Fixed by deriving the flag from the SLI type in a new
`single_query_rows`. Deliberately **not** fixed inside `to_row` by
falling back to `total` — that would read a count bucket whose `SUM`
came back NULL as *fully good*, inventing uptime. A test forbids it.

**Stored history is not rewritten.** It ages out within one window; an
operator can force a rebuild sooner by making a real edit (a no-op
re-save does nothing — `update` bumps only on `definition_changed`). No
auto-migration: bumping every time-slice SLO at boot would freeze every
attached alert org-wide until backfill cleared the coverage floor, and
double each org's row budget for 97 days — a self-inflicted monitoring
gap to repair a reporting error.

## PromQL support

**Time slice** carried a `query_language` field the planner ignored, so
a PromQL time-slice SLO saved cleanly and measured nothing, forever.

- `SliQueryPlan::PromQlValue` — one expression, not a good/total pair,
planned at slice ends from the same helper the count arm uses.
- The expression is passed through **verbatim**. Grouping comes from
series labels; wrapping it in `sum by (...)` the way the SQL arm injects
GROUP BY would destroy the aggregate (the sum of four pods' p95 is not a
p95).
- `promql_value_rows` **rejects** two series colliding on one
`(slice_start, group_key)` via `RejectReason::AmbiguousSeries` instead
of summing. Summing is right for counters (what `promql_rows` does) and
meaningless here. Detection is unconditional, so the ungrouped case — an
unaggregated `histogram_quantile` returning one series per instance — is
covered. Rejects are plumbed through `fetch_rows` into
`PassResult.rejected` on both the live and backfill paths.

**Count** already had a working `CountSource::PromQl` backend that no UI
could reach.

## Metrics streams choose SQL or PromQL

`language_suits_stream` refused SQL whenever `stream_type == "metrics"`,
and the form derived the language from the stream type — so metrics had
no SQL option. A metrics stream is an ordinary stream with a `value`
column, and `SELECT histogram(_timestamp, '5 minute'), avg(value) FROM
cpu_usage` is a perfectly good SLI. SQL is now valid for any stream
type; PromQL stays metrics-only. The form asks via a toggle (defaulting
to PromQL) instead of deriving.

## Validation

- `scope` refused for PromQL — a SQL `WHERE` fragment with nowhere to go
in a PromQL plan.
- Empty `query` refused for **both** languages (the SQL aggregate was
previously never validated — same silent-freeze shape).
- Expressions parsed with `promql-parser` at save, in `core` (`config`
has no parser).
- **Parse-only by design.** A grouping check requiring the root to be an
aggregation whose `by(...)` matches `group_by` wrongly rejects
`histogram_quantile(0.95, sum by (le, region)(...))` (function-call
root; `le` is a necessary label superset) and binary-op roots like `sum
by (region)(increase(a[5m])) - sum by (region)(increase(b[5m]))`. A
"warn instead" variant needs a warnings channel the save API doesn't
have.

## Also fixed

- **Time-slice comparator 422.** `SliConfig::TimeSlice.comparator` has
no serde default, so a dropdown the user never opened was omitted and
the save died with a bare 422 — blocking creation of *any* time-slice
SLO from the UI. Seeded to `<` only when blank, so a stored comparator
survives an edit.
- **SLO/alert/service-stream regressions** (the commit from #13738):
fractional-threshold serde round-trip, `SloAlertCondition` UI, alerts
table, service streams.

## Verification

- 487 config · 206 core · 45 infra · 418 frontend — all passing; clippy
`-D warnings`, `cargo fmt`, prettier, eslint, `vue-tsc` all clean.
- Driven through a real browser against a running server (isolated
instance, own data dir). All four SLI/language combinations create and
store correctly: count·PromQL, count·**SQL over metrics**,
time-slice·PromQL, time-slice·**SQL over metrics**. Also verified: the
toggle swaps field shapes both ways, scope shows for SQL and hides for
PromQL, PromQL-only hints appear only in PromQL mode, and editing a
stored SQL-over-metrics SLO reopens as SQL with its predicate intact.
- Validation rejections confirmed live with actionable messages:
PromQL-with-scope, unparseable PromQL, and an empty SQL aggregate.

Known limitation, pre-existing: `threshold` likewise has no serde
default and is not seeded, so leaving it blank still 422s. That is
genuine user error rather than an untouched-control trap; adding form
validation was out of scope.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-11 17:35:52 +00:00
Hengfei Yang 9f76ff6388 refactor(search): enhance filtering logic for FTS fields in search function (#13767) 2026-08-11 14:15:33 +00:00
Bhargav b07b3d4229 feat(functions): replace language radios with a segmented toggle (#13766)
The Add Function header selected VRL vs JavaScript with a radio group
and a single info icon. That icon only ever described the
ALREADY-SELECTED language, so learning what the other one was for meant
switching to it first.

- Radio group -> OFormToggleGroup segmented control with a sliding
indicator, each option carrying a language mark (V / JS). The badge
variants match the transType column in FunctionList so VRL/JS read the
same everywhere.
- Give EACH option its own info icon and static tooltip, so both
languages are readable without selecting either. Forced-language hosts
(workflow function nodes are JS-only) hide the toggle, so the locked
language's tip moves to a standalone icon.
- Add a pipe divider between the two options, and one between the
function-name title and the toggle.

data-test renames `...-vrl-radio` -> `...-vrl-option` (same for js):
"radio" described the old implementation. E2E page objects would have
broken on this change regardless of naming -- toggle items expose
data-state="on|off", not the radio group's "checked|unchecked".

Verified: FunctionsToolbar.spec.ts 18/18 pass, vue-tsc clean. ESLint
could not be run locally (@intlify/eslint-plugin-vue-i18n missing from
node_modules).
2026-08-11 13:25:45 +00:00
Bhargav bf27a488d6 fix(ci): stop retrying translation pushes that a merge queue has frozen (#13765)
`auto_merge_enabled` is one of the two events that start this workflow,
and it fires on the same click that enqueues the PR. GitHub freezes a
queued PR's branch, so by the time the run finishes translating, the
push it exists to make is rejected with GH006.

The push loop then made the failure much harder to read than it needed
to be. It was written for one failure mode -- losing a race to a
concurrent push -- so it treated every rejection as a lost race and
rebased. GH006 is a policy rejection, not a race: the rebase is a no-op
("Current branch ... is up to date") and the next two attempts fail
identically, burying the real reason under a log that looks like
flakiness.

- Classify the rejection at push time. GH006 / merge-queue /
protected-branch stops on the FIRST attempt with an error naming the
cause and the fix (dequeue, re-run, re-queue). Non-fast-forward keeps
the existing rebase retry.
- Check for queue membership in the guard, before any API spend. When
the PR is already queued the run cannot land a commit, so translating
would spend the DeepSeek budget building one with nowhere to go.
verify-translations.yml still gates the queue, so a genuinely
untranslated PR is still blocked from main.

Fail-open on the queue lookup: a transient API error falls through to
"not queued" so a blip can never silently stop translations from being
generated. The push-time check backstops it.

No keys are lost in either path -- .translation_state.json still lists
them as pending, so the next run picks them up.
2026-08-11 13:23:20 +00:00
Omkar Kesarkhane 4b9204f69f feat(synthetics): collapse journey advisories into an on-demand suggestions chip (#13759)
## Problem

The browser journey editor stacked two **always-on** advisory cards
between the toolbar and the first step:

- *"This journey does not verify anything"* — no `assert` step in the
journey
- *"No test attributes found in this recording"* — no step produced a
`test_attribute` locator candidate

Both derived their visibility from the journey's own content, and both
conditions are the normal case for a real recording — so they rendered
on every visit to every affected journey. Each was dismissible, but
dismissal lived in a local `ref(false)`, so it died on remount: authors
closed the same two cards on every visit and still began reading their
steps two cards down the page.

## Change

One warning badge in the Steps toolbar — a warning icon and a count, no
prose:

- **Hover** names it ("1 suggestion for this journey"), from the same
string as the trigger's `aria-label` so the two cannot drift.
- **Click** opens a popover with each suggestion in full, separated by
rules, plus a footer stating that suggestions never block saving or
running.
- **Never opens on its own**, on any render.
- **No dismiss.** The chip costs no vertical space, so suppressing it
buys nothing — and a suggestion that can be waved away is one an author
never has to actually decline. A suggestion leaves when the condition
behind it is resolved.

A zero-assertion journey remains accepted with a warning; nothing here
blocks a save.

Transient banners are deliberately untouched — replay
running/passed/failed/stopped, incognito, pre-flight, recorder error.
They appear because something just happened, and a replay result that
does not announce itself is a worse product.

## Implementation

| File | |
|---|---|
| `web/src/utils/synthetics/journeySuggestions.ts` | new — pure
derivation over the steps; one small producer per rule; carries i18n
**keys** rather than text, so it needs neither Vue nor `useI18n` and a
rule is testable without mounting anything |
| `web/src/components/synthetics/journey/JourneySuggestions.vue` | new —
chip, tooltip, popover; presentation only |
| `web/src/components/synthetics/journey/BrowserJourney.vue` | computes
the suggestions, renders the chip in the toolbar, appends the offered
assertion |
| `ZeroAssertionNotice.vue`, `TestIdMisconfiguredNotice.vue` (+ specs) |
deleted — each was a visibility computation plus a card, and both halves
now have better homes |

Adding a future advisory is one producer and no template change.

Placement note: this lives in `utils/synthetics/` rather than a
composable because it holds no state and touches no reactivity — the
same split `deriveLocatorKind.ts` and `locatorStability.ts` already use
for derivations consumed by these components.

## Tests

- **17** on the derivation — both rules, empty journey, navigate-only
journey, empty candidate list, a test attribute outranked *within* a
step, both conditions at once in stable order, and that the offered
assertion is empty (`element_visible` with no selector/locator) rather
than guessed.
- **15** on the component — no chip at zero, count only and no prose,
pluralization, closed on mount, opens on click, tooltip suppressed while
open, action emits and closes, separators between suggestions.
- **5** on the `BrowserJourney` wiring — old hooks gone, suggestions
handed down, nothing offered when `readonly`, the assertion appended to
the tail with prior step order and ids intact, and the suggestion
dropping once the parent commits.

Two things the tests caught that review would not have:

1. Resolving the last suggestion unmounts the popover but not the
component holding its `open` flag — so a panel left open came *back*
open the next time a suggestion appeared. Regression test written,
confirmed failing, then fixed.
2. One component block runs against the **real** `OPopover` and `OBadge`
instead of a stub. `OBadge` declares `inheritAttrs: false` and the
popover trigger is `as-child` — precisely the pairing that silently
drops a click handler and leaves a chip that looks correct and does
nothing. Every other test stubs the popover and would not see it.

## Accessibility

`⚠ 1` has an accessible name of "1", so the trigger's `aria-label` is
load-bearing and carries the full count string. Child-mode tooltips are
pointer-only, so a keyboard user's path is that label plus the popover
they open with Enter.

Accepted trade-off, stated plainly: the old cards carried
`role="status"` and were announced on appearance. Nothing is announced
now — that announcement is what this change exists to suppress.

## Verification

- `npx vitest run src/components/synthetics src/utils/synthetics
src/views/synthetics` — 1147 passed, 58 files
- `npm run type-check` — clean
- `npm run lint` — clean for every touched file (the repo's pre-existing
`eslint.config.js` / `vitest.config.ts` findings are unchanged and
present on `main`)
2026-08-11 12:18:16 +00:00
Ashish Kolhe de0d37e23d fix(alerts): stop losing Slack alerts on undeliverable link URLs (#13742) (#13753)
Fixes #13742
Fixes openobserve/o2-enterprise#2394

## The bug

A content template link with a non-http scheme (`javascript:alert(1)`)
made Slack reject the **entire** notification with `400
invalid_attachments`. The alert was lost outright and `triggers`
recorded `notify_failed`.

## Root cause

`safe_url` neutralises a hostile scheme by substituting `BLOCKED_URL`
(`"#"`). That is **safe** but not **deliverable** — Slack, Teams,
Discord and PagerDuty validate action URLs server-side and reject the
whole payload when one is not an absolute `http(s)` URL. A security
substitution was turning into total message loss.

The two properties are different, and links need both. So the fix splits
by medium:

| renderer | link handling |
|---|---|
| Slack buttons, Teams actions, Discord `embed.url`, PagerDuty `href` |
`dispatchable_url` → **drop** |
| HTML email, email plaintext, SNS plaintext, Discord description |
`safe_url` → substitute `#` |
| Webhook envelope | unfiltered — versioned machine contract, documented
|

Dropping beats rewriting: the alert body is worth more than the button,
and a button pointing somewhere misleading is worse than no button.

`dispatchable_url` requires a **non-empty authority**, not just an
allowlisted scheme — `http:javascript:alert(1)` and `https://` are valid
*opaque* RFC 3986 http(s) URLs that Slack still rejects. It also rejects
raw spaces and control characters, which variable substitution can
introduce.

## Related issues fixed in the same area

- **Empty `ZO_WEB_URL`** (not in the report): `alert_url` becomes a
root-relative `/web/logs?…`, which `safe_url` passes through by design,
and it lands in the always-appended "View in OpenObserve" button. Same
`invalid_attachments`, on deployments that never authored a hostile
template.
- **PagerDuty** validates `links[].href` server-side too — same
lost-alert class, converted.
- **Save-time rejection** (`ContentSpec::validate`): a clear 400 naming
the offending link, instead of a template whose link silently never
works. Also wired into **preview** and **test-send**, which previously
accepted a draft that couldn't be saved and echoed the raw hostile URL
back to the UI.
- **Slack image-fallback misdiagnosis**: removing the image proves the
image was at fault, but only an image pointing at *our* `web_url`
justifies tripping the process-wide chart suppression (1h, across every
alert) and telling the operator to fix `ZO_WEB_URL`. A custom template's
third-party image no longer does either.
- **a11y**: `OInput` links its error message to the control via
`aria-describedby`; the textarea branch also gained `aria-invalid`.

## o2-enterprise#2394 (fixed here, not in the enterprise repo)

Clearing the Monaco body and saving toasted "Please fill required
fields" with no indication of *where*. The schema already produced an
error on the `body` path — a bare Monaco bridged in via `setFieldValue`
just had no `OFormInput` error slot to render it. Now a red border +
message driven by the same field state `OFormInput` reads.

The aria wiring goes on the **shell**, not on `<QueryEditor>`:
`CodeQueryEditor` sets `inheritAttrs: false` and binds `$attrs` to a
non-focusable wrapper, so attributes passed to the component would be
inert — false assurance for a screen-reader user.

> The issue names the title field too. It's an `OFormInput` and already
highlights correctly; I added a regression test pinning that rather than
changing it. This fix is entirely in OSS (`web/`) since the template
form isn't enterprise-gated, so there's no companion enterprise PR.

## Layering note

Save-time validation is a **UX gate, not the security boundary**. Link
URLs are templates — a scheme that only materialises after `{variable}`
substitution can't be judged at save time. The render layer is what
actually stops delivery, and it also covers every template saved before
this validation existed. Both layers are needed; neither is sufficient.

## Testing

- Every fix written test-first (verified RED) and **mutation-tested** —
each new test fails when its fix is reverted in substance.
- Golden files unchanged: legitimate URLs render byte-identically, so no
over-blocking.
- Rust: `alerts::notifications` 159 · `config` 3130 · `api-management`
424 — all passing. `cargo clippy --all-targets` and `cargo fmt --check`
clean.
- Frontend: full suite **41,597 passing** (run because `OInput` is
shared library code). `vue-tsc`, `eslint`, and `npm run format:check`
clean.
- Two independent cold-start review passes; findings from both are
folded in, including a P0 where the first version of `dispatchable_url`
still let the original bug through.

Pre-existing and unrelated:
`alerts::destinations::tests::test_alert_destination_template_is_optional`
fails locally on a DB connection reset — verified failing on unmodified
`main` too.
2026-08-11 12:08:20 +00:00