Compare commits

...

40 Commits

Author SHA1 Message Date
ktx-vaidehi f37e77535f
Merge 32ec311792 into 021d2135b5 2026-08-03 13:29:57 +00:00
ktx-vaidehi 32ec311792 Merge branch 'main' into feat/i18n-lint-enforcement 2026-08-03 18:59:33 +05:30
ktx-vaidehi 3e7f5eb916 Merge branch 'main' into feat/i18n-lint-enforcement 2026-08-03 18:40:17 +05:30
ktx-vaidehi ff79a38ba6 Merge branch 'main' into feat/i18n-lint-enforcement 2026-08-03 17:31:35 +05:30
Omkar Kesarkhane 021d2135b5
fix(folders): stop the move dialog offering the folder you are already in (#13599)
## The bug

Selecting checks in Synthetic Monitoring and pressing **Move** opened a
dialog reading:

```
Current Folder:  Foo
Select Folder:   Foo
```

The same folder name twice — which reads as "move this to where it
already is".

Nothing could actually go wrong: `primary-button-disabled` already
blocked a same-folder submit. The dialog simply gave no clue *why* Move
was greyed out, because the destination it had chosen for you was the
one destination it would not accept.

## Cause

`MoveAcrossFolders` seeded `selectedFolder` from `activeFolderId`, and
`SelectFolderDropDown` listed every folder including that one.

## Fix

- `SelectFolderDropDown` gains an optional `excludeFolderId` prop: it
filters that folder out of the options and refuses to seed the selection
with it.
- `MoveAcrossFolders` passes `:excludeFolderId="activeFolderId"`, opens
with no destination chosen, and additionally disables Move while the
destination is empty — *"not the active folder"* is not the same as
*"somewhere to move to"*.
- The folder-list watcher now keeps a still-offerable choice. Creating a
folder from the **+** button arrives here as a store list change, and
the re-seed would otherwise clear the folder just created and selected.

## Why the prop is opt-in

`SelectFolderDropDown` has six other consumers — ImportAlert,
AlertList's clone dialog, CreateReport, AddSlo, SloList, and the
synthetics *duplicate* dialog. Every one of those picks a folder to file
something **into**, where opening on the active folder is correct.

Unset, the filter matches every folder (`!== undefined`) and the seeding
is untouched, so those call sites are byte-for-byte unchanged.

## Scope

This dialog is shared, so the change reaches **alerts, pipelines,
reports, SLOs and synthetics**. Behaviour is strictly narrowed: the
option removed was already a guaranteed no-op behind the disabled-submit
guard.

`MoveDashboardToAnotherFolder` uses a separate `SelectFolderDropdown`
under `components/dashboards/` and has the same behaviour, but it is a
different component tree and is deliberately left alone.

## Tests

5 new cases on the dropdown (default offers everything, excluded id
dropped, blank start, normal seeding still works, choice survives a list
change) and 1 on the dialog.

Three existing tests asserted the old seeding and are updated. **One
deserves review:** `should compute primaryButtonDisabled=false when
selectedFolder value is null` asserted that a *null* destination
**enables** Move — executing that submits `dst_folder_id: null`. I read
it as wrong rather than intentional and inverted it. If that null path
is load-bearing somewhere I have not found, this is the thing to
challenge.

## Verification

Run on this branch, based on `main`:

- `src/components/common/sidebar/` — 291 passed (6 files)
- Consumers: SyntheticMonitoring, AlertList, ImportAlert,
MoveDashboardToAnotherFolder — 216 passed, 10 pre-existing skips
- `eslint` on all four changed files — clean
- `npm run lint:design:strict` — OK, no regressions
- `npm run type-check` — exit 0
2026-08-03 11:57:26 +00:00
Shrinath Rao 503eca6264
fix(e2e): deflake logs autocomplete value-suggestion capture (#13593)
## Problem

In [o2-enterprise run
30791574230](https://github.com/openobserve/o2-enterprise/actions/runs/30791574230)
the **Logs-Features** shard logged **2 failed, 6 flaky, 3 skipped, 63
passed** with **52 retries**. Seven of the eight troubled tests are in
`logs-autocomplete-suggestions.spec.js` (the eighth `code`-field test
shares the cause), and every one dies on the identical assertion:

```
Error: Expected to find a field with captured values
expect(received).not.toBeNull()
Received: null
```

— i.e. the shared `findFieldWithStringValues()` helper returned `null`.

## Root cause

A **click-time race**, not missing data (the `e2e_automate` stream holds
3848 rows ingested at shard start). The helper clicked a field's sidebar
expand button and then polled IndexedDB for 10s — but the 10s window
started at **click time** and raced the `/_values_stream` fetch. In the
failing run the runner was slow (a perf assertion showed queries ~2.6×
over baseline: 1055ms vs <403ms). The values response landed **after**
the IndexedDB poll had already given up, so the record read as `null`
and the assertion failed hard. Because `findFieldWithStringValues()` is
shared across ~8 tests, one slow window knocked out the whole cluster,
and each burned up to 4 attempts → 52 retries.

An intended fix (`clickFieldExpandAndWaitValues`, a deterministic
`/_values_stream` response wait) already existed in the file but was
**never wired in**.

## Fix

- **`expandFieldAndCaptureValues()`** — anchor the IndexedDB
confirmation **after** the actual `/_values_stream` response instead of
racing it from click time; re-click to re-expand if a prior collapse
meant no fetch fired. Replaces the dead `clickFieldExpandAndWaitValues`
helper and the bare click+poll in the `code`-field test.
- **`findFieldWithStringValues()`** — scan via the anchored capture,
with a bounded refresh-and-retry round so a transient empty `/_values`
response self-heals rather than failing the test.
- **Readiness gate** — add `waitForStreamData()` to every `beforeEach`
so freshly-ingested data is searchable before the UI drives value
capture (WAL → index lag). Best-effort so an already-warm shared stream
never blocks.

## Testing

- `node --check` passes; no linter is configured for the suite.
- The slow-runner condition can't be reproduced deterministically
locally; the change is a test-harness reliability fix (deterministic
response-anchoring + readiness gate) that removes the timing dependency
that caused the flake. Will confirm green via the ENT playwright
regression this PR dispatches.
2026-08-03 11:27:33 +00:00
Omkar Kesarkhane aedcc83a24
fix(synthetics): stop a browser replay without stranding a step in progress (#13592)
## Problem

Recording a browser test and replaying it, then pressing **Stop** in the
journey editor:

1. The step the replay was on stayed stuck in the in-progress (spinner)
state forever.
2. The extension did not stop promptly — it kept going for another step
or two.

## Root cause

One defect produced both symptoms, and it lives in the recorder
extension's player
(`CrxPlayer.stop()` in `openobserve/playwright-crx`): it did not abort
the in-flight
Playwright call. It only raised a flag that `_checkStopped()` reads at
the **start** of an
action — never while a `click` / `fill` / `expect` is pending, and those
carry a 60s timeout.

So after Stop: the current step ran to completion, then the loop
advanced and emitted
`stepStarted` for the *next* step before unwinding. That step could
never report a result.
`useSyntheticsRecorder` set `activeStepId` from it and `stepDotState`
rendered it as
`"active"` — a permanent spinner.

The player fix is a companion PR in `openobserve/playwright-crx`. **This
PR is the O2 half**,
which is also what makes the UI honest while the stop is in flight.

## Changes

- **`ReplayPhase` gains `stopping`** — the interval between the click
and the extension
confirming. Stop becomes a disabled, loading button in the same slot (no
layout shift) and
the banner reads "Stopping replay…", so the UI no longer claims the run
is over while it is
  still winding down.
- **`useSyntheticsRecorder.stopReplay()` owns the `running → stopping →
stopped` transition**,
the way `replay()` already owns `running → passed/failed`. It clears
`activeStepId`, since
the interrupted step never reports a result and nothing else would ever
clear it. Bounded by
the existing `COMMAND_TIMEOUT_MS`, so `stopping` cannot strand the
journey.
- **A replay generation counter** orphans the in-flight replay promise
on stop. Otherwise the
abandoned run answers later and knocks a newer `running` back to
`stopped`, or resurrects a
banner the user just dismissed. Latent before — making stop fast is what
would start firing it.
- **`stepDotState` renders `active` only while `running`**, so an
unresolved step falls through
to `pending` — an empty circle, which is the truth: it never completed.
- **Stream gating** — a late `stepReplayStarted` is ignored outside
`running`; a
`stepReplayResult` already in flight still counts toward "completed X of
N", because that step
  genuinely ran.
- `CreateBrowserTest.onStopReplay` collapses to pure delegation; its
route guard treats
  `stopping` as live.

Also on this branch: a small `cursor-help` → `cursor-pointer` change in
`MonitorTable.vue`
(separate commit, unrelated to the above).

## Deliberately unchanged

The incognito replay window is **not** closed on Stop, so window reuse
across
record → replay → re-run is untouched.
(`crxApp.close({closeWindows:true})` was not an option
anyway — it removes *every* incognito window, including the user's own.)

`stopReplayAndForget` (route guards, `beforeunload`), recording
start/stop, and the
passed/failed replay paths are untouched.

## Testing

Regression tests in `useSyntheticsRecorder.spec.ts`,
`BrowserJourney.spec.ts` and
`CreateBrowserTest.spec.ts` — **each verified to fail with its guard
removed**, including the
headline one: `replayPhase: "stopped"` with a non-null `activeStepId`
must render `pending`,
not `active`.

The end-to-end proof lives in the companion PR: a browser test drives
the real extension in
real Chrome, blocks a step for 40s, presses Stop, and asserts prompt
acknowledgement,
`stopped: true`, and that no later step was announced. It passes in ~5s
with the player fix;
without it the extension never acknowledges the stop within 15s at all.

## Verification

- `npm run lint` — 0 errors in changed files (4 repo-wide errors are
pre-existing in
  `eslint.config.js` and `TraceDAG.spec.ts`)
- `npm run type-check` — clean
- `npx vitest run src/components/synthetics src/views/synthetics
src/composables/useSyntheticsRecorder.spec.ts`
  — **817/817 passing across 44 spec files**

## Note for reviewers

This needs the companion `playwright-crx` player fix to actually stop
promptly; without it the
UI is honest but the extension still winds down slowly. The extension
`dist/` must be rebuilt
and reloaded in `chrome://extensions`.
2026-08-03 11:04:22 +00:00
Loakesh Indiran f16e8e53ff
perf(synthetics): config caching, run-budget validation, HA correctness, step-grain Steps tab (#13580)
Synthetics performance and correctness work. Four areas: caching the
config reads on the job path, a false-alarm bug in validation, four HA
correctness bugs found by auditing what happens with more than one alert
manager, and the Steps tab moving to a step-grain stream.

Pairs with o2-enterprise#2325 — **merge this first**, that one calls
functions introduced here.

---

## Caching
- [x] Probe-token auth cache — 10s TTL, negative results cached so a bad
token can't force a query per request
- [x] Locations whole-table cache — 30s, serves `get` / `find_by_pool` /
`list_visible` from one load
- [x] Check definition cache via `get_cached()` — was read twice per job
(resolve + ack)
- [x] Agent capabilities cache via `get_cached()` — was read on every
lease poll
- [x] Org ingest-token cache — `find_default_enabled()` replaces three
hand-rolled `list_by_org().find(enabled)` copies
- [x] `list_by_locations()` — one batched agents query, replacing one
per location

## Cross-node invalidation
- [x] `coordinator::synthetics` — event invalidation following the
alerts/pipelines pattern
- [x] Write paths call `invalidate_and_publish()`, the watcher calls
plain `invalidate_cache()` so events don't echo
- [x] Deletes emit a delete event, so coordinator keys don't accumulate
one per check ever created
- [x] Ingest-token invalidation reuses the existing
`/org_ingestion_tokens/` watch — no second event stream
- [x] SQLite write mutex released before emitting — `get_lock()` and the
coordinator's `put()` take the same lock, so emitting while holding it
deadlocked the process

## HA — correctness with more than one alert manager
Confirmed with @subhradeep that a region can run multiple alert
managers. The synthetics scheduler, dispatcher, reaper and staleness
watcher all run on **every** one of them.

- [x] **Duplicate runs** — `claim_due()` claims due checks with `SELECT
… FOR UPDATE SKIP LOCKED`, advancing the schedule inside the same
transaction
- [x] **Duplicate "location down" pages** — the one-shot flag moved from
an in-process `HashSet` to `synthetics_locations.down_notified_at`,
claimed with a CAS
- [x] **Duplicate run completion** — `increment_jobs_done` claims
completion via `completed_at IS NULL`; two acks of the same run could
both be told they completed it
- [x] **Inert dedup index** — `scheduled_ts` is now the schedule slot,
not each node's tick clock, so the existing `synthetics_jobs_dedup_uq` +
`ON CONFLICT DO NOTHING` actually fires
- [x] Migration `m20260803_000001` adds `down_notified_at`, defaulted so
no backfill is needed
- [x] `DB_SCHEMA_VERSION` 62 → 63 — without it the migration never runs
on an already-upgraded deployment

## The false-alarm fix
- [x] Validate against the run budget (840s), not the job lease (900s)
- [x] The three bounds are deployment-configurable:
`O2_SYNTHETICS_MAX_CHECK_BUDGET_SECS` / `_JOB_LEASE_SECS` /
`_MAX_NET_TIMEOUT_MS`
- [x] Rejected limits are logged, not fatal — nothing installed means
validation keeps the conservative defaults
- [x] Browser retries capped at 2; protocol types stay at 3; default
stays 0 for every type

## Steps tab — the step-grain stream (B10)
- [x] Three `GROUP BY`s over the step stream replace downloading 5000
execution rows and tallying their JSON in the browser
- [x] Falls back to the old tally when the stream is empty, so the tab
does not blank for checks whose agents have not been upgraded
- [x] Gated on the `step_id` column rather than stream existence — a
partially-created stream would otherwise cause four failed queries

## Overview KPIs
- [x] Count tiles summed from the cached histogram; `buildKpiSql`
deleted

## Rename
- [x] `synthetics_monitors` → `synthetics_checks`, `monitor_type` →
`check_type`
- [x] Three UI call sites that read the old field names, including a
delete guard that silently disabled itself

---

**Why validation moved off the lease.** Three bounds stack:

```
check worst case  <=  max_check_budget_secs  <  job_lease_secs
     840s                    840s                   900s
```

Validation used the lease, so the server accepted checks the execution
environment cannot finish. On the managed path the probe is a Lambda,
and a function that hits its timeout is killed mid-run — lease headroom
is irrelevant once the process is gone. The net probe's function timeout
had drifted to **60s** while validation accepted up to 900s, so a plain
`timeout_ms=30s, retries=2` (worst case 100s) was accepted and then
killed, reporting a failure the target never had.

**Why the limits are ENT-declared but OSS-consumed.** Synthetics is
enterprise-only, so the values live in `SyntheticsConfig`. The
validation they bound lives in `config`, which cannot depend on
`o2_enterprise`. `SyntheticsLimits` + `init_limits` is the seam, falling
back to built-in defaults in OSS builds and tests. A bad limit is logged
rather than fatal: refusing to start ingest, search and dashboards
because a probe ceiling is misconfigured would be the worse outage.

**The duplicate-run bug.** `fetch_due` was a plain `SELECT` and the
advance was an unconditional `UPDATE … WHERE id = ?`, so nothing stopped
two alert managers firing the same check. Each inserted its own
`synthetics_runs` row under a fresh KSUID, which made the resulting job
rows genuinely distinct — `lease_batch`'s `status = 0` guard cannot
collapse them, because they are not contending for one row. N alert
managers meant every check ran N times.

`SKIP LOCKED` rather than a compare-and-swap because it *distributes*: a
second scheduler skips the rows the first holds and picks up different
due checks, so N replicas split the backlog. A CAS gives the same
exactly-once guarantee but every node reads the same candidates, one
wins them all, and the losers each issue a doomed `UPDATE`. The lock and
the advance share a transaction because the locks only live until
`COMMIT`; the fan-out stays outside it so config rows are not locked
across N job inserts.

**Duplicate run completion needed no second alert manager.** The
increment was atomic, but the completion decision re-read the row in a
second statement and returned "complete" to whichever caller saw
`jobs_done >= job_count` — which both acks of a 2-job run do. Acks are
served on ingesters, of which every environment runs two, and the probes
of one run finish independently. Effect: `consecutive_failures` counted
one run twice on every check, plus a genuinely duplicated notification
on checks left at the default `cooldown_mins = 0`.

**The Steps tab was truncating silently.** It downloaded up to 5000
execution rows and tallied `last_attempt_steps` in the browser. On the
busiest check measured — 18,079 executions over 7 days — that cap
covered 4 of the 7 days and reported a **56.3% fail rate against a true
33.7%**. The win is correctness; the ~18MB payload reduction is
secondary.

## Verification
`cargo check --workspace`, clippy, fmt clean. Config 98, infra 33
synthetics tests pass. Frontend: **839 synthetics tests pass**. Three
frontend spec files fail to import on `useLocalTimezone` — verified
identical on clean `main`, unrelated to this branch.
2026-08-03 10:14:11 +00:00
ktx-kirtan 4fa71dd15c
feat(ui): calm-signal pass over IAM and Synthetics Monitors (#13568)
Second batch of the "Calm Signal" pass (after #13562: Streams,
Pipelines, Nodes).

## Users
- Summary strip that doubles as the role facet. Tiles are the roles
**actually present in the data**, so custom roles appear without being
enumerated in code. Counts are role *membership* — a user with Admin + a
custom role is counted under both, so tiles intentionally don't sum to
the total. Top 5 by size; the tail folds into one "+N more roles" tile
counting unique users.
- Pending users now show the invited chip on the enterprise path (it
only appeared on the open-source one).
- Custom roles get one stable neutral chip instead of an untyped tag
whose colour was derived from the role's spelling.
- Removed a `<style scoped>` block whose only selector is used nowhere.

## Roles
- Member count per role, muted when nobody holds it. Comes from the
batched user→roles map — **one request for the org, not one per role** —
and is gated to enterprise, where that endpoint exists. Rows render
immediately; counts patch in when they land.

## Service Accounts
- Created reads as relative age, with a dot on keys minted this week.

## Synthetics Monitors
- Status counts were hidden inside a dropdown's option labels ("Passed
(12)"). They're now a strip that acts as the facet; the dropdown and its
dead options plumbing are gone.
- Health row rail, plus the red / muted row wash for failing and paused
monitors that Alerts and Pipelines already use.
- Last check renders through `OTimeCell` instead of a local time-ago
copy.
- **Fixes styling that never applied:** the uptime sparkline and its
hover tooltip referenced `.spark` / `.stt-*` classes that are defined
nowhere in the repo, so the bars had no size and the tooltip no surface.
Rebuilt with tokens.
- `timezone` is a prop now — a leaf table shouldn't reach into the
store.

## No strip on Dashboards, Roles, Service Accounts or Groups
Their candidate tiles were structurally constant (System = 1),
near-always zero, derivable from a neighbour, or already in the table
footer. Dashboards was already compliant (favourites, owner cell,
relative dates) and is untouched; Groups' API returns names only, so
per-row counts would cost one request per group.

`calm-signal.md` records both rules this batch produced: the
three-question test for a tile, and rail-vs-wash (a full-row wash only
when the state means *act now*, is rare, and the row is the unit of
action).

## Testing
`lint`, `lint:design:strict`, `lint:styles`, `lint:tokens`,
`lint:token-purity`, `type-check` pass. 885 unit tests across IAM and
Synthetics pass.

Synthetics needs a monitor in each state to eyeball the rail/wash;
Roles' member column needs an enterprise build (403s and hides itself
otherwise).

---------

Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
2026-08-03 08:54:15 +00:00
Huaijin e1e3091c06
refactor: move streaming aggregates to OSS (#13589)
## Summary

- move streaming aggregation cache, aggregate execution, and optimizer
code into the OSS search crate
- move cache aggregation SQL analysis into the SQL visitor and enable
streaming aggregation in search_service without enterprise gating
- move MetadataCountExec into OSS and use it for metadata count
optimization in all builds
- remove migration-only dead code while keeping the cache module under
search

Companion cleanup: openobserve/o2-enterprise#2327.

## Testing

- cargo fmt --all -- --check
- cargo test -p search metadata_count --lib
- cargo test -p search aggregate_optimize_rewrite --lib
- cargo test -p search streaming_aggregate --lib
- cargo test -p search cache::streaming_agg --lib
- cargo test -p search datafusion::optimizer::stream_aggregate --lib
- cargo test -p search_service partition --lib
- cargo check -p search_service
2026-08-03 08:44:55 +00:00
ktx-vaidehi f8253d4b24
fix: dashboard table chart width (#13587) 2026-08-03 07:55:33 +00:00
ktx-akshay 7f87242ac9
fix: dashboard reports drawer duplicates entries and hides reports in custom folders (#13569)
## Summary

This PR fixes two bugs in the Dashboard Reports drawer
(`ScheduledDashboards.vue` / `ViewDashboard.vue`).

**Issue 1 — Reports duplicate on every reopen**
Opening the Reports drawer, creating a report, then reopening the drawer
for the same dashboard caused the same report to appear as a duplicate
row. Reopening again added yet another duplicate, so the count grew by
one on every open. This happened because the drawer component never
unmounts between opens (it's toggled via `v-model:open`, not `v-if`),
and the report list was being appended to on each reopen instead of
being rebuilt.

**Issue 2 — Reports in a custom folder don't show up**
Reports created from a dashboard using the Default folder appeared
correctly in the drawer, but reports created using a custom report
folder did not — even though creation succeeded. The drawer's list
request was filtering by the dashboard's own folder id, but that filter
is meant to match the *report's* folder, not the dashboard's. Any report
saved to a folder other than the dashboard's own folder was silently
excluded.

| # | Issue | Root Cause | Fix |
|---|-------|------------|-----|
| 1 | Reports duplicate in the drawer each time it's reopened |
`formatReports()` in `ScheduledDashboards.vue` pushed new rows onto the
local `scheduledReports` list without clearing it first; since the
drawer stays mounted across opens, old rows persisted and new ones piled
on top | Rebuild the list fresh from `props.reports` on every call
instead of accumulating |
| 2 | Reports saved to a custom folder don't appear in the dashboard's
Reports drawer | `openScheduledReports()` in `ViewDashboard.vue` passed
the dashboard's folder id as the `folder_id` filter on the reports list
API, which filters by the *report's own* folder, not the dashboard's |
Removed the folder filter so the drawer lists all reports for the
dashboard regardless of which report folder they're saved in |

Issue : https://github.com/openobserve/o2-enterprise/issues/2319

---------

Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
2026-08-03 07:28:17 +00:00
Shrinath Rao de95f7f2d8
fix: restore Table default log-detail view (#13368) + heal alert-name inline-edit read (#13585)
## What & why

Two **Playwright Regression** nightly failures, reproduced identically
on both OSS ([run
30785096054](https://github.com/openobserve/openobserve/actions/runs/30785096054))
and ENT ([run
30785071382](https://github.com/openobserve/o2-enterprise/actions/runs/30785071382))
— same two shared-`tests/` specs, all 3 retries failed each. Both are
OSS-owned specs (ENT overlays only its own `tests/*` and builds OSS
`web/`), so this single OSS PR fixes both nightlies.

### 1. Logs — real product regression (`logs-regression.spec.js:1158`)
`Log detail sidebar opens with Table tab by default (#13368)` → `Error:
Table tab should be selected by default`.

**Root cause:**
[#13368](https://github.com/openobserve/openobserve/pull/13368) (Jul 23)
deliberately made the log-detail sidebar open on the **Table** tab.
[#13451](https://github.com/openobserve/openobserve/pull/13451) (Jul 30,
*"table migration to same components"*) silently flipped
`detailTableInitialTab` back to `"json"` in `SearchResult.vue` (both the
`ref` init and the per-open reset). The unchanged test correctly caught
the reverted behavior.

**Fix:** restore `"table"` as the default log-detail tab. Product fix —
no test change.

### 2. Alerts — stale test vs redesigned component
(`alerts-stream-switching.spec.js:443`)
`Bug #11577: Alert preview chart y-axis displays numeric values` →
`TimeoutError: locator.inputValue: Timeout 45000ms exceeded`.

**Root cause:** the alert name became an **`OInlineEdit`** title
(`OFormInlineEdit`, from the
[#13547](https://github.com/openobserve/openobserve/pull/13547) Alerts
revamp). In display mode the value lives in the `…-value` span; the
`…-input` only exists **while editing**. The test read `.inputValue()`
on the non-existent input → 45s timeout.

**Fix:** read the committed display value via a new `getAlertName()`
page-object helper (with an edit-mode `inputValue()` fallback).

## Verification
- **Alerts #11577** —  passed locally against a local O2 build (`1
passed`); `getAlertName()` reads the name and the full
save/verify/delete flow works.
- **Logs #13368** — restores the exact `detailTableInitialTab = "table"`
value #13368 set and the unchanged test already validated as green.
Confirmed by this run's Logs-Regression job + the dispatched ENT e2e
gate.

## Scope
- `web/src/plugins/logs/SearchResult.vue` — 2 lines (product)
-
`tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-stream-switching.spec.js`
— read display value
- `tests/ui-testing/pages/alertsPages/alertsPage.js` — `alertNameValue`
locator + `getAlertName()` helper
2026-08-03 07:19:28 +00:00
Huaijin 872ceb724a
refactor: move search and compaction optimizations to OSS (#13583)
## Summary

- move optimized Tantivy simple and multi histogram collectors into the
open-source config crate
- move general broadcast join eligibility, rewrite, execution, TmpExec
codec, and shared async state into open-source search
- enable enrichment-table broadcast join in OSS, including join
reordering, remote-scan rewriting, EnrichmentExec, and its physical-plan
codec
- move AggregateTopkExec, its heap/sort streams, physical optimizer
rule, and codec into open-source search
- replace the enterprise-only Aggregate TopK settings with OSS-owned
`ZO_DF_USE_AGG_TOPK_HEAP` and `ZO_DF_TOPK_HEAP_MAX_LIMIT` settings
- move `approx_topk` and `approx_topk_distinct` UDAFs into open-source
search and register them in both OSS and enterprise builds
- omit the Aggregate TopK benchmark, benchmark documentation, and
data-generator example from OSS
- move Bloom term extraction, post-merge build, `.bf` writing, and
orphan cleanup into the OSS compaction crate
- run the Bloom build path after compaction in both OSS and enterprise
builds, with non-fatal fallback to regular Tantivy search
- move the direct `tantivy` and `object_store` dependency ownership
required by Bloom building into OSS compaction
- enable sorted timestamp, block, RANK, broadcast join, Aggregate TopK,
approximate TopK, and Bloom paths in both OSS and enterprise builds
- retain cross-super-cluster TmpExec fetching behind the enterprise
feature
- remove feature-gated enterprise imports and use the shared OSS
implementations
- add fastdivide for histogram bucket computation
- resolve the duplicated SLO test attribute warning and remove an unused
test-only alert-state migration helper

## Tests

- `cargo fmt --all -- --check` (OSS and enterprise)
- `cargo clippy --locked -p search --lib -- -D warnings`
- `cargo clippy --locked -p compaction --lib -- -D warnings`
- `cargo clippy --locked -p o2_enterprise --lib -- -D warnings`
- `cargo test -p config tantivy::query::histogram_collector --lib` (21
passed)
- `cargo test -p search tantivy::search::tests --lib` (9 passed)
- `cargo test -p search enrichment --lib` (2 passed)
- `cargo test -p search broadcast_join --lib` (7 passed)
- `cargo test -p search join_reorder --lib` (8 passed)
- `cargo test -p search datafusion::distributed_plan::codec::tmp_exec
--lib` (1 passed)
- `cargo test -p search aggregate_topk --lib` (7 passed)
- `cargo test --locked -p search approx_topk --lib` (8 passed)
- `cargo test --locked -p config meta::slo --lib` (371 passed)
- `cargo test --locked -p infra bloom --lib` (30 passed)
- `cargo check --locked -p config --tests`
- `cargo check --locked -p infra --tests`
- `cargo check --locked -p search --lib`
- `cargo check --locked -p compaction --lib`
- `cargo check --locked` (OSS root package)
- `cargo check --locked --workspace` (enterprise workspace)
- `cargo check --offline -p compaction --lib --features enterprise` with
the paired repositories
- `cargo check --offline -p openobserve --no-default-features --features
enterprise` with the paired repositories

Companion enterprise cleanup PR:
https://github.com/openobserve/o2-enterprise/pull/2326
2026-08-03 06:20:37 +00:00
ktx-vaidehi 08b8087190 fix: i18n migration 2026-08-03 11:22:57 +05:30
Omkar Kesarkhane fc8e3c1657
fix: redesign the step editor and the run evidence surfaces (#13582) 2026-08-03 12:45:16 +08:00
Ashish Kolhe 23e816b1e4
feat: External Alert Sources incident ingestion (#13578)
## Summary
Adds a full "External Alert Sources" feature so OpenObserve Incidents
can serve as ingestion/correlation layer for third-party alert tools
(Grafana, Alertmanager, generic webhooks):

- Token-authenticated inbound webhook endpoint (`o2iat_` tokens) that
normalizes Grafana/Alertmanager/generic payloads and correlates them
into Incidents alongside internally-generated alerts.
- Incident integrations CRUD (create, list, enable/disable, rotate
token, delete — default integration protected from deletion) with
per-sender observed status.
- Settings UI: single always-open alert sources table (merged from an
earlier two-panel design), shared-token badge, add/rotate/delete flows.
- Incident detail UX: contributing-alert Source/Labels columns, "view
raw payload" action (surfaces the exact webhook JSON received), and an
external-source badge on the incident header so it's obvious at a glance
whether an incident has external contributors.
- **Auto-resolve**: incidents now close automatically once every
contributing external alert reports `status: resolved` — previously
required manual resolution even when the source alert cleared.

## Notable follow-up fixes bundled in
- Rebased onto latest `main`; repaired two build breaks surfaced by
upstream changes that landed after this branch diverged
(`CorrelationSubject` refactor's new `eval_level` param, and a new
`SeverityEscalated` correlation outcome) — both textually merged clean
but were semantically incomplete for the external-event path.
- Removed the `O2_INCIDENTS_EXTERNAL_SOURCES_ENABLED` enterprise config
flag (companion removal in o2-enterprise) — external sources are now
gated solely by `O2_INCIDENTS_ENABLED`, removing a redundant toggle.

## Test plan
- [x] `cargo build --no-default-features --features enterprise` — clean,
no warnings introduced
- [x] `cargo clippy` on touched crates (`infra`, `openobserve-core`,
`openobserve-api-management`, `openobserve-api-http`) — no new warnings
- [x] `cargo fmt --check` — clean on all touched files
- [x] `cargo test` — all tests in touched modules pass
(`alert_incidents`, `incident_integrations`, `external_alerts`,
`alerts::*` — 274/274); two unrelated flaky DB-pool-timeout tests
(`db::tests::test_delete`,
`table::ratelimit::tests::test_add_batch_missing_rule_id`) confirmed
pre-existing and pass in isolation
- [x] Frontend: `vitest run` on all touched components — 156/156
passing; `vue-tsc --noEmit` clean
- [x] Manually verified end-to-end against a running dev server:
fired/resolved external alerts, confirmed auto-resolve, delete, and
raw-payload retrieval all work against real HTTP calls
2026-08-02 16:16:50 +00:00
Prabhat Sharma 0cfa518433
feat(slos): SLO measurement UI — trend charts, previews, and slice defaults (#13579)
## What this is

The SLO measurement UI: the Reliability nav entry and SLO routes, the
list and
detail pages, the create/edit form, and the trend charts that make an
error
budget readable.

## The charts

The detail page draws **burndown** and **burn rate** side by side. They
read the
same slices and differ only in the window they aggregate over, which is
what
makes the pair legible: burn spikes above ×1, budget bends downward.

Each panel carries a **second y-axis** that relabels the first through a
fixed
affine map — a burn multiple *is* an SLI, a share of budget *is* a
number of
errors. This is not the dual axis that makes crossing points an
artifact: there
is one series, and the right axis is the same axis in the unit people
speak in.
The SLO is written as "99.9%", and "we can afford 12 more errors" is a
sentence
an on-call engineer can act on.

That demands exact gridline alignment, so `chartScale.ts` pins the left
axis and
*derives* the right one. Two consequences worth knowing:

- **Precision follows the axis step, not the unit.** A four-nines target
leaves a
0.01-wide budget, so its SLI axis moves in thousandths — two decimals
would
  print every gridline as "100.00%".
- **The burndown's top is pinned at 100**, not rounded up to a multiple
of its
step. A window 1,500% overspent takes a 500-point step, and rounding the
top
squashed the whole healthy stretch of the line into the top fifth of the
panel.

An info tooltip on each panel gives the plain reading, the formula, and
how to
read the threshold line. The formulas mirror `toBurndownSeries` (and so
`config::meta::slo::math`) exactly — two statements of "burn rate" that
disagree
by a rounding convention is a support ticket.

## Previews in the form

A count SLI is a **predicate** and the doubt is whether it parses; a
time-slice
SLI is a **number** someone has to guess (232 ms) and nothing told them
whether
that makes 1% or 40% of slices bad. Each now gets the preview that
answers its
own question, and the time-slice one puts the verdict in the header —
"83.3%
good — 5/6 slices" — beside the target just typed.

The threshold never reaches SQL, mirroring the ingest pass, which
aggregates
first and classifies after so a threshold edit re-reads stored slices
instead of
contradicting them. Dragging it therefore rescores instantly with no
query.

The aggregate field moves from a bare input to the same expression
editor scope
and good-when already use — it names a column, and a mistyped column is
invisible until the ingest query fails.

## Defaults

**5-minute slices are now the form default.** For a count SLI the slice
width does
not touch the arithmetic — the window SLI is Σgood/Σtotal and
repartitioning the
same events changes neither sum — so 60s returned bit-identical numbers
for five
times the stored rows (139,680 per series at the 97-day horizon against
27,936).
60s stays available because for a time-slice SLI the width *is* the
definition:
at 99.9% over 7 days a single bad 5-minute slice spends about half the
budget.
Backend validation is untouched; both widths remain legal.

## Verification

Beyond unit tests, the charts were rendered against a live instance,
which caught
three defects the tests could not:

- The burndown's second axis counted **seconds** for time-slice SLOs
while
labelling them slices — off by 300× (2,340 shown where the honest answer
is
  7.5). Now converted via a tested `budgetUnitsFor`.
- `histogram()` returns `slice_start` as an ISO string without a zone
marker.
`Number()` gave NaN (empty panel forever); parsing as local time would
have
  shifted the series by the viewer's offset.
- The preview's result set is not the range — a slot with no rows
produces no row
at all, so the tally read over an undisclosed sample (4 of 12 slots on a
7.3%-coverage SLO). The gap count is now stated, and the pass/fail
colour is
withheld below ten measured slices rather than calling 66.7% a failure
on the
  strength of three.

`npm run lint`, `vue-tsc`, `lint:design:strict` and the `utils/slos`
suites all pass.

## Known follow-up, deliberately not in here

SQL function autocomplete renders its whole call as the label, uses the
"abc"
(Text) icon, and inserts a literal `'undefined'` for any function taking
more than
one argument — the provider passes one argument to a label builder that
takes
several. It is app-wide (`useSuggestions.ts` + `CodeQueryEditor.vue`),
not
SLO-local, so it wants its own change.
2026-08-02 16:06:05 +00:00
Prabhat Sharma d99ecff3e8
feat(nav): add Reliability menu, restore deferred SLO routes (#13577)
Groups the alerting surface under one left-rail tile, and un-defers the
SLO backend that shipped disabled.

> **Lands with** openobserve/o2-enterprise#2321, which adds
`ROUTE_PERMISSIONS` for the restored SLO endpoints — the route-coverage
test pairs the two, so this one alone fails it.

## Reliability menu

New `reliability` nav group absorbing **Alerts, SLOs, Incidents**, plus
**Notification Destinations** and **Templates**, which move out of
Settings — they are alerting configuration, not deployment
configuration. Settings keeps Pipeline Destinations (that one really is
pipeline config); its group is renamed "Destinations".

Their routes are **top-level and flat** (`/alert-destinations`,
`/alert-templates`) rather than nested under `/alerts`. They are
siblings of Alerts, not sub-pages of it — nesting them made the URL
claim otherwise and the rail believed it, highlighting Alerts alongside
them.

- Route **names** are unchanged, so the ~15 call sites that navigate by
name are untouched.
- Old `/settings/*` paths redirect, with the query preserved —
`?action=import` deep links keep working.

## SLO routes

`/api/{org}/slos` answered **404**. The routes were deliberately
unregistered before the last release (`6977b4020d`, `ac78a622e1`) while
the rest of the feature — `core/src/slo/*`, the infra tables, both
migrations, the `slo_maintenance` job, all seven handlers — stayed in
the tree. This restores the five registrations and the OpenAPI entries,
recovered from the pre-strip commit rather than rewritten.

`ZO_SLO_ENABLED` still **defaults to false**. It is now published as
`slo_enabled` on `/config`, so the menu entry follows the flag instead
of offering a page the API answers with 501.

## Nav active-state

Active state was decided per child, so any section whose path prefixed
the current route lit up alongside it. It is now resolved once per
flyout: exact route-name match wins, else the **deepest** path prefix —
so a genuine drill-down like `/alerts/detail/:id` is still attributed to
Alerts. `placeAfter` also accepts a group key, letting Data anchor on
the Reliability tile it follows rather than on an item that tile
absorbs.

## Tests

`tests/ui-testing` referenced `[data-test="alert-destinations-tab"]` /
`alert-templates-tab` in six files — those data-tests were on the
Settings rail items removed here, so the locators would have matched
nothing and hung until timeout. Those paths now go through the existing
`openNavFlyoutChild` helper, extended with the Reliability group. Two
URL regexes that would have silently mismatched on the hyphen are fixed
(one ended in `.catch(() => {})`, so it would have degraded to a silent
15s stall).

## Verification

- 3597 unit tests pass; `vue-tsc` and eslint clean; enterprise build
compiles.
- `playwright test --list`: all 141 tests across 23 files load, no
module errors.
- Against a live server: `GET /slos` → 200, `POST /slos/move` → 422 (so
the literal wins over the `{slo_id}` catch-all), `GET /slos/x` →
handler-level `{"code":404,"message":"SLO not found"}`.
- In the running app: `/alert-destinations`, `/alert-templates` and
`/alerts` each highlight exactly one row;
`/settings/alert_destinations?...&action=import` redirects with the
query intact; rail order unchanged.
2026-08-02 05:44:01 +00:00
Hengfei Yang 72f7bff378
perf: optimize AI session list queries (#13574)
## Summary

- Page sessions by `session_id` before computing the expensive summary
fields.
- Aggregate only the selected page of sessions with exact
`count(DISTINCT trace_id)`, removing trace ID materialization and
Rust-side per-trace rollups.
- Generate direct Agent predicates and evaluate them as aggregate
membership conditions without a session-membership subquery.
- Support efficient server pagination by fetching one extra session to
determine `has_more`, without counting every distinct session on every
page.

## Why

The previous endpoint first materialized `array_agg(DISTINCT trace_id)`
for every candidate session and then issued another query using those
trace IDs. A single-pass full rollup was also slower because it
aggregated every summary field for all matching sessions before applying
TopK.

This change keeps the inexpensive session page selection separate and
bounds the expensive rollup to the requested page size. Page selection
and display both use the latest `end_time` across the complete session;
Agent predicates only decide whether a session is a member of the result
set.

The endpoint previously returned `total = hits.len()`. With a 20-row
page, OTable therefore calculated one total page and permanently
disabled Next even when more sessions existed. An exact `count(DISTINCT
session_id)` query scanned the full data set and took about 1.18 seconds
by itself on this benchmark. Instead, the page query now requests `size
+ 1`: while another page exists, `total` is a documented lower bound and
the response includes `has_more=true` / `total_is_exact=false`. The
final page returns the exact total. OTable displays lower bounds with
`+` and hides Last-page navigation until the exact total is known.

## Performance

Measured locally against `bench_traces` (167 files, about 99.9 million
records), using five runs and ignoring the first two:

| Query shape | Mean wall time |
| --- | ---: |
| Original trace-ID flow | 6.829 s |
| Page session IDs + membership subquery | 3.219 s |
| Page session IDs + Last activity | 0.850 s |

The optimized query shape is about 87.6% faster than the original flow.
The second-phase rollup returned 20 rows with about 74 KB of
intermediate output and about 2.45 MB peak memory in the query plan.
Fetching the extra pagination sentinel did not introduce a meaningful
page-size-dependent regression in the final runtime sweep.

## Behavior note

The Agent filter controls membership through `HAVING max(CASE WHEN
<filter> THEN 1 ELSE 0 END) = 1`, while the final rollup includes all
spans for each selected session. Both pagination and display use the
complete session's `max(end_time)`, ordered by `session_last_activity
DESC, session_id DESC` for stable offset pagination. The UI labels this
value `Last activity`; session start and duration remain available from
the rollup.

Current clients send direct Agent predicates. For compatibility, the
endpoint also recognizes the previous narrow same-stream `session_id IN
(SELECT session_id ... GROUP BY session_id)` shape and safely extracts
its inner WHERE predicate; unrelated subqueries are left unchanged.

This assumes every relevant span has a `session_id`.

## Validation

- `cargo build --features mimalloc --profile release`
- `cargo test -p openobserve-api-search traces::session --lib` (18
passed)
- `cargo test -p openobserve-api-search user::tests --lib` (13 passed in
the earlier query-change validation)
- `cargo fmt --all`
- `git diff --check`
- targeted Vitest: Sessions composable, SessionsList, and OTable (169
passed)
- `vue-tsc --noEmit -p tsconfig.vitest.json --composite false`
- targeted ESLint and Prettier checks
- runtime pagination check against the supplied legacy-filter curl:
  - page 1: 20 hits, `total=21`, `has_more=true`, `total_is_exact=false`
  - page 2: 20 hits, `total=41`, `has_more=true`, `total_is_exact=false`
- final page: 3 hits, `total=184443`, `has_more=false`,
`total_is_exact=true`
- pages 1 and 2 returned 40 unique sessions in descending `end_time`
order across the page boundary
2026-08-01 08:35:55 +00:00
Prabhat Sharma 8f60dbcc82
fix(metrics): widen rate windows when the scrape interval overstates cadence (#13576)
## Problem

Metrics explorer cards for histograms, counters, and summaries showed
**"Too few samples for a rate"** even though the streams had data. The
rate window is sized from the org's configured `scrape_interval` (e.g.
15s → `[1m]`), but when data actually arrives less often (the OTel demo
app exports every 60s), `rate()` almost never finds the two samples it
needs — so every rate-based card rendered the sparse message while
gauges charted fine.

Reproduced against a live instance:
`sum(rate(app_cart_add_item_latency_count[1m]))` → empty; the same query
with `[4m]` → `0.172/s`.

## Fix

**Widened-window retry** (`useMetricsExplorerGrid.ts`,
`computeWidenedRateWindows` in `metricDefaults.ts`): when a rate-based
card comes back empty but the presence probe confirms samples exist in
the window, retry with geometrically wider windows — 4×, then 16× the
standard window, capped at half the range — and chart the first rung
that answers. The sparse message now only appears when no honest window
could produce a chart.

**Editor / dashboard handoff** (`MetricsExplorer.vue`): the drill-in
normally hands the editor `$__rate_interval`, which resolves from the
same overstated scrape interval — opening a blank editor for a metric
the card visibly charts. The preview now remembers which rung worked
(`widenedRateWindow`, persisted through the card cache like
`nanGuardApplied`), and both the card→editor drill-in and the
favorites→dashboard conversion hand over that concrete window instead.
Cards that never needed widening keep the adaptive `$__rate_interval`
behavior.

Supporting changes:
- `previewKeysOf` enumerates the widened query keys so refresh
invalidates them instead of replaying stale retries from the queue
cache.
- Persisted-cache identity gains a shape version (`v: 2`) so pre-field
entries (widened data with no memory of the window) miss once and
re-learn live.
- A failing widened retry settles for the sparse answer already in hand
rather than repainting the card as an error; cancellations still abort.
- During a refresh the loading state carries `widenedRateWindow`
alongside the old results it keeps on screen, so a mid-refresh drill-in
stays correct.

## Verification

- Unit tests: retry ladder math (caps, dedup, no-room-to-widen), first-
and second-rung success, all-rungs-fail keeps the sparse card, window
carried on the preview, handoff passes the concrete window vs
`$__rate_interval` — 182 tests across the four spec files pass; ESLint
and vue-tsc clean.
- Live against a dev backend: previously-sparse `app_cart_*` cards all
render (network log shows `[1m]` → empty, presence probe, `[4m]` →
data); drill-in opens the editor seeded with `sum(rate(...[4m]))` and a
rendered chart, from both the live-query and cache-restored paths.
2026-07-31 19:45:26 +00:00
ktx-vaidehi 77d62ddee2
fix(logs): bound the last selected column to the viewport (#13573)
`body` — and any last selected field — sized itself to its longest value
instead of taking the leftover width, dragging the row into thousands of
pixels of horizontal scroll. Two separate causes:

- the absorber marked the column `meta.autoWidth`, which in OTable means
"no width at all", and a horizontalScroll table carries `min-w-max`,
which sizes every column to its max-content;
- sized columns in a scrolling table had no `max-width`, so as soon as
another field was added and `body` stopped being the absorber, its own
text stretched it to ~3000px.

Split the two behaviours the pre-migration table kept separate: plain
`autoWidth` stays content-sized (the default `source` column was `width:
auto` and scrolled), while the new `meta.fillRemaining` takes the
leftover width but stays inside the container and ellipsis-truncates
(pre-migration `flex: 1 1 auto; overflow: hidden`). A bounded filler
drops the table's `min-w-max` and pins its sized siblings at their own
size in both directions.

Opt-in, so the other horizontalScroll tables (traces, dashboard panels,
monitors, search history) keep their existing layout untouched.

Verified against the pre-migration build (044f561506) running side by
side on identical data: column widths and scroll offsets match exactly.
2026-07-31 14:12:12 +00:00
Omkar Kesarkhane bbed40d8f4
feat(synthetics): author-owned locators, and step-scoped run evidence (#13563)
## Summary

Three synthetics workstreams on one branch: author-owned locators (Phase
2b), retirement of version-1 monitors (Phase 2c), and making a failed
browser step diagnosable without leaving the Steps tab.

Everything else in the synthetics suite passes (422 tests across the 21
suites the evidence work touches).

## Author-owned locators (Phase 2b)

The recorder proposes a locator bundle; the author owns its order.
Schema, combined-locator build/read, provenance pinned across the
recorder bridge, and the payload contract tested from both sides.

`337911158d` `e63f8f429f` `50b811a865` `2a978b9408` `c8c081c3c5`
`35ef1c36cb`

## Version-1 retirement (Phase 2c)

Stop creating v1 monitors, delete the v1 migration path and its step
fields, drop `steps_version` from the schema.

`b58b03077d` `75984ad222` `68a33e885d` `a2bffae7b7`

## Run detail — evidence, scoped to the step

Implements §5.3 of `docs/synthetics/step-failure-evidence-design.md`
("render the bundle, scoped to the step"), which was specified and never
built: only the kind-grouped run-level Evidence tab shipped.

**Per-step page activity.** The expanded step now shows the
`evidence.ndjson` events attributed to it — severity-ranked, capped at
5, with "View all N →" deep-linking into the Evidence tab pre-filtered
to that step. One shared `OTable` wrapper renders rows for both surfaces
so they cannot drift, and one composable fetches the bundle once for
both.

`a483796d57` `a7cbe0a3e0` `f63973285e` `eb7ec4f3e6` `1ed226febe`
`e3d969bcb2`

**Settle signals were attributed to the wrong step.**
`failure_detail.settle_signals` accumulates across the whole journey. On
live run `3HFCZ3fm` it held 13 signals — one from step 2, six from step
8, two from step 14 — while the failed step 15 owned none of them, and
the panel rendered all 13 under step 15. The signal that mattered
(`stale POST **/_search_stream, 30274ms`) belongs to step 14, which the
run's own error string says, and step 14 displayed nothing.
`last_attempt_steps[i].settle_signals` had carried correct per-step
attribution all along and was read by nothing.

`6ba6f8d633`

**Artifact URLs carried no origin.** `artifactUrl()` returned a bare
path while every other call in that service goes through `http()` with
`baseURL: store.state.API_ENDPOINT`. Artifact URLs go to `<img src>` and
`fetch()` directly, so in dev they resolved against Vite on `:8081`,
which has no `/api` route and no proxy — every screenshot and evidence
bundle 404'd. Verified against a running backend with a real key:
`:8081` → 404, `:5080` → 200. Production, served from the API's own
origin, is unchanged.

`baa7407cb5`

**The locator ladder was invisible.** `candidates_tried` lists only the
rungs the probe stood on, so a step that matched on its primary looked
identical to a step that had no second candidate — both one row, both a
grey `used_as_primary` badge. On live run `3HGDMrlU` the failing assert
had one authored locator and every `assert`/`fill` step in that journey
is built the same way, while `click` steps average 2.4 candidates. The
section now leads with "N of M tried", names a one-rung ladder as having
no fallback, labels outcomes in words instead of enum values, and dims
unreached rungs the way the timeline dims a skipped step.

`c05e1ce133`

## Test plan

- `npx vitest run src/views/synthetics src/components/synthetics/results
src/components/synthetics/StepEvidence.spec.ts
src/composables/synthetics src/composables/useSyntheticEvidence.spec.ts
src/services/synthetics.spec.ts` → 422 passing
- `npm run type-check` → clean
- `npm run lint:design:strict` → 2 pre-existing regressions above (see
Known failing)
- Behaviour on the run-detail changes was verified against live records
from `OpenObserve Sys Query` and `OpenObserve Cloud Happy Path` rather
than fixtures alone

## Not covered

- The "Add a fallback locator" action from the locator-ladder work needs
a step-level deep link; `synthetics/edit/:id` has no step anchor, so the
warning states the finding without an action.
- Browser-level confirmation of the artifact-URL and locator-ladder
changes is outstanding — the local backend's search service was
returning `tcp connect error` during verification, so those two were
confirmed against records and by unit test rather than in a running
page.
2026-07-31 11:19:46 +00:00
ktx-kirtan 073c5c2680
feat(ui): calm-signal pass over Streams, Pipelines and Cluster Nodes (#13562)
Continues the "Calm Signal" colour language shipped on the Alerts list:
a calm neutral canvas, with saturated colour spent only on the one
signal each screen exists to surface. These three pages were picked from
a ranked audit of the remaining list screens.

## Streams — `web/src/views/LogStream.vue`

Signal: **ingestion liveness**.

- **Last Ingested** column — relative time from `stats.doc_time_max`,
with a dot on streams taking data within the hour.
- **Liveness row rail** — green (ingested today) / amber (was ingesting,
quiet for over a day) / grey (never ingested). Grey rather than amber
for "never": a brand-new or schema-only stream is legitimately empty, so
amber there would cry wolf on every fresh stream. No full-row wash — a
stream list has no outright failure state, and washing "quiet" or
"empty" would tint most rows in a normal org.
- **Compression** column from ingested/compressed size, coloured only
when it drops below 1x (the compressed copy is *larger* than the raw
data — compression is doing nothing for that stream).
- **Org footprint strip** in `OPageLayout`'s `#subnav`, deliberately
**outside** the table: the list is server-paginated and these totals
cover every stream type, so in the table's `#subheader` they would read
as a summary of the visible rows. Same figures, labels, icons and
formatters as the Home → Usage tiles.

**Bug fixed along the way:** `compressed_size` / `index_size` were
declared outside the row `map`, so a stream without `stats` inherited
the previous row's numbers. Rows now carry raw per-row values and render
`—` when a stat is absent, instead of a misleading `0 MB`.

## Pipelines — `web/src/components/pipeline/PipelinesList.vue`

Signal: **operational state**.

- **State** column (Errored / Paused / Active) built the same way as the
Alerts reference, plus a selectable state strip and a matching row rail.
Errored rows also show when they last failed — recency on the exception
only.
- The state facet is orthogonal to the existing type toggle group, so
the two never express the same filter.
- "Errored" is driven by `last_error`, which the backend expires via
`ZO_PIPELINE_ERROR_RETENTION_MINS` (default 60 min), so the state clears
itself and cannot get stuck.

## Cluster Nodes — `web/src/components/settings/Nodes.vue`

Signal: **health**.

- Health strip doubling as the status facet, **Status** chips, and a new
**Role** column via a `nodeRole` badge group (roles were filterable but
never displayed).
- Node health previously relied on `status-row` / `status-*` row classes
that **exist nowhere in the repo**, so status rendered no colour at all.
Replaced with a real rail plus an exception tint.
- CPU/memory bars now go amber at 70% and red at 85%, and a hardcoded
literal colour (`bg-[lightgrey]`) is gone.
- Replaced the fake `#` data column (a real, draggable, resizable column
whose numbers stopped matching the rows once sorted or filtered) with
`OTable`'s `show-index` gutter, and the positional `columns.splice(2,
1)` with a filter by column id.

## Shared

- `OStatCard`: the **value no longer truncates** — a clipped `499.51…`
is a useless number, so the label absorbs the squeeze instead.
`OStatStrip` wraps a tile earlier so a five-tile strip stops squeezing
on a laptop.
- `formatEventCount` moved out of `UsageTab.vue` into
`utils/formatters.ts`, so Home and Streams cannot disagree about the
same number (`2.9B` vs `2,900,000,000`).
- Streams list endpoint: `stream_comparator` gains `doc_time_max` and
`compression` sort arms, with unit tests.

## Note on the two new sorts

`Last Ingested` and `Compression` are intentionally **not sortable in
the UI yet**. The table is `sorting="server"`, and an unknown sort key
falls through to `_ => name` with no error — a sort control would
silently order by name. Sorting them client-side isn't possible either:
server pagination means the page holds 20 of N rows. The backend arms
ship in this PR; enabling the columns is flipping `sortable: false` →
`true` once a build carrying them is deployed (the column ids already
match the keys).

## Testing

- `npm run lint`, `lint:design:strict`, `lint:tokens`,
`lint:token-purity`, `lint:styles`, `type-check` — all pass.
- 285 unit tests across LogStream, PipelinesList, Nodes, UsageTab,
formatters and badgeGroups pass.
- The Rust arms and their tests have **not been compiled locally** (no
cargo toolchain on the dev machine) — first real compile is CI.
- Cluster Nodes needs an enterprise build **and** the meta org to be
reachable (Settings → Nodes is gated `isEnterprise && isMetaOrg`).

---------

Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
2026-07-31 10:57:51 +00:00
Abhay Padamani 1112423ea8
fix: dashboard table chart width issue and color styling issue (#13567) 2026-07-31 10:40:03 +00:00
ktx-kirtan 83d12fff04
feat(web): inline-rename page titles with auto-generated default names (#13520)
## What changed

Naming an alert, panel, workflow, pipeline or function meant inventing a
name up front in a form field before anything else could be configured.
Two pieces replace that.

**Inline rename** — the page title *is* the input. `OInlineEdit` /
`OFormInlineEdit` (`web/src/lib/forms/InlineEdit`): click to rename,
Enter or blur commits, Escape restores the pre-edit value. A `#trail`
slot carries the "Auto" badge in display mode only.

**Auto-generated names** — `utils/autoName.ts` builds the name,
`composables/useAutoName.ts` decides when it's allowed to win. A name is
AUTO until the user types, then it's theirs for good; clearing the field
and blurring re-arms auto. The generators describe only what is actually
configured, so a half-built panel yields a shorter name rather than a
speculative one, and an empty string when there is nothing to say.

- Panel names read as prose — `Avg of duration by service`, `Record
count`, `k8s_logs overview`. Both live panel-field shapes are handled
(builder `functionName` + `args`, and legacy flat `column` +
`aggregationFunction`); the time column is read from
`zoConfig.timestamp_column` rather than hardcoded. Hand-written queries
fall back to the stream, since naming a panel after `x_axis_1` is worse
than saying nothing.
- Alert names are identifiers — `anomaly_k8s_logs`,
`k8s_logs_status_gte_500` — built to survive the backend's
`RE_OFGA_UNSUPPORTED_NAME` rule, with operators spelled out and a
`slugify` backstop. A leading underscore is preserved so internal
streams (`_rumdata`) keep their names.

### Supporting library changes

| Component | Change |
| --- | --- |
| `OPageHeader` / `OPageLayout` | New `titleOverflow="visible"` — the
`<h1>`'s `truncate` clips an interactive title's focus ring and the
error message it floats below itself |
| `OSelect` | New `appearance="inline"` — borderless, no fixed height,
sized to content, for a value that reads as a word inside running text
("Add Alert **in KTX**") |

All new strings go through i18n (`common.inlineEdit.*`,
`dashboard.autoName.*`, `alerts.autoName.*`, per-module `renameHint`).
Interpolated identifiers — column names, SQL functions, stream names —
stay verbatim.

## Wired into

AddPanel, AddAlert (+ `useAlertForm`), WorkflowEditor, PipelineEditor,
Functions / FunctionsToolbar, IncidentDetailDrawer,
InlineSelectFolderDropdown.

## Tested

244 unit tests across the 5 affected suites, all passing — including new
specs for `OInlineEdit`, `useAutoName`, and `autoName`.

One pre-existing expectation in `autoName.spec.ts` was updated: it
asserted `Avg of duration, max of duration +1 more`, capitalising only
the first word of a multi-measure name. `measureLabel` capitalises per
measure by design, so the expectation now reads `Avg of duration, Max of
duration +1 more`.

---------

Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
2026-07-31 08:50:19 +00:00
Dhruv Patel e85baca92d
fix: logs timechart required-fields error and URL refresh failure (#13244)
## Summary

Fixes #12897 — two defects on the Logs page **Timechart** tab:

1. The error **"Please select required fields to render the chart"**
appeared even though the chart should render.
2. **Refreshing the URL** broke the tab entirely: a *"Select \* query is
not supported for visualization"* toast plus a permanent **No Data**
state.

## Root cause

### 1. False "required fields" error (regression)

`convertPanelData()` throws this error when a chart-type panel has empty
`x`/`y` fields. The check was introduced in #10305, then **commented out
in #11297 specifically because it broke the logs visualize toggle** (the
NOTE explaining that was left in the code), and then **re-enabled in
#11586**, which reintroduced the bug.

The Timechart drives a **custom-query** panel whose axes are populated
*asynchronously* from the result schema (`resetFields()` →
`result_schema` network call → repopulate). Empty `x`/`y` is therefore a
legitimate transient state for this panel — any render that landed
inside that window (or after an extraction early-exit) hit the throw and
pushed the error into the errors panel.

### 2. URL refresh broken

Two ordering problems on page load:

- `handleBeforeMount()` restores `logsVisualizeToggle` from the URL
**before** the stream selection is restored, so the visualize toggle
watcher fired with an empty stream and built literally `select * from
"undefined"` → rejected with the SELECT \* toast (confirmed by
instrumenting the running app).
- Nothing ever re-ran the visualization after URL restoration completed
— `loadVisualizeData()` only loads stream fields — so the tab stayed on
**No Data** forever.

## The fix

| Change | File | What it does |
|---|---|---|
| Scope the empty-fields guard to builder-mode panels
(`!query.customQuery`) | `web/src/utils/dashboard/convertPanelData.ts` |
Builder panels keep the protection (the panel editor's own validation
also still blocks empty applies); custom-query panels (logs Timechart,
alerts preview) are no longer blocked by their transient empty-fields
state |
| Bail out of the visualize toggle watcher when no stream is selected
yet | `web/src/plugins/logs/Index.vue` | Stops the premature page-load
run from building `select * from "undefined"` and toasting |
| Run the visualization after URL restoration completes |
`web/src/plugins/logs/Index.vue` (`setupLogsTab`) | Mirrors the
manual-toggle setup via a shared `prepareVisualizeMode()` (quick-mode
auto-enable, layout, `customQuery`, VRL copy), restores the saved chart
type/config from `visualization_data`
(`restoreVisualizationFromUrlOnLoad()`), then triggers the run. Scoped
to the visualize toggle only — Search/build/patterns tabs untouched |
| Broaden the stream-field readiness check |
`web/src/utils/logs/visualizeStreamFields.ts` (new) | Extracted
predicate also reloads fields when quick mode is on but
`interestingFieldList` hasn't loaded yet — the state that produced
SELECT \* mid-restore |

## Verification

- **Unit tests (written failing-first against the unfixed code):** guard
skips custom-query panels / still throws for builder panels / PromQL
exempt; readiness-predicate cases. 75/75 pass across the two touched
suites.
- **End-to-end (local backend + dev UI + Playwright):** reproduced both
symptoms on the unfixed build, then verified on this branch: run →
render; chart-type switch (h-bar) → URL refresh → **chart type + config
+ query restored and chart rendered, zero errors**; VRL-function variant
renders; dashboards builder empty-fields apply is still blocked by its
validation; build-tab refresh behavior unchanged.
- `eslint` clean on all touched files (the one `no-fallthrough` error in
`convertPanelData.ts` pre-exists on `main`, untouched line); `vue-tsc`
clean.

## Before / After

Both recordings follow the identical flow: select stream →
`match_all('error')` → open Timechart → switch chart type to horizontal
bar → **refresh the URL**.

### Before (`main`) — refresh shows the SELECT \* toast and permanent No
Data

![before animation: refresh → error toast → No
Data](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/before-refresh-bug.gif)

📹 Full recording:
[before-refresh-bug.webm](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/before-refresh-bug.webm)

### After (this branch) — refresh restores chart type, query, and
renders

![after animation: refresh → chart type and query restored → chart
renders](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/after-fix.gif)

📹 Full recording:
[after-fix-full-flow.webm](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/after-fix-full-flow.webm)

<details>
<summary>Screenshots — final states and pre-refresh states on both
builds</summary>

Final state after refresh (unfixed build):

![before: refresh breaks with SELECT * toast and No
Data](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/03-before-BUG-refresh-toast-and-no-data.png)

Final state after refresh (this branch):

![after: refresh restores h-bar chart with no
errors](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/06-after-FIXED-refresh-restores-chart.png)

Timechart renders before refresh (unfixed build):

![before: timechart
renders](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/01-before-timechart-renders.png)

H-bar renders before refresh (unfixed build):

![before: h-bar
renders](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/02-before-hbar-renders.png)

Timechart on this branch:

![after: timechart
renders](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/04-after-timechart-renders.png)

H-bar on this branch:

![after: h-bar
renders](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/05-after-hbar-renders.png)

</details>
2026-07-31 07:45:44 +00:00
Prabhat Sharma 4772df1723
feat: per-group and per-series alerting, and SLO measurement (#13547)
Design at: alerts_2.md

Per-group and per-series alerting, SLO measurement, and the UI for both.

## Alerts

**Multi-alerts (Feature 3).** A grouped alert can opt in to evaluating
and paging **per group** rather than collapsing to one verdict. Opt-in
only (`multi_alert`), so no existing alert changes behaviour: the flag
cannot be present in JSON written before it existed. Per-group state,
transitions, silence fingerprints, disappearance/reaping and a
cardinality cap, with the count-gate rules that make "most severe group"
and the legacy collapsed level provably the same verdict.

**Per-series alerting for PromQL.** The same feature for the metrics
family, where a group is one returned **series** keyed by its full label
set — that is a series' identity in Prometheus, and the expression's own
`by (…)` clause is already where the label set is chosen, so a second
picker could only disagree with it. One additive nullable column; NULL
means off, so stored alerts are untouched.

**Alert detail page**, replacing the row-click side panel: stat strip,
evaluation chart with threshold marklines, per-group table and history.
Simple alerts get a real history too — evaluations (one record per run)
by default, level changes alongside.

## SLOs

Count / time-slice / alert SLIs, error-budget and burn-rate arithmetic,
rolling windows, per-group slices with an exact rollup, coverage gating,
backfill, and SLO-based alerts.

Two capabilities the model could not express, both added tests-first:

- **Metrics-native counting** (`CountSource::PromQl`) — counter metrics
have no rows for a predicate to classify, so "good" exists only as
arithmetic between series, and correct counter arithmetic is
`increase()`.
- **Freshness semantics** (`absent_is_bad`) — for a pipeline SLO,
silence IS the failure, which the default gap rule can never report. A
*failed* query still freezes rather than pages; only a query that PROVED
emptiness counts as bad.

**SLO form**: live good/bad preview so a wrong predicate is caught
before saving rather than days later, real query editors with field
autocomplete, folders shared with alerts, and a move endpoint.

## Notes for review

- The SLO and composite features are **deferred (TODO)**:
`ZO_SLO_ENABLED` stays default **false** and the SLOs menu entry is
hidden until they ship; the SLO backend and UI remain in the tree behind
the flag, and composites stay pure logic + tests.
- One migration (schema 61): a nullable column for the PromQL opt-in. No
backfill.
- Two fixes outside the feature that it surfaced: query-editor
completions were duplicated per mounted editor (Monaco registers
providers per *language*), and the alert detail chart had no PromQL
branch, so it charted row counts against the series-count gate.
- Lands together with the matching o2-enterprise PR — `QueryType::Slo`
is new here and the enterprise dedup matches are exhaustive over it.
- `ratelimit::test_add_batch_missing_rule_id` fails intermittently on
`main` as well; unrelated to this branch.

---------

Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
Co-authored-by: hengfeiyang <hengfei.yang@gmail.com>
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
2026-07-31 07:23:22 +00:00
Huaijin 015a20a184
refactor: remove HTTP API compatibility re-exports (#13560)
## Summary

- remove the HTTP request/model compatibility re-exports introduced by
the domain crate split
- migrate router, OpenAPI, enterprise action-server, and
integration-test callers to the crates that own each API
- remove the ingest Content-Type re-export and declare the direct
root/test dependencies
- delete the pure `handler::http::models` facade module

## Validation

- `cargo fmt --all -- --check`
- `cargo clippy -p openobserve-api-ingest -p openobserve-api-http -- -D
warnings`
- `cargo test --test integration_test --no-run`
- `cargo check -p openobserve --no-default-features`
- enterprise overlay: `cargo test --offline --workspace
--no-default-features --features enterprise --no-run`
- cloud overlay: `cargo test --offline --workspace --no-default-features
--features cloud --no-run`
- `git diff --check`
2026-07-31 06:24:43 +00:00
Prabhat Sharma 16bab4c700
fix(metrics): heatmap step unit, explorer back nav, gauge percentiles (#13557)
Four fixes to the metrics explorer and the panel editor it drills into.
Each was reproduced from a real symptom; the root causes turned out to
be independent.

## 1. Histogram heatmap rendered as a single column in the panel editor

The same panel that renders correctly as a card collapsed to one
full-width bar per bucket once opened in the editor.

`usePanelPromQLExecutor` computed the heatmap step with `range / 1000`,
but `startISOTimestamp`/`endISOTimestamp` are **microseconds**:

- `plugins/metrics/Index.vue:294` documents `getConsumableDateTime()` as
microseconds, and line 424 builds `meta.dateTime` from it with no
conversion
- `usePanelDataLoader.ts:412` preserves that magnitude via `.getTime()`
- `usePanelVariableSubstitution.ts:410-412` reads the *same pair* and
correctly divides by `1_000_000`

So every range looked 1000x longer. A 15m window asked for a **7500s
step over a 900s range** → Prometheus returned one sample per series →
one bar per `le`. Now `max(15, ceil(900/120))` = 15s / 60 columns,
matching the card.

Only heatmaps were affected — every other chart type sends `step: "0"`
and lets the backend choose.

**Why this shipped:** the spec asserted the same wrong unit, feeding
`1_700_000_000_000` ms — a value the loader never produces — so it
passed while the product was broken. Rewritten to microseconds, with an
assertion that no range collapses below 10 columns.

## 2. Browser Back from Visualize landed on Logs

Explore → Visualize is entirely in-page (`mode` is a query param on the
`metrics` route; `MetricsVisualize.vue` is a child component, not a
route). The only URL write was `router.replace`, so the Explore entry
was **overwritten** rather than stacked, and Back popped straight past
the page to whatever preceded `/metrics` — reliably `/logs`, since
`logsUtils.ts:519-528` pushes an entry per search.

Mode transitions now `push`; filter, time-range and refresh edits still
`replace` so they don't stack history. `syncVisualizeUrl` stays
`replace` — it fires repeatedly while editing. The existing mode-only
fast path (`MetricsExplorer.vue:1388-1392`) restores Explore without
re-querying the ~40 cards.

## 3. Gauge percentiles all drew the same line

`quantile(φ, v)` aggregates **across the series** present at each
timestamp, so on a single-series gauge p50, p90 and p99 all return that
series' own value — three identical lines under three legends.

Switched to `quantile_over_time` — percentiles over **time**, which is
the question a gauge actually poses and which stays meaningful at any
series count. (Histogram percentiles still use `histogram_quantile`,
correctly untouched.)

Two constraints shaped this:

- **A range vector may only be taken over a selector.** The NaN-guard
retry rewrites the gauge selector into `(x and x > -Inf)`, and
`(...)[5m]` doesn't parse — the hazard already documented at
`metricDefaults.ts:586`. The subquery escape is closed too:
`src/promql/src/engine.rs:281-299` rejects a non-matrix inner
expression. So these queries are built from an unguarded `rawSel`. The
guard still applies to every variant that can carry it (asserted in
tests).
- **The rate window is the wrong size for a quantile.** Sized for
`rate()`'s two-sample need, it gave a 15m view a 1m window — four
samples, where p90 (index 2.7) and p99 (2.97) interpolate between the
*same* top pair and draw as one line. Percentiles now get
`computePercentileWindow`: floored at the rate window (no gaps between
evaluation points), targeting `MIN_PERCENTILE_SAMPLES (20) × scrape`,
capped at `range / 4` (a wider window flattens the chart).

| Range | Rate window | Percentile window | Samples |
|---|---|---|---|
| 15m | 1m | **3m45s** | 4 → **15** |
| 1h | 1m | **5m** | 4 → **20** |
| 6h | 3m15s | **5m** | 13 → **20** |
| 24h | 12m15s | 12m15s | 49 (unchanged) |
| 7d | 1h24m15s | 1h24m15s | 337 (unchanged) |

At long ranges the floor dominates and nothing changes, correctly.

A new `$__percentile_interval` panel variable mirrors all three bounds
so the card and the panel it drills into resolve to the **same** window
— asserted directly against `computePercentileWindow`. Without it the
editor would have kept the narrow window and disagreed with the card,
the exact failure `computeRateWindow`'s own doc warns about.

## 4. Multi-series charts were monochrome

`fixed` resolves to `fixedColor[0]` for *every* series name
(`colorPalette.ts:279`), and these charts carry no legend — so
Percentiles and Min/Max drew every line in one colour: indistinguishable
when they differ, and invisible when they coincide.

Now `palette-classic-by-series` above one series; a single series keeps
the card's accent colour. Hashing the series *name* keeps `p50` the same
colour across re-renders and between the preview tile and the card.
Verified `p50`/`p90`/`p99` and `min`/`max` land on distinct palette
slots in both light and dark themes.

Heatmaps are unaffected — `convertPromQLHeatmapChart.ts` uses a
hardcoded `HEATMAP_VISUAL_MAP_COLORS` and never reads `config.color`.

## Testing

- **15,743 passed, 0 failed** across `src/utils`, `src/composables`,
`src/plugins/metrics`, `src/components/promql`,
`src/components/dashboards`
- `vue-tsc` and `eslint` clean on every changed file
- New coverage: heatmap step at five ranges, mode-transition
push/replace, percentiles-over-time shape, the NaN-guard/range-vector
interaction, window bounds (including the invariant that the percentile
window is never narrower than the rate window, across ranges and scrape
intervals), card↔panel window agreement, and four colour-mode cases

Two existing assertions were corrected: one pinned the old `quantile()`
string, and the router mock's `push: vi.fn()` returned `undefined` where
the real router returns a Promise.

## Not addressed

A separate issue found while reviewing: `cache_hit_ratio` displays as
~8500%. `metricDefaults.ts:236` infers any `*_ratio` name as `percent-1`
(0.0–1.0), whose formatter multiplies by 100, but that producer emits
0–100. The inference follows Prometheus/Grafana convention, so the fix
is arguably in the emitter — and `FnOverride` currently has no unit
field, so there's no UI escape hatch. Left out deliberately pending a
decision.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-31 02:49:18 +00:00
Loakesh Indiran a90bdea831
fix(synthetics): name the degradation instead of calling it flaky (#13556)
QA reported an alert that contradicts itself:

```
🟡 EU - TLS cert passed only after retries (flaky)
   Error: certificate expiring soon
```

Flaky means the check already fixed itself and there is nothing to do. A
certificate running out will not fix itself. The headline is the part
that gets read first, and it was pointing the reader away from the
problem.

Notification half. Pairs with synthetic-o2-agent#8 and
o2-enterprise#2312 — each is inert without the others, since the reason
has to travel probe → control plane → notification.

### What changed

`CheckNotification` carries `status_reason`, so the Slack subject and
the headline can name the condition:

| | before | after |
| --- | --- | --- |
| subject | `🟡 <name> is DEGRADED` | `🟡 <name> — CERTIFICATE EXPIRING
SOON` |
| headline | `is reachable but degrading` | `the TLS certificate is
expiring soon, renew it before it lapses` |

The subject matters most: it is what decides whether anyone opens the
alert. `CERTIFICATE EXPIRING SOON` gets renewed; a generic `DEGRADED`
gets skimmed.

Falls back to the existing generic wording when the probe sends no
reason, so an older probe reads exactly as it does today.

Covers `sftp_degraded` too — an SSH check that connects and
authenticates but whose SFTP probe fails was mislabelled the same way,
just never reported.

### Verified live

Local o2 built from these branches, against `eu1.openobserve.ai` (28
days left, 30-day window):

```
🟡 LCL - TLS cert expiring — the TLS certificate is expiring soon, renew it before it lapses
   Target: eu1.openobserve.ai
   Error:  certificate expiring soon
```

Record: `status=warning`, `attempts=1`. That number is the point — the
reported alert required attempts > 1 to claim retries had happened.

Binary confirmed to contain the new strings before testing, so the
result is not a stale build.

`cargo check` clean on both OSS and enterprise.
2026-07-31 02:35:06 +00:00
Prabhat Sharma 620873d897
fix: dashboard back button should follow history, not the folder in the URL (#13555)
## Problem

Favorite a dashboard, open it from the **Favorites** folder, then click
the back button in the dashboard header — you land in the folder the
dashboard *lives in*, not back in Favorites.

## Root cause

`goBackToDashboardList` in `web/src/views/Dashboards/ViewDashboard.vue`
never used history. It rebuilt the list route from the URL:

```js
router.push({ path: "/dashboards", query: { folder: route.query.folder ?? "default", ... } })
```

When a dashboard is opened from Favorites, `Dashboards.vue:1134` sets
`folder` to the dashboard's **real** folder (`row.folder_id`) — the
Favorites pseudo-folder (`__favorites__`) has no backend list, so the
view needs a real folder id to fetch. The back button then read that
same value back out and navigated to the owning folder.

## Fix

`goBackToDashboardList` now calls `router.back()` when the previous
history entry is the dashboards list, and keeps the folder-scoped push
as the fallback:

- **Deep links / fresh tabs** — no previous entry, so the push fallback
applies. Unchanged behavior.
- **Arrived via `add_panel` or another module** — push fallback, so the
button labeled "Dashboards" never lands somewhere it doesn't name.
- **In-view URL syncs** (tab, time range) use `router.replace`, so they
don't bury the list history entry — one step back still reaches the
list.

Returning to `/dashboards?folder=__favorites__` restores the Favorites
view via the existing landing rule at `Dashboards.vue:867`.

## Tests

Added history-hit and history-miss cases to `viewDashboard.spec.ts`, and
wired `options.history.state` plus a shared `back()` spy into the router
mock, which previously exposed neither.

`86/86` pass in `viewDashboard.spec.ts`; `vue-tsc --noEmit` clean.

## Verification steps

1. Favorite a dashboard that lives in a non-default folder.
2. Navigate to the **Favorites** folder and open that dashboard.
3. Click the back button in the dashboard header.
4. You should return to **Favorites** (previously: the dashboard's own
folder).
5. Regression check: open a dashboard by direct URL in a new tab, click
back — should still land on that dashboard's folder.
2026-07-31 02:34:21 +00:00
Loakesh Indiran db2f0fb62a
feat(synthetics): attempts view, evidence panel, alerting, concurrency (#13543)
> Note: this branch carries ~20 earlier synthetics commits. The alerting
piece is ~280 lines and is cleanly separable if a smaller review is
wanted.

## Backend — alert state

- **Four columns on `synthetics`** (`consecutive_failures`,
`last_alert_at`, `alerting`, `degraded_notified_at`), all defaulted so
no backfill is needed
- **`DB_SCHEMA_VERSION` 55 → 56** — without the bump the migrator
short-circuits and the columns never appear
- Guarded with `has_column` on **both** `up` and `down`: SQLite ignores
`IF NOT EXISTS` on `ADD COLUMN`, and sea-query has no
`drop_column_if_exists`
- **Compare-and-swap** accessor (`update_alert_state_if`), because the
caller does a read-modify-write with no transaction
- `alerting` is a separate column because without it "recovered" cannot
be told from "was never alerting"
- **`synthetics_jobs::failing_locations`** — the notification previously
had no location field, so a six-location check with one broken region
could only say "the check is failing"
- Distinct subject, headline, icon and colour for FLAKY vs DEGRADED,
from one shared `locations_line` helper so Slack/plain-text/email cannot
drift

> The table is `synthetics`, not `synthetics_monitors` — the entity
module is named after the concept but declares `table_name =
"synthetics"`. Recorded at the `DeriveIden` so the next column addition
doesn't repeat it.

## Performance — the step-stats panel fetched ~35 MB per load

- **`recorded_steps` out of the 5000-row tally** — ~60% of payload; ~4
KB/row and near-identical within a config version, so an *aggregation*
re-read the same definitions 5000 times. Still written to every record,
because steps are editable and history must not be rewritten
- **`retry_history` out too**, replaced by `retry_step_ids` scanned only
over rows that actually retried
- **Key step stats by `step_id`, not name** — a rename *split* one step
into two rows; two steps sharing a name *merged* into one
- **Single-pass sparklines** — was O(steps²) per row: ~2M iterations for
a 20-step journey, ~50M for a 100-step one
- **Flaky Rate could exceed 100%** — the fallback incremented the
numerator without the denominator
- **Label the real window** — a 1-min check × 2 locations × 4 device
combos is 11,520 executions/day, so a 5000-row cap is ~10 hours of a
"last 7 days" selection

## UI

- **Attempts selector** in the run detail — costs no request,
`retry_history` is already on the row. Labels are 1-based while
`attempt` is 0-based
- Each attempt resolves **its own** screenshots, trace and evidence
bundle; a superseded attempt's live under an `attempt-N-` key
- **Evidence panel** — parses `evidence.ndjson` per line, grouped by
kind in severity order, chips with counts visible at zero, third-party
dimmed not dropped, truncation stated, four distinct empty states
- **Flaky / Degraded / Unstable tiles.** Unstable is partitioned by
`(location, device, engine)` — aggregated, a check solidly broken in one
region and healthy in five is indistinguishable from one intermittently
broken everywhere. It measures *oscillation*, not badness
- All flakiness tiles read **"—" at `retries = 0`**, never `0.0%`: a
zero is a measurement, that is the absence of one
- **`init_ms` and queue delay shown separately** — init was observed at
113,131 ms on a cold Lambda against a 243 ms check; unsubtracted, Lambda
locations look permanently slower at every percentile
- Steps and Evidence are sibling `OTabPanel`s — stacking them broke the
drawer's scroll

## Bugs fixed during review

- **`mapRetryHistory` guarded on `Array.isArray`** but the search API
returns blob columns as JSON *strings*, so it returned `[]` for every
real record — the attempts strip could never render, whatever the query
selected. Same bug in `mapEvidence` and the `attempts` fallback
- **`mapRunDetail` read `rawHit.attempt`**; the field is `attempts`. The
count was 0 on every run
- **`mapRetryHistory` hard-coded `status: "failed"`** — reported a flaky
run's *passing* final attempt as a failure
- **A superseded attempt's failing step rendered as a pass** — the
compact timeline's `passed`/`failed` was never normalised to the
`ok`/`fail` vocabulary every consumer tests for
- **`failure_detail` is not a column** — OpenObserve flattens nested
objects; naming the object is rejected by the search API
- **The retry-attribution query named `status_reason` unconditionally.**
That field only exists once something has been a `warning`, and the
three step queries share a `Promise.all` — so one missing column emptied
the *entire* Steps tab
- **`fetchSchemaFields` returned an empty Set on failure**, which the
gate reads as "the schema has no fields", silently degrading every
optional column to a literal

## Lease correctness (added after review of the concurrency docs)

Two corruption-class bugs, both of which double-count the alert failure
streak this PR introduces. Included here rather than in the concurrency
branch because they undermine *this* PR's own feature.

- **The browser lease validation was per-device while the work is
per-job.** `validate_browser_config` computed `(retries+1) x
journey_budget + retries x wait` against the 900s lease, but the probe
runs `browser_devices` **sequentially inside** the leased job and
nothing multiplied by the combo count (`MAX_BROWSER_DEVICE_COMBOS =
12`).

At defaults a plain **desktop + mobile** config is 1210s of work;
validation computed 605s and allowed it. It then blew its 900s lease on
**every run, forever**, with nothing failing at save. At the cap: 7260s
vs 900s.

The consequence is verbatim what the `LEASE_SECS` comment in
`dispatcher/mod.rs` was written to prevent — *"the reaper would requeue
mid-run and the journey would EXECUTE AGAIN — duplicate result records,
multiplied browser cost, and false alerts caused by the reliability fix
itself."* All three happen at two devices today, on the **managed**
path, since browser is Lambda-only.

> ⚠️ **Breaking validation change.** An existing 2+ device check will
fail to save until its budget, retries or combo count comes down. The
error now names the combo count as a lever. **Query dev and prod for
`json_array_length(config->'browser_devices') > 1` before rolling out.**

- **`ack_complete` had no status guard** — `WHERE id = $4` alone. A
duplicate ack succeeded and the caller called `increment_jobs_done`
again for one job: `jobs_done` overshot `job_count`, the run was
declared complete on a **partial set**, and since this PR it also
advanced `consecutive_failures` twice, so `alert_if_fails: 3` fires
after two failures.

Reachable today with no concurrency work: `aws-sdk-lambda` retries a
timed-out `RequestResponse` invoke, re-executing a check already in
flight.

`AND status = 1` makes it idempotent, and `ack_complete` now returns
`None` when it did not apply so the caller skips run accounting. Reaper
reassignment is covered too, as of the concurrency work merged below:
`AND claimed_by = $5`.

## Verification

`vue-tsc` + `type-check:app` clean · Prettier + design ratchet clean ·
**711 synthetics tests** · `cargo check` clean · infra unit tests pass.

Migration applied against a real SQLite metastore: `55 → 56`, all
columns correct. Every drawer field verified against live ingested data.

> `MonitorResults.spec.ts` fails on a pre-existing `storage.ts` import
error — identical on a stashed tree.

## Requires

o2-enterprise#2305 — the `AlertDecision` this consumes. Merge together;
each is inert alone.

---

# Also merged: probe concurrency (#13552)

The concurrency branch merged into this one, so this PR now carries
both. Everything below is a correctness fix — none of it is throughput
work.

### The reaper never completed a run, on any path

A run completes when `jobs_done` reaches `job_count`, and only three
callers move that counter: the probe ack, the dispatcher's failed
invoke, and the reaper. The reaper never did — including in
`dead_letter_expired`, whose whole purpose is to report "we gave up
after N attempts". It wrote a customer-visible error record into a run
that then stayed open forever: no `completed_at`, and since this PR's
alerting work, no `resolve_alert` either, so the failure streak never
moved.

The mechanism was `requeue_expired` ignoring `valid_until`. It reset a
job to Pending, and `prune_stale` — later in the **same tick** — deleted
it again, because `valid_until` is one interval (60s for a 1-minute
check) while a lease is 300s+. `dispatch_attempts` never got past 1, so
`MAX_DISPATCH_ATTEMPTS` was unreachable and the retry budget was dead
code on the agent path.

- requeue only while the job is still inside its window
- dead-letter everything that can no longer report, tagged with why
(attempts exhausted / window closed / never dispatched), so a dead agent
does not read as an unresponsive probe
- per-row compare-and-swap instead of SELECT-then-bulk-UPDATE; the
reaper runs on every alert_manager node
- increment **before** the ingest-token lookup, which returns early when
an org has no token

### Every check's retry sequence is bounded by the lease

Retries run *inside* the leased job. The browser path has checked this
since `journey_budget_ms`; the protocol path never did, and its inputs
were unbounded — the `5_000..=300_000` bound on `timeout_ms` is
**browser-only**, so a TCP check with `timeout_ms: 3_600_000` saved
fine.

With the reaper now completing runs, the symptom is a wrong answer
rather than a stuck one: the lease expires mid-flight, the reaper
completes the run as Error, and the probe's real ack is rejected as
stale. A passing check reports Error permanently. `timeout_ms=100s,
retries=2, wait=30` needs 360s and hits it.

`lease_batch` treats the client's `lease_secs` as a floor request, not
the decision — both probes guess 300s, and a probe cannot know how long
its own job may take.

### Guards on the ack, and on dispatch failure

`AND claimed_by = $5` closes reaper reassignment. `claimed_by` is
`Option` during rollout: an older probe acks without it and falls back
to the status guard, so nothing is discarded.

`fail_dispatch` had no status guard, so a dispatch judged failed *after*
the probe acked reset a **completed** job to Pending — leased again, run
again, duplicate result, `increment_jobs_done` twice. Third outcome
`AlreadySettled` added; the dispatcher matches all three with no
wildcard, so handling it is compile-time enforced.

`limit` is now clamped before the query. It is cast to `u64`, where a
negative wraps to ~1.8e19 and the LIMIT goes unbounded — one agent
leasing the entire pending queue for its pool. Reachable from one
mistyped env var.

### Queue backlog is observable

`synthetics_pending_jobs` and `synthetics_oldest_pending_age_seconds`,
per location and pool. `started_ts - scheduled_ts` already covered work
that *ran*; a check nobody leased produces no record, so its backlog was
invisible by construction.

## Before merging this to main

The `timeout_ms` validation is a **breaking change for existing protocol
configs**. It runs at create/update only, so nothing stops on deploy —
but an over-budget check is rejected the next time someone edits it.
Expected result is zero rows: `retries` of 0 or 1 cannot trip the budget
rule at all, and the defaults sit at 10,000ms, 1.1% of the lease.

```sql
WITH c AS (
  SELECT id, org_id, name, synthetics_type,
         COALESCE((config->>'timeout_ms')::bigint, 10000)           AS t,
         COALESCE((settings->>'retries')::bigint, 0)                AS r,
         COALESCE((settings->>'wait_before_retry_secs')::bigint, 5) AS w
  FROM synthetics WHERE synthetics_type <> 'browser'
)
SELECT id, org_id, name, t AS timeout_ms, r AS retries, w AS wait_s,
       (r + 1) * t + r * w * 1000 AS worst_case_ms
FROM c
WHERE t NOT BETWEEN 1000 AND 300000
   OR (r + 1) * t + r * w * 1000 > 900000
ORDER BY worst_case_ms DESC;
```

Anything returned is a check that **cannot work today either** — fix the
config rather than loosening the rule.

## Not done

Wiring the reaper's now-completing runs to a **notification**. The send
lives in a crate that depends on `o2_enterprise`, so the reaper cannot
call up into it; it needs the send moved into
`openobserve_core::synthetics` or a queue. No regression — before this,
the run never completed, so there was no notification either.

---------

Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: omkark06 <omkar@zinclabs.io>
Co-authored-by: Harsh Mahajan <115500013+007harshmahajan@users.noreply.github.com>
Co-authored-by: Yashodhan Joshi <yashodhan@openobserve.ai>
Co-authored-by: Yashodhan Joshi <yjdoc2@gmail.com>
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 01:00:19 +00:00
Huaijin 6856e0cfc4
chore: write o2 file meta into vortex files (#13553)
Refs #13398

## What

Parquet files carry `min_ts` / `max_ts` / `records` / `original_size` as
footer key/values (`new_parquet_writer` on write, `FileMeta:
From<&[KeyValue]>` on read). Vortex files carried nothing. Vortex 0.81.0
added user metadata segments (vortex-data/vortex#5813), which our pinned
rev has, so the merge path now writes the same four fields into an
`o2_file_meta` segment.

- `config::utils::parquet`: `VORTEX_FILE_META_KEY` +
`encode_vortex_file_meta`
- `merge::write_vortex`: attaches the segment (the only place vortex
files are produced)

While in the same code, a related gap on the parquet side:
`write_downsampled_parquet` calls `append_metadata` when it rolls over
to a new file, but closed the **final** file without it, so the last
file of every downsampling run shipped with an empty footer (`min_ts` /
`max_ts` / `records` / `original_size` all read back as 0). Fixed here
too. file_list was unaffected — the correct meta is returned via
`MergeParquetResult::Multiple.file_metas` — the file itself just was not
self-describing.

## Notes

- Vortex metadata segments belong to the write options, so they must be
known before the first row is written and cannot be appended at close
time the way `append_metadata` does for parquet. `records` may therefore
drift from the rows actually written — a reader that needs the exact
count should take it from the footer `row_count()`, which is always
accurate.
- **Write side only.** There is no consumer for the read side today:
`read_metadata_from_file` only ever sees WAL `.par` files (parquet),
`read_metadata_from_bytes` has no callers, and the only reader of
storage-file metadata is the `load_file_list_from_s3` recovery CLI via
`storage::get_file_meta`, which is still parquet-only (unchanged,
already failed on `.vortex` before this PR). Making that path
format-aware needs a vortex dependency in `infra` and is better done
when it is actually wanted.
- For the same "must be known up front" reason,
`write_downsampled_vortex` cannot carry the segment: it splits output by
accumulated size and only knows each file's meta once that file is
closed. Would need a different split strategy (or buffering batches per
output file) — not attempted here.

## Test

- `test_encode_decode_vortex_file_meta` — payload shape, unknown/missing
keys tolerated
- `test_write_vortex_carries_file_meta` — drives the real
`write_vortex`, reopens the buffer with `include_metadata()` and reads
the segment back
- `test_write_downsampled_parquet_writes_metadata_for_every_file` —
1-byte file-size limit so each batch becomes its own file, then compares
every buffer's footer against the returned `FileMeta`. Verified it fails
without the downsampling fix (footer `min_ts` 0 vs expected 3000). This
one is behind the `enterprise` feature, so it was run against the real
enterprise workspace, not the OSS stub.
2026-07-30 13:35:19 +00:00
ktx-kirtan 6fb5eecdc5
fix: table migration to same components (#13451)
## What changed

Every table in the app now renders through the shared `OTable`
(`web/src/lib/core/Table`). Both legacy implementations are deleted —
`components/TenstackTable.vue` and `plugins/logs/TenstackTable.vue` —
for ~7,000 lines removed and one table behaviour contract instead of
three.

To get there, `OTable` gained: per-cell hover actions, per-column value
filter, opt-in column close "x", sticky pivot total row + right-pinned
total columns, pivot row-field cell merge, caller-owned pagination bar,
delegated scroll (shared scroller with the logs/traces histogram), and
variable-height virtual rows.

## Tables migrated

| Area | Tables |
| --- | --- |
| Logs | Search results grid, correlated logs |
| Traces | Search results grid, trace details sidebar (attributes /
events / links), service graph node panel |
| Dashboards | Table panel + pivot (incl. PromQL), panel pagination
controls, import error panel |
| RUM | Session player traces tab |
| Pipelines | Scheduled pipeline SQL preview |

## Tested

775 unit tests across the 12 affected suites, all passing.

Manually verified per table:

- **Columns** — resize, reorder, close "x", persistence across reload;
horizontal scroll on wide grids; wrap toggle with virtual scroll; client
+ 3-state server sort
- **Pagination** — custom records-per-page, enabling pagination after
mount, scroll-to-top on page change, no duplicate pager
- **Logs** — FTS highlighting, row expand → JSON preview and its
actions, hover actions + AI button, cell font / row density / status
spine, histogram pinned while scrolling sideways
- **Dashboards** — pivot multi-level headers, cell merge, sticky totals
staying aligned under horizontal scroll, cell colouring (auto palette /
value mapping / conditional rules / overrides), CSV + JSON export
- **Traces, RUM, Pipelines** — events table expansion, error-row border,
row click → trace navigation, SQL preview copy + send-to-AI

---------

Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
2026-07-30 12:18:23 +00:00
Hengfei Yang 044f561506
feat(evaluation): eval scheduler metrics, window-split executor, completion-window defaults and guard rails (#13457)
Design at: docs/online-evaluation-scheduler-queue-review.zh-CN.md 

OSS-side half of the phase-2 eval scheduler rework (paired o2-enterprise
PR linked in comments). The enterprise PR depends on the metrics and the
executor contract below, so **this PR must merge first**.

## Scheduler metrics

- `eval_scheduler_pending_targets` (gauge, org/stream) — in-flight
pending targets tracked across scheduler passes
- `eval_scheduler_pending_memory_bytes` (gauge, state=used|limit) —
accounted pending memory vs the configured budget
- `eval_scheduler_forced_ready_total` (counter, org/stream) — targets
force-evaluated early by budget enforcement
- `eval_scheduler_watermark_lag_seconds` (gauge, org/stream) — scan
cursor vs committed (persisted) watermark

Registration only; all emission sites live in the enterprise scheduler.

## Detection-search executor: window-split support

The scheduler's search executor no longer errors on an oversized scan
window. It reports `over_limit` against the shared 100k-row cap
(`EVAL_DETECTION_MAX_ROWS`) so the scheduler can split the window in
half and re-query, and fetches a capped result set only when the
scheduler explicitly allows truncation at the minimum window width. This
removes the previous failure mode where a high-throughput window could
never be fetched and retried forever.

## Completion-window defaults, floor, and hard caps

Eval jobs that don't set their own trace/session completion windows now
take deployment-wide defaults, declared in the enterprise
`LlmEvaluationConfig` (`O2_EVAL_TRACE_IDLE_TIMEOUT_SECS`=30,
`O2_EVAL_TRACE_MAX_AGE_SECS`=1800,
`O2_EVAL_SESSION_IDLE_TIMEOUT_SECS`=1800,
`O2_EVAL_SESSION_MAX_AGE_SECS`=14400 — the design-spec baselines) and
injected into infra at startup via `init_completion_window_defaults`
(same registration pattern as the search executor). Sessions span user
think-time between traces, hence the much wider session idle window.

- **Floor**: the old 45s idle-window minimum is dropped to 1s. Readiness
fires only once the observed ingest-time silence reaches the window, so
sub-poll-interval windows are correct — their firing is merely quantized
to the next scan pass.
- **Hard caps** (`validate_trace_completion_window` /
`validate_session_completion_window`, applied identically to
job-supplied values and env defaults): trace idle ≤ 30m / max age ≤ 2h,
session idle ≤ 4h / max age ≤ 24h. Max age bounds pending-target memory
residency, committed-watermark lag, and restart rescan, so it is a guard
rail, not an operator preference. The enterprise config parser calls the
same validators and **fails startup** on out-of-range env values (0
still means built-in default).
- **Frontend** mirrors all of it: per-scope defaults and limits in
`completionWindow.ts`, zod schema validation, input `max` attributes,
and validation messages across all 15 locales.

## Legacy eval-buffer cleanup (OSS side)

Removes the pipeline-update eviction call into the legacy
trace-buffering LLM evaluation node (`LlmEvaluationNode::remove_buffer`)
— a no-op on an always-empty registry since span evaluation moved to the
eval-task queue. The paired enterprise PR deletes the legacy module
itself.

## Module split

`infra::table::online_eval_jobs` splits into submodules —
`completion_window.rs` (defaults, guard rails, trace/session configs)
and `span_selector.rs` — with re-exports keeping every external path
unchanged.
2026-07-30 10:34:42 +00:00
Hengfei Yang 0150472981
fix: default /prometheus/api/v1/series time range to last 24h when omitted (#13550)
Design at: #13120

Fixes #13120

## What changed

- `get_series` (src/core/src/metrics/prom.rs) now normalizes the time
range before building the search request: a missing/zero `start`
defaults to `end - 24h`, a missing/zero `end` defaults to now. The
lookback is a plain constant (`DEFAULT_SERIES_LOOKBACK_MICROS`), no new
env knob.
- Route docs for `/api/v1/series` (utoipa params + x-o2-mcp description)
no longer claim start/end are required.

Backend-only fix: the web UI keeps sending explicit `start`/`end` and is
not touched.

## Why

Per the Prometheus HTTP API spec, `start`/`end` are optional for
`/api/v1/series`. The handler defaulted a missing `start` to `0`, which
`infra::file_list::validate_time_range` rejects (`start == 0` is its
unset sentinel), so every time-range-less `/series` call returned HTTP
500 `[file_list] invalid time range`.

24h was chosen over an unbounded range (Prometheus' own default) because
`/series` here is a columnar scan over object storage, not a local TSDB
index lookup — full-retention scans are exactly what
`validate_time_range` exists to prevent. 24h keeps low-frequency metrics
(daily jobs) discoverable while bounding cost, and matches the fallback
the dashboard frontend already uses client-side.

Scope note: `/labels` and `/label/{name}/values` are intentionally
untouched — they resolve via stream-stats intersection where `start=0`
already means "all data", which is spec-correct.

## Testing

- 3 new unit tests for `normalize_series_time_range` (default lookback,
default end, explicit values untouched) — pass.
- `cargo check -p openobserve-core -p openobserve-api-search` — clean.
2026-07-30 08:37:09 +00:00
Ashish Kolhe 2cc39d0fff
fix: persist canonical gen_ai agent env/version on UDS streams (#13544)
## Problem

AI Observability pages fail with `Search field not found: Schema error:
No field named gen_ai_agent_version` when an agent version/env filter is
applied (cascade picker), on any LLM trace stream that has a
user-defined schema.

Root cause chain (verified live on introspect against
`sre_agent_traces_production_eu`):

1. The agent registry resolves env/version from source attributes (e.g.
`service_service_version`) and writes canonical `gen_ai_agent_env` /
`gen_ai_agent_version` onto the span at ingest.
2. On UDS streams, `refactor_map` rebuilds the record keeping only
`defined_schema_fields` — which never included the two new columns — so
they are silently dropped.
3. `restore_canonical_agent_fields` exists to protect canonical agent
fields from exactly this, but only restored name/id (env/version
write-back landed later and this path was never extended).

Result: the registry advertises env/version variants, the picker offers
them, but the column never reaches the stream schema — every filtered
query errors.

## Fix

- **`GEN_AI_SCHEMA_FIELDS`** now includes `gen_ai_agent_env` and
`gen_ai_agent_version`. This provisions the columns (Arrow schema + UDS
field list) through the existing paths:
- existing LLM streams: `ensure_gen_ai_fields_in_schema` runs on every
OTLP ingest → self-heals on the next span, no migration needed
  - new streams: `set_stream_is_llm` provisions on first LLM detection
- merging into the Arrow schema alone already stops the hard query error
(old spans read as NULL)
- **`restore_canonical_agent_fields`** now carries env/version via a
`CanonicalAgentFields` struct and re-inserts them after the UDS refactor
— covers the first-ever LLM batch, where the stream is only marked LLM
at end of request so the UDS list doesn't have the fields yet.

## Testing

- New unit test
`test_restore_canonical_agent_fields_restores_env_and_version`
- Extended
`test_append_gen_ai_fields_to_defined_schema_fields_adds_migrated_fields`
for the two new fields (kept the `_o2_ingest_ts` assertion from #13506)
- `cargo check` / `clippy` / `fmt` clean on `openobserve-core` and `db`
- End-to-end write-back verified against a live enterprise build: probe
span with `service.version` + `deployment.environment.name` resource
attrs came back queryable as `gen_ai_agent_version` / `gen_ai_agent_env`
2026-07-30 07:52:57 +00:00
Bhargav 8b8406282f
fix: Rum side filter (#13542) 2026-07-30 04:54:43 +00:00
Huaijin dc83ec2947
perf: use Zstd only for long Vortex text (#13545)
## Summary

- replace the all-UTF8 `Utf8Compressor` policy with a configurable
`LongTextCompressor`
- apply direct Zstd only to sufficiently large, long UTF8 chunks;
delegate short UTF8, binary, and other types to BtrBlocks
- use the same compressor for dictionary-layout probing so long text
falls back to direct Zstd instead of being wrapped in a dictionary
layout
- keep Zstd and detection defaults together in
`LongTextCompressionOptions`
- use Zstd level 1 by default after benchmarking levels 1, 3, and 6

Default detection thresholds are an average length of 64 bytes, a
long-value length of 64 bytes, an 80% long-value ratio, and at least 64
KiB of UTF8 payload per chunk. Zstd uses level 1 with 8192 values per
frame.

## Validation

- `cargo fmt --check`
- `cargo test -p search datafusion::vortex::tests` (7 passed)
- `cargo build --features mimalloc --profile release-profiling`

The integration test verifies that a long `body` field is written as
`vortex.zstd` without `vortex.dict`, while a short low-cardinality `tag`
field retains `vortex.dict`. A regression test pins the default Zstd
level and values-per-frame settings.

## Benchmark

Local macOS arm64, 12 threads, fixed 280-file Parquet input containing
460,748,144 rows (14.905 GiB). Query figures are sums of per-query
medians over three runs using the same `openobserve-main` reader binary.

### Dictionary-layout change

| Metric | body Dict + Zstd L3 | body direct Zstd L3 | Delta |
| --- | ---: | ---: | ---: |
| Vortex size | 16.965 GiB | 16.286 GiB | -4.00% |
| conversion wall time | 93.83s | 77.28s | -17.6% |
| query median sum, pushdown=false | 10.845s | 9.959s | -8.17% |
| query median sum, pushdown=true | 10.712s | 9.837s | -8.17% |

### Zstd level selection

| Metric | L1 | L3 | L6 |
| --- | ---: | ---: | ---: |
| conversion wall time | 73.40s | 77.28s | 143.70s |
| conversion throughput | 6.277M rows/s | 5.962M rows/s | 3.206M rows/s
|
| average CPU | 1088% | 1093% | 1132% |
| peak RSS | 3.802 GiB | 3.813 GiB | 4.128 GiB |
| Vortex size | 16.360 GiB | 16.286 GiB | 13.703 GiB |
| query median sum, pushdown=true | 9.608s | 9.832s | 9.010s |

Compared with L3, L1 converts 5.0% faster while increasing size by only
0.46%; query performance is effectively unchanged. L6 reduces size by
15.86% but increases conversion time by 85.95%, so L1 is the better
general-purpose default.

Benchmark runtime used `ZO_COMPACT_ENABLED=false`, result cache
disabled, pushdown enabled for the level comparison, and
Tantivy/inverted indexing disabled.
2026-07-30 04:47:30 +00:00
1185 changed files with 107765 additions and 19492 deletions

View File

@ -333,6 +333,16 @@ considering the UI done:
from `<div>` + utility classes. Classes are for layout only.
- [ ] Tabular data uses `OTable` with `OTableColumnDef[]` columns; server mode
only for backend-paginated data.
- [ ] **Server mode was checked against the backend**: every `sortable: true`
column has a real sort key in the handler (an unknown key falls back
silently and orders by something else), and any page-relative device
(`ODataBarCell` bars, a `#subheader` count strip) is on a **client**-paginated
table only. No hand-rolled `#` index column (`show-index`), no positional
`columns.splice`, and column `size` fits the header + sort chevron.
See [core-controls-table](references/core-controls-table.md).
- [ ] A figure/label that already exists on another screen reuses **that screen's
formatter and i18n key** (promote a component-local formatter into
`utils/formatters.ts` rather than copying it).
- [ ] Listing page uses the **full-height flush skeleton** (root
`flex flex-col h-full p-0`, header `shrink-0 border-b` — OPageHeader bakes
in its own `px-page-edge`, never add a `px-*`, table wrapper

View File

@ -52,10 +52,36 @@ All token-backed and dark-mode-safe. Reuse these before inventing anything.
`badgeGroups.ts` (`alertStatus`, `alertType`, `severity`, `streamType`,
`userRole`, `serviceStatus`, …). One registry → the same value is the same
colour everywhere. Need a new family? Add a group there, don't hand-roll a pill.
Check the group covers **every** value the API can return (a missing key falls
back to a generic chip) — and don't set `size` on the group: a group-level size
silently overrides the call sites, so its chips end up a different size from the
Status chip beside them. Pass `size` at the call site, matching its siblings.
- **Row state signal** — an extreme-left colour **rail** via `OTable`'s
per-row `getRowStyle` (inset box-shadow, rem width, token colour) + a **light
exception highlight** via `row-class` (tint only the rows that need action —
never the normal ones).
exception highlight** via `row-class`. The rail and the wash are two different
strengths of the same signal, and the rail is the default:
| | Rail (`getRowStyle`) | Wash (`row-class`) |
| --- | --- | --- |
| Cost | a few px at the row edge | ~the whole row |
| Use for | **every** state, always | only the two cases below |
A full-row wash is the loudest thing on a list, so it earns its place only when
**all three** hold: the state means **act now** (not merely "not green"), it is
**rare in a healthy system**, and **the row is the unit of action**. In practice
that leaves exactly two washes:
- `!bg-status-error-bg` — failing/errored/offline. *Alerts* failed, *Pipelines*
errored, *Nodes* offline, *Synthetics* failed.
- `!bg-surface-panel` — paused/disabled. This one is **de-emphasis, not alarm**:
the row is deliberately inert, so it recedes rather than shouts.
Everything else keeps a clean row and reads from the rail — including states
that are "bad but not urgent": **degraded/warning** (worth noticing, not worth
acting on this second), **stale**, **unknown**, **never-ran**. And whatever the
state means, if it is **common** the wash is wrong regardless: *Streams* drops it
entirely, because "never ingested" and "quiet for a day" describe a large share
of rows in a normal org and a table where most rows are tinted signals nothing.
Pages with no failure state at all — Users, Roles, Dashboards — never wash.
- **Recency**`OTimeCell` `mode="relative"` (`"3 min ago"`) with a hot/warm/
cold dot, instead of a raw timestamp column.
- **People**`OUserCell` for owner/author columns.
@ -77,15 +103,43 @@ Once a strip is `selectable`, four rules keep every strip behaving identically
active → paused`, `P1 → P2 → P3 → P4`, `degraded → paused → active → draft →
archived`. This mirrors the row rail's "critical colour on the left edge": what
needs attention lives on the left, everywhere.
- **The "All" / "Total" tile is LAST and never highlighted.** It only clears the
facet; it is never itself the active tile. Wire `:selected-key` to the *raw
filter value* (`null` / `"all"` when unfiltered) so nothing shows the ring while
viewing everything — never fall back to selecting "All" as the default.
- **The "All" / "Total" tile is LAST, stays CLICKABLE, and is never highlighted.**
It only clears the facet; it is never itself the active tile. Wire `:selected-key`
to the *raw filter value* (`null` / `"all"` when unfiltered) so nothing shows the
ring while viewing everything — never fall back to selecting "All" as the default.
**Never put `selectable: false` on it** — that is the one wrong way to express
"never highlighted": it turns the tile into a plain `<div>`, so clicking it does
nothing and the strip has no way back to unfiltered (the bug users report as
"All isn't clickable"). "Never highlighted" is achieved by the `selectedKey`
wiring above, not by disabling the tile. `selectable: false` is only for a tile
that is genuinely not a facet at all (a pure read-out with no matching filter).
- **Selection toggles off.** Re-clicking the already-active tile clears the filter
(back to unfiltered), matching every other strip:
`onSelect(key) → filter = key === "all" || filter === key ? cleared : key`.
- **Selected state is an accent border, not a fill** — the `OStatCard` default;
see Step 3.
- **A strip in `#subheader` is a CLAIM ABOUT THE ROWS — scope decides position.**
Inside the table frame, a strip promises "these numbers describe the list below",
so it must be computed from the same filtered set *and* be facet-clickable. Before
writing one, check the fetch: **if the list is server-paginated you only hold one
page**, so page-local sums are not totals — never sum the visible page and label
it "Total". When the numbers genuinely can't meet the promise (server pagination,
or totals that come from a different, wider endpoint such as an org-summary API),
do **not** put them in `#subheader` — lift them to the page level as a read-only
strip in `OPageLayout`'s **`#subnav`**, where they read as page context instead of
a row summary, and label them for their real scope ("Total Streams", not
"Streams"). What stays honest under pagination is anything derived from the single
row in front of you: relative recency, a state rail, a per-row ratio. Reference:
Alerts (client-side list → filterable `#subheader` strip) vs Streams
(server-paginated list → org footprint in `#subnav`).
- **`ODataBarCell` needs the whole set — client-paginated tables only.** Its bar is
a share of the `max` the caller computes over the rendered rows, so on a
**server-paginated** table it silently means "biggest on this page": the scale
changes as you page, and the same stream draws a different bar on page 1 and page
4. On such a table drop the bars and let the (sortable) numbers rank the rows —
right-aligned + `tabular-nums` already scans fine, and the stray part-width
underlines read as artefacts rather than data. Same test as the strip: can this
mark be computed from data you actually hold?
**Tile → section → drawer linkage.** When a tile drills into a table or a detail
drawer, reuse the **same glyph + tone** on the section header and the drawer header
@ -97,12 +151,55 @@ strip feeds a drill-down.
---
### Before you colour a state, check how it CLEARS
A State column is a claim about *now*. Read the write path of whatever field backs
it and confirm something resets it — an expiry/retention job, a success write, a
status transition. A sticky error field (written on failure, never cleared) pins a
row to "Errored" forever and the column becomes noise within a week. Pipelines'
`last_error` is safe because the backend expires it on a retention interval;
verify the equivalent before promoting any field to a chip. If nothing clears it,
label it for what it is ("Last error", a timestamp) instead of a live state.
### Grey vs amber — the two "not green" states
They are not interchangeable, and the wrong pick cries wolf:
- **Grey = no data / unknown / not in use.** Never ingested, never run, not yet
configured. Usually benign — a stream created five minutes ago, a schema-only
stream, a job that has not had its first run. Pair with a muted "Never".
- **Amber = it WAS working and went quiet, or is degrading.** Silence from a thing
that used to report is the case worth a second look.
Amber on the "never" case fires on every freshly created object, which trains
people to ignore the colour. When in doubt, grey.
## Step 3 — Keep everything else calm
Colour only earns attention if most of the screen stays quiet:
- **Earn every tile — a strip is not a page decoration.** Before adding one, put
each tile to three questions: does the number **vary**, does someone **act** on
it, and is it **not already on screen**? Tiles that fail are noise dressed as
signal:
- *structurally constant* — "System accounts" is 1 in almost every org, so the
tile is a label with a number stuck to it;
- *almost always zero* — "New this week" on a list that gains an item a quarter;
- *derivable from its neighbours* — "In use" beside "Unused" and "Total";
- *already in the footer*`footerTitle` renders "N Dashboards" under every
table, so a Total tile alone is not a reason to have a strip.
A page whose only candidates fail these gets **no strip** — keep the per-row
signals (relative recency, a state rail, a count column) and stop. Dashboards,
Service Accounts and Roles all ended up here: pages where a strip added pixels
and no information. Roles is the clearest case — "Unused" is just the member
column sorted ascending, so two tiles restated what the rows already said. **A
count column plus sorting usually beats a strip**; reach for a strip only when
the page has a real distribution to summarise (Users across roles, monitors
across health) *and* the tiles double as the facet.
- **Highlight exceptions, not the norm.** Tint the failed/paused rows; leave the
healthy majority clean. A table where every row is coloured signals nothing.
And if a page has no true failure state (a catalog list), the calm answer is a
rail with no row wash at all — not a wash invented for symmetry with Alerts.
- **Muted zero.** A `0` renders muted, not in the loud tone colour, and "no
data" is a `—`, not a wall of zeros. (`OStatCard` does this.)
- **State is border/colour, not fills.** Selected/hover on interactive tiles use

View File

@ -284,6 +284,31 @@ view (src/views)
test mock a service, and keeps the store from silting up with ephemeral state. A
component that calls axios directly can't be reused or tested without a network.
### The same figure must render identically everywhere
**What.** When a number/label you're adding already appears on another screen —
the same API field, the same meaning — reuse that screen's **formatter and i18n
key**; don't re-derive either. If the existing formatter is a private function
inside a component, promote it to `utils/formatters.ts` and have both import it.
**Why.** Two call sites formatting one field always drift: Home → Usage printed
`2.9B` while a new Streams tile printed `2,900,000,000` from the identical
`/summary` payload, and a duplicated `formatEventCount` was the cause. Same for
labels — parallel `page.summaryStreams` / `home.streams` keys with the same
English value will diverge the first time someone edits one. Sharing the key is
what makes the two surfaces stay in sync.
**How.**
- Before adding a formatter, grep `utils/formatters.ts` (and the sibling screen)
for one that already exists — `formatEventCount`, `formatSizeFromMB`,
`addCommasToNumber`, `formatLargeNumber`, `formatTimeWithSuffix`.
- Reuse the other screen's i18n key outright when the label is the same thing
(`t("home.totalDataIngested")` from the Streams page is correct, not a
duplicate). A new key is for a genuinely new label.
- Match the icon and tone family too when the tiles sit on comparable surfaces —
but never copy a *semantic* colour (green/amber/red) onto a non-health number
just to match; that's the one thing worth diverging on.
### Registering a new page in navigation
**What.** A new page isn't reachable until it's (1) registered as a **route** and

View File

@ -304,6 +304,53 @@ interface OTableColumnDef<TData = any> {
}
```
**Server mode — check the backend BEFORE you write the column.** In
`sorting="server"` the table does not sort anything: it emits the column **id** as
the `sort` field and the backend does the work. OpenObserve's list endpoints match
that field against a fixed list and **fall through to a default on anything else**
(`stream_comparator`'s `_ => a.name.cmp(&b.name)` is typical) — no 400, no console
error, just rows quietly ordered by something else. Symptom: the header shows its
arrow, the rows re-order, and the order is wrong.
- Open the handler and read its sort match/allow-list. A column gets
`sortable: true` **only if a key exists for it**; otherwise `sortable: false`
plus a one-line comment naming the missing key. Never leave a control that
silently lies.
- Name the column `id` after the backend key (`doc_time_max`, not `lastIngested`)
so the mapping is obvious, or map it explicitly via `sortFieldMap`.
- **Derived columns can't be rescued client-side.** Under server pagination you
hold one page of N rows, so sorting a computed ratio locally would just reorder
that page. Either add the sort key to the backend or ship the column unsorted.
- Same reasoning for `filterMode="server"` and any `keyword`/facet param: if the
endpoint ignores it, the UI shows an unfiltered list as if it were filtered.
- **The dev UI usually talks to a REMOTE backend**, not your working tree —
`web/.env`'s `VITE_OPENOBSERVE_ENDPOINT` points at a shared dev deployment. A
Rust change in your branch is not live there, so "the API ignores my new
param" is expected until it deploys. Confirm which is which before debugging the
frontend: check the request in DevTools (the param IS being sent), then check
whether the endpoint you're hitting is local.
**Column hygiene** — mistakes that keep recurring:
- **Never hand-roll a `#` index column.** A data column with `accessorKey: "id"`
is draggable, resizable, hideable, and its numbers stop matching the visible
rows the moment the table is sorted or filtered. Pass `showIndex` and let OTable
render the fixed gutter.
- **Hide conditional columns by id, not by position**`columns.splice(2, 1)`
silently removes the wrong column as soon as someone inserts one above it. Use
`columns.filter((c) => c.id !== "region")`.
- **Size a column for its header plus the sort chevron.** The shared `COL.*`
presets fit typical *values*, not long labels — `COL.sizeBytes` truncates
"Compressed Size" to "Compressed Si…". Set an explicit `size` (+ `minSize` floor)
when the header is longer than the numbers under it.
- **`persistColumns` persists widths as well as visibility**, keyed by `tableId`
in localStorage. Once a user drags a column, later changes to that column's
default `size` never reach them — test width changes in a fresh profile, and
point users at the column menu's reset action.
- **One number per numeric cell.** Two values in one right-aligned cell means
neither owns the column's edge and both look indented at random; give the second
value its own column.
**Main emits:**
- Pagination: `update:currentPage`, `update:pageSize`, `pagination-change`
- Sorting: `update:sortBy`, `update:sortOrder`, `sort-change`
@ -368,7 +415,7 @@ Prebuilt cell components — pass as a column's `cell`. Import individually or f
- **OUserCell** (`@/lib/core/Table/cells/OUserCell.vue`) — person/owner/created-by column; renders email or explicit `name` as truncated plain text, dash when empty.
- **ONumberCell** (`@/lib/core/Table/cells/ONumberCell.vue`) — consistent numeric rendering (tabular-nums); `format` = `number`/`compact`/`bytesFromMB`/`durationSec`/`durationMs`/`durationUs`/`durationNs`/`percent`. Pair the column with `meta.align: "right"`.
- **OCodeCell** (`@/lib/core/Table/cells/OCodeCell.vue`) — monospace identifiers / SQL / tokens, truncated with title tooltip and optional hover copy button (`copy`, default true).
- **ODataBarCell** (`@/lib/core/Table/cells/ODataBarCell.vue`) — value with a proportional background bar (width = value/`max`, caller supplies the column max and pre-formatted `display`); `variant` `default`/`warning`/`danger` for threshold columns.
- **ODataBarCell** (`@/lib/core/Table/cells/ODataBarCell.vue`) — value with a proportional background bar (width = value/`max`, caller supplies the column max and pre-formatted `display`); `variant` `default`/`warning`/`danger` for threshold columns. ⚠️ **Client-paginated tables only:** the caller can only compute `max` over the rows it holds, so on a server-paginated table the bar means "biggest on this page" — the scale shifts as you page and the same row draws a different bar. There, drop the bars and let sortable numbers rank the rows.
Also exported from the barrel: `statusVariant`, `humanizeStatus` helpers (+ `StatusTone`, `StatusVariantResult` types) for status badge styling.

View File

@ -285,6 +285,13 @@ jobs:
"alerts-template-prebuilt-guard.spec.js",
"alerts-form-validation.spec.js",
"anomaly-detection-form-validation.spec.js",
# Alerts 4.0 (multi-alerts) e2e — PR #13547
"alerts-priority-tags.spec.js",
"alerts-multialert-api.spec.js",
"alerts-multialert-ui.spec.js",
"alerts-multialert-regression.spec.js",
"alerts-multialert-firing.spec.js",
"alerts-multialert-transitions.spec.js",
]
- testfolder: "Dashboards-Core"
browser: "chrome"
@ -321,6 +328,12 @@ jobs:
"dashboard-legends-copy.spec.js",
"dashboard-raw-query.spec.js",
]
# Dashboard panel tests are grouped by the feature they exercise so
# each shard stays well under the ephemeral-runner endurance window.
# A single combined 16-spec shard ran ~40 min on CI and got reclaimed
# mid-run ("runner lost communication"). Add new specs to the shard of
# the feature they cover; the Tables shard also owns the pagination and
# pivot-table specs (previously their own single-spec shards).
- testfolder: "Dashboards-Charts"
browser: "chrome"
run_files:
@ -330,17 +343,29 @@ jobs:
"dashboard-maps.spec.js",
"dashboard-multi-y-axis.spec.js",
"dashboard-html-chart.spec.js",
"visualize.spec.js",
"visualize-vrl.spec.js",
"dashboard-create-alert.spec.js",
"dashboard-sankey.spec.js",
"dashboard-pie-donut.spec.js",
"dashboard-metric-camelcase.spec.js",
"dashboard-create-alert.spec.js",
]
- testfolder: "Dashboards-Visualize"
browser: "chrome"
run_files:
[
"visualize.spec.js",
"visualize-vrl.spec.js",
]
- testfolder: "Dashboards-Tables"
browser: "chrome"
run_files:
[
"dashboard-table-chart.spec.js",
"dashboard-table-copy-cell.spec.js",
"dashboard-table-csv-download.spec.js",
"dashboard-table-column-formatting.spec.js",
"dashboard-table-column-filtering.spec.js",
"dashboard-metric-camelcase.spec.js",
"dashboard-table-pagination.spec.js",
"dashboard-pivot-table.spec.js",
]
- testfolder: "Dashboards-Streaming"
browser: "chrome"
@ -460,12 +485,6 @@ jobs:
"dashboard-variables-stream-field.spec.js",
"dashboard-mustache-variables.spec.js",
]
- testfolder: "Dashboard-Table-Pagination"
browser: "chrome"
run_files: ["dashboard-table-pagination.spec.js"]
- testfolder: "Dashboard-Pivot-Table"
browser: "chrome"
run_files: ["dashboard-pivot-table.spec.js"]
- testfolder: "Dashboards-Panel-Level-DateTime-Config"
browser: "chrome"
run_files:
@ -615,7 +634,7 @@ jobs:
"Logs-Builder-Basic"|"Logs-Builder-Advanced"|"Logs-Core"|"Logs-Features")
ACTUAL_FOLDER="Logs"
;;
"Dashboards-Core"|"Dashboards-Settings"|"Dashboards-Charts"|"Dashboards-Streaming"|"Dashboards-Variables"|"Dashboard-Table-Pagination"|"Dashboard-Pivot-Table"|"Dashboards-Panel-Level-DateTime-Config"|"Dashboard-Config-Settings"|"Dashboards-Isolated")
"Dashboards-Core"|"Dashboards-Settings"|"Dashboards-Charts"|"Dashboards-Visualize"|"Dashboards-Tables"|"Dashboards-Streaming"|"Dashboards-Variables"|"Dashboards-Panel-Level-DateTime-Config"|"Dashboard-Config-Settings"|"Dashboards-Isolated")
ACTUAL_FOLDER="Dashboards"
;;
"RUM-Token")

13
.gitignore vendored
View File

@ -5,6 +5,11 @@ report.json
flamegraph.svg
flamegraph.html
# node
node_modules/
.vite/
.vitest/
# Generated by Cargo
# will have compiled files and executables
/examples/
@ -92,3 +97,11 @@ tests/ui-testing/.env.old
/.claude/skills/*
!/.claude/skills/ui-architect/
!/.claude/skills/eslint-error-handling/
alert_mock
node_modules/
# Local verification artifacts (browser screenshots, per-crate scratch DBs)
/alerts-list-f2.png
/final-list.png
/form-advanced.png
src/*/data/

5
Cargo.lock generated
View File

@ -2480,12 +2480,14 @@ dependencies = [
"itertools 0.14.0",
"log",
"o2_enterprise",
"object_store",
"parking_lot 0.12.5",
"parquet",
"rand 0.10.1",
"schema",
"search",
"search_service",
"tantivy",
"tantivy_utils",
"tokio",
]
@ -2551,6 +2553,7 @@ dependencies = [
"dotenv_config",
"dotenvy",
"expect-test",
"fastdivide",
"faststr",
"float-cmp",
"futures",
@ -7258,6 +7261,7 @@ dependencies = [
"openobserve-api-grpc",
"openobserve-api-http",
"openobserve-api-management",
"openobserve-api-pipelines",
"openobserve-core",
"openobserve-jobs",
"openobserve-mcp",
@ -10529,6 +10533,7 @@ dependencies = [
"futures",
"futures-util",
"hashbrown 0.16.1",
"hashlink 0.11.0",
"infra",
"itertools 0.14.0",
"log",

View File

@ -223,6 +223,7 @@ sourcemap.workspace = true
audit.workspace = true
openobserve-api-http.workspace = true
openobserve-api-grpc.workspace = true
openobserve-api-management.workspace = true
web.workspace = true
openobserve-api-common.workspace = true
openobserve-core.workspace = true
@ -243,7 +244,7 @@ openobserve-mcp.workspace = true
arrow.workspace = true
bytes.workspace = true
enrichment-data.workspace = true
openobserve-api-management.workspace = true
openobserve-api-pipelines.workspace = true
expect-test.workspace = true
base64.workspace = true
float-cmp.workspace = true
@ -455,6 +456,7 @@ sqlparser = { version = "0.62", features = ["serde", "visitor"] }
dotenv_config = "0.2"
dotenvy = "0.15"
env_logger = "0.11"
fastdivide = "0.4"
faststr = { version = "0.2", features = ["serde"] }
flate2 = { version = "1.0", features = ["zlib"] }
futures = "0.3"

View File

@ -3,13 +3,28 @@
How the OpenObserve web app guarantees that user-facing text is translatable, and
what to do when a check fires.
**Status: enforcement complete and green.** `lint:ci` 0 errors · `type-check:app`
0 errors · spec-inclusive `type-check` 0 errors · `format:check` clean.
**Status: enforcement complete.** `lint:ci` 0 errors · `type-check:app` **exit 0**
(with `--composite false`, which the npm script passes, no TS6307 surfaces) ·
`format:check` clean.
> **Known measurement gap:** the "spec-inclusive" `type-check` (tsconfig.vitest.json)
> currently checks **no spec files at all** — its include is `src/**/*.spec.{ts,js}`,
> and TypeScript globs do **not** brace-expand, so the pattern matches nothing.
> Any past "spec-inclusive type-check 0 errors" claim was vacuous. Fixing it means
> splitting the include into `src/**/*.spec.ts` + `src/**/*.spec.js` AND then fixing
> the spec-only type errors that surface (e.g. `metricGrouping.spec.ts` assigns keys
> that don't exist in en-US to `I18nKey` fixtures). Tracked in §9.
The mechanisms are done and so is the message cleanup — **0 fake plurals** remain in
en-US. What is left is narrower: **non-English plural rules**, the last **6 `gt` sites**,
and **one CI gap**. See [§9 Outstanding work](#9-outstanding-work) — that section is the
to-do list, kept in sync with measurements.
en-US, the 10 surviving `gt()` calls are each individually justified (§8), `TEXT_ATTRS`
has been retired in favour of prop types (§8), and the dead-key sweep is complete (§9).
What is left is narrower: **non-English plural rules** and **spec type-checking**
(a broken tsconfig glob plus a missing PR gate, §9.3). See
[§9 Outstanding work](#9-outstanding-work) — that section is the to-do list, kept in
sync with measurements.
Converting the 71 dynamic key sites so they can be type-checked was **considered and
declined** — it is recorded under §4 as an accepted limit, not pending work.
---
@ -89,11 +104,11 @@ only two remaining `(s)` values are the seconds unit. Keep it that way.
`{{ t('common.save') }}` · `:label="t('x')"` · `:label="row.name"` (a variable
contributes no literal) · `:label="'—'"` (no letters).
### Two curated lists
### One curated list
`TEXT_ATTRS` is **gone** — prop types now decide what counts as a text prop, so there
is no list to maintain (§8). Declaring a prop `I18nText` is what makes it enforced.
- **`TEXT_ATTRS`** (47 names) — props that carry user-facing text. Feeds both the
static and bound rules, so "what counts as a text prop" is defined once. **Add a
name here when a component takes UI text through a new prop.**
- **`NON_TRANSLATABLE`** (39 entries) — tokens that must read identically in every
language: units (`px`, `ms`, `ns`, `min`), symbols (`×`, `→`, `~`), protocol and
spec identifiers (`GET`, `UTC`, `SQL`, `PromQL`, the OpenTelemetry statuses
@ -121,7 +136,7 @@ follows a pattern the library already used for icons (`iconLeft?: IconName`).
| **`I18nKey`** | a field holding an **i18n key as data** (`titleKey`, `labelKey`) |
| **`useI18nTyped()`** | replaces `useI18n()` in components — `t` returns `I18nText` |
| **`TranslateFn`** | the type of `t` when a composable/util takes it as a **parameter** |
| **`gt()`** | last resort for genuinely context-free code — **1 file**, see below |
| **`gt()`** | last resort for genuinely context-free code — **4 files**, see below |
| **`raw()`** | the explicit opt-out for text that must not be translated |
```ts
@ -155,7 +170,7 @@ presets.ts(76,5): error TS2820: Type '"emptyState.noLogs.titel"' is not assignab
to type 'I18nKey'. Did you mean '"emptyState.noLogs.title"'?
```
Cost: **~31 s** for the full app type-check with the 10,665-key union. Not a
Cost: **~31 s** for the full app type-check with the ~9,800-key union. Not a
perf concern.
### Getting a `t`: three cases, in order of preference
@ -179,7 +194,7 @@ perf concern.
}
```
This is the standard for non-component code (**27 files**). It is preferred over
This is the standard for non-component code (**35 files**). It is preferred over
`gt()` because these functions are also called directly by specs, outside any
component, where `useI18n()` would throw. Specs pass the real translator:
@ -192,13 +207,27 @@ perf concern.
composable translates, `t` belongs on that function, not on the composable.
3. **Genuinely context-free code**`gt("some.key")`. Only where there is neither a
setup context nor a caller to thread `t` through. Exactly **one** file qualifies:
`useUnauthorizedErrorGrouper.ts`, reached from an axios 403 interceptor registered
once at module load (interceptor → 300 ms `setTimeout` → toast → click handler).
setup context nor a caller to thread `t` through. Exactly **four** files qualify
(10 call sites), each for a distinct structural reason:
- `useUnauthorizedErrorGrouper.ts` (6) — reached from an axios 403 interceptor
registered once at module load (interceptor → 300 ms `setTimeout` → toast →
click handler). The founding case.
- `ingestion/setupCard/content/kubernetes.ts` (2) — the setup-card registry pins
every builder to `(subs: CardSubstitutions) => RichCardContent`; threading `t`
would change that shared contract for ~20 cards. Static prose in cards uses
`descriptionKey`/`helpKey` (renderer-resolved); `gt` covers only the one
description that interpolates a URL, translated at card-build time.
- `utils/query/searchError.ts` (1) — the translated **default** of an optional
`fallback: I18nText` parameter, evaluated per call; callers that hold `t`
override it (e.g. `parseSearchError(err, gt("search.unknownError"))`).
- `usePanelPromQLExecutor.ts` (1) — reached through composable chains that are
not guaranteed to run in a setup context, where `useI18n()` would throw.
`gt` is deliberately a narrow escape hatch, not an alternative to case 2. Before
reaching for it, check whether a caller can pass `t` instead — 43 of the original
49 `gt` sites turned out to be case 2.
reaching for it, check whether a caller can pass `t` instead — the overwhelming
majority of historical `gt` sites (including the import validators and
`useQualityDetailCharts`, converted since) turned out to be case 2.
> **Keys resolved at display time, not module load.** When storing keys as data, keep
> the `I18nKey` map at module scope but resolve it inside the function that renders.
@ -250,11 +279,18 @@ annotation is made by hand at the declaration rather than by a pattern match.
| `t()` keys — literal | enforced (ESLint, `.vue` **and** `.ts`) |
| i18n keys stored as data | enforced (`I18nKey`, 19 files) |
| Toast / notification text | enforced (`I18nText`, incl. the store + wrappers) |
| Text reached via `useI18nTyped()` | branded across **663** files |
| Text reached via `useI18nTyped()` | branded across **668** files |
| Composables / utils taking `t: TranslateFn` | **27** files |
**Numbers:** en-US grew 10,216 → **10,665 keys**; **311** sit under `toastMessages.*`
(28 module groups). 62 `raw()` opt-outs. **6** `gt()` call sites, all in one file.
**Numbers** (re-measured 2026-07-31, after the post-review debt cleanup): en-US holds
**9,837 keys** across **72 namespaces** (10,216 → 11,002 as text was migrated in,
→ 9,648 after the dead-key sweep §9, → 9,837 as the remaining hardcoded prose —
import validators, setup-card descriptions, status labels, dialog buttons — was
keyed); **309** sit under `toastMessages.*`. **977** `raw()` opt-outs — the count
rose steeply when props became `I18nText`, since every genuinely non-translatable
value (units, tokens, glyphs, API data) has to say so explicitly, then fell as
`raw()`-wrapped prose was converted to keys. **10** `gt()` call sites in **4**
files (see §3 case 3 for the per-file justification).
**Where the two checkers divide.** `no-missing-keys` validates `t('x.y')` **calls**;
it cannot see a key assigned to a field. Keys stored as **data** are validated instead
@ -264,27 +300,54 @@ be green to claim key coverage; neither alone is sufficient.
### Not covered
- **Dynamic keys** — ``t(`about.feature_${id}`)`` (**312** sites). No lint or type
check can resolve these. The `t` key parameter is deliberately permissive
(`I18nKey | (string & {})`) so they keep compiling.
- **Dynamic keys — 71 sites. Considered and DECLINED; do not re-open as pending work.**
``t(`about.feature_${id}`)`` and similar. A renamed or deleted key reached this way is
caught by nothing: ESLint sees no literal, `t`'s permissive
`I18nKey | (string & {})` parameter accepts any string, vue-i18n returns the key
instead of throwing, and the production build strips its dev warning. It renders the
raw dotted path on screen.
Nothing is currently broken — every key these sites can request was expanded and
verified present. The exposure is a future rename, concentrated in `onlineEvals`
(40 of the 71 sites).
Closing it would mean removing the `(string & {})` arm, which surfaces **117 errors**:
~80 are unrelated consumers declaring their translator loosely as
`(key: string) => string` (a contravariance failure, not a key problem), 15 need a
variable widened to a literal union, 9 need a `Record<string, I18nKey>` map, and a
handful are genuine key/code mismatches. Two-thirds of the work is therefore not about
dynamic keys at all, which is why this was judged not worth it as a standalone project.
If the guarantee is ever wanted, the cheap route is a second strict export
(`(key: I18nKey)`) used by new code only — no flag day, no 98-file PR.
> Count these with a word boundary: `grep 't(\`'` also matches `` fetch(`…`) `` and any
other call ending in `t`, which inflates the figure roughly fourfold. A further 21
> sites use backticks but interpolate nothing — those are ordinary static keys.
- **Interfaces not yet annotated.** `I18nText` guards what it is applied to. That is
_incomplete_, never _wrong_ — coverage grows one declaration at a time, at the
definition site, with no central registry to keep in sync.
- **Other locales.** The 14 non-en locales are generated from en-US and lag behind;
`localeDir` points at en-US only, on purpose. Never hand-edit them.
- **Unused keys.** `@intlify/vue-i18n/no-unused-keys` is available but **not
enabled**. Measured on this repo it reports 2,421 keys of which only ~1,302 are
genuinely dead — 681 are reached via a key-string, 437 via a dynamic prefix. If
you enable it, use `warn`, never `enableFix`, and populate `ignores` with the
dynamic prefixes; its autofix would delete live translations.
- **Unused keys.** `@intlify/vue-i18n/no-unused-keys` is available but **not enabled**.
It reports 2,421 keys, but it only sees `t('literal')` calls — so keys reached
through a variable or a dynamic prefix look unused to it, and its autofix would
delete them. A direct measurement (literal tokens + dynamic prefixes + the one
concatenation site) put the genuinely dead set at **1,363**, since deleted (§9).
If you ever enable the rule, use `warn`, never `enableFix`, and populate `ignores`
with the dynamic prefixes. Note that even the direct measurement missed 9 live keys
(§9) — no automated sweep sees keys held in JSON data files.
---
## 5. Deliberately deferred: `strictTemplates`
Typing a component prop `label: I18nText` only gates `<OButton label="Save" />` if
Vue's `strictTemplates` is on. It is **not** enabled, which is why `TEXT_ATTRS`
still exists.
A **declared** prop typed `label: I18nText` gates `<OButton label="Save" />` with or
without `strictTemplates` — declared props are always checked. `strictTemplates`
governs **undeclared** attributes only. This was verified by probe, and it is what
allowed `TEXT_ATTRS` to be retired (§8) while `strictTemplates` stays off.
Measured twice on this branch:
@ -297,10 +360,9 @@ Of those, ~303 are genuine `TS2322` type mismatches (real latent bugs); the rest
undeclared pass-through attributes.
**It was left out on purpose.** It is a large _type-safety_ migration, not i18n work,
and its i18n payoff is only retiring the `TEXT_ATTRS` list — whose real gap was
measured at **five sites** (all fixed here: `reveal-tooltip`, `hide-tooltip`,
`unstable-dimension-tooltip`, `date-disabled-tooltip`). Worth doing as its own PR;
not worth burying this one under 2,655 unrelated errors.
and it turned out to carry **no i18n payoff at all** — retiring `TEXT_ATTRS` did not
require it. Worth doing as its own PR for the ~303 genuine `TS2322` mismatches; not
worth burying this one under 2,655 unrelated errors.
---
@ -320,8 +382,17 @@ not worth burying this one under 2,655 unrelated errors.
| A recurring unit/symbol/code token | add to `NON_TRANSLATABLE` with a one-line reason |
| A whole file that is genuinely code | add to the SyntaxGuide exemption block |
New text-carrying **component prop** → add its name to `TEXT_ATTRS`.
New text-carrying **component prop** → declare it `I18nText`. In `<script setup>`,
`defineProps<{ label: I18nText }>()`. In the Options API the double cast is required,
because `StringConstructor` resolves to an unbranded `string`:
```ts
title: { type: String as unknown as PropType<I18nText>, default: raw("") }
```
New **interface field** carrying text or a key → declare it `I18nText` / `I18nKey`.
New **i18n key stored in a `.json` data file** → add it to the `localeKeys.spec.ts`
guard; no type or lint rule can see it (§9).
---
@ -397,10 +468,25 @@ Recorded so they are not re-reported as open:
- **Verb injection.** 5 messages interpolated an English verb into a sentence
(`"Failed to {action} the alert"`), which is unlocalisable word order; each is now a
complete message per case.
- **`gt` proliferation.** 49 sites / 22 files → **6 sites / 1 file**, by passing
`t: TranslateFn` (§3, case 2).
- **`gt` proliferation.** 49 sites / 22 files → **10 sites / 4 files**, by passing
`t: TranslateFn` (§3, case 2). The audit (2026-07-31) converted every site whose
caller could supply `t` — the onlineEvals import validators now take `t` via their
`ctx`, `useQualityDetailCharts` and `computePrefixAssignment` take it as a
parameter. The 10 that remain are each structurally unable to receive a `t`
(axios interceptor, fixed registry contract, optional-param default, non-setup
composable chain — the per-file list is in §3 case 3) and are **intentional and
final**. Two alternatives were considered and rejected: deferring keys to render
time, and injecting a translator at the composition root. `gt` stays exported as
a deliberate, narrow escape hatch; **do not re-raise this as cleanup.**
- **The `useI18n` import ban** is clean tree-wide. The only remaining `vue-i18n` import
outside the exempt paths is `createI18n` in a test fixture, which the ban permits.
- **`TEXT_ATTRS` is gone.** The 47-name attribute allowlist and the bound-prop half of
`local/no-bare-bound-text-props` were deleted; prop types now do that job (§3). A
declared `I18nText` prop is checked in templates **without** `strictTemplates`
`strictTemplates` governs _undeclared_ attributes only, so §5 is not a blocker for
this. Type checking is also _stricter_ than the old rule: it rejects a plain `string`
variable, which the rule allowed. `vue/no-bare-strings-in-template` stays enabled,
because a bare text node has no prop to annotate; `NON_TRANSLATABLE` still feeds it.
- **Fake plurals everywhere else.** The remaining 104 `(s)` messages are gone — pipe
plurals went 53 → **139**. Breakdown of how they were fixed:
- 69 already interpolated `{count}`/`{n}`, so the call sites were already correct and
@ -424,9 +510,50 @@ Recorded so they are not re-reported as open:
## 9. Outstanding work
Ordered by value. Item 3 is the cheapest; item 1 is the only user-visible one.
### ~~1. Delete dead keys from `en-US.json`~~ — done
### 1. Non-English plural rules (`pluralRules`)
`en-US.json` went from **11,002 → 9,648 keys** (75 → 71 namespaces, ~90 KB smaller).
1,363 keys were deleted and **9 were restored** after audit; see below. (The count
has since grown again to **9,837 / 72 namespaces** as the post-review debt cleanup
keyed the remaining hardcoded prose — that growth is live keys, not resurrected
dead ones.)
**What the scan excluded up front.** Keys with a literal reference in prod code or
specs, keys reachable through a dynamic prefix (a conservative exclusion — spared
because a dynamic key _might_ reach them, not because they are proven live), and the
5 keys reachable through the one concatenation site (`utils/common.ts`, `"message." +
code`).
**The audit that followed, and why it mattered.** Six independent read-only agents
re-checked all 1,363 deletions, each told to hunt specifically for what a
quoted-literal scan structurally cannot see. They found **9 genuine false deletions**
across two distinct blind spots — both now closed by guards that were verified to
fail when the key is removed:
| Blind spot | Keys | Guard added |
| ----------------------------------------------------------------- | ---- | ------------------------------------------------------------- |
| Keys living in a **JSON data file** (`constants/features.json`) | 5 | `localeKeys.spec.ts` — resolves every key features.json cites |
| Template literal **assigned to a variable** before reaching `t()` | 4 | `useGreeting.ts``const key: I18nKey = …` |
The first is the more important lesson: TypeScript widens imported JSON string values
to `string`, so keys stored in a data file cannot be typed as `I18nKey` and are
invisible to _every_ static check. Annotating `FeatureAvailability` documents intent
but **does not enforce**`FEATURE_REGISTRY` casts with `as`, and the widening defeats
it regardless. Only the spec guard actually catches this.
**If you delete keys again.** Regenerate the list (the set moves as code changes) and
scan **`.json`/`.md`/`.yaml` as well as `.ts`/`.vue`** — the original sweep read only
JS/TS and that is exactly how the features.json keys were lost. Never use
`no-unused-keys --fix`: it shares the blind spot and would delete the dynamic-reachable
keys. `no-missing-keys` and `I18nKey` together catch an over-delete of any _literal_
reference, but neither sees the two cases above.
**Other locales.** The 14 non-English files still carry ~1,317 of the deleted keys.
This is expected and self-healing: `scripts/translations/README.md` documents the
pipeline as pruning keys removed from `en-US.json`. Harmless meanwhile — nothing
requests them and en-US is the fallback.
### 2. Non-English plural rules (`pluralRules`)
The en-US messages are now real pipe plurals, but **branch selection still uses
vue-i18n's built-in default for every locale** because this repo configures no
@ -444,26 +571,47 @@ generated and must not be hand-edited, so they keep their `(s)` until regenerate
This degrades safely: if en-US has a pipe and fr-FR does not, vue-i18n returns the
French message unchanged rather than mis-selecting a branch.
### 2. The last 6 `gt()` sites
### 3. Spec type-checking does not exist — anywhere
`useUnauthorizedErrorGrouper.ts`. Blocked on architecture, not effort — see §3 case 3
for why there is no caller to thread `t` through. Two designs were considered and
rejected (deferring keys to render time; injecting a translator at the composition
root). Closing this is what would allow `gt` to be deleted from `types/i18n.ts`
entirely.
Two separate problems compound here.
### 3. Spec-inclusive `type-check` does not gate PRs
**First, the config is broken.** `tsconfig.vitest.json`'s include is
`src/**/*.spec.{ts,js}` — but TypeScript globs do **not** support brace expansion,
so the pattern matches **zero files**. `npm run type-check` "passes" in seconds
because it checks nothing but `vitest.config.ts`/`vite.config.ts` (verify with
`npx tsc -p tsconfig.vitest.json --listFilesOnly`). Any past claim that the tree
is "green under both configs" was vacuous. Additionally, the npm script's
`NODE_OPTIONS=…` prefix is POSIX-only, so `npm run type-check` errors outright on
Windows shells.
`unit-tests.yml` runs `type-check:app`, which **excludes specs**. The spec-inclusive
`type-check` runs only in `npm-update.yml`, a scheduled dependency job — so a spec that
fails to compile can reach `main`. Adding one step to `unit-tests.yml` closes it, and
the tree is currently green under both, so it would go in clean.
**Second, even a working spec type-check would not gate PRs.** `unit-tests.yml`
(the PR gate) runs only `type-check:app`, which excludes specs. Vitest transpiles
through esbuild, which strips types without checking them, so a type-broken spec
still runs and can pass while testing the wrong thing. This branch hit exactly
that — when `useDashboardPanelData()` gained its `t` parameter, **55 specs** kept
the old signature while `type-check:app` reported 0 errors.
Fix is two steps, in order:
1. Split the include in `tsconfig.vitest.json` into `src/**/*.spec.ts` and
`src/**/*.spec.js`, run it, and fix what surfaces — spec-only type errors exist
today (e.g. `metricGrouping.spec.ts` assigns non-existent keys to `I18nKey`
fixtures), so expect this to start **red**, not clean.
2. Then add the step next to the existing one in `unit-tests.yml`:
```yaml
- name: Type-check specs (any error fails)
working-directory: web
run: npm run type-check
```
### 4. Standing limits (accepted, not scheduled)
- **312 dynamic keys** — unresolvable by design (§4).
- **`no-unused-keys` not enabled** — ~1,302 genuinely dead keys of the 2,421 it
reports; its autofix would delete live translations (§4).
- **71 dynamic keys** — conversion considered and **declined**; see §4 for the full
reasoning and what closing it would cost.
- **`no-unused-keys` not enabled** — it reports 2,421 keys but shares the blind spot
that makes ~1,000 of those false positives, and its autofix deletes what it flags.
The dead-key cleanup in §9.1 uses a directly-measured list instead (§4).
- **`strictTemplates` deferred** — 2,655 errors, a type-safety migration rather than
i18n work (§5).
- **`eslint.config.js` duplicate rule keys** — `prettier/prettier` appears 3×,

View File

@ -43,7 +43,7 @@ they hold the authoritative, ESLint-encoded conventions. Flag changed code that
- **No `any`, no `!` non-null assertions, no use-site `as` casts** (except `as const` and
`Array/Object as PropType<T>`) — type at the declaration site instead
- **Mutating a prop** directly (`vue/no-mutating-props`) — must go through a computed alias / emit
- New Quasar (`<q-*>`) elements or `Notify` where an O2 component / `toast()` exists (ESLint
`vue/no-restricted-html-elements` / `no-restricted-imports` gives the exact replacement)
- Bare HTML controls or third-party UI primitives where an O2 component / `toast()` exists
(ESLint `vue/no-restricted-html-elements` / `no-restricted-imports` gives the exact replacement)
- Hardcoded px / hex colors / user-facing strings instead of design tokens + i18n
- New code that is not type-clean or lint-clean (the gates are a hard 0)

View File

@ -21,7 +21,7 @@ This is **OpenObserve**, an open-source observability platform written in **Rust
- **Frontend**: Vue 3 SPA with Vite, using Vuex for state management. When reviewing changes under
`web/`, **read the tracked skills `.claude/skills/ui-architect/SKILL.md` (UI house rules) and
`.claude/skills/eslint-error-handling/SKILL.md` (lint/type-check playbook)** first — they are the
authoritative source for frontend conventions (O2 component library over Quasar, no
authoritative source for frontend conventions (build from the O2 component library, no
`any`/`!`/use-site `as`, type-clean + lint-clean, design tokens + i18n over hardcoded strings).
Flag deviations from them.
- **Testing**: Rust unit tests with `cargo test`, E2E with Playwright

View File

@ -784,7 +784,8 @@ async fn check_and_create_org(user_id: &str, method: &Method, path: &str) -> Res
&& (path_columns[2].eq("alerts")
|| path_columns[2].eq("folders")
|| path_columns[2].eq("reports")
|| path_columns[2].eq("synthetics"))
|| path_columns[2].eq("synthetics")
|| path_columns[2].eq("incidents"))
{
path_columns[1]
} else {

View File

@ -14,6 +14,5 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use openobserve_api_common::auth;
pub mod models;
pub mod request;
pub mod router;

View File

@ -15,25 +15,3 @@
pub mod mcp;
pub mod ratelimit;
pub use openobserve_api_ingest::request::{
CONTENT_TYPE_JSON, CONTENT_TYPE_PROTO, clusters, logs, metrics, rum,
};
#[cfg(feature = "cloud")]
pub use openobserve_api_management::request::cloud;
#[cfg(feature = "profiling")]
pub use openobserve_api_management::request::profiling;
#[cfg(feature = "enterprise")]
pub use openobserve_api_management::request::{
action_server, domain_management, eval_jobs, license, providers, score_configs, scorers,
};
pub use openobserve_api_management::request::{
actions, gen_ai, keys, kv, model_pricing, service_accounts, service_streams, short_url,
sourcemaps, status, stream, synthetics,
};
#[cfg(feature = "enterprise")]
pub use openobserve_api_management::request::{ai, anomaly_detection, workflows};
#[cfg(feature = "enterprise")]
pub use openobserve_api_pipelines::request::re_pattern;
pub use openobserve_api_pipelines::request::{enrichment_table, functions, pipeline, pipelines};
pub use openobserve_api_search::search::patterns;

View File

@ -25,9 +25,16 @@ use axum::{
};
use config::get_config;
use openobserve_api_common::X_O2_ASSISTANT_SESSION_ID;
use openobserve_api_ingest::request::{clusters, logs, metrics, rum};
#[cfg(feature = "cloud")]
use openobserve_api_management::request::cloud;
#[cfg(feature = "profiling")]
use openobserve_api_management::request::profiling;
use openobserve_api_management::request::{
alerts, authz, dashboards, folders, organization, users,
alerts, authz, dashboards, folders, kv, model_pricing, organization, service_accounts,
short_url, slos, sourcemaps, status, stream, users,
};
use openobserve_api_pipelines::request::{enrichment_table, functions, pipeline, pipelines};
use openobserve_api_search::{promql, search, traces};
use openobserve_core::auth::AuthExtractor;
use tower_http::{
@ -46,9 +53,17 @@ use {
auditor::{AuditMessage, Protocol, ResponseMeta},
config::get_config as get_o2_config,
},
openobserve_api_management::request::{
actions, ai, anomaly_detection, domain_management, eval_jobs, gen_ai, keys, license,
providers, score_configs, scorers, service_streams, synthetics, workflows,
},
openobserve_api_pipelines::request::re_pattern,
openobserve_api_search::search::patterns,
};
use super::request::*;
use super::request::mcp;
#[cfg(feature = "enterprise")]
use super::request::ratelimit;
use crate::{
common::meta::{middleware_data::RumExtraData, proxy::PathParamProxyURL},
handler::http::{
@ -64,7 +79,7 @@ pub mod decompression;
pub mod middlewares;
pub mod openapi;
pub use common::meta::http::ERROR_HEADER;
use common::meta::http::ERROR_HEADER;
/// Create CORS layer for axum
pub fn cors_layer() -> CorsLayer {
@ -633,6 +648,23 @@ pub fn basic_routes() -> Router {
router = router.route("/docs", get(|| async { Redirect::permanent("/swagger/") }));
}
// External alert source webhooks — token-authenticated inside the handler itself
// (never via auth_middleware), so these must stay in basic_routes rather than
// service_routes. See GHSA-wffq-g8qf-ccmv: do not widen the shared token
// classifier in validator.rs to cover this token type.
router = router
.route(
"/api/v2/{org_id}/incidents/events",
post(alerts::external_events::ingest_events),
)
.route(
"/api/v2/{org_id}/incidents/events/{token}",
post(alerts::external_events::ingest_events_url_token),
)
.route_layer(DefaultBodyLimit::max(
alerts::external_events::MAX_BODY_BYTES,
));
router
}
@ -829,6 +861,26 @@ pub fn service_routes() -> Router {
.route("/v2/{org_id}/reports/{report_id}/enable", patch(dashboards::reports::enable_report_v2))
.route("/v2/{org_id}/reports/{report_id}/trigger", put(dashboards::reports::trigger_report_v2))
// SLOs. Deliberately NOT enterprise-gated: nothing about SLO
// measurement is an enterprise capability, and the handlers already
// return 501 when ZO_SLO_ENABLED is false. Literal segments are
// registered before the {slo_id} catch-all, per the router's ordering
// rule.
.route(
"/{org_id}/slos",
get(slos::list_slos).post(slos::create_slo),
)
// Before the {slo_id} catch-all, or "move" is parsed as an SLO id.
.route("/{org_id}/slos/move", post(slos::move_slos))
.route("/{org_id}/slos/{slo_id}/enable", put(slos::enable_slo))
.route("/{org_id}/slos/{slo_id}/groups", get(slos::get_slo_groups))
.route(
"/{org_id}/slos/{slo_id}",
get(slos::get_slo)
.put(slos::update_slo)
.delete(slos::delete_slo),
)
// Folders (v2)
.route("/v2/{org_id}/folders/{folder_type}", get(folders::list_folders).post(folders::create_folder))
.route("/v2/{org_id}/folders/{folder_type}/{folder_id}", get(folders::get_folder).put(folders::update_folder).delete(folders::delete_folder))
@ -837,6 +889,8 @@ pub fn service_routes() -> Router {
// Alerts (v2)
.route("/v2/{org_id}/alerts", get(alerts::list_alerts).post(alerts::create_alert))
.route("/v2/{org_id}/alerts/{alert_id}", get(alerts::get_alert).put(alerts::update_alert).delete(alerts::delete_alert))
.route("/v2/{org_id}/alerts/{alert_id}/groups", get(alerts::list_alert_groups))
.route("/v2/{org_id}/alerts/{alert_id}/groups/transitions", get(alerts::list_alert_group_transitions))
.route("/v2/{org_id}/alerts/{alert_id}/export", post(alerts::export_alert))
.route("/v2/{org_id}/alerts/bulk", delete(alerts::delete_alert_bulk))
.route("/v2/{org_id}/alerts/{alert_id}/enable", patch(alerts::enable_alert))
@ -846,18 +900,25 @@ pub fn service_routes() -> Router {
.route("/v2/{org_id}/alerts/{alert_id}/clone", post(alerts::clone_alert))
.route("/v2/{org_id}/alerts/generate_sql", post(alerts::generate_sql))
.route("/v2/{org_id}/alerts/move", patch(alerts::move_alerts))
.route("/v2/{org_id}/alerts/tags", get(alerts::list_alert_tags))
.route("/v2/{org_id}/alerts/history", get(alerts::history::get_alert_history))
.route("/v2/{org_id}/alerts/dedup/summary", get(alerts::dedup_stats::get_dedup_summary))
// Alerts - incidents must be before alerts to avoid route conflicts
.route("/v2/{org_id}/alerts/incidents", get(alerts::incidents::list_incidents))
.route("/v2/{org_id}/alerts/incidents/stats", get(alerts::incidents::get_incident_stats))
.route("/v2/{org_id}/alerts/incidents/external-alerts/{external_alert_id}/payload", get(alerts::incidents::get_external_alert_payload))
.route("/v2/{org_id}/alerts/incidents/{incident_id}", get(alerts::incidents::get_incident))
.route("/v2/{org_id}/alerts/incidents/{incident_id}/rca", post(alerts::incidents::trigger_incident_rca).delete(alerts::incidents::cancel_incident_rca))
.route("/v2/{org_id}/alerts/incidents/{incident_id}/rca/history", get(alerts::incidents::get_incident_rca_history))
.route("/v2/{org_id}/alerts/incidents/{incident_id}/update", patch(alerts::incidents::update_incident))
.route("/v2/{org_id}/alerts/incidents/{incident_id}/events", get(alerts::incidents::get_incident_events))
.route("/v2/{org_id}/alerts/incidents/{incident_id}/events/comment", post(alerts::incidents::post_incident_comment))
.route("/v2/{org_id}/incidents/integrations", get(alerts::incident_integrations::list_integrations).post(alerts::incident_integrations::create_integration))
.route("/v2/{org_id}/incidents/integrations/{integration_id}", delete(alerts::incident_integrations::delete_integration))
.route("/v2/{org_id}/incidents/integrations/{integration_id}/enable", patch(alerts::incident_integrations::set_integration_enabled))
.route("/v2/{org_id}/incidents/integrations/{integration_id}/rotate", post(alerts::incident_integrations::rotate_integration_token))
.route("/v2/{org_id}/incidents/integrations/{integration_id}/senders", get(alerts::incident_integrations::list_integration_senders))
// Alert templates
.route("/{org_id}/alerts/templates", get(alerts::templates::list_templates).post(alerts::templates::save_template))
@ -958,7 +1019,7 @@ pub fn service_routes() -> Router {
.route("/{org_id}/settings/gen_ai/agent_registry", delete(gen_ai::clear_agent_registry))
.route("/{org_id}/gen_ai/agents", get(gen_ai::list_scored_agents));
if get_o2_config().common.online_evals_enabled {
if get_o2_config().llm_eval_config.enabled {
router = router
// LLM Providers (Online Eval Phase 2)
.route("/{org_id}/providers", get(providers::list_providers).post(providers::create_provider))

View File

@ -16,17 +16,27 @@
use config::{get_config, meta::stream::StreamType};
#[cfg(feature = "enterprise")]
use o2_ratelimit::dataresource::default_rules::OpenapiInfo;
use openobserve_api_ingest::request::{clusters, logs, metrics, rum};
use openobserve_api_management::request::{
actions, gen_ai, keys, kv, service_accounts, service_streams, short_url, status, stream,
synthetics,
};
use openobserve_api_pipelines::request::{enrichment_table, functions, pipeline, pipelines};
use openobserve_api_search::search::patterns;
use utoipa::{
Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
};
use crate::{common::meta, handler::http::request};
use crate::{
common::meta,
handler::http::request::{mcp, ratelimit},
};
#[derive(OpenApi)]
#[openapi(
paths(
request::status::healthz,
status::healthz,
openobserve_api_management::request::users::list,
openobserve_api_management::request::users::save,
openobserve_api_management::request::users::update,
@ -50,23 +60,23 @@ use crate::{common::meta, handler::http::request};
openobserve_api_management::request::organization::system_settings::set_user_setting,
openobserve_api_management::request::organization::system_settings::delete_org_setting,
openobserve_api_management::request::organization::system_settings::delete_user_setting,
request::stream::list,
request::stream::schema,
request::stream::create,
request::stream::update_settings,
request::stream::delete_fields,
request::stream::delete,
request::logs::ingest::bulk,
request::logs::ingest::multi,
request::logs::ingest::json,
request::logs::loki::loki_push,
stream::list,
stream::schema,
stream::create,
stream::update_settings,
stream::delete_fields,
stream::delete,
logs::ingest::bulk,
logs::ingest::multi,
logs::ingest::json,
logs::loki::loki_push,
openobserve_api_search::traces::traces_write,
openobserve_api_search::traces::get_latest_traces,
openobserve_api_search::traces::session::get_latest_sessions,
openobserve_api_search::traces::session::get_session_details,
openobserve_api_search::traces::user::get_latest_users,
openobserve_api_search::traces::dag::get_trace_dag,
request::metrics::ingest::json,
metrics::ingest::json,
openobserve_api_search::promql::remote_write,
openobserve_api_search::promql::query_get,
openobserve_api_search::promql::query_range_get,
@ -75,11 +85,11 @@ use crate::{common::meta, handler::http::request};
openobserve_api_search::promql::labels_get,
openobserve_api_search::promql::label_values,
openobserve_api_search::promql::format_query_get,
request::enrichment_table::save_enrichment_table,
request::enrichment_table::save_enrichment_table_from_url,
request::rum::ingest::log,
request::rum::ingest::data,
request::rum::ingest::sessionreplay,
enrichment_table::save_enrichment_table,
enrichment_table::save_enrichment_table_from_url,
rum::ingest::log,
rum::ingest::data,
rum::ingest::sessionreplay,
openobserve_api_search::search::search,
openobserve_api_search::search::search_partition,
openobserve_api_search::search::around_v1,
@ -103,12 +113,12 @@ use crate::{common::meta, handler::http::request};
openobserve_api_management::request::folders::deprecated::get_folder,
openobserve_api_management::request::folders::deprecated::get_folder_by_name,
openobserve_api_management::request::folders::deprecated::update_folder,
request::functions::list_functions,
request::functions::update_function,
request::functions::save_function,
request::functions::delete_function,
request::functions::list_pipeline_dependencies,
request::functions::test_function,
functions::list_functions,
functions::update_function,
functions::save_function,
functions::delete_function,
functions::list_pipeline_dependencies,
functions::test_function,
openobserve_api_management::request::dashboards::create_dashboard,
openobserve_api_management::request::dashboards::update_dashboard,
openobserve_api_management::request::dashboards::list_dashboards,
@ -137,6 +147,7 @@ use crate::{common::meta, handler::http::request};
openobserve_api_management::request::alerts::clone_alert,
openobserve_api_management::request::alerts::generate_sql,
openobserve_api_management::request::alerts::move_alerts,
openobserve_api_management::request::alerts::list_alert_tags,
openobserve_api_management::request::alerts::history::get_alert_history,
openobserve_api_management::request::alerts::incidents::list_incidents,
openobserve_api_management::request::alerts::incidents::get_incident,
@ -156,38 +167,38 @@ use crate::{common::meta, handler::http::request};
openobserve_api_management::request::alerts::destinations::save_destination,
openobserve_api_management::request::alerts::destinations::update_destination,
openobserve_api_management::request::alerts::destinations::delete_destination,
request::kv::get,
request::kv::set,
request::kv::delete,
request::kv::list,
request::clusters::list_clusters,
request::short_url::shorten,
request::short_url::retrieve,
request::ratelimit::list_module_ratelimit,
request::ratelimit::list_role_ratelimit,
request::ratelimit::update_ratelimit,
request::service_accounts::list,
request::service_accounts::save,
request::service_accounts::update,
request::service_accounts::delete,
request::mcp::handle_mcp_post,
request::mcp::handle_mcp_get,
request::mcp::oauth_authorization_server_metadata,
request::pipeline::save_pipeline,
request::pipeline::list_pipelines,
request::pipeline::get_pipeline,
request::pipeline::list_streams_with_pipeline,
request::pipeline::delete_pipeline,
request::pipeline::update_pipeline,
request::pipeline::enable_pipeline,
request::pipeline::enable_pipeline_bulk,
request::pipelines::history::get_pipeline_history,
request::pipelines::backfill::create_backfill,
request::pipelines::backfill::list_backfills,
request::pipelines::backfill::get_backfill,
request::pipelines::backfill::enable_backfill,
request::pipelines::backfill::update_backfill,
request::pipelines::backfill::delete_backfill,
kv::get,
kv::set,
kv::delete,
kv::list,
clusters::list_clusters,
short_url::shorten,
short_url::retrieve,
ratelimit::list_module_ratelimit,
ratelimit::list_role_ratelimit,
ratelimit::update_ratelimit,
service_accounts::list,
service_accounts::save,
service_accounts::update,
service_accounts::delete,
mcp::handle_mcp_post,
mcp::handle_mcp_get,
mcp::oauth_authorization_server_metadata,
pipeline::save_pipeline,
pipeline::list_pipelines,
pipeline::get_pipeline,
pipeline::list_streams_with_pipeline,
pipeline::delete_pipeline,
pipeline::update_pipeline,
pipeline::enable_pipeline,
pipeline::enable_pipeline_bulk,
pipelines::history::get_pipeline_history,
pipelines::backfill::create_backfill,
pipelines::backfill::list_backfills,
pipelines::backfill::get_backfill,
pipelines::backfill::enable_backfill,
pipelines::backfill::update_backfill,
pipelines::backfill::delete_backfill,
openobserve_api_management::request::dashboards::reports::create_report,
openobserve_api_management::request::dashboards::reports::update_report,
openobserve_api_management::request::dashboards::reports::list_reports,
@ -204,12 +215,12 @@ use crate::{common::meta, handler::http::request};
openobserve_api_management::request::dashboards::reports::enable_report_v2,
openobserve_api_management::request::dashboards::reports::trigger_report_v2,
openobserve_api_management::request::dashboards::reports::move_reports,
request::actions::action::upload_zipped_action,
request::actions::action::delete_action,
request::actions::action::serve_action_zip,
request::actions::action::update_action_details,
request::actions::action::list_actions,
request::actions::action::get_action_from_id,
actions::action::upload_zipped_action,
actions::action::delete_action,
actions::action::serve_action_zip,
actions::action::update_action_details,
actions::action::list_actions,
actions::action::get_action_from_id,
openobserve_api_management::request::authz::fga::create_role,
openobserve_api_management::request::authz::fga::delete_role,
openobserve_api_management::request::authz::fga::get_roles,
@ -223,11 +234,11 @@ use crate::{common::meta, handler::http::request};
openobserve_api_management::request::authz::fga::get_groups,
openobserve_api_management::request::authz::fga::get_group_details,
openobserve_api_management::request::authz::fga::delete_group,
request::keys::save,
request::keys::get,
request::keys::list,
request::keys::delete,
request::keys::update,
keys::save,
keys::get,
keys::list,
keys::delete,
keys::update,
openobserve_api_search::search::search_job::submit_job,
openobserve_api_search::search::search_job::list_status,
openobserve_api_search::search::search_job::get_status,
@ -237,17 +248,17 @@ use crate::{common::meta, handler::http::request};
openobserve_api_search::search::search_job::retry_job,
openobserve_api_search::search::search_stream::search_http2_stream,
openobserve_api_search::search::search_stream::values_http2_stream,
request::patterns::extract_patterns,
patterns::extract_patterns,
openobserve_core::traces::service_graph::api::get_current_topology,
request::service_streams::list_services,
request::service_streams::get_dimension_analytics,
request::service_streams::correlate_streams,
request::service_streams::get_identity_config,
request::service_streams::save_identity_config,
request::gen_ai::clear_agent_registry,
request::gen_ai::get_agent_mapping,
request::gen_ai::list_scored_agents,
request::gen_ai::save_agent_mapping,
service_streams::list_services,
service_streams::get_dimension_analytics,
service_streams::correlate_streams,
service_streams::get_identity_config,
service_streams::save_identity_config,
gen_ai::clear_agent_registry,
gen_ai::get_agent_mapping,
gen_ai::list_scored_agents,
gen_ai::save_agent_mapping,
openobserve_api_management::request::alerts::deduplication::get_config,
openobserve_api_management::request::alerts::deduplication::set_config,
openobserve_api_management::request::alerts::deduplication::delete_config,
@ -255,19 +266,27 @@ use crate::{common::meta, handler::http::request};
openobserve_api_management::request::alerts::deduplication::preview_semantic_groups_diff,
openobserve_api_management::request::alerts::deduplication::save_semantic_groups,
openobserve_api_management::request::alerts::dedup_stats::get_dedup_summary,
request::synthetics::list_synthetics,
request::synthetics::create_synthetic,
request::synthetics::get_synthetic,
request::synthetics::update_synthetic,
request::synthetics::delete_synthetic,
request::synthetics::set_synthetic_enabled,
request::synthetics::run_synthetic_now,
request::synthetics::list_locations,
request::synthetics::list_runs,
request::synthetics::get_run_detail,
request::synthetics::job_resolve,
request::synthetics::job_lease,
request::synthetics::job_ack,
openobserve_api_management::request::slos::list_slos,
openobserve_api_management::request::slos::get_slo,
openobserve_api_management::request::slos::create_slo,
openobserve_api_management::request::slos::update_slo,
openobserve_api_management::request::slos::delete_slo,
openobserve_api_management::request::slos::enable_slo,
openobserve_api_management::request::slos::get_slo_groups,
openobserve_api_management::request::slos::move_slos,
synthetics::list_synthetics,
synthetics::create_synthetic,
synthetics::get_synthetic,
synthetics::update_synthetic,
synthetics::delete_synthetic,
synthetics::set_synthetic_enabled,
synthetics::run_synthetic_now,
synthetics::list_locations,
synthetics::list_runs,
synthetics::get_run_detail,
synthetics::job_resolve,
synthetics::job_lease,
synthetics::job_ack,
),
components(
schemas(
@ -284,8 +303,8 @@ use crate::{common::meta, handler::http::request};
config::meta::stream::StreamStats,
config::meta::stream::UpdateStreamSettings,
config::meta::gen_ai::GenAiAgentMappingConfig,
request::gen_ai::GenAiAgentListItem,
request::gen_ai::GenAiAgentListResponse,
gen_ai::GenAiAgentListItem,
gen_ai::GenAiAgentListResponse,
config::meta::dashboards::Dashboard,
config::meta::dashboards::v1::AxisItem,
config::meta::dashboards::v1::Dashboard,
@ -317,7 +336,7 @@ use crate::{common::meta, handler::http::request};
// Enrichment Tables
config::meta::enrichment_table::EnrichmentTableStatus,
config::meta::enrichment_table::EnrichmentTableUrlJob,
crate::handler::http::request::enrichment_table::EnrichmentTableUrlRequest,
enrichment_table::EnrichmentTableUrlRequest,
// Dashboards
openobserve_api_management::models::dashboards::DashboardRequestBody,
openobserve_api_management::models::dashboards::DashboardResponseBody,
@ -336,6 +355,7 @@ use crate::{common::meta, handler::http::request};
openobserve_api_management::models::alerts::responses::GetAlertResponseBody,
openobserve_api_management::models::alerts::responses::ListAlertsResponseBody,
openobserve_api_management::models::alerts::responses::ListAlertsResponseBodyItem,
openobserve_api_management::request::alerts::AlertTagCount,
openobserve_api_management::models::alerts::responses::EnableAlertResponseBody,
openobserve_api_management::models::alerts::Alert,
openobserve_api_management::models::alerts::TriggerCondition,
@ -429,7 +449,7 @@ use crate::{common::meta, handler::http::request};
meta::organization::RumIngestionToken,
openobserve_api_management::request::organization::assume_service_account::AssumeServiceAccountRequest,
openobserve_api_management::request::organization::assume_service_account::AssumeServiceAccountResponse,
request::status::HealthzResponse,
status::HealthzResponse,
ingestion_common::BulkResponse,
ingestion_common::BulkResponseItem,
ingestion_common::ShardResponse,
@ -437,7 +457,7 @@ use crate::{common::meta, handler::http::request};
config::meta::promql::Metadata,
config::meta::promql::MetricType,
// Service Streams (enterprise)
request::service_streams::CorrelationRequest,
service_streams::CorrelationRequest,
config::meta::service_streams::CorrelationResponse,
config::meta::service_streams::DimensionAnalytics,
config::meta::service_streams::DimensionAnalyticsSummary,
@ -454,8 +474,8 @@ use crate::{common::meta, handler::http::request};
config::meta::alerts::deduplication::SendStrategy,
openobserve_api_management::request::alerts::dedup_stats::DedupSummaryResponse,
// Backfill
request::pipelines::backfill::BackfillRequest,
request::pipelines::backfill::BackfillResponse,
pipelines::backfill::BackfillRequest,
pipelines::backfill::BackfillResponse,
openobserve_core::alerts::backfill::BackfillJobStatus,
// Synthetics
config::meta::synthetics::Synthetic,

View File

@ -38,8 +38,7 @@ use opentelemetry_proto::tonic::collector::logs::v1::ExportLogsServiceRequest;
use prost::Message;
use crate::{
common::meta::http::HttpResponse as MetaHttpResponse,
request::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTO},
common::meta::http::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTO, HttpResponse as MetaHttpResponse},
service::{
ingestion::get_thread_id,
logs::{self, otlp::handle_request},

View File

@ -27,8 +27,10 @@ use prost::Message;
use proto::loki_rpc;
use crate::{
common::meta::loki::{LokiError, LokiPushRequest},
request::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTO},
common::meta::{
http::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTO},
loki::{LokiError, LokiPushRequest},
},
service::{ingestion::get_thread_id, logs},
};

View File

@ -31,8 +31,7 @@ use openobserve_core::auth::UserEmail;
use openobserve_core::ingestion::check_ingestion_allowed;
use crate::{
common::meta::http::HttpResponse as MetaHttpResponse,
request::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTO},
common::meta::http::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTO, HttpResponse as MetaHttpResponse},
service::metrics,
};

View File

@ -17,5 +17,3 @@ pub mod clusters;
pub mod logs;
pub mod metrics;
pub mod rum;
pub use common::meta::http::{CONTENT_TYPE_JSON, CONTENT_TYPE_PROTO};

View File

@ -27,6 +27,33 @@ use serde_json::Value as JsonValue;
use svix_ksuid::Ksuid;
use utoipa::ToSchema;
/// Deserialize an optional float that has to survive a `#[serde(flatten)]`.
///
/// `serde_json` is built workspace-wide with `arbitrary_precision` (root
/// `Cargo.toml`), which represents a non-integer number as a magic one-key
/// map rather than visiting `f64`. `CreateAlertRequestBody`/`UpdateAlert...`
/// flatten the whole `Alert`, and `flatten` makes serde buffer the body
/// through its `Content` type first — at which point that map can no longer
/// be visited as an `f64`. A plain `Option<f64>` field therefore rejects
/// `99.5` with "invalid type: map, expected f64" while quietly accepting
/// `99`, so a fractional warning threshold is unreachable over the API.
///
/// Routing through `serde_json::Number` reads both the buffered map and a
/// direct number, and still rejects strings and non-numeric values.
fn de_opt_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let parsed = Option::<serde_json::Number>::deserialize(deserializer)?;
parsed
.map(|n| {
n.as_f64().ok_or_else(|| {
serde::de::Error::custom(format!("`{n}` is not representable as a number"))
})
})
.transpose()
}
/// Alert configuration for monitoring streams and triggering notifications.
///
/// An alert watches a stream (logs, metrics, or traces) using SQL or PromQL queries,
@ -145,6 +172,24 @@ pub struct Alert {
#[serde(default)]
#[schema(example = json!(["abcde12345"]))]
pub workflows: Vec<String>,
/// Priority 1..=5 (P1 = most urgent), Feature 2 / PT-1.
///
/// Mutable configuration; display + propagation only — it does not affect
/// when the alert fires. Omitted = unset.
///
/// `value_type` is required because the enum serializes as an integer via
/// serde `try_from`/`into`; without it the OpenAPI schema would advertise a
/// string enum and lie about the payload.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<u8>, example = 3)]
pub priority: Option<config::meta::alerts::priority::AlertPriority>,
/// Selection tags (PT-6): `prod`, `service:checkout`. Normalized and
/// validated on save; omitted when empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[schema(example = json!(["prod", "service:checkout"]))]
pub tags: Vec<String>,
}
/// Configuration for when and how an alert should be triggered.
@ -177,6 +222,23 @@ pub struct TriggerCondition {
#[schema(example = 100)]
pub threshold_count: i64,
/// Optional WARNING threshold, sharing `operator` with `threshold` — one
/// operator for both levels, no mixed directions. Omitted = single-level
/// alert, i.e. exactly the current behaviour. Must be strictly less severe than `threshold` —
/// "less severe" is operator-dependent, so `>` requires a smaller value and
/// `<` a larger one; `=`/`!=`/`contains` reject it outright.
#[serde(rename = "warning_threshold", skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[schema(example = 50)]
pub warning_threshold_count: Option<i64>,
/// Whether a Warning-level match sends a notification. Defaults to true —
/// opting out is explicit. Set false for "page me only on critical" —
/// warnings still update state, history and the UI.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(example = true)]
pub notify_on_warning: Option<bool>,
/// How often (in minutes) to run the alert query. Used with frequency_type="minutes".
#[serde(rename = "frequency")]
#[serde(default)]
@ -293,6 +355,28 @@ pub struct QueryCondition {
/// Condition to apply to PromQL results. Required with type="promql".
pub promql_condition: Option<Condition>,
/// Optional WARNING value for the PromQL condition, sharing
/// `promql_condition.operator` with critical. Omitted = single-level.
// Lenient: reached through CreateAlertRequestBody's `#[serde(flatten)]`,
// which buffers via `Value`, where `arbitrary_precision` makes a number a
// map. Without this a FRACTIONAL warning is rejected while an integer one
// is accepted. Both branches found this independently; `de_opt_f64` is the
// local helper and `config::meta::slo::lenient_f64` the shared one used
// where this helper is not reachable.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "de_opt_f64"
)]
#[schema(example = 300.0)]
pub promql_warning_value: Option<f64>,
/// Evaluate and page per SERIES rather than collapsing the query to one
/// verdict (M-9). PromQL's counterpart to `aggregation.multi_alert`; the
/// group key is the series' full label set, chosen by the expression's own
/// `by (…)` clause.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub promql_multi_alert: bool,
/// Aggregation configuration for "custom" query type.
pub aggregation: Option<Aggregation>,
@ -308,13 +392,40 @@ pub struct QueryCondition {
/// Historical comparison periods for anomaly detection.
#[serde(default)]
pub multi_time_range: Option<Vec<CompareHistoricData>>,
/// SLO condition. Required with type="slo" (Feature 5, D42).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Object>)]
pub slo_condition: Option<config::meta::slo::condition::SloCondition>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, PartialEq)]
pub struct Aggregation {
pub group_by: Option<Vec<String>>,
pub function: AggFunction,
/// CRITICAL threshold for the aggregate value.
pub having: Condition,
/// Optional WARNING threshold, sharing `having.operator` and
/// `having.column` with critical. Omitted = single-level aggregation
/// alert. Must be strictly less severe than `having.value` — direction
/// depends on the operator.
// Same lenient deserialization as `promql_warning_value`, same reason.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "de_opt_f64"
)]
#[schema(example = 50.0)]
pub warning_value: Option<f64>,
/// Opt in to per-group evaluation (multi-alerts): each group gets its own
/// level, state row and notifications. Omitted = `false` = the alert
/// evaluates as a single collapsed result, exactly as before.
///
/// Requires a non-empty `group_by`, an orderable `having.operator`, and
/// "any group" count thresholds — see `alerts_2.md` M-9/M-10.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
#[schema(example = false)]
pub multi_alert: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
@ -352,6 +463,10 @@ pub enum QueryType {
SQL,
#[serde(rename = "promql")]
PromQL,
/// Feature 5 (D28). An SLO alert reads precomputed SLO status rather than
/// running a query.
#[serde(rename = "slo")]
Slo,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
@ -449,6 +564,8 @@ impl From<(meta_alerts::alert::Alert, Option<Trigger>)> for Alert {
deduplication: alert.deduplication,
creates_incident: alert.creates_incident,
workflows: alert.workflows,
priority: alert.priority,
tags: alert.tags,
}
}
}
@ -459,6 +576,8 @@ impl From<meta_alerts::TriggerCondition> for TriggerCondition {
period_minutes: value.period,
operator: value.operator.into(),
threshold_count: value.threshold,
warning_threshold_count: value.warning_threshold,
notify_on_warning: value.notify_on_warning,
frequency_minutes: value.frequency / 60,
cron: value.cron,
frequency_type: value.frequency_type.into(),
@ -496,12 +615,15 @@ impl From<meta_alerts::QueryCondition> for QueryCondition {
sql: value.sql,
promql: value.promql,
promql_condition: value.promql_condition.map(|pc| pc.into()),
promql_warning_value: value.promql_warning_value,
promql_multi_alert: value.promql_multi_alert,
aggregation: value.aggregation.map(|a| a.into()),
vrl_function: value.vrl_function,
search_event_type: value.search_event_type.map(|t| t.into()),
multi_time_range: value
.multi_time_range
.map(|cs| cs.into_iter().map(|c| c.into()).collect()),
slo_condition: value.slo_condition,
}
}
}
@ -512,6 +634,8 @@ impl From<meta_alerts::Aggregation> for Aggregation {
group_by: value.group_by,
function: value.function.into(),
having: value.having.into(),
warning_value: value.warning_value,
multi_alert: value.multi_alert,
}
}
}
@ -540,6 +664,7 @@ impl From<meta_alerts::QueryType> for QueryType {
meta_alerts::QueryType::Custom => Self::Custom,
meta_alerts::QueryType::SQL => Self::SQL,
meta_alerts::QueryType::PromQL => Self::PromQL,
meta_alerts::QueryType::Slo => Self::Slo,
}
}
}
@ -631,6 +756,8 @@ impl From<Alert> for meta_alerts::alert::Alert {
alert.deduplication = value.deduplication;
alert.creates_incident = value.creates_incident;
alert.workflows = value.workflows;
alert.priority = value.priority;
alert.tags = value.tags;
alert
}
@ -643,6 +770,8 @@ impl From<TriggerCondition> for meta_alerts::TriggerCondition {
period: value.period_minutes,
operator: value.operator.into(),
threshold: value.threshold_count,
warning_threshold: value.warning_threshold_count,
notify_on_warning: value.notify_on_warning,
frequency: value.frequency_minutes * 60,
cron: value.cron,
frequency_type: value.frequency_type.into(),
@ -679,12 +808,15 @@ impl From<QueryCondition> for meta_alerts::QueryCondition {
sql: value.sql,
promql: value.promql,
promql_condition: value.promql_condition.map(|pc| pc.into()),
promql_warning_value: value.promql_warning_value,
promql_multi_alert: value.promql_multi_alert,
aggregation: value.aggregation.map(|a| a.into()),
vrl_function: value.vrl_function,
search_event_type: value.search_event_type.map(|t| t.into()),
multi_time_range: value
.multi_time_range
.map(|cs| cs.into_iter().map(|c| c.into()).collect()),
slo_condition: value.slo_condition,
}
}
}
@ -695,6 +827,8 @@ impl From<Aggregation> for meta_alerts::Aggregation {
group_by: value.group_by,
function: value.function.into(),
having: value.having.into(),
warning_value: value.warning_value,
multi_alert: value.multi_alert,
}
}
}
@ -723,6 +857,7 @@ impl From<QueryType> for meta_alerts::QueryType {
QueryType::Custom => Self::Custom,
QueryType::SQL => Self::SQL,
QueryType::PromQL => Self::PromQL,
QueryType::Slo => Self::Slo,
}
}
}
@ -1186,6 +1321,8 @@ mod tests {
#[test]
fn test_trigger_condition_from_meta_converts_frequency_to_minutes() {
let meta = meta_alerts::TriggerCondition {
warning_threshold: None,
notify_on_warning: None,
period: 15,
operator: meta_alerts::Operator::GreaterThan,
threshold: 5,
@ -1211,6 +1348,8 @@ mod tests {
#[test]
fn test_trigger_condition_to_meta_converts_frequency_to_seconds() {
let tc = TriggerCondition {
warning_threshold_count: None,
notify_on_warning: None,
period_minutes: 10,
operator: Operator::LessThan,
threshold_count: 3,
@ -1236,6 +1375,8 @@ mod tests {
#[test]
fn test_aggregation_from_meta() {
let meta = meta_alerts::Aggregation {
warning_value: None,
multi_alert: false,
group_by: Some(vec!["service".to_string(), "region".to_string()]),
function: meta_alerts::AggFunction::Count,
having: meta_alerts::Condition {
@ -1257,6 +1398,8 @@ mod tests {
#[test]
fn test_aggregation_to_meta() {
let agg = Aggregation {
warning_value: None,
multi_alert: false,
group_by: None,
function: AggFunction::Avg,
having: Condition {
@ -1280,10 +1423,13 @@ mod tests {
sql: Some("SELECT count(*) FROM logs".to_string()),
promql: None,
promql_condition: None,
promql_warning_value: None,
promql_multi_alert: false,
aggregation: None,
vrl_function: None,
search_event_type: None,
multi_time_range: None,
slo_condition: None,
};
let qc = QueryCondition::from(meta);
assert!(matches!(qc.query_type, QueryType::SQL));
@ -1292,6 +1438,65 @@ mod tests {
assert!(qc.aggregation.is_none());
}
/// The API model is the only path a client can set this flag through, so
/// a conversion that silently dropped it would make the whole feature
/// unreachable over HTTP while every layer below it still worked.
#[test]
fn test_promql_multi_alert_survives_the_round_trip_through_the_api_model() {
let qc = QueryCondition {
query_type: QueryType::PromQL,
conditions: None,
sql: None,
promql: Some("sum by (pod) (rate(errors[5m]))".to_string()),
promql_condition: None,
promql_warning_value: None,
promql_multi_alert: true,
aggregation: None,
vrl_function: None,
search_event_type: None,
multi_time_range: None,
slo_condition: None,
};
let meta = meta_alerts::QueryCondition::from(qc);
assert!(meta.promql_multi_alert);
assert!(meta.multi_alert_enabled());
// ...and back out again, for the GET that renders the edit form.
let back = QueryCondition::from(meta);
assert!(back.promql_multi_alert);
}
#[test]
fn test_promql_multi_alert_defaults_off_through_the_api_model() {
let meta = meta_alerts::QueryCondition {
query_type: meta_alerts::QueryType::PromQL,
conditions: None,
sql: None,
promql: Some("up == 0".to_string()),
promql_condition: None,
promql_warning_value: None,
promql_multi_alert: false,
aggregation: None,
vrl_function: None,
search_event_type: None,
multi_time_range: None,
slo_condition: None,
};
assert!(!QueryCondition::from(meta).promql_multi_alert);
}
/// A request body that never mentions the field must parse, and parse as
/// off — that is every PromQL alert any existing client sends.
#[test]
fn test_an_api_payload_without_the_field_parses_as_off() {
let body = serde_json::json!({
"type": "promql",
"promql": "up == 0",
});
let qc: QueryCondition = serde_json::from_value(body).expect("deserializable");
assert!(!qc.promql_multi_alert);
}
#[test]
fn test_query_condition_to_meta_sql() {
let qc = QueryCondition {
@ -1300,10 +1505,13 @@ mod tests {
sql: Some("SELECT count(*) FROM logs".to_string()),
promql: None,
promql_condition: None,
promql_warning_value: None,
promql_multi_alert: false,
aggregation: None,
vrl_function: Some("fn".to_string()),
search_event_type: None,
multi_time_range: None,
slo_condition: None,
};
let meta = meta_alerts::QueryCondition::from(qc);
assert!(matches!(meta.query_type, meta_alerts::QueryType::SQL));

View File

@ -108,7 +108,12 @@ pub struct AnomalyAlertFields {
pub retrain_interval_days: Option<i32>,
/// Percentile threshold (50.099.9). Default: 97.0
/// Also accepts the name `threshold` (integer, e.g. 97) for API convenience.
#[serde(alias = "threshold")]
#[serde(
default,
alias = "threshold",
skip_serializing_if = "Option::is_none",
deserialize_with = "config::meta::slo::lenient_f64::deserialize_opt"
)]
pub percentile: Option<f64>,
pub rcf_num_trees: Option<i32>,
pub rcf_tree_size: Option<i32>,
@ -173,6 +178,13 @@ pub struct UpdateAnomalyAlertFields {
pub detection_window_seconds: Option<i64>,
pub training_window_days: Option<i32>,
pub retrain_interval_days: Option<i32>,
/// Also accepts the name `threshold`, matching the create body.
#[serde(
default,
alias = "threshold",
skip_serializing_if = "Option::is_none",
deserialize_with = "config::meta::slo::lenient_f64::deserialize_opt"
)]
pub percentile: Option<f64>,
pub alert_enabled: Option<bool>,
pub enabled: Option<bool>,
@ -235,6 +247,28 @@ pub struct ListAlertsQuery {
/// Optional alert type filter: `all` (default), `scheduled`, `realtime`,
/// or `anomaly_detection`.
pub alert_type: Option<meta_alerts::AlertTypeFilter>,
/// Optional priority filter (PT-3), OR semantics.
///
/// Accepts BOTH shapes, because both are natural to type and to generate:
/// * repeated — `?priority=1&priority=2`
/// * comma-separated — `?priority=1,2`
///
/// Each value may be `1` or `P1`. Declared as a `Vec` so repeated keys
/// deserialize; absent leaves it empty, which means *no filter*.
#[serde(default)]
pub priority: Vec<String>,
/// Optional tag filter (PT-8). Comma-separated, **AND** semantics — an
/// alert must carry every listed tag.
pub tags: Option<String>,
/// Optional sort column: `priority` or `name`. Anything else keeps the
/// historical ordering.
pub sort_by: Option<String>,
/// Sort direction: `desc` for descending, anything else ascending.
pub sort_order: Option<String>,
}
/// HTTP URL query component that contains parameters for enabling alerts.
@ -275,8 +309,58 @@ impl ListAlertsQuery {
.page_size
.map(|page_size| (page_size, self.page_idx.unwrap_or(0))),
alert_type: self.alert_type.unwrap_or_default(),
priority: parse_priority_filter(&self.priority),
// Resolved by the service layer, which owns the alert cache the
// infra layer cannot reach (PT-8).
tag_alert_ids: None,
sort_by: match self.sort_by.as_deref() {
Some("priority") => Some(meta_alerts::AlertSortField::Priority),
Some("name") => Some(meta_alerts::AlertSortField::Name),
_ => None,
},
sort_desc: matches!(self.sort_order.as_deref(), Some("desc")),
}
}
/// The requested tag filter, normalized leniently (PT-8/D22).
///
/// Invalid tokens are KEPT: dropping them would collapse `?tags=!!!` into
/// an empty filter, and an empty filter matches every alert — a request
/// that must return nothing would return everything.
pub fn requested_tags(&self) -> Vec<String> {
let raw: Vec<String> = self
.tags
.as_deref()
.unwrap_or_default()
.split(',')
.map(|t| t.to_string())
.collect();
config::meta::alerts::tags::normalize_filter_tags(&raw)
}
}
/// Parse a priority filter, accepting repeated keys and comma-separated
/// values, `1` and `P1` alike.
///
/// Returns `None` only when NOTHING was requested. When the caller did ask for
/// priorities but none parsed, this returns `Some(empty)` — which the query
/// layer treats as "match nothing". Returning `None` there would turn
/// `?priority=P9` into an unfiltered list and hand back every alert, the
/// match-all bug the tag filter already guards against.
fn parse_priority_filter(
raw: &[String],
) -> Option<Vec<config::meta::alerts::priority::AlertPriority>> {
// Split each entry on commas so both shapes collapse to one token list.
let tokens: Vec<&str> = raw
.iter()
.flat_map(|v| v.split(','))
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.collect();
if tokens.is_empty() {
return None; // genuinely no filter
}
Some(tokens.iter().filter_map(|t| t.parse().ok()).collect())
}
#[derive(Deserialize, ToSchema)]
@ -324,6 +408,101 @@ pub fn combine_detection_function(
mod tests {
use super::*;
/// Minimal create body carrying an aggregation warning threshold.
fn body_with_warning_value(warning: &str) -> String {
format!(
r#"{{
"name": "agg",
"stream_type": "logs",
"stream_name": "s",
"is_real_time": false,
"destinations": ["d"],
"query_condition": {{
"type": "custom",
"conditions": [],
"aggregation": {{
"group_by": ["host"],
"function": "avg",
"having": {{"column": "latency", "operator": ">=", "value": 500}},
"warning_value": {warning}
}}
}},
"trigger_condition": {{
"period": 1, "operator": ">=", "threshold": 1,
"frequency": 1, "frequency_type": "minutes", "silence": 0
}}
}}"#
)
}
fn parse_warning_value(warning: &str) -> Result<Option<f64>, serde_json::Error> {
let body: CreateAlertRequestBody = serde_json::from_str(&body_with_warning_value(warning))?;
Ok(body
.alert
.query_condition
.aggregation
.and_then(|a| a.warning_value))
}
/// The regression: `serde_json`'s `arbitrary_precision` renders a float as
/// a magic map, and `CreateAlertRequestBody`'s `#[serde(flatten)]` buffers
/// the body before the field is read — so a plain `Option<f64>` used to
/// reject every fractional threshold with "invalid type: map, expected
/// f64" while accepting the integer next to it. An average-latency
/// warning band is fractional by nature, so this made the field
/// unreachable over the API.
#[test]
fn test_fractional_warning_value_survives_the_flatten_buffer() {
assert_eq!(parse_warning_value("199.5").unwrap(), Some(199.5));
assert_eq!(parse_warning_value("0.25").unwrap(), Some(0.25));
assert_eq!(parse_warning_value("-2.5").unwrap(), Some(-2.5));
}
#[test]
fn test_integer_and_absent_warning_values_are_unchanged() {
assert_eq!(parse_warning_value("200").unwrap(), Some(200.0));
assert_eq!(parse_warning_value("null").unwrap(), None);
}
#[test]
fn test_non_numeric_warning_value_is_still_rejected() {
// Leniency is only about representation, not about accepting junk.
assert!(parse_warning_value("\"200\"").is_err());
assert!(parse_warning_value("true").is_err());
}
/// Pins the defect the `de_opt_f64` workaround exists for, so the two
/// tests above cannot silently become tautologies.
///
/// A plain `Option<f64>` behind a `#[serde(flatten)]` still cannot read a
/// fractional number while `arbitrary_precision` is on. **If this test
/// ever fails, the bug is fixed upstream (or the feature was dropped) and
/// `de_opt_f64` can be deleted** — it is not a test of our code so much as
/// of the constraint our code works around.
#[test]
fn test_plain_option_f64_behind_flatten_still_cannot_read_a_float() {
#[derive(serde::Deserialize, Debug)]
struct Inner {
plain: Option<f64>,
}
#[derive(serde::Deserialize, Debug)]
struct Outer {
#[serde(flatten)]
inner: Inner,
}
// The integer form works, which is exactly why the bug hid so well.
let ok: Outer = serde_json::from_str(r#"{"plain": 200}"#).unwrap();
assert_eq!(ok.inner.plain, Some(200.0));
let err = serde_json::from_str::<Outer>(r#"{"plain": 199.5}"#)
.expect_err("arbitrary_precision + flatten should still reject a bare f64 float");
assert!(
err.to_string().contains("invalid type: map"),
"unexpected failure mode, workaround may need revisiting: {err}"
);
}
#[test]
fn test_combine_none_function_returns_none() {
assert_eq!(combine_detection_function(None, None), None);
@ -457,6 +636,10 @@ mod tests {
page_size: Some(20),
page_idx: Some(1),
alert_type: None,
priority: vec![],
tags: None,
sort_by: None,
sort_order: None,
};
let params = q.into("my_org");
assert_eq!(params.org_id, "my_org");
@ -478,6 +661,10 @@ mod tests {
page_size: None,
page_idx: None,
alert_type: None,
priority: vec![],
tags: None,
sort_by: None,
sort_order: None,
};
let params = q.into("org2");
assert_eq!(params.org_id, "org2");
@ -497,6 +684,10 @@ mod tests {
page_size: Some(5),
page_idx: None,
alert_type: None,
priority: vec![],
tags: None,
sort_by: None,
sort_order: None,
};
let params = q.into("org3");
assert_eq!(params.page_size_and_idx, Some((5, 0)));
@ -517,3 +708,124 @@ pub struct GenerateSqlRequestBody {
/// The conditions field within QueryCondition supports both V1 and V2 formats
pub query_condition: QueryCondition,
}
#[cfg(test)]
mod priority_tag_query_tests {
use config::meta::alerts::priority::AlertPriority;
use super::*;
fn q(
priority: Option<&str>,
tags: Option<&str>,
sort_by: Option<&str>,
order: Option<&str>,
) -> ListAlertsQuery {
// `priority` is a Vec so repeated query keys deserialize; a single
// comma-separated string is still one entry.
let priority = priority.map(|p| vec![p.to_string()]).unwrap_or_default();
ListAlertsQuery {
folder: None,
alert_name_substring: None,
stream_type: None,
stream_name: None,
enabled: None,
owner: None,
page_size: None,
page_idx: None,
alert_type: None,
priority,
tags: tags.map(|s| s.to_string()),
sort_by: sort_by.map(|s| s.to_string()),
sort_order: order.map(|s| s.to_string()),
}
}
/// Query strings are typed by humans, so both `1` and `P1` parse (PT-3).
#[test]
fn test_priority_filter_accepts_both_forms_and_ors_them() {
let p = q(Some("1,P2, p3"), None, None, None).into("org");
assert_eq!(
p.priority,
Some(vec![
AlertPriority::P1,
AlertPriority::P2,
AlertPriority::P3
])
);
}
/// A bad filter value narrows nothing rather than failing the page — a
/// filter is a view, not a write.
#[test]
fn test_unparseable_priority_values_are_skipped_not_fatal() {
let p = q(Some("banana,2,P9"), None, None, None).into("org");
assert_eq!(p.priority, Some(vec![AlertPriority::P2]));
}
#[test]
fn test_absent_priority_means_no_filter() {
// None, NOT Some(empty): nothing was requested, so nothing is filtered.
assert_eq!(q(None, None, None, None).into("org").priority, None);
}
/// REGRESSION GUARD: an all-invalid priority filter must narrow to NOTHING.
///
/// If parsing returned `None` here, the query layer would read it as "no
/// filter" and `?priority=P9` would hand back every alert — the same
/// match-all bug the tag filter guards against.
#[test]
fn test_all_invalid_priority_filter_matches_nothing_not_everything() {
let p = q(Some("P9,banana"), None, None, None).into("org");
assert_eq!(
p.priority,
Some(vec![]),
"must be an empty filter set, never None"
);
}
/// PT-3 requires the repeated form as well as the comma-separated one.
#[test]
fn test_repeated_priority_keys_are_supported() {
let mut query = q(None, None, None, None);
query.priority = vec!["1".to_string(), "P2".to_string()];
assert_eq!(
query.into("org").priority,
Some(vec![AlertPriority::P1, AlertPriority::P2])
);
}
#[test]
fn test_sort_by_and_order_parse() {
let p = q(None, None, Some("priority"), Some("desc")).into("org");
assert_eq!(p.sort_by, Some(meta_alerts::AlertSortField::Priority));
assert!(p.sort_desc);
let n = q(None, None, Some("name"), None).into("org");
assert_eq!(n.sort_by, Some(meta_alerts::AlertSortField::Name));
assert!(!n.sort_desc);
// Unknown column keeps the historical ordering rather than erroring.
let u = q(None, None, Some("nonsense"), None).into("org");
assert_eq!(u.sort_by, None);
}
/// REGRESSION GUARD: an all-invalid tag filter must NOT collapse to empty,
/// because an empty filter matches every alert.
#[test]
fn test_invalid_tags_are_retained_so_the_filter_still_narrows() {
let tags = q(None, Some("!!!"), None, None).requested_tags();
assert_eq!(tags, vec!["!!!"], "must not collapse to a match-all filter");
}
#[test]
fn test_tag_filter_is_case_normalized_and_drops_separator_blanks() {
let tags = q(None, Some("Prod,,SERVICE:Checkout"), None, None).requested_tags();
assert_eq!(tags, vec!["prod", "service:checkout"]);
}
#[test]
fn test_absent_tags_means_no_filter() {
assert!(q(None, None, None, None).requested_tags().is_empty());
}
}

View File

@ -70,6 +70,158 @@ pub struct ListAlertsResponseBodyItem {
/// Last error message from training or detection. Only present for `anomaly_detection` items.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
/// Outcome of the most recent evaluation: "firing" | "normal" | "error" |
/// "notify_failed" | "succeeded". Absent when the alert has never run.
///
/// This is the LAST RUN OUTCOME, not a live "currently firing" flag —
/// always render it alongside `last_outcome_at`. See Part IV of alerts.md.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_outcome: Option<String>,
/// When `last_outcome` was recorded (microseconds).
#[serde(skip_serializing_if = "Option::is_none")]
pub last_outcome_at: Option<i64>,
/// When `last_outcome` last CHANGED (microseconds) — i.e. how long the
/// alert has been in its current state.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_outcome_since: Option<i64>,
/// Severity of the last classification: "ok" | "warning" | "critical".
///
/// A SEPARATE axis from `last_outcome`: that says whether the evaluation
/// fired, this says how bad. An alert can be `firing` at `warning`.
/// Absent for single-level alerts that never classified.
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
/// When `level` last CHANGED — powers "critical for 20 minutes".
#[serde(skip_serializing_if = "Option::is_none")]
pub level_since: Option<i64>,
/// Configured priority as the integer storage id 1..=5 (Feature 2, PT-3).
///
/// A THIRD axis: `enabled` is is-it-running, `last_outcome` is
/// did-it-fire, `level` is how-bad-now, and this is how much humans care.
/// Absent when unset, which is every pre-Feature-2 alert.
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<u8>, example = 3)]
pub priority: Option<u8>,
/// Normalized selection tags (PT-6). Omitted when empty.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Multi-alerts only (§5.4): how many groups the last evaluation observed,
/// counted **before** the M-6 cap truncated them. Absent for every alert
/// that has not opted in.
#[serde(skip_serializing_if = "Option::is_none")]
pub groups_observed: Option<i32>,
/// Multi-alerts only: how many of those groups were warning-or-worse,
/// also counted pre-cap. With `groups_observed` this is the "N of M groups
/// firing" chip. Counting retained state rows instead would silently
/// under-report whenever more than the cap's worth of groups fire, which
/// is the silent truncation M-6 forbids.
#[serde(skip_serializing_if = "Option::is_none")]
pub groups_firing: Option<i32>,
/// Whether `groups_observed` is a `>=` lower bound — the bounded fetch
/// page came back full, so groups beyond it were never seen. Render the
/// count with a `≥`.
#[serde(skip_serializing_if = "Option::is_none")]
pub groups_observed_is_lower_bound: Option<bool>,
/// Whether `groups_firing` is a `>=` lower bound. Tracked separately
/// because the two diverge: a full page that still reached healthy groups
/// has seen every firing group, so this stays exact while
/// `groups_observed` does not.
#[serde(skip_serializing_if = "Option::is_none")]
pub groups_firing_is_lower_bound: Option<bool>,
}
/// One tracked group of a multi-alert (§5.4's group table).
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct AlertGroupResponseItem {
/// Deterministic hash of the group's label set — the state identity, and
/// the key the per-group history filter takes.
pub group_key: String,
/// Rendered `k=v,k=v` labels, for display only. `group_key` stays the
/// identity because a readable rendering is ambiguous once a label value
/// contains the separators.
#[serde(skip_serializing_if = "Option::is_none")]
pub group_labels: Option<String>,
/// Parsed form of `group_labels`, so the UI does not re-parse the string.
pub labels: Vec<AlertGroupLabel>,
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
/// When `level` last changed — "critical for 20 minutes".
#[serde(skip_serializing_if = "Option::is_none")]
pub level_since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_outcome: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_outcome_at: Option<i64>,
/// Last evaluation that actually included this group (M-7). A group whose
/// `last_seen` is falling behind is on its way to being resolved.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_seen: Option<i64>,
/// Per-group silence window (MN-2): suppress re-delivery until this
/// instant. Absent when the group is not silenced.
#[serde(skip_serializing_if = "Option::is_none")]
pub silenced_until: Option<i64>,
/// Level of this group's last *successful* delivery — what escalation is
/// measured against. Absent when it has never paged.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_notified_level: Option<String>,
}
/// One `key=value` pair of a group's label set.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct AlertGroupLabel {
pub name: String,
pub value: String,
}
/// HTTP response body for `ListAlertGroups`.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct ListAlertGroupsResponseBody {
/// Tracked groups, most severe first.
pub list: Vec<AlertGroupResponseItem>,
/// Pre-cap totals from the rollup row, so the caller can render
/// "N of M groups firing" without counting the (post-cap) list above.
#[serde(skip_serializing_if = "Option::is_none")]
pub groups_observed: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub groups_firing: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub groups_observed_is_lower_bound: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub groups_firing_is_lower_bound: Option<bool>,
/// True when the last evaluation observed more groups than the M-6 cap
/// tracks, so `list` is a truncated view. Drives the cap banner — M-6
/// forbids truncating silently.
pub capped: bool,
/// The cap in force, for the banner's wording.
pub group_cap: usize,
}
/// One per-group state transition (M-8's history source).
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct AlertGroupTransitionItem {
pub group_key: String,
/// Carried on the transition itself, so history outlives the state row the
/// reaper deletes.
#[serde(skip_serializing_if = "Option::is_none")]
pub group_labels: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub to_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_outcome: Option<String>,
pub to_outcome: String,
pub at: i64,
/// Observed value at transition time. `None` where nothing was observed —
/// a group that vanished has no reading, and rendering 0 would be a lie.
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<f64>,
}
/// HTTP response body for `ListAlertGroupTransitions`.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct ListAlertGroupTransitionsResponseBody {
pub list: Vec<AlertGroupTransitionItem>,
}
/// HTTP response body for `EnableAlert` endpoint.
@ -137,6 +289,19 @@ impl TryFrom<(meta_folders::Folder, meta_alerts::Alert, Option<Trigger>)>
last_trained_at: None,
status: None,
last_error: None,
last_outcome: None,
last_outcome_at: None,
last_outcome_since: None,
level: None,
level_since: None,
priority: alert.priority.map(|p| p.to_i32() as u8),
tags: alert.tags,
// Filled from the rollup state row by `enrich_with_run_state`,
// alongside the other run-state fields above.
groups_observed: None,
groups_firing: None,
groups_observed_is_lower_bound: None,
groups_firing_is_lower_bound: None,
})
}
}
@ -206,11 +371,35 @@ pub fn anomaly_config_to_list_item(v: &serde_json::Value) -> Option<ListAlertsRe
is_real_time: false,
last_trained_at: v.get("training_completed_at").and_then(|t| t.as_i64()),
status,
// Anomaly configs do not flow through the alert scheduler's state
// write path; leave run state unset rather than implying "never fired".
last_outcome: None,
last_outcome_at: None,
last_outcome_since: None,
level: None,
level_since: None,
last_error: v
.get("last_error")
.and_then(|e| e.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
// Feature 2: anomaly configs now carry the same triage metadata as
// alerts, so they filter and sort alongside them instead of being
// excluded wholesale.
priority: v
.get("priority")
.and_then(|p| p.as_u64())
.and_then(|p| u8::try_from(p).ok())
.filter(|p| (1..=5).contains(p)),
tags: v
.get("tags")
.and_then(|t| serde_json::from_value::<Vec<String>>(t.clone()).ok())
.unwrap_or_default(),
// Anomaly configs have no grouping, so there is nothing to count.
groups_observed: None,
groups_firing: None,
groups_observed_is_lower_bound: None,
groups_firing_is_lower_bound: None,
})
}
@ -284,6 +473,17 @@ mod tests {
last_trained_at: None,
status: None,
last_error: None,
last_outcome: None,
last_outcome_at: None,
last_outcome_since: None,
level: None,
level_since: None,
priority: None,
tags: vec![],
groups_observed: None,
groups_firing: None,
groups_observed_is_lower_bound: None,
groups_firing_is_lower_bound: None,
};
let json = serde_json::to_value(&item).unwrap();
let obj = json.as_object().unwrap();
@ -292,6 +492,13 @@ mod tests {
assert!(!obj.contains_key("last_trained_at"));
assert!(!obj.contains_key("status"));
assert!(!obj.contains_key("last_error"));
// §5.4: a non-multi alert must not advertise a group summary at all —
// an absent field reads as "not a multi-alert", a zero would read as
// "observed no groups".
assert!(!obj.contains_key("groups_observed"));
assert!(!obj.contains_key("groups_firing"));
assert!(!obj.contains_key("groups_observed_is_lower_bound"));
assert!(!obj.contains_key("groups_firing_is_lower_bound"));
}
#[test]
@ -467,3 +674,78 @@ pub struct GenerateSqlMetadata {
/// Whether GROUP BY is present
pub has_group_by: bool,
}
#[cfg(test)]
mod anomaly_priority_tag_tests {
use super::*;
fn cfg(extra: serde_json::Value) -> serde_json::Value {
let mut base = serde_json::json!({
"anomaly_id": <Ksuid as svix_ksuid::KsuidLike>::new(None, None).to_string(),
"name": "anom",
"folder_id": "default",
"stream_name": "s",
"stream_type": "logs",
"enabled": true,
});
if let (Some(b), Some(e)) = (base.as_object_mut(), extra.as_object()) {
for (k, v) in e {
b.insert(k.clone(), v.clone());
}
}
base
}
/// Feature 2: anomaly configs surface priority/tags on the list, so they
/// render and filter alongside alerts instead of always showing "—".
#[test]
fn test_anomaly_list_item_carries_priority_and_tags() {
let item = anomaly_config_to_list_item(&cfg(serde_json::json!({
"priority": 2,
"tags": ["prod", "service:checkout"],
})))
.expect("should map");
assert_eq!(item.priority, Some(2));
assert_eq!(item.tags, vec!["prod", "service:checkout"]);
}
/// Pre-Feature-2 configs have neither key; they must map to unset rather
/// than failing the whole list.
#[test]
fn test_anomaly_list_item_without_the_fields_is_unset() {
let item = anomaly_config_to_list_item(&cfg(serde_json::json!({}))).expect("should map");
assert_eq!(item.priority, None);
assert!(item.tags.is_empty());
}
/// A corrupt row must degrade to unset, never take the alert list down:
/// an out-of-range id is not a valid priority, and a non-array tags blob
/// is not a tag list.
#[test]
fn test_anomaly_list_item_degrades_on_corrupt_values() {
for bad in [
serde_json::json!(0),
serde_json::json!(6),
serde_json::json!(99),
] {
let item =
anomaly_config_to_list_item(&cfg(serde_json::json!({ "priority": bad }))).unwrap();
assert_eq!(item.priority, None, "id {bad} must not decode");
}
let item = anomaly_config_to_list_item(&cfg(serde_json::json!({
"tags": {"not": "an array"}
})))
.unwrap();
assert!(item.tags.is_empty());
}
/// The list response omits both when unset, so a pre-Feature-2 anomaly
/// config serializes exactly as it did before.
#[test]
fn test_unset_fields_are_omitted_from_the_response() {
let item = anomaly_config_to_list_item(&cfg(serde_json::json!({}))).unwrap();
let json = serde_json::to_value(&item).unwrap();
let obj = json.as_object().unwrap();
assert!(!obj.contains_key("priority"));
assert!(!obj.contains_key("tags"));
}
}

View File

@ -518,7 +518,10 @@ mod tests {
let job = infra::table::online_eval_jobs::OnlineEvalJob::try_from(body).unwrap();
assert_eq!(job.target_scope, TargetScope::Trace);
assert_eq!(job.trace_config.unwrap().idle_window_secs, 120);
assert_eq!(
job.trace_config.unwrap().idle_window_secs,
infra::table::online_eval_jobs::DEFAULT_TRACE_IDLE_WINDOW_SECS
);
assert!(job.session_config.is_none());
}
@ -539,7 +542,10 @@ mod tests {
let job = infra::table::online_eval_jobs::OnlineEvalJob::try_from(body).unwrap();
assert_eq!(job.target_scope, TargetScope::Session);
assert_eq!(job.session_config.unwrap().idle_window_secs, 120);
assert_eq!(
job.session_config.unwrap().idle_window_secs,
infra::table::online_eval_jobs::DEFAULT_SESSION_IDLE_WINDOW_SECS
);
assert!(job.trace_config.is_none());
}
@ -712,13 +718,13 @@ mod tests {
}
#[test]
fn test_eval_job_request_body_rejects_idle_window_below_scheduler_poll_interval() {
fn test_eval_job_request_body_rejects_a_non_positive_idle_window() {
let json = r#"{
"name": "j",
"stream": "traces",
"streamType": "traces",
"targetScope": "trace",
"traceConfig": { "idleWindowSecs": 44, "maxAgeSecs": 1800 },
"traceConfig": { "idleWindowSecs": 0, "maxAgeSecs": 1800 },
"filterCondition": {},
"scorers": ["scorer-1"],
"samplingMode": "all",
@ -728,7 +734,7 @@ mod tests {
let body: EvalJobRequestBody = serde_json::from_str(json).unwrap();
let err = infra::table::online_eval_jobs::OnlineEvalJob::try_from(body).unwrap_err();
assert_eq!(err, "Completion idle window must be at least 45 seconds");
assert_eq!(err, "Completion idle window must be at least 1 second");
}
#[test]

View File

@ -0,0 +1,252 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Token-authenticated inbound webhook endpoint for external alert sources
//! (Grafana, Alertmanager, generic webhooks). Deliberately registered in
//! `basic_routes()` (not the authenticated `service_routes` scope) — token
//! validation happens only inside this handler, never in
//! `src/api/common/src/auth/validator.rs`. See GHSA-wffq-g8qf-ccmv: widening
//! the shared token classifier there caused an auth bypass in the past; this
//! new `o2iat_` token type must not repeat that mistake.
use axum::{Json, extract::Path, http::HeaderMap, response::Response};
use crate::common::meta::http::HttpResponse as MetaHttpResponse;
/// Maximum number of individual alerts accepted in a single webhook request.
pub const MAX_ALERTS_PER_REQUEST: usize = 200;
/// Maximum request body size accepted for these routes (bytes).
pub const MAX_BODY_BYTES: usize = 1_048_576;
/// Extracts the integration token from either the `Authorization: Bearer` header
/// or a path-embedded token, preferring the header when both are present.
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub(crate) fn extract_token(auth_header: Option<&str>, path_token: Option<&str>) -> Option<String> {
if let Some(h) = auth_header
&& let Some(t) = h.strip_prefix("Bearer ")
&& t.starts_with(infra::table::incident_integrations::INCIDENT_INTEGRATION_TOKEN_PREFIX)
{
return Some(t.to_string());
}
path_token
.filter(|t| {
t.starts_with(infra::table::incident_integrations::INCIDENT_INTEGRATION_TOKEN_PREFIX)
})
.map(|t| t.to_string())
}
#[cfg(feature = "enterprise")]
pub async fn ingest_events(
Path(org_id): Path<String>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> Response {
let auth = headers.get("authorization").and_then(|v| v.to_str().ok());
handle_events(org_id, extract_token(auth, None), headers, body).await
}
#[cfg(feature = "enterprise")]
pub async fn ingest_events_url_token(
Path((org_id, token)): Path<(String, String)>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> Response {
handle_events(org_id, extract_token(None, Some(&token)), headers, body).await
}
#[cfg(feature = "enterprise")]
async fn handle_events(
org_id: String,
token: Option<String>,
headers: HeaderMap,
body: serde_json::Value,
) -> Response {
use o2_enterprise::enterprise::common::config::get_config as o2_config;
if !o2_config().incidents.enabled {
return MetaHttpResponse::forbidden("External alert sources not enabled");
}
let Some(token) = token else {
return MetaHttpResponse::not_found("not found"); // never confirm token semantics
};
let integration = match infra::table::incident_integrations::find_by_token(&token).await {
Ok(Some(i)) if i.org_id == org_id => i,
Ok(_) => return MetaHttpResponse::not_found("not found"),
Err(e) => return MetaHttpResponse::internal_error(e),
};
let now = chrono::Utc::now().timestamp_micros();
let ua = headers.get("user-agent").and_then(|v| v.to_str().ok());
let detected = if integration.source_type == "auto" {
openobserve_core::alerts::external_alerts::detect_source(ua, &body)
} else {
// explicit source_type pins the parser
match integration.source_type.as_str() {
"grafana" => openobserve_core::alerts::external_alerts::DetectedSource::Grafana,
"alertmanager" => {
openobserve_core::alerts::external_alerts::DetectedSource::Alertmanager
}
_ => openobserve_core::alerts::external_alerts::DetectedSource::Generic,
}
};
let events = match openobserve_core::alerts::external_alerts::normalize(detected, &body, now) {
Ok(evs) => evs,
Err(reason) => {
let _ = infra::table::incident_integrations::touch_sender(
infra::table::incident_integrations::TouchSenderParams {
integration_id: &integration.id,
detected_source: detected.as_str(),
sender_label: None,
now,
accepted: 0,
rejected: 1,
saw_resolved: false,
},
)
.await;
return MetaHttpResponse::bad_request(reason);
}
};
if events.len() > MAX_ALERTS_PER_REQUEST {
return MetaHttpResponse::bad_request(format!(
"too many alerts in one request: {} > {MAX_ALERTS_PER_REQUEST}",
events.len()
)); // 400; sources should configure max_alerts/grouping
}
let base_destinations: Vec<String> = integration.config["destinations"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let (mut accepted, mut rejected, mut saw_resolved) = (0u32, 0u32, false);
let mut errors: Vec<serde_json::Value> = Vec::new();
for (i, ev) in events.iter().enumerate() {
if ev.status == config::meta::alerts::incidents::ExternalAlertStatus::Resolved {
saw_resolved = true;
}
match infra::table::external_alerts::upsert_event(
&org_id,
&integration.id,
detected.as_str(),
ev,
)
.await
{
Ok((record, outcome)) => {
accepted += 1;
use infra::table::external_alerts::UpsertOutcome::*;
if matches!(outcome, Inserted | Refreshed | Reopened)
&& let Err(e) = openobserve_core::alerts::incidents::correlate_external_event(
&org_id,
&record,
base_destinations.clone(),
)
.await
{
log::warn!(
"[external_alerts] correlation failed for {}: {e}",
record.id
);
} else if matches!(outcome, ResolvedApplied)
&& let Err(e) =
openobserve_core::alerts::incidents::try_auto_resolve_incident_for_external_alert(
&org_id, &record.id,
)
.await
{
log::warn!(
"[external_alerts] auto-resolve check failed for {}: {e}",
record.id
);
}
}
Err(e) => {
rejected += 1;
errors.push(serde_json::json!({"index": i, "reason": e.to_string()}));
}
}
}
let sender_label = openobserve_core::alerts::external_alerts::derive_sender_label(&events);
let _ = infra::table::incident_integrations::touch_sender(
infra::table::incident_integrations::TouchSenderParams {
integration_id: &integration.id,
detected_source: detected.as_str(),
sender_label: sender_label.as_deref(),
now,
accepted,
rejected,
saw_resolved,
},
)
.await;
Response::builder()
.status(axum::http::StatusCode::ACCEPTED)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(
serde_json::json!({"accepted": accepted, "rejected": rejected, "errors": errors})
.to_string()
.into(),
)
.unwrap()
}
#[cfg(not(feature = "enterprise"))]
pub async fn ingest_events(
_path: Path<String>,
_headers: HeaderMap,
_body: Json<serde_json::Value>,
) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(not(feature = "enterprise"))]
pub async fn ingest_events_url_token(
_path: Path<(String, String)>,
_headers: HeaderMap,
_body: Json<serde_json::Value>,
) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_token_prefers_bearer_then_path() {
assert_eq!(
extract_token(Some("Bearer o2iat_abc"), None),
Some("o2iat_abc".to_string())
);
assert_eq!(
extract_token(None, Some("o2iat_path")),
Some("o2iat_path".to_string())
);
assert_eq!(
extract_token(Some("Bearer o2iat_abc"), Some("o2iat_path")),
Some("o2iat_abc".to_string())
);
assert_eq!(extract_token(Some("Basic xyz"), None), None); // wrong scheme
assert_eq!(extract_token(Some("Bearer nope_prefix"), None), None); // wrong prefix
assert_eq!(extract_token(None, None), None);
}
}

View File

@ -22,7 +22,7 @@ use common::utils::sql::escape_like;
use config::{
meta::{
search::{Query as SearchQuery, Request as SearchRequest},
self_reporting::usage::TRIGGERS_STREAM,
self_reporting::usage::{TRIGGERS_STREAM, TriggerDataType, normalize_outcome},
stream::StreamType,
},
utils::time::now_micros,
@ -95,6 +95,29 @@ pub struct AlertHistoryEntry {
/// Number of anomalies found in this evaluation run (anomaly detection only).
#[serde(skip_serializing_if = "Option::is_none")]
pub anomaly_count: Option<i32>,
// ── Value context (T-9/T-10, alerts_2.md §7.5) ──────────────────────────
// Absent on rows written before these fields existed; the UI renders "—".
/// Severity of the matched threshold: "ok" | "warning" | "critical".
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
/// The value that was compared (row count, or the aggregate).
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_value: Option<f64>,
/// The threshold that matched. Absent on `normal` rows — T-10 renders
/// those as actual value + Ok, with no threshold.
#[serde(skip_serializing_if = "Option::is_none")]
pub threshold_value: Option<f64>,
/// Operator, so the row reads standalone ("112 >= 100").
#[serde(skip_serializing_if = "Option::is_none")]
pub threshold_operator: Option<String>,
/// Which group/series produced `actual_value` ("host=b,region=eu");
/// absent for count alerts and pre-change rows.
#[serde(skip_serializing_if = "Option::is_none")]
pub group_label: Option<String>,
/// True when `actual_value` is a LOWER BOUND (legacy capped count fetch,
/// §7.5) — the UI renders "≥ N". Absent = exact.
#[serde(skip_serializing_if = "Option::is_none")]
pub value_is_lower_bound: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@ -333,28 +356,26 @@ pub async fn get_alert_history(
.hits
.into_iter()
.map(|hit| {
// Post-cutover rows already carry `firing`/`normal` directly;
// legacy rows stored `completed` regardless of the anomaly
// count, so the normalizer re-reads `success_response` for
// those. See Part III of alerts.md.
let raw_status = hit
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
// Derive result: parse anomalies_found from success_response JSON.
let anomaly_count = hit
.get("success_response")
.and_then(|v| v.as_str())
let success_response = hit.get("success_response").and_then(|v| v.as_str());
let anomaly_count = success_response
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v["anomalies_found"].as_i64())
.unwrap_or(0);
let result = match raw_status {
"failed" => "failed".to_string(),
"skipped" => "skipped".to_string(),
_ => {
if anomaly_count > 0 {
"anomaly".to_string()
} else {
"normal".to_string()
}
}
};
let result = normalize_outcome(
raw_status,
&TriggerDataType::AnomalyDetection,
success_response,
)
.map(|o| o.to_string())
.unwrap_or_else(|| "unknown".to_string());
AlertHistoryEntry {
timestamp: hit.get("_timestamp").and_then(|v| v.as_i64()).unwrap_or(0),
alert_name: String::new(),
@ -380,6 +401,14 @@ pub async fn get_alert_history(
grouped: None,
group_size: None,
anomaly_count: Some(anomaly_count as i32),
// Anomaly rows: the count IS the observed value; the level
// comes from the normalized outcome upstream.
level: None,
actual_value: Some(anomaly_count as f64),
threshold_value: None,
threshold_operator: None,
group_label: None,
value_is_lower_bound: None,
}
})
.collect();
@ -620,7 +649,8 @@ pub async fn get_alert_history(
// Step 2: Get the actual paginated results
// Build data query with LIMIT/OFFSET for pagination
let data_sql = format!(
"SELECT _timestamp, org, key, status, is_realtime, is_silenced, \
"SELECT _timestamp, org, key, status, level, actual_value, threshold_value, \
threshold_operator, group_label, value_is_lower_bound, is_realtime, is_silenced, \
start_time, end_time, retries, \
delay_in_secs, evaluation_took_in_secs, \
source_node, query_took, error \
@ -687,11 +717,20 @@ pub async fn get_alert_history(
.and_then(|v| v.as_str())
.unwrap_or(&org_id)
.to_string(),
// Normalize legacy values so the API speaks one vocabulary across
// the retention window (Part III of alerts.md).
status: hit
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
.and_then(|raw| {
normalize_outcome(
raw,
&TriggerDataType::Alert,
hit.get("success_response").and_then(|v| v.as_str()),
)
})
.map(|o| o.to_string())
.unwrap_or_else(|| "unknown".to_string()),
is_realtime: hit
.get("is_realtime")
.and_then(|v| v.as_bool())
@ -729,6 +768,25 @@ pub async fn get_alert_history(
.and_then(|v| v.as_i64())
.map(|v| v as i32),
anomaly_count: None,
// Value context (T-9). `level` is stored as an int; map it back to
// the string vocabulary the UI already understands.
level: hit
.get("level")
.and_then(|v| v.as_i64())
.and_then(|v| config::meta::alerts::level::AlertLevel::from_i32(v as i32))
.map(|l| l.to_string()),
actual_value: hit.get("actual_value").and_then(|v| v.as_f64()),
threshold_value: hit.get("threshold_value").and_then(|v| v.as_f64()),
threshold_operator: hit
.get("threshold_operator")
.and_then(|v| v.as_str())
.map(String::from),
group_label: hit
.get("group_label")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
value_is_lower_bound: hit.get("value_is_lower_bound").and_then(|v| v.as_bool()),
});
}
@ -921,24 +979,19 @@ pub async fn get_all_anomaly_history(
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let anomaly_count = hit
.get("success_response")
.and_then(|v| v.as_str())
let success_response = hit.get("success_response").and_then(|v| v.as_str());
let anomaly_count = success_response
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v["anomalies_found"].as_i64())
.unwrap_or(0);
let status = match raw_status {
"failed" => "failed",
"skipped" => "skipped",
_ => {
if anomaly_count > 0 {
"anomaly"
} else {
"normal"
}
}
};
let status = normalize_outcome(
raw_status,
&TriggerDataType::AnomalyDetection,
success_response,
)
.map(|o| o.to_string())
.unwrap_or_else(|| "unknown".to_string());
bucket.push(serde_json::json!({
"timestamp": hit.get("_timestamp").and_then(|v| v.as_i64()).unwrap_or(0),
@ -1016,6 +1069,12 @@ mod tests {
grouped: None,
group_size: None,
anomaly_count: None,
level: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
group_label: None,
value_is_lower_bound: None,
};
assert_eq!(entry.alert_name, "test_alert");
@ -1067,6 +1126,12 @@ mod tests {
grouped: None,
group_size: None,
anomaly_count: None,
level: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
group_label: None,
value_is_lower_bound: None,
};
let response = AlertHistoryResponse {
@ -1107,6 +1172,12 @@ mod tests {
grouped: None,
group_size: None,
anomaly_count: None,
level: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
group_label: None,
value_is_lower_bound: None,
};
assert_eq!(entry.status, "error");
@ -1321,6 +1392,12 @@ mod tests {
grouped: Some(false),
group_size: None,
anomaly_count: None,
level: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
group_label: None,
value_is_lower_bound: None,
};
let json = serde_json::to_string(&entry).unwrap();
@ -1358,6 +1435,12 @@ mod tests {
grouped: None,
group_size: None,
anomaly_count: None,
level: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
group_label: None,
value_is_lower_bound: None,
};
let response = AlertHistoryResponse {
@ -1401,6 +1484,12 @@ mod tests {
grouped: None,
group_size: None,
anomaly_count: None,
level: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
group_label: None,
value_is_lower_bound: None,
};
let json = serde_json::to_value(&entry).unwrap();
let obj = json.as_object().unwrap();
@ -1437,6 +1526,12 @@ mod tests {
grouped: Some(true),
group_size: Some(3),
anomaly_count: Some(2),
level: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
group_label: None,
value_is_lower_bound: None,
};
let json = serde_json::to_value(&entry).unwrap();
let obj = json.as_object().unwrap();

View File

@ -0,0 +1,500 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Admin-facing CRUD for external alert source integrations. Unlike
//! `external_events`, these routes are authenticated and registered in
//! `service_routes()` — auth is enforced by the router's auth middleware,
//! not inside these handlers.
use axum::{Json, extract::Path, response::Response};
#[cfg(feature = "enterprise")]
use openobserve_api_common::extractors::Headers;
#[cfg(feature = "enterprise")]
use openobserve_core::auth::UserEmail;
use serde::{Deserialize, Serialize};
use crate::common::meta::http::HttpResponse as MetaHttpResponse;
/// Allowed values for `source_type` on create.
const ALLOWED_SOURCE_TYPES: &[&str] = &["auto", "grafana", "alertmanager", "generic"];
#[derive(Debug, Deserialize)]
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub struct CreateIntegrationPayload {
pub name: String,
pub source_type: Option<String>,
pub config: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub struct SetEnabledPayload {
pub enabled: bool,
}
#[derive(Debug, Serialize)]
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub struct IntegrationResponse {
pub id: String,
pub org_id: String,
pub name: String,
pub source_type: String,
pub token: String,
pub enabled: bool,
pub config: serde_json::Value,
pub created_by: String,
pub created_at: i64,
pub updated_at: i64,
pub url: String,
}
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
impl IntegrationResponse {
fn from_record(
org_id: &str,
record: infra::table::incident_integrations::IncidentIntegrationRecord,
) -> Self {
let url = format!("/api/v2/{org_id}/incidents/events/{}", record.token);
Self {
id: record.id,
org_id: record.org_id,
name: record.name,
source_type: record.source_type,
token: record.token,
enabled: record.enabled,
config: record.config,
created_by: record.created_by,
created_at: record.created_at,
updated_at: record.updated_at,
url,
}
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub struct ListIntegrationsResponse {
pub integrations: Vec<IntegrationResponse>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub struct SenderResponse {
pub integration_id: String,
pub detected_source: String,
pub display_name: String,
pub first_received_at: i64,
pub last_received_at: i64,
pub accepted_count: i64,
pub rejected_count: i64,
pub resolved_seen: bool,
pub resolve_wiring_hint: bool,
}
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
impl From<infra::table::incident_integrations::SenderRecord> for SenderResponse {
fn from(r: infra::table::incident_integrations::SenderRecord) -> Self {
let resolve_wiring_hint = r.accepted_count > 0 && !r.resolved_seen;
let display_name = openobserve_core::alerts::external_alerts::resolve_display_name(
&r.detected_source,
r.sender_label.as_deref(),
);
Self {
integration_id: r.integration_id,
detected_source: r.detected_source,
display_name,
first_received_at: r.first_received_at,
last_received_at: r.last_received_at,
accepted_count: r.accepted_count,
rejected_count: r.rejected_count,
resolved_seen: r.resolved_seen,
resolve_wiring_hint,
}
}
}
/// Validates a create-integration payload: non-empty name (<=100 chars) and
/// an allowed `source_type` (defaulting to "auto" when absent).
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub(crate) fn validate_create(payload: &CreateIntegrationPayload) -> Result<(), String> {
if payload.name.trim().is_empty() {
return Err("name cannot be empty".to_string());
}
if payload.name.len() > 100 {
return Err("name cannot exceed 100 characters".to_string());
}
if let Some(st) = payload.source_type.as_deref()
&& !ALLOWED_SOURCE_TYPES.contains(&st)
{
return Err(format!(
"source_type must be one of: {}",
ALLOWED_SOURCE_TYPES.join(", ")
));
}
Ok(())
}
#[cfg(feature = "enterprise")]
fn gate_enabled() -> Option<Response> {
use o2_enterprise::enterprise::common::config::get_config as o2_config;
if !o2_config().incidents.enabled {
return Some(MetaHttpResponse::forbidden(
"External alert sources not enabled",
));
}
None
}
#[cfg(feature = "enterprise")]
pub async fn list_integrations(
Path(org_id): Path<String>,
Headers(user_email): Headers<UserEmail>,
) -> Response {
if let Some(resp) = gate_enabled() {
return resp;
}
if let Err(e) =
infra::table::incident_integrations::ensure_default_for_org(&org_id, &user_email.user_id)
.await
{
return MetaHttpResponse::internal_error(e);
}
match infra::table::incident_integrations::list_by_org(&org_id).await {
Ok(records) => MetaHttpResponse::json(ListIntegrationsResponse {
integrations: records
.into_iter()
.map(|r| IntegrationResponse::from_record(&org_id, r))
.collect(),
}),
Err(e) => MetaHttpResponse::internal_error(e),
}
}
#[cfg(feature = "enterprise")]
pub async fn create_integration(
Path(org_id): Path<String>,
Headers(user_email): Headers<UserEmail>,
Json(payload): Json<CreateIntegrationPayload>,
) -> Response {
if let Some(resp) = gate_enabled() {
return resp;
}
if let Err(e) = validate_create(&payload) {
return MetaHttpResponse::bad_request(e);
}
let now = chrono::Utc::now().timestamp_micros();
let record = infra::table::incident_integrations::IncidentIntegrationRecord {
id: config::ider::uuid(),
org_id: org_id.clone(),
name: payload.name,
source_type: payload.source_type.unwrap_or_else(|| "auto".to_string()),
token: infra::table::incident_integrations::generate_token(),
enabled: true,
config: payload.config.unwrap_or_else(|| serde_json::json!({})),
created_by: user_email.user_id,
created_at: now,
updated_at: now,
};
match infra::table::incident_integrations::add(&record).await {
Ok(()) => MetaHttpResponse::json(IntegrationResponse::from_record(&org_id, record)),
Err(e) => MetaHttpResponse::bad_request(e),
}
}
#[cfg(feature = "enterprise")]
pub async fn set_integration_enabled(
Path((org_id, integration_id)): Path<(String, String)>,
Json(payload): Json<SetEnabledPayload>,
) -> Response {
if let Some(resp) = gate_enabled() {
return resp;
}
match infra::table::incident_integrations::set_enabled(
&org_id,
&integration_id,
payload.enabled,
)
.await
{
Ok(()) => MetaHttpResponse::ok("updated"),
Err(e) => MetaHttpResponse::internal_error(e),
}
}
#[cfg(feature = "enterprise")]
pub async fn rotate_integration_token(
Path((org_id, integration_id)): Path<(String, String)>,
) -> Response {
if let Some(resp) = gate_enabled() {
return resp;
}
match infra::table::incident_integrations::rotate_token(&org_id, &integration_id).await {
Ok(token) => MetaHttpResponse::json(serde_json::json!({"token": token})),
Err(e) => MetaHttpResponse::internal_error(e),
}
}
#[cfg(feature = "enterprise")]
pub async fn delete_integration(
Path((org_id, integration_id)): Path<(String, String)>,
) -> Response {
if let Some(resp) = gate_enabled() {
return resp;
}
let integration = match infra::table::incident_integrations::list_by_org(&org_id).await {
Ok(records) => records.into_iter().find(|r| r.id == integration_id),
Err(e) => return MetaHttpResponse::internal_error(e),
};
let Some(integration) = integration else {
return MetaHttpResponse::not_found("Integration not found");
};
if integration.name == infra::table::incident_integrations::DEFAULT_INTEGRATION_NAME {
return MetaHttpResponse::bad_request(
"The default alert source cannot be deleted — disable it instead",
);
}
match infra::table::incident_integrations::delete(&org_id, &integration_id).await {
Ok(true) => MetaHttpResponse::ok("deleted"),
Ok(false) => MetaHttpResponse::not_found("Integration not found"),
Err(e) => MetaHttpResponse::internal_error(e),
}
}
#[cfg(feature = "enterprise")]
pub async fn list_integration_senders(
Path((org_id, integration_id)): Path<(String, String)>,
) -> Response {
if let Some(resp) = gate_enabled() {
return resp;
}
// Verify the integration belongs to this org before returning sender data.
let belongs = match infra::table::incident_integrations::list_by_org(&org_id).await {
Ok(records) => records.iter().any(|r| r.id == integration_id),
Err(e) => return MetaHttpResponse::internal_error(e),
};
if !belongs {
return MetaHttpResponse::not_found("Integration not found");
}
match infra::table::incident_integrations::list_senders(&integration_id).await {
Ok(senders) => {
let senders: Vec<SenderResponse> =
senders.into_iter().map(SenderResponse::from).collect();
MetaHttpResponse::json(serde_json::json!({"senders": senders}))
}
Err(e) => MetaHttpResponse::internal_error(e),
}
}
#[cfg(not(feature = "enterprise"))]
pub async fn list_integrations(_path: Path<String>) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(not(feature = "enterprise"))]
pub async fn create_integration(
_path: Path<String>,
_body: Json<CreateIntegrationPayload>,
) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(not(feature = "enterprise"))]
pub async fn set_integration_enabled(
_path: Path<(String, String)>,
_body: Json<SetEnabledPayload>,
) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(not(feature = "enterprise"))]
pub async fn rotate_integration_token(_path: Path<(String, String)>) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(not(feature = "enterprise"))]
pub async fn list_integration_senders(_path: Path<(String, String)>) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(not(feature = "enterprise"))]
pub async fn delete_integration(_path: Path<(String, String)>) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(test)]
mod tests {
use super::*;
fn payload(name: &str, source_type: Option<&str>) -> CreateIntegrationPayload {
CreateIntegrationPayload {
name: name.to_string(),
source_type: source_type.map(String::from),
config: None,
}
}
#[test]
fn test_validate_create_rejects_empty_name() {
assert_eq!(
validate_create(&payload("", None)),
Err("name cannot be empty".to_string())
);
assert_eq!(
validate_create(&payload(" ", None)),
Err("name cannot be empty".to_string())
);
}
#[test]
fn test_validate_create_rejects_long_name() {
let long_name = "a".repeat(101);
assert!(validate_create(&payload(&long_name, None)).is_err());
let ok_name = "a".repeat(100);
assert!(validate_create(&payload(&ok_name, None)).is_ok());
}
#[test]
fn test_validate_create_rejects_bad_source_type() {
assert!(validate_create(&payload("n", Some("bogus"))).is_err());
}
#[test]
fn test_validate_create_accepts_allowed_source_types() {
for st in ALLOWED_SOURCE_TYPES {
assert!(validate_create(&payload("n", Some(st))).is_ok());
}
}
#[test]
fn test_validate_create_defaults_source_type_none_ok() {
assert!(validate_create(&payload("n", None)).is_ok());
}
#[test]
fn test_create_payload_deserializes() {
let json = r#"{"name":"grafana-prod","source_type":"grafana","config":{"destinations":["slack"]}}"#;
let p: CreateIntegrationPayload = serde_json::from_str(json).unwrap();
assert_eq!(p.name, "grafana-prod");
assert_eq!(p.source_type.as_deref(), Some("grafana"));
assert_eq!(p.config.unwrap()["destinations"][0], "slack");
}
#[test]
fn test_create_payload_optional_fields_absent() {
let json = r#"{"name":"minimal"}"#;
let p: CreateIntegrationPayload = serde_json::from_str(json).unwrap();
assert_eq!(p.name, "minimal");
assert!(p.source_type.is_none());
assert!(p.config.is_none());
}
#[test]
fn test_set_enabled_payload_round_trip() {
let json = r#"{"enabled":false}"#;
let p: SetEnabledPayload = serde_json::from_str(json).unwrap();
assert!(!p.enabled);
}
#[test]
fn test_sender_response_resolve_wiring_hint_true_when_accepted_but_unresolved() {
let r = infra::table::incident_integrations::SenderRecord {
integration_id: "i1".into(),
detected_source: "grafana".into(),
first_received_at: 1,
last_received_at: 2,
accepted_count: 5,
rejected_count: 0,
resolved_seen: false,
sender_label: None,
};
let resp: SenderResponse = r.into();
assert!(resp.resolve_wiring_hint);
}
#[test]
fn test_sender_response_resolve_wiring_hint_false_when_resolved_seen() {
let r = infra::table::incident_integrations::SenderRecord {
integration_id: "i1".into(),
detected_source: "grafana".into(),
first_received_at: 1,
last_received_at: 2,
accepted_count: 5,
rejected_count: 0,
resolved_seen: true,
sender_label: None,
};
let resp: SenderResponse = r.into();
assert!(!resp.resolve_wiring_hint);
}
#[test]
fn test_sender_response_resolve_wiring_hint_false_when_no_accepted() {
let r = infra::table::incident_integrations::SenderRecord {
integration_id: "i1".into(),
detected_source: "grafana".into(),
first_received_at: 1,
last_received_at: 2,
accepted_count: 0,
rejected_count: 3,
resolved_seen: false,
sender_label: None,
};
let resp: SenderResponse = r.into();
assert!(!resp.resolve_wiring_hint);
}
#[test]
fn test_sender_response_display_name_uses_label_when_present() {
let record = infra::table::incident_integrations::SenderRecord {
integration_id: "int-1".to_string(),
detected_source: "generic".to_string(),
sender_label: Some("solarwinds".to_string()),
first_received_at: 1,
last_received_at: 2,
accepted_count: 3,
rejected_count: 0,
resolved_seen: false,
};
let response = SenderResponse::from(record);
assert_eq!(response.display_name, "solarwinds");
assert_eq!(response.detected_source, "generic");
}
#[test]
fn test_sender_response_display_name_falls_back_to_detected_source() {
let record = infra::table::incident_integrations::SenderRecord {
integration_id: "int-1".to_string(),
detected_source: "grafana".to_string(),
sender_label: None,
first_received_at: 1,
last_received_at: 2,
accepted_count: 3,
rejected_count: 0,
resolved_seen: false,
};
let response = SenderResponse::from(record);
assert_eq!(response.display_name, "grafana");
}
}

View File

@ -294,6 +294,47 @@ pub async fn update_incident(
}
}
#[cfg(feature = "enterprise")]
/// GetExternalAlertPayload
#[utoipa::path(
get,
path = "/v2/{org_id}/alerts/incidents/external-alerts/{external_alert_id}/payload",
context_path = "/api",
tag = "Incidents",
operation_id = "GetExternalAlertPayload",
summary = "Get the raw payload of an external alert",
description = "Retrieves the original, unmodified webhook payload received for an external alert event. Used to inspect exactly what a source system (Grafana, Alertmanager, etc.) sent.",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("external_alert_id" = String, Path, description = "External alert ID"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = ()),
(status = 404, description = "Not found", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Alerts", "operation": "get"})),
("x-o2-mcp" = json!({"description": "Get the raw payload of an external alert", "category": "alerts"}))
)
)]
pub async fn get_external_alert_payload(
Path((org_id, external_alert_id)): Path<(String, String)>,
) -> Response {
match infra::table::external_alerts::get_by_id(&org_id, &external_alert_id).await {
Ok(Some(record)) => MetaHttpResponse::json(serde_json::json!({
"id": record.id,
"detected_source": record.detected_source,
"source_url": record.source_url,
"first_seen_at": record.first_seen_at,
"last_seen_at": record.last_seen_at,
"last_payload": record.last_payload,
})),
Ok(None) => MetaHttpResponse::not_found("External alert not found"),
Err(e) => MetaHttpResponse::internal_error(e),
}
}
#[cfg(feature = "enterprise")]
/// GetIncidentStats
#[utoipa::path(
@ -967,6 +1008,33 @@ pub async fn get_incident(_path: Path<(String, String)>) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(not(feature = "enterprise"))]
#[utoipa::path(
get,
path = "/v2/{org_id}/alerts/incidents/external-alerts/{external_alert_id}/payload",
context_path = "/api",
tag = "Incidents",
operation_id = "GetExternalAlertPayload",
summary = "Get the raw payload of an external alert",
description = "Retrieves the original webhook payload for an external alert event. This endpoint is only available with enterprise features enabled.",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("external_alert_id" = String, Path, description = "External alert ID"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = ()),
(status = 403, description = "Enterprise feature", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Alerts", "operation": "get"})),
("x-o2-mcp" = json!({"description": "Get the raw payload of an external alert", "category": "alerts"}))
)
)]
pub async fn get_external_alert_payload(_path: Path<(String, String)>) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(not(feature = "enterprise"))]
#[utoipa::path(
get,

View File

@ -21,6 +21,7 @@ use axum::{
http::StatusCode,
response::Response,
};
use axum_extra::extract::Query as ExtraQuery;
use config::meta::{
alerts::alert::{Alert as MetaAlert, AlertTypeFilter},
triggers::{Trigger, TriggerModule},
@ -57,9 +58,10 @@ use crate::{
UpdateAlertRequestBody,
},
responses::{
AlertBulkEnableResponse, EnableAlertResponseBody, GenerateSqlMetadata,
GenerateSqlResponseBody, GetAlertResponseBody, ListAlertsResponseBody,
ListAlertsResponseBodyItem,
AlertBulkEnableResponse, AlertGroupLabel, AlertGroupResponseItem,
AlertGroupTransitionItem, EnableAlertResponseBody, GenerateSqlMetadata,
GenerateSqlResponseBody, GetAlertResponseBody, ListAlertGroupTransitionsResponseBody,
ListAlertGroupsResponseBody, ListAlertsResponseBody, ListAlertsResponseBodyItem,
},
},
request::{
@ -71,7 +73,9 @@ use crate::{
pub mod dedup_stats;
pub mod deduplication;
pub mod destinations;
pub mod external_events;
pub mod history;
pub mod incident_integrations;
pub mod incidents;
pub mod templates;
@ -184,10 +188,213 @@ async fn create_anomaly_alert(
.filter(|f| !f.is_empty())
.or_else(|| Some(query_folder_id.to_string()).filter(|f| !f.is_empty())),
owner,
// Feature 2: anomaly configs take the same triage metadata as
// alerts, threaded from the shared request body.
priority: req_body.alert.priority,
tags: req_body.alert.tags,
};
match openobserve_core::anomaly_detection::create_config(org_id, req).await {
Ok(v) => MetaHttpResponse::json(v),
// A bad tag is user input: the same 400 the alert save path gives.
Err(e)
if e.downcast_ref::<config::meta::alerts::tags::TagError>()
.is_some() =>
{
MetaHttpResponse::bad_request(e.to_string())
}
Err(e) => MetaHttpResponse::internal_error(e.to_string()),
}
}
/// Split a rendered `k=v,k=v` label string back into pairs.
///
/// Rendering is lossy where a value contains the separators, which is exactly
/// why `group_key` — not this — is the identity. Splitting on the first `=`
/// keeps `path=/a=b` intact, the common case; anything genuinely ambiguous
/// still displays, it just may split oddly.
fn parse_group_labels(rendered: Option<&str>) -> Vec<AlertGroupLabel> {
rendered
.unwrap_or("")
.split(',')
.filter(|s| !s.is_empty())
.filter_map(|pair| {
pair.split_once('=').map(|(name, value)| AlertGroupLabel {
name: name.to_string(),
value: value.to_string(),
})
})
.collect()
}
/// ListAlertGroups
#[utoipa::path(
get,
path = "/v2/{org_id}/alerts/{alert_id}/groups",
context_path = "/api",
tag = "Alerts",
operation_id = "ListAlertGroups",
summary = "List a multi-alert's tracked groups",
description = "Returns the per-group state rows of a multi-alert, most severe first, with the pre-cap group counts needed to render 'N of M groups firing'. Empty for alerts that have not opted in to per-group evaluation.",
security(
("Authorization"= [])
),
params(
("org_id" = String, Path, description = "Organization name"),
("alert_id" = String, Path, description = "Alert ID"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = inline(ListAlertGroupsResponseBody)),
(status = 404, description = "NotFound", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Alerts", "operation": "get"})),
("x-o2-mcp" = json!({"description": "List the per-group states of a multi-alert", "category": "alerts"}))
)
)]
pub async fn list_alert_groups(Path((org_id, alert_id)): Path<(String, String)>) -> Response {
let Ok(ksuid) = Ksuid::from_str(&alert_id) else {
return MetaHttpResponse::not_found(format!("invalid alert id {alert_id}"));
};
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
// Resolve through the alert itself so this endpoint inherits the same
// org scoping and not-found behaviour as every other alert read — a raw
// state-table query would happily serve another org's group labels, which
// carry host and service names.
if let Err(e) = alert::get_by_id(client, &org_id, ksuid).await {
return e.into();
}
let mut groups = match infra::table::alert_states::list_groups(&alert_id).await {
Ok(g) => g,
Err(e) => return MetaHttpResponse::internal_error(e.to_string()),
};
// Most severe first (§5.4), then by key so equal levels keep a stable
// order across polls instead of shuffling on every refresh.
groups.sort_by(|a, b| {
let rank = |s: &config::meta::alerts::state::AlertState| {
s.level.map(|l| l.severity_rank()).unwrap_or(0)
};
rank(b)
.cmp(&rank(a))
.then_with(|| a.group_key.cmp(&b.group_key))
});
let rollup = infra::table::alert_states::get_rollups(std::slice::from_ref(&alert_id))
.await
.ok()
.and_then(|mut r| r.pop());
let groups_observed = rollup
.as_ref()
.and_then(|r| r.groups_observed)
.and_then(|n| i32::try_from(n).ok());
let group_cap = config::get_config().limit.alert_max_groups;
let list: Vec<AlertGroupResponseItem> = groups
.into_iter()
.map(|g| AlertGroupResponseItem {
labels: parse_group_labels(g.group_labels.as_deref()),
group_key: g.group_key,
group_labels: g.group_labels,
level: g.level.map(|l| l.to_string()),
level_since: g.level_since,
last_outcome: g.last_outcome.map(|o| o.to_string()),
last_outcome_at: g.last_outcome_at,
last_seen: g.last_seen,
silenced_until: g.silenced_until,
last_notified_level: g.last_notified_level.map(|l| l.to_string()),
})
.collect();
// Compare the PRE-cap observed total against the cap, not the length of
// `list`: the retained rows are post-cap, so they would report "cap of
// cap" and an overflowing alert would look identical to one that fit.
let capped =
group_cap > 0 && groups_observed.is_some_and(|observed| observed as usize > group_cap);
MetaHttpResponse::json(ListAlertGroupsResponseBody {
list,
groups_observed,
groups_firing: rollup
.as_ref()
.and_then(|r| r.groups_firing)
.and_then(|n| i32::try_from(n).ok()),
groups_observed_is_lower_bound: rollup
.as_ref()
.and_then(|r| r.groups_observed_is_lower_bound),
groups_firing_is_lower_bound: rollup.as_ref().and_then(|r| r.groups_firing_is_lower_bound),
capped,
group_cap,
})
}
/// ListAlertGroupTransitions
#[utoipa::path(
get,
path = "/v2/{org_id}/alerts/{alert_id}/groups/transitions",
context_path = "/api",
tag = "Alerts",
operation_id = "ListAlertGroupTransitions",
summary = "Per-group state history for a multi-alert",
description = "Returns level/outcome transitions for a multi-alert, newest first, optionally scoped to one group (M-8). Reads the durable transitions table rather than the triggers stream, so history survives group reaping.",
security(
("Authorization"= [])
),
params(
("org_id" = String, Path, description = "Organization name"),
("alert_id" = String, Path, description = "Alert ID"),
("group_key" = Option<String>, Query, description = "Restrict to one group. Omit for every group."),
("limit" = Option<u64>, Query, description = "Max rows (default 100, max 1000)"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = inline(ListAlertGroupTransitionsResponseBody)),
(status = 404, description = "NotFound", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Alerts", "operation": "get"})),
("x-o2-mcp" = json!({"description": "Per-group alert state history", "category": "alerts"}))
)
)]
pub async fn list_alert_group_transitions(
Path((org_id, alert_id)): Path<(String, String)>,
Query(query): Query<HashMap<String, String>>,
) -> Response {
let Ok(ksuid) = Ksuid::from_str(&alert_id) else {
return MetaHttpResponse::not_found(format!("invalid alert id {alert_id}"));
};
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
if let Err(e) = alert::get_by_id(client, &org_id, ksuid).await {
return e.into();
}
// `None` means every group, which is NOT `Some("")` — that is the rollup
// row's own key. An empty query value therefore has to mean "unset".
let group_key = query
.get("group_key")
.map(|s| s.trim())
.filter(|s| !s.is_empty());
let limit = query
.get("limit")
.and_then(|l| l.parse::<u64>().ok())
.unwrap_or(100)
.clamp(1, 1000);
match infra::table::alert_states::list_transitions_filtered(&alert_id, group_key, limit).await {
Ok(transitions) => MetaHttpResponse::json(ListAlertGroupTransitionsResponseBody {
list: transitions
.into_iter()
.map(|t| AlertGroupTransitionItem {
group_key: t.group_key,
group_labels: t.group_labels,
from_level: t.from_level.map(|l| l.to_string()),
to_level: t.to_level.map(|l| l.to_string()),
from_outcome: t.from_outcome.map(|o| o.to_string()),
to_outcome: t.to_outcome.to_string(),
at: t.at,
value: t.value,
})
.collect(),
}),
Err(e) => MetaHttpResponse::internal_error(e.to_string()),
}
}
@ -582,10 +789,24 @@ async fn build_and_run_anomaly_update(
enabled: fields.enabled,
folder_id: fields.folder_id,
owner,
// The v2 PUT carries the FULL alert body, so this is replace
// semantics: wrapping in `Some` means an omitted priority clears it,
// matching how tags behave one line down.
priority: Some(alert.priority),
// `Some(vec![])` clears; the shared body always supplies a Vec,
// so an edit that removes every tag does clear them.
tags: Some(alert.tags),
};
match openobserve_core::anomaly_detection::update_config(org_id, anomaly_id, req).await {
Ok(v) => MetaHttpResponse::json(v),
// Same 400 as create: an invalid tag is the caller's to fix.
Err(e)
if e.downcast_ref::<config::meta::alerts::tags::TagError>()
.is_some() =>
{
MetaHttpResponse::bad_request(e.to_string())
}
Err(e) => MetaHttpResponse::internal_error(e.to_string()),
}
}
@ -755,6 +976,102 @@ pub async fn delete_alert_bulk(
})
}
/// Query parameters for the tag facet endpoint (PT-8b).
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
#[into_params(style = Form, parameter_in = Query)]
#[serde(rename_all = "snake_case")]
pub struct ListAlertTagsQuery {
/// Optional case-insensitive prefix filter for autocomplete.
pub prefix: Option<String>,
/// Maximum tags to return. Defaults to 100, capped at 1000.
pub limit: Option<usize>,
/// Restrict to one folder, matching the list endpoint's scope.
pub folder: Option<String>,
}
/// One tag and how many visible alerts carry it.
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct AlertTagCount {
pub tag: String,
pub count: u64,
}
/// ListAlertTags
///
/// Distinct alert tags for autocomplete and facets (PT-8b).
///
/// **Authorization is load-bearing, not incidental (D23):** tag values leak
/// service, environment, team and customer names, so this returns only tags
/// carried by alerts the caller may actually list. It reuses the list
/// endpoint's permission path rather than scanning the org-wide cache.
#[utoipa::path(
get,
path = "/{org_id}/alerts/tags",
context_path = "/api/v2",
tag = "Alerts",
operation_id = "ListAlertTags",
summary = "List distinct alert tags",
description = "Returns distinct tags across the alerts the caller can see, with occurrence counts, for autocomplete and filter facets.",
security(("Authorization" = [])),
params(("org_id" = String, Path, description = "Organization name"), ListAlertTagsQuery),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Vec<AlertTagCount>),
(status = 403, description = "Forbidden", content_type = "application/json"),
),
)]
pub async fn list_alert_tags(
Path(org_id): Path<String>,
Query(query): Query<ListAlertTagsQuery>,
#[cfg(feature = "enterprise")] Headers(user_email): Headers<UserEmail>,
) -> Response {
#[cfg(not(feature = "enterprise"))]
let user_id = None;
#[cfg(feature = "enterprise")]
let user_id = Some(user_email.user_id.as_str());
// Bounded (PT-8b): 1,000 alerts x 64 tags is 64,000 values, so "return
// everything" is not an option the response size can afford.
const DEFAULT_LIMIT: usize = 100;
const MAX_LIMIT: usize = 1000;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
// Resolve the caller's VISIBLE alerts through the same permission path the
// list endpoint uses — this is what keeps the facet from leaking tags off
// alerts the caller cannot see.
let mut params = config::meta::alerts::alert::ListAlertsParams::new(&org_id);
if let Some(folder) = query.folder.clone() {
params = params.in_folder(&folder);
}
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
let visible_ids: Vec<String> = match alert::list_v2(client, user_id, params).await {
Ok(list) => list
.into_iter()
.filter_map(|(_, a)| a.id.map(|id| id.to_string()))
.collect(),
Err(e) => return e.into(),
};
let mut counts = db::alerts::alert::tag_counts_for_alerts(&org_id, &visible_ids).await;
if let Some(prefix) = query.prefix.as_deref() {
let prefix = prefix.trim().to_lowercase();
if !prefix.is_empty() {
counts.retain(|(tag, _)| tag.starts_with(&prefix));
}
}
// Deterministic order: most-used first, then lexicographic so the tail is
// stable rather than hash-ordered.
counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
counts.truncate(limit);
let body: Vec<AlertTagCount> = counts
.into_iter()
.map(|(tag, count)| AlertTagCount { tag, count })
.collect();
MetaHttpResponse::json(body)
}
/// ListAlerts
#[utoipa::path(
get,
@ -785,7 +1102,11 @@ pub async fn delete_alert_bulk(
)]
pub async fn list_alerts(
Path(org_id): Path<String>,
Query(query): Query<ListAlertsQuery>,
// `axum_extra`'s Query (serde_html_form) rather than axum's
// (serde_urlencoded): only the former deserializes REPEATED keys into a
// `Vec`, which PT-3 requires for `?priority=1&priority=2`. axum's Query
// errors with "invalid type: string, expected a sequence".
ExtraQuery(query): ExtraQuery<ListAlertsQuery>,
#[cfg(feature = "enterprise")] Headers(user_email): Headers<UserEmail>,
) -> Response {
#[cfg(not(feature = "enterprise"))]
@ -800,12 +1121,38 @@ pub async fn list_alerts(
#[cfg(feature = "enterprise")]
let page_size_and_idx = query.page_size.map(|s| (s, query.page_idx.unwrap_or(0)));
// Resolve the tag filter to an alert-ID set BEFORE building the query
// (PT-8): the tags column is JSON, and the filter must enter the SQL as an
// ID predicate so pagination and sorting stay correct rather than
// post-filtering an already-fetched page.
let requested_tags = query.requested_tags();
#[cfg(not(feature = "enterprise"))]
let params = query.into(&org_id);
let mut params = query.into(&org_id);
#[cfg(feature = "enterprise")]
let mut params = query.into(&org_id);
if !requested_tags.is_empty() {
// `Some(empty)` is meaningful: no alert carries these tags, so the
// result must be empty. Leaving it `None` would match everything.
params = params.with_tag_alert_ids(
db::alerts::alert::resolve_alert_ids_by_tags(&org_id, &requested_tags).await,
);
}
let alert_type = params.alert_type;
// Anomaly configs are merged in AFTER the SQL query, so the priority/tag
// filters in the query never touch them — they must be applied in Rust to
// the merged rows instead (see below). Captured before `params` is moved.
// (enterprise-only consumers; OSS builds merge no anomaly configs)
#[cfg_attr(not(feature = "enterprise"), allow(unused_variables))]
let priority_filter = params.priority.clone();
#[cfg_attr(not(feature = "enterprise"), allow(unused_variables))]
let tag_filter = requested_tags.clone();
// Anomaly rows bypass the SQL ORDER BY too, so a merged list must be
// re-sorted in memory.
#[cfg_attr(not(feature = "enterprise"), allow(unused_variables))]
let requested_sort = (params.sort_by, params.sort_desc);
// In enterprise builds, pagination is applied after merging regular alerts with
// anomaly detection configs, so we fetch all matching results from the DB here.
@ -863,7 +1210,33 @@ pub async fn list_alerts(
)
.await
{
list.extend(configs.iter().filter_map(anomaly_config_to_list_item));
// Apply the Feature-2 filters here, because these rows bypassed the
// SQL WHERE clause entirely. Without this a priority or tag filter
// would return every anomaly config alongside the matching alerts.
let before = list.len();
list.extend(
configs
.iter()
.filter_map(anomaly_config_to_list_item)
.filter(|item| match &priority_filter {
None => true,
// An empty set means "asked for priorities, none valid" —
// matches nothing, same as the SQL side.
Some(wanted) => item
.priority
.is_some_and(|p| wanted.iter().any(|w| w.to_i32() as u8 == p)),
})
.filter(|item| {
config::meta::alerts::tags::matches_all_tags(&item.tags, &tag_filter)
}),
);
// Without this the appended rows pin to the tail whatever order was
// requested, and the pagination below cuts the combined list in the
// wrong places. Only when something merged — an untouched list keeps
// the database's own collation.
if list.len() > before {
sort_merged_alert_list(&mut list, requested_sort.0, requested_sort.1);
}
}
// Apply pagination to the combined list (regular alerts + anomaly configs).
@ -879,9 +1252,108 @@ pub async fn list_alerts(
list
};
// Enrich with durable run state (Part IV of alerts.md). One batched query
// over the page that is actually being returned — not per alert.
let mut list = list;
enrich_with_run_state(&mut list).await;
MetaHttpResponse::json(ListAlertsResponseBody { list })
}
/// Attach `last_outcome` / `last_outcome_at` / `last_outcome_since` to a page of
/// alerts from the `alert_states` rollup rows.
///
/// Best-effort: if the lookup fails the list is still returned, just without run
/// state. A state table problem must not take down the alerts page.
/// Re-sort a merged (regular + anomaly) list the way the SQL ORDER BY sorts
/// the regular one (PT-3): unset priority LAST in both directions, ties broken
/// on (name, folder name, id) so pagination stays a total order.
#[cfg(feature = "enterprise")]
fn sort_merged_alert_list(
list: &mut [ListAlertsResponseBodyItem],
sort_by: Option<config::meta::alerts::alert::AlertSortField>,
sort_desc: bool,
) {
use std::cmp::Ordering;
use config::meta::alerts::alert::AlertSortField;
let tail = |a: &ListAlertsResponseBodyItem, b: &ListAlertsResponseBodyItem| {
a.name
.cmp(&b.name)
.then_with(|| a.folder_name.cmp(&b.folder_name))
.then_with(|| a.alert_id.to_string().cmp(&b.alert_id.to_string()))
};
match sort_by {
Some(AlertSortField::Priority) => list.sort_by(|a, b| {
// NULL priority sorts last regardless of direction, matching the
// SQL's explicit CASE.
let nulls = (a.priority.is_none() as u8).cmp(&(b.priority.is_none() as u8));
let pri = match (a.priority, b.priority) {
(Some(x), Some(y)) if sort_desc => y.cmp(&x),
(Some(x), Some(y)) => x.cmp(&y),
_ => Ordering::Equal,
};
nulls.then(pri).then_with(|| tail(a, b))
}),
Some(AlertSortField::Name) => list.sort_by(|a, b| {
let name = if sort_desc {
b.name.cmp(&a.name)
} else {
a.name.cmp(&b.name)
};
name.then_with(|| a.folder_name.cmp(&b.folder_name))
.then_with(|| a.alert_id.to_string().cmp(&b.alert_id.to_string()))
}),
// Historical default, matching the SQL arm.
None => list.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| a.folder_name.cmp(&b.folder_name))
}),
}
}
async fn enrich_with_run_state(list: &mut [ListAlertsResponseBodyItem]) {
if list.is_empty() {
return;
}
let ids: Vec<String> = list.iter().map(|i| i.alert_id.to_string()).collect();
let states = match infra::table::alert_states::get_rollups(&ids).await {
Ok(s) => s,
Err(e) => {
log::warn!("failed to load alert run state for list: {e}");
return;
}
};
if states.is_empty() {
return;
}
let by_id: std::collections::HashMap<_, _> = states
.into_iter()
.map(|s| (s.alert_id.clone(), s))
.collect();
for item in list.iter_mut() {
if let Some(state) = by_id.get(&item.alert_id.to_string()) {
item.last_outcome = state.last_outcome.as_ref().map(|o| o.to_string());
item.last_outcome_at = state.last_outcome_at;
item.last_outcome_since = state.since;
item.level = state.level.map(|l| l.to_string());
item.level_since = state.level_since;
// §5.4: the group counts live on the rollup row and are the only
// source for the "N of M groups firing" chip — they are computed
// pre-cap, so they cannot be reconstructed by counting the
// retained state rows. The exactness markers ride along because
// exactness likewise cannot be re-derived from the counts and a
// mutable cap setting.
item.groups_observed = state.groups_observed.and_then(|n| i32::try_from(n).ok());
item.groups_firing = state.groups_firing.and_then(|n| i32::try_from(n).ok());
item.groups_observed_is_lower_bound = state.groups_observed_is_lower_bound;
item.groups_firing_is_lower_bound = state.groups_firing_is_lower_bound;
}
}
}
/// EnableAlert
#[utoipa::path(
patch,

View File

@ -49,6 +49,7 @@ pub mod scorers;
pub mod service_accounts;
pub mod service_streams;
pub mod short_url;
pub mod slos;
pub mod sourcemaps;
pub mod status;
pub mod stream;

View File

@ -0,0 +1,434 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! SLO CRUD (`alerts_2.md` §6b).
//!
//! SLOs live in **alert folders** and are authorized as `alerts`, following
//! the `anomaly_detection` precedent. An SLO is alerting configuration and the
//! alerts built on it are ordinary alert rows (D28), so a separate permission
//! surface would mean granting two things to accomplish one.
use axum::{
Json,
extract::{Path, Query},
http::StatusCode,
response::{IntoResponse, Response},
};
use config::meta::slo::{Slo, SloStatusView};
use openobserve_api_common::extractors::Headers;
use openobserve_core::{auth::UserEmail, slo::service as slo_service};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::common::meta::http::HttpResponse as MetaHttpResponse;
#[derive(Debug, Default, Deserialize, ToSchema)]
pub struct ListQuery {
/// Restrict to one folder. Absent lists every folder in the org.
pub folder: Option<String>,
}
/// An SLO plus its current measurement, which is what a list view needs.
#[derive(Debug, Serialize, ToSchema)]
pub struct SloListItem {
#[serde(flatten)]
pub slo: Slo,
/// `None` until the first pass has measured anything. Deliberately not
/// zeroed: "not yet measured" and "measured as zero" are different, and a
/// UI that conflates them shows a brand-new SLO as 0% available.
pub status: Option<SloStatusView>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct SloListResponse {
pub list: Vec<SloListItem>,
}
fn disabled() -> Option<Response> {
if config::get_config().slo.enabled {
return None;
}
Some(
MetaHttpResponse::error(
StatusCode::NOT_IMPLEMENTED.as_u16(),
"SLOs are disabled. Set ZO_SLO_ENABLED=true to enable them.".to_string(),
)
.into_response(),
)
}
/// List SLOs in an organization.
#[utoipa::path(
get,
path = "/{org_id}/slos",
context_path = "/api",
tag = "SLOs",
operation_id = "ListSlos",
summary = "List SLOs",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization identifier"),
("folder" = Option<String>, Query, description = "Filter by folder ID"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = SloListResponse),
(status = 500, description = "Internal Server Error", content_type = "application/json", body = MetaHttpResponse),
),
)]
#[tracing::instrument(skip_all, fields(org_id = %org_id))]
pub async fn list_slos(Path(org_id): Path<String>, Query(q): Query<ListQuery>) -> Response {
if let Some(r) = disabled() {
return r;
}
match openobserve_core::slo::service::list_with_status(&org_id, q.folder.as_deref()).await {
Ok(list) => MetaHttpResponse::json(SloListResponse {
list: list
.into_iter()
.map(|(slo, status)| SloListItem { slo, status })
.collect(),
}),
Err(e) => internal(e),
}
}
/// Get one SLO with its current measurement.
#[utoipa::path(
get,
path = "/{org_id}/slos/{slo_id}",
context_path = "/api",
tag = "SLOs",
operation_id = "GetSlo",
summary = "Get an SLO",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization identifier"),
("slo_id" = String, Path, description = "SLO identifier"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = SloListItem),
(status = 404, description = "Not Found", content_type = "application/json", body = MetaHttpResponse),
),
)]
#[tracing::instrument(skip_all, fields(org_id = %org_id, slo_id = %slo_id))]
pub async fn get_slo(Path((org_id, slo_id)): Path<(String, String)>) -> Response {
if let Some(r) = disabled() {
return r;
}
match openobserve_core::slo::service::get_with_status(&org_id, &slo_id).await {
Ok(Some((slo, status))) => MetaHttpResponse::json(SloListItem { slo, status }),
Ok(None) => not_found(),
Err(e) => internal(e),
}
}
/// Create an SLO.
#[utoipa::path(
post,
path = "/{org_id}/slos",
context_path = "/api",
tag = "SLOs",
operation_id = "CreateSlo",
summary = "Create an SLO",
security(("Authorization" = [])),
params(("org_id" = String, Path, description = "Organization identifier")),
request_body(content = Slo, description = "SLO definition", content_type = "application/json"),
responses(
(status = 200, description = "Created", content_type = "application/json", body = MetaHttpResponse),
(status = 400, description = "Bad Request", content_type = "application/json", body = MetaHttpResponse),
),
)]
#[tracing::instrument(skip_all, fields(org_id = %org_id))]
pub async fn create_slo(
Path(org_id): Path<String>,
Headers(user_email): Headers<UserEmail>,
Json(mut slo): Json<Slo>,
) -> Response {
if let Some(r) = disabled() {
return r;
}
// The path segment is authoritative: it is what the permission check ran
// against, so a body claiming a different org must not be honoured.
slo.org = org_id;
if slo.id.is_empty() {
slo.id = config::ider::generate();
}
if slo.folder_id.is_empty() {
slo.folder_id = config::meta::folder::DEFAULT_FOLDER.to_string();
}
if slo.owner.is_none() {
slo.owner = Some(user_email.user_id.clone());
}
match slo_service::create(&mut slo).await {
Ok(()) => MetaHttpResponse::json(
MetaHttpResponse::message(StatusCode::OK, "SLO saved")
.with_id(slo.id.clone())
.with_name(slo.name.clone()),
),
Err(e) => save_error(e),
}
}
/// Update an SLO.
#[utoipa::path(
put,
path = "/{org_id}/slos/{slo_id}",
context_path = "/api",
tag = "SLOs",
operation_id = "UpdateSlo",
summary = "Update an SLO",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization identifier"),
("slo_id" = String, Path, description = "SLO identifier"),
),
request_body(content = Slo, description = "SLO definition", content_type = "application/json"),
responses(
(status = 200, description = "Updated", content_type = "application/json", body = MetaHttpResponse),
(status = 400, description = "Bad Request", content_type = "application/json", body = MetaHttpResponse),
),
)]
#[tracing::instrument(skip_all, fields(org_id = %org_id, slo_id = %slo_id))]
pub async fn update_slo(
Path((org_id, slo_id)): Path<(String, String)>,
Headers(user_email): Headers<UserEmail>,
Json(mut slo): Json<Slo>,
) -> Response {
if let Some(r) = disabled() {
return r;
}
// Both taken from the path, for the same reason as create: they are what
// the permission check ran against.
slo.org = org_id;
slo.id = slo_id;
if slo.owner.is_none() {
slo.owner = Some(user_email.user_id.clone());
}
match slo_service::update(&mut slo).await {
Ok(()) => MetaHttpResponse::json(
MetaHttpResponse::message(StatusCode::OK, "SLO updated")
.with_id(slo.id.clone())
.with_name(slo.name.clone()),
),
Err(openobserve_core::slo::service::SloError::NotFound) => not_found(),
Err(e) => save_error(e),
}
}
/// Delete an SLO.
#[utoipa::path(
delete,
path = "/{org_id}/slos/{slo_id}",
context_path = "/api",
tag = "SLOs",
operation_id = "DeleteSlo",
summary = "Delete an SLO",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization identifier"),
("slo_id" = String, Path, description = "SLO identifier"),
),
responses(
(status = 200, description = "Deleted", content_type = "application/json", body = MetaHttpResponse),
(status = 404, description = "Not Found", content_type = "application/json", body = MetaHttpResponse),
),
)]
#[tracing::instrument(skip_all, fields(org_id = %org_id, slo_id = %slo_id))]
pub async fn delete_slo(Path((org_id, slo_id)): Path<(String, String)>) -> Response {
if let Some(r) = disabled() {
return r;
}
match slo_service::delete(&org_id, &slo_id).await {
Ok(true) => {
MetaHttpResponse::json(MetaHttpResponse::message(StatusCode::OK, "SLO deleted"))
}
Ok(false) => not_found(),
Err(e) => internal(anyhow::anyhow!(e.to_string())),
}
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct MoveSlosRequestBody {
/// The SLOs to relocate.
pub slo_ids: Vec<String>,
/// Destination folder. An **alert** folder — SLOs share the alert folder
/// namespace rather than having a type of their own.
pub dst_folder_id: String,
}
/// Move SLOs between folders.
#[utoipa::path(
post,
path = "/{org_id}/slos/move",
context_path = "/api",
tag = "SLOs",
operation_id = "MoveSlos",
summary = "Move SLOs between folders",
description = "Relocates one or more SLOs into another folder. SLOs share the alert folder namespace, so the destination is an alert folder. A move never changes an SLO's definition and never restarts its measurement.",
security(("Authorization" = [])),
params(("org_id" = String, Path, description = "Organization identifier")),
request_body(content = inline(MoveSlosRequestBody), description = "The SLOs and the destination folder", content_type = "application/json"),
responses(
(status = 200, description = "Moved", content_type = "application/json", body = MetaHttpResponse),
(status = 404, description = "Not Found", content_type = "application/json", body = MetaHttpResponse),
(status = 409, description = "Name already used in the destination", content_type = "application/json", body = MetaHttpResponse),
),
extensions(
("x-o2-ratelimit" = json!({"module": "SLOs", "operation": "update"})),
)
)]
#[tracing::instrument(skip_all, fields(org_id = %org_id))]
pub async fn move_slos(
Path(org_id): Path<String>,
Headers(user_email): Headers<UserEmail>,
Json(req_body): Json<MoveSlosRequestBody>,
) -> Response {
if let Some(r) = disabled() {
return r;
}
if req_body.slo_ids.is_empty() {
return MetaHttpResponse::error(
StatusCode::BAD_REQUEST.as_u16(),
"no SLOs given to move".to_string(),
)
.into_response();
}
match slo_service::move_to_folder(
&org_id,
&req_body.slo_ids,
&req_body.dst_folder_id,
Some(&user_email.user_id),
)
.await
{
// Nothing matched: every id was unknown or belonged to another org.
// Reported rather than passed off as success, which is what a bare
// "moved" would do for a typo'd id.
Ok(0) => not_found(),
Ok(n) => MetaHttpResponse::json(MetaHttpResponse::message(
StatusCode::OK,
if n == 1 { "SLO moved" } else { "SLOs moved" },
)),
Err(e) => save_error(e),
}
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct EnableQuery {
pub value: bool,
}
/// Enable or pause an SLO.
///
/// Separate from update because pausing must never be able to change the
/// definition — and therefore can never bump the generation or discard
/// measurement.
#[utoipa::path(
put,
path = "/{org_id}/slos/{slo_id}/enable",
context_path = "/api",
tag = "SLOs",
operation_id = "EnableSlo",
summary = "Enable or pause an SLO",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization identifier"),
("slo_id" = String, Path, description = "SLO identifier"),
("value" = bool, Query, description = "true to enable, false to pause"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = MetaHttpResponse),
(status = 404, description = "Not Found", content_type = "application/json", body = MetaHttpResponse),
),
)]
#[tracing::instrument(skip_all, fields(org_id = %org_id, slo_id = %slo_id))]
pub async fn enable_slo(
Path((org_id, slo_id)): Path<(String, String)>,
Query(q): Query<EnableQuery>,
) -> Response {
if let Some(r) = disabled() {
return r;
}
match slo_service::set_enabled(&org_id, &slo_id, q.value).await {
Ok(true) => MetaHttpResponse::json(MetaHttpResponse::message(
StatusCode::OK,
if q.value { "SLO enabled" } else { "SLO paused" },
)),
Ok(false) => not_found(),
Err(e) => internal(anyhow::anyhow!(e.to_string())),
}
}
/// The per-group breakdown for one SLO.
#[utoipa::path(
get,
path = "/{org_id}/slos/{slo_id}/groups",
context_path = "/api",
tag = "SLOs",
operation_id = "GetSloGroups",
summary = "Per-group SLO status",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization identifier"),
("slo_id" = String, Path, description = "SLO identifier"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
),
)]
#[tracing::instrument(skip_all, fields(org_id = %org_id, slo_id = %slo_id))]
pub async fn get_slo_groups(Path((org_id, slo_id)): Path<(String, String)>) -> Response {
if let Some(r) = disabled() {
return r;
}
match openobserve_core::slo::service::group_status(&org_id, &slo_id).await {
Ok(groups) => MetaHttpResponse::json(serde_json::json!({ "list": groups })),
Err(e) => internal(e),
}
}
fn not_found() -> Response {
MetaHttpResponse::error(StatusCode::NOT_FOUND.as_u16(), "SLO not found".to_string())
.into_response()
}
fn internal(e: anyhow::Error) -> Response {
tracing::error!("[slo] request failed: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
.into_response()
}
/// Map a save failure to a status the caller can act on.
///
/// A budget rejection is a 4xx carrying the arithmetic, not a 500: the user
/// can fix it by deleting an SLO or narrowing this one, and §6b.4d requires
/// the rejection to show its working.
fn save_error(e: openobserve_core::slo::service::SloError) -> Response {
use openobserve_core::slo::service::SloError;
let status = match &e {
SloError::Validation(_) => StatusCode::BAD_REQUEST,
SloError::Budget(_) => StatusCode::PAYLOAD_TOO_LARGE,
SloError::NotFound => StatusCode::NOT_FOUND,
// A name clash is the user's to fix, not a server fault.
SloError::DuplicateName(_) | SloError::MoveNameConflict => StatusCode::CONFLICT,
SloError::FolderNotFound(_) => StatusCode::NOT_FOUND,
SloError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
if status == StatusCode::INTERNAL_SERVER_ERROR {
tracing::error!("[slo] save failed: {e}");
}
MetaHttpResponse::error(status.as_u16(), e.to_string()).into_response()
}

View File

@ -206,6 +206,10 @@ struct ConfigResponse<'a> {
online_evals_enabled: bool,
anomaly_detection_enabled: bool,
synthetics_enabled: bool,
/// SLO measurement (`ZO_SLO_ENABLED`). Not enterprise-gated — the SLO APIs
/// answer 501 while it is off, so the UI uses this to hide the menu entry
/// rather than offer a page that cannot work.
slo_enabled: bool,
enable_cross_linking: bool,
show_fts_field_values: bool,
search_inspector_enabled: bool,
@ -364,7 +368,7 @@ pub async fn zo_config() -> impl IntoResponse {
// Anomaly detection is on when the enterprise feature is compiled in, unless turned off at
// runtime via O2_ANOMALY_DETECTION_DISABLED. When disabled the UI hides the anomaly tab.
let anomaly_detection_enabled = enterprise_value!(false, !o2cfg.anomaly_detection.disabled);
let online_evals_enabled = enterprise_value!(false, o2cfg.common.online_evals_enabled);
let online_evals_enabled = enterprise_value!(false, o2cfg.llm_eval_config.enabled);
let synthetics_enabled = enterprise_value!(false, o2cfg.synthetics.enabled);
#[cfg(all(feature = "cloud", not(feature = "enterprise")))]
@ -479,6 +483,7 @@ pub async fn zo_config() -> impl IntoResponse {
online_evals_enabled,
anomaly_detection_enabled,
synthetics_enabled,
slo_enabled: cfg.slo.enabled,
enable_cross_linking: cfg.common.enable_cross_linking,
show_fts_field_values: cfg.common.show_fts_field_values,
search_inspector_enabled: cfg.common.search_inspector_enabled,

View File

@ -34,7 +34,7 @@ use crate::service::auth::{UserEmail, check_permissions};
pub struct ListSyntheticsQuery {
pub folder: Option<String>,
#[serde(rename = "type")]
pub monitor_type: Option<config::meta::synthetics::SyntheticType>,
pub check_type: Option<config::meta::synthetics::SyntheticType>,
pub enabled: Option<bool>,
pub location: Option<String>,
pub tag: Option<String>,
@ -46,7 +46,7 @@ impl From<ListSyntheticsQuery> for config::meta::synthetics::ListSyntheticsParam
fn from(q: ListSyntheticsQuery) -> Self {
Self {
folder_id: q.folder,
monitor_type: q.monitor_type,
check_type: q.check_type,
enabled: q.enabled,
location: q.location,
tag: q.tag,
@ -88,11 +88,11 @@ pub struct ListRunsQuery {
context_path = "/api",
tag = "Synthetics",
operation_id = "ListSyntheticsRuns",
summary = "List runs for a monitor",
summary = "List runs for a check",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
("start_time" = Option<i64>, Query, description = "Filter runs with scheduled_ts >= start_time (microseconds)"),
("end_time" = Option<i64>, Query, description = "Filter runs with scheduled_ts <= end_time (microseconds)"),
("page" = Option<i64>, Query, description = "Page number (0-indexed, default 0)"),
@ -146,7 +146,7 @@ pub async fn list_runs(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
("run_id" = String, Path, description = "Run ID (KSUID)"),
),
responses(
@ -332,7 +332,7 @@ pub async fn job_upload(
}
}
// ── Monitors ──────────────────────────────────────────────────────────────────
// ── Checks ──────────────────────────────────────────────────────────────────
#[utoipa::path(
get,
@ -345,7 +345,7 @@ pub async fn job_upload(
params(
("org_id" = String, Path, description = "Organization name"),
("folder" = Option<String>, Query, description = "Filter by folder ID (KSUID)"),
("type" = Option<String>, Query, description = "Filter by monitor type (http|browser|tcp|tls|ssh)"),
("type" = Option<String>, Query, description = "Filter by check type (http|browser|tcp|tls|ssh)"),
("enabled" = Option<bool>, Query, description = "Filter by enabled status"),
("location" = Option<String>, Query, description = "Filter by location"),
("tag" = Option<String>, Query, description = "Filter by tag"),
@ -428,7 +428,7 @@ pub async fn create_synthetic(
)
.await
{
Ok(monitor) => MetaHttpResponse::json(monitor),
Ok(check) => MetaHttpResponse::json(check),
Err(e) => {
let msg = e.to_string();
if msg.starts_with("validation: ") {
@ -457,7 +457,7 @@ pub async fn create_synthetic(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = config::meta::synthetics::Synthetic),
@ -488,8 +488,8 @@ pub async fn get_synthetic(
return MetaHttpResponse::forbidden("Forbidden");
}
match o2_enterprise::enterprise::synthetics::service::get_synthetic(&org_id, &id).await {
Ok(Some(monitor)) => MetaHttpResponse::json(monitor),
Ok(None) => MetaHttpResponse::not_found("monitor not found"),
Ok(Some(check)) => MetaHttpResponse::json(check),
Ok(None) => MetaHttpResponse::not_found("check not found"),
Err(e) => {
tracing::error!("[synthetics] get_synthetic: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
@ -514,7 +514,7 @@ pub async fn get_synthetic(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
("folder" = Option<String>, Query, description = "Current folder ID of the synthetic (for RBAC)"),
),
request_body(content = config::meta::synthetics::Synthetic, description = "Updated synthetic definition", content_type = "application/json"),
@ -550,7 +550,7 @@ pub async fn update_synthetic(
match o2_enterprise::enterprise::synthetics::service::update_synthetic(&org_id, &id, body)
.await
{
Ok(monitor) => MetaHttpResponse::json(monitor),
Ok(check) => MetaHttpResponse::json(check),
Err(e) => {
let msg = e.to_string();
if msg.starts_with("validation: ") {
@ -582,7 +582,7 @@ pub async fn update_synthetic(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
("folder" = Option<String>, Query, description = "Current folder ID of the synthetic (for RBAC)"),
),
responses(
@ -614,8 +614,8 @@ pub async fn delete_synthetic(
return MetaHttpResponse::forbidden("Forbidden");
}
match o2_enterprise::enterprise::synthetics::service::delete_synthetic(&org_id, &id).await {
Ok(true) => MetaHttpResponse::ok("monitor deleted"),
Ok(false) => MetaHttpResponse::not_found("monitor not found"),
Ok(true) => MetaHttpResponse::ok("check deleted"),
Ok(false) => MetaHttpResponse::not_found("check not found"),
Err(e) => {
tracing::error!("[synthetics] delete_synthetic: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
@ -662,7 +662,7 @@ pub async fn delete_synthetics_bulk(
)
.await
{
Ok(_) => MetaHttpResponse::ok("monitors deleted"),
Ok(_) => MetaHttpResponse::ok("checks deleted"),
Err(e) => {
tracing::error!("[synthetics] delete_synthetics_bulk: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
@ -732,7 +732,7 @@ pub async fn move_synthetics(
)
.await
{
Ok(_) => MetaHttpResponse::ok("monitors moved"),
Ok(_) => MetaHttpResponse::ok("checks moved"),
Err(e) => {
tracing::error!("[synthetics] move_synthetics: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
@ -757,7 +757,7 @@ pub async fn move_synthetics(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
),
request_body(content = Object, description = r#"{"enabled": true}"#, content_type = "application/json"),
responses(
@ -799,11 +799,11 @@ pub async fn set_synthetic_enabled(
.await
{
Ok(true) => MetaHttpResponse::ok(if enabled {
"monitor enabled"
"check enabled"
} else {
"monitor paused"
"check paused"
}),
Ok(false) => MetaHttpResponse::not_found("monitor not found"),
Ok(false) => MetaHttpResponse::not_found("check not found"),
Err(e) => {
tracing::error!("[synthetics] set_synthetic_enabled: {e}");
MetaHttpResponse::error(StatusCode::INTERNAL_SERVER_ERROR.as_u16(), e.to_string())
@ -828,7 +828,7 @@ pub async fn set_synthetic_enabled(
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("id" = String, Path, description = "Monitor ID"),
("id" = String, Path, description = "Check ID"),
),
responses(
(status = 202, description = "Accepted — scheduler will fire within 5 seconds"),
@ -886,7 +886,7 @@ pub async fn run_synthetic_now(
context_path = "/api",
tag = "Synthetics",
operation_id = "SyntheticsJobResolve",
summary = "Resolve a job — probe fetches monitor config (authenticated via o2syn_ token)",
summary = "Resolve a job — probe fetches check config (authenticated via o2syn_ token)",
params(
("org_id" = String, Path, description = "Organization name"),
),
@ -1106,20 +1106,31 @@ async fn process_ack(
key: format!("{}/{}", resp.synthetics_name, resp.synthetics_id),
start_time: checked_at,
end_time: checked_at,
status: config::meta::self_reporting::usage::TriggerDataStatus::Completed,
status: config::meta::self_reporting::usage::RunOutcome::Succeeded,
success_response: Some(status.clone()),
error: error.clone(),
evaluation_took_in_secs: Some(response_time_ms / 1000.0),
..Default::default()
});
// Notify once per run, not once per job ack.
if resp.run_complete && !resp.destinations.is_empty() {
// Notify once per run, not once per job ack — and only when the check's own
// `alert_if_fails` / `cooldown_mins` settings say so. This used to fire on
// every completed run that had a destination, which is why `alert_if_fails:
// 3` alerted on the first failure and a 30-minute cooldown sent thirty
// notifications.
use o2_enterprise::enterprise::synthetics::job_api::AlertDecision;
let recovery = matches!(resp.alert, AlertDecision::Recovered);
let flaky = matches!(resp.alert, AlertDecision::Flaky);
// A degrading target is `warning` on every run for as long as the condition
// lasts, so this one is throttled by transition upstream, not by cooldown.
let degraded = matches!(resp.alert, AlertDecision::Degraded);
let should_notify = !matches!(resp.alert, AlertDecision::Silent);
if should_notify && !resp.destinations.is_empty() {
let notification = openobserve_core::synthetics::CheckNotification {
org_id: resp.org_id.clone(),
monitor_name: resp.synthetics_name.clone(),
monitor_id: resp.synthetics_id.clone(),
monitor_type: resp.synthetic_type.clone(),
check_name: resp.synthetics_name.clone(),
check_id: resp.synthetics_id.clone(),
check_type: resp.synthetic_type.clone(),
target: resp.target.clone(),
destinations: resp.destinations.clone(),
run_id: resp.run_id.clone(),
@ -1127,6 +1138,13 @@ async fn process_ack(
job_count: resp.job_count as i64,
error: error.clone(),
checked_at,
recovery,
consecutive_failures: resp.consecutive_failures,
flaky,
degraded,
status_reason: resp.status_reason.clone(),
failing_locations: resp.failing_locations.clone(),
passing_locations: resp.passing_locations.clone(),
};
tokio::spawn(async move {
openobserve_core::synthetics::notify_check_result(notification).await;

View File

@ -735,8 +735,8 @@ pub async fn metadata(
params(
("org_id" = String, Path, description = "Organization name"),
("match[]" = String, Query, description = "<series_selector>: Series selector argument that selects the series to return"),
("start" = Option<String>, Query, description = "<rfc3339 | unix_timestamp>: Start timestamp"),
("end" = Option<String>, Query, description = "<rfc3339 | unix_timestamp>: End timestamp"),
("start" = Option<String>, Query, description = "<rfc3339 | unix_timestamp>: Start timestamp, optional, defaults to 24 hours before end"),
("end" = Option<String>, Query, description = "<rfc3339 | unix_timestamp>: End timestamp, optional, defaults to now"),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object, example = json!({
@ -763,7 +763,7 @@ pub async fn metadata(
),
extensions(
("x-o2-ratelimit" = json!({"module": "Metrics", "operation": "get"})),
("x-o2-mcp" = json!({"description": "Get Prometheus series, must have start and end time", "category": "metrics"}))
("x-o2-mcp" = json!({"description": "Get Prometheus series, start and end time are optional (default: last 24 hours)", "category": "metrics"}))
)
)]
pub async fn series_get(

View File

@ -56,26 +56,6 @@ pub(crate) mod schema_compat;
pub mod session;
pub mod user;
#[derive(Default, Clone, Debug)]
pub(crate) struct TraceDetail {
pub(crate) start_time: i64,
pub(crate) end_time: i64,
pub(crate) gen_ai_usage_input_tokens: i64,
pub(crate) gen_ai_usage_output_tokens: i64,
pub(crate) gen_ai_usage_total_tokens: i64,
pub(crate) gen_ai_usage_cost: f64,
pub(crate) gen_ai_usage_cache_read_input_tokens: i64,
pub(crate) gen_ai_usage_cache_creation_input_tokens: i64,
pub(crate) gen_ai_usage_cost_cache_read_input: f64,
pub(crate) gen_ai_usage_cost_cache_creation_input: f64,
pub(crate) gen_ai_usage_cost_estimated_without_cache: f64,
pub(crate) gen_ai_usage_cost_cache_read_savings: f64,
pub(crate) gen_ai_usage_cost_net_cache_impact: f64,
pub(crate) error_count: i64,
pub(crate) user_id: Option<String>,
pub(crate) first_user_message: Option<String>,
}
/// TracesIngest
#[utoipa::path(
post,

File diff suppressed because it is too large Load Diff

View File

@ -414,7 +414,6 @@ pub async fn get_latest_users(
gen_ai_usage_cost: json::get_float_value(
item.get("gen_ai_usage_cost_details").unwrap_or_default(),
),
..Default::default()
},
);
}
@ -458,7 +457,13 @@ pub async fn get_latest_users(
})
}
use super::TraceDetail;
#[derive(Default, Clone, Debug)]
struct TraceDetail {
start_time: i64,
end_time: i64,
gen_ai_usage_total_tokens: i64,
gen_ai_usage_cost: f64,
}
#[derive(Debug, Serialize)]
struct UserResponseItem {

View File

@ -285,7 +285,7 @@ pub struct TriggerStatus {
#[derive(Serialize, Deserialize, Debug)]
pub struct TriggerStatusSearchResult {
pub module: usage::TriggerDataType,
pub status: usage::TriggerDataStatus,
pub status: usage::RunOutcome,
}
impl TriggerStatus {
@ -302,10 +302,15 @@ impl TriggerStatus {
for result in results {
if result.module == module {
match result.status {
usage::TriggerDataStatus::Completed
| usage::TriggerDataStatus::ConditionNotSatisfied => status.healthy += 1,
usage::TriggerDataStatus::Failed => status.failed += 1,
usage::TriggerDataStatus::Skipped => status.warning += 1,
// Health = did the evaluation run cleanly. A firing alert
// is a healthy trigger; a failed delivery is not.
usage::RunOutcome::Firing
| usage::RunOutcome::Normal
| usage::RunOutcome::Succeeded => status.healthy += 1,
usage::RunOutcome::Error | usage::RunOutcome::NotifyFailed => {
status.failed += 1
}
usage::RunOutcome::Skipped => status.warning += 1,
}
}
}
@ -1141,11 +1146,11 @@ mod tests {
let results = vec![
TriggerStatusSearchResult {
module: usage::TriggerDataType::DerivedStream,
status: usage::TriggerDataStatus::Completed,
status: usage::RunOutcome::Firing,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::DerivedStream,
status: usage::TriggerDataStatus::ConditionNotSatisfied,
status: usage::RunOutcome::Normal,
},
];
@ -1162,15 +1167,15 @@ mod tests {
let results = vec![
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Completed,
status: usage::RunOutcome::Firing,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Failed,
status: usage::RunOutcome::Error,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Failed,
status: usage::RunOutcome::Error,
},
];
@ -1186,15 +1191,15 @@ mod tests {
let results = vec![
TriggerStatusSearchResult {
module: usage::TriggerDataType::DerivedStream,
status: usage::TriggerDataStatus::Completed,
status: usage::RunOutcome::Firing,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::DerivedStream,
status: usage::TriggerDataStatus::Skipped,
status: usage::RunOutcome::Skipped,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::DerivedStream,
status: usage::TriggerDataStatus::Skipped,
status: usage::RunOutcome::Skipped,
},
];
@ -1211,19 +1216,19 @@ mod tests {
let results = vec![
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Completed,
status: usage::RunOutcome::Firing,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Failed,
status: usage::RunOutcome::Error,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Skipped,
status: usage::RunOutcome::Skipped,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::ConditionNotSatisfied,
status: usage::RunOutcome::Normal,
},
];
@ -1251,15 +1256,15 @@ mod tests {
let results = vec![
TriggerStatusSearchResult {
module: usage::TriggerDataType::DerivedStream,
status: usage::TriggerDataStatus::Completed,
status: usage::RunOutcome::Firing,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Failed,
status: usage::RunOutcome::Error,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::DerivedStream,
status: usage::TriggerDataStatus::Failed,
status: usage::RunOutcome::Error,
},
];
@ -1276,11 +1281,11 @@ mod tests {
let results = vec![
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Completed,
status: usage::RunOutcome::Firing,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Failed,
status: usage::RunOutcome::Error,
},
];
@ -1298,15 +1303,15 @@ mod tests {
let results = vec![
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Failed,
status: usage::RunOutcome::Error,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Failed,
status: usage::RunOutcome::Error,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::Alert,
status: usage::TriggerDataStatus::Failed,
status: usage::RunOutcome::Error,
},
];
@ -1322,11 +1327,11 @@ mod tests {
let results = vec![
TriggerStatusSearchResult {
module: usage::TriggerDataType::DerivedStream,
status: usage::TriggerDataStatus::Skipped,
status: usage::RunOutcome::Skipped,
},
TriggerStatusSearchResult {
module: usage::TriggerDataType::DerivedStream,
status: usage::TriggerDataStatus::Skipped,
status: usage::RunOutcome::Skipped,
},
];

View File

@ -28,11 +28,13 @@ ingester.workspace = true
itertools.workspace = true
log.workspace = true
o2_enterprise = { workspace = true, optional = true, default-features = false }
object_store.workspace = true
parking_lot.workspace = true
parquet.workspace = true
rand.workspace = true
schema.workspace = true
search.workspace = true
search_service.workspace = true
tantivy.workspace = true
tantivy_utils.workspace = true
tokio.workspace = true

View File

@ -0,0 +1,127 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Build SBBF blooms by iterating tantivy term dictionaries.
//!
//! For an indexed field, tantivy already stores a deduplicated term
//! dictionary. Iterating it is much cheaper than re-scanning the parquet
//! column — terms come back already unique, sorted, and as raw bytes.
//!
//! Used by the compactor merge hook to build per-(file, field)
//! blooms after the .ttv file is written.
use std::collections::HashSet;
use anyhow::Context;
use hashbrown::HashMap;
use infra::bloom::{BloomBuilder, FieldBloom};
use tantivy::Index;
use tantivy_utils::puffin_directory::reader::warm_up_terms;
/// Build per-field SBBFs for one file using a group-uniform `num_blocks`.
///
/// Every file in a (stream, hour, bloom_ver) group must pass the same
/// `num_blocks` so the transposed `.bf` layout can read one block-row per
/// group (see `infra::bloom` module docs). The caller derives it once from
/// the configured expected cardinality.
///
/// Behavior:
/// - Fields not present in the schema are silently skipped — the compactor passes the union of
/// `index_fields ∩ bloom_filter_fields` over potentially many streams, and not every field exists
/// everywhere.
/// - Terms across all segments of the index are merged into one bloom per field. Today
/// `create_tantivy_index` produces a single segment, but this is robust to that changing.
pub(super) async fn build_blooms_from_index(
index: &Index,
file_id: u64,
fields: &[String],
num_blocks: u32,
) -> Result<Vec<FieldBloom>, anyhow::Error> {
if fields.is_empty() {
return Ok(Vec::new());
}
let schema = index.schema();
let reader = index
.reader_builder()
.reload_policy(tantivy::ReloadPolicy::Manual)
.num_warming_threads(0)
.try_into()
.context("open tantivy reader")?;
let searcher = reader.searcher();
let warm_terms: HashMap<tantivy::schema::Field, HashMap<tantivy::Term, bool>> = HashMap::new();
let mut need_all_term_fields = HashSet::new();
for field in fields {
let Ok(field) = schema.get_field(field) else {
continue;
};
need_all_term_fields.insert(field);
}
// warm_up_terms operates on one SegmentReader at a time; warm each segment.
// need_all_term_fields / need_fast_field are consumed per call, so clone them.
for seg in searcher.segment_readers() {
warm_up_terms(
seg,
&warm_terms,
need_all_term_fields.clone(),
HashSet::new(),
)
.await?;
}
let mut builder = BloomBuilder::new();
for field_name in fields {
let Ok(field) = schema.get_field(field_name) else {
continue;
};
// Skip fields with no terms in this file so they don't become an
// empty column in the transposed matrix.
let mut has_terms = false;
for seg in searcher.segment_readers() {
if let Ok(inv) = seg.inverted_index(field)
&& inv.terms().num_terms() > 0
{
has_terms = true;
break;
}
}
if !has_terms {
continue;
}
// Uniform block count across the whole group (caller-provided).
let idx = builder.begin_with_blocks(file_id, field_name, num_blocks);
for seg in searcher.segment_readers() {
let inv = match seg.inverted_index(field) {
Ok(i) => i,
Err(_) => continue,
};
let mut stream = inv
.terms()
.stream()
.with_context(|| format!("stream terms for {field_name}"))?;
while let Some((term_bytes, _info)) = stream.next() {
builder.insert(idx, term_bytes);
}
}
}
Ok(builder.finish())
}

View File

@ -0,0 +1,349 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Bloom filter build + orphan cleanup for the compactor.
//!
//! One public entry point, [`build_for_stream`], called once per hour bucket
//! at the end of a compaction merge round. It does two things in order:
//!
//! 1. **Orphan cleanup.** Given the `bloom_ver` values of the files this merge round just deleted,
//! [`cleanup_orphan_blooms`] retires any `.bf` no longer referenced by a live row in the bucket.
//! 2. **Build.** It queries the bucket itself (`query_for_bloom` → rows with `index_size > 0` and
//! `bloom_ver = 0`) and builds `.bf` coverage for those files. The caller does not pass a file
//! list; this module owns both the "which files" and the "how to bloom" decisions.
//!
//! `bloom_ver` is not append-only: a merge produces new output files with a
//! fresh `bloom_ver` and deletes the small inputs, so an old `.bf` whose every
//! file got merged away becomes an orphan — step 1 catches it.
//!
//! Failure is **always non-fatal**. A failed build leaves `bloom_ver = 0`
//! on the affected files and the search side falls back to opening every
//! tantivy file in the bucket. A failed cleanup leaves an orphan `.bf` for
//! data-retention to reap. Neither affects correctness, so neither
//! propagates to the caller as a hard error.
use std::{collections::HashSet, sync::Arc};
use anyhow::Context;
use bytes::Bytes;
use config::{
get_config,
meta::stream::{FileKey, FileListDeleted, StreamType},
utils::{inverted_index::to_tantivy_name, time::now_micros},
};
use infra::{
bloom::{BloomWriter, FieldBloom, path::bloom_path},
errors::Result,
file_list as infra_file_list, storage,
};
use tantivy::Directory;
use tantivy_utils::puffin_directory::{
caching_directory::CachingDirectory, footer_cache::FooterCache, reader::PuffinDirReader,
};
use super::builder::build_blooms_from_index;
/// Clean orphan `.bf`s for `orphan_blooms`, then build `.bf` coverage for the
/// bucket's `bloom_ver = 0` files (queried internally via `query_for_bloom`).
///
/// `date_key` is the `YYYY/MM/DD/HH` string used in `file_list.date`.
/// `orphan_blooms` is the `bloom_ver` values of the files the merge round just
/// deleted — used only for cleanup. Files already carrying a non-zero
/// `bloom_ver` are never re-stamped, so no live file migrates off a `bloom_ver`
/// that a dumped row may still point to.
///
/// Returns `Ok(false)` when bloom is disabled or there is nothing to build (no
/// target fields, nothing at `bloom_ver = 0`); `Ok(true)` when a build ran.
/// Recoverable per-file/per-chunk errors are logged, not propagated — never
/// surfaces a build failure to user requests.
pub(crate) async fn build_for_stream(
org_id: &str,
stream_type: StreamType,
stream_name: &str,
date_key: &str,
is_incremental: bool,
orphan_blooms: Vec<i64>,
) -> Result<bool> {
let cfg = get_config();
if !cfg.common.bloom_filter_enabled {
return Ok(false);
}
// clean up orphan blooms
if let Err(e) =
cleanup_orphan_blooms(org_id, stream_type, stream_name, date_key, orphan_blooms).await
{
log::warn!("[BLOOM_BUILD] cleanup orphan blooms failed: {e}");
}
// don't build bloom for incremental round
if is_incremental {
return Ok(false);
}
// Resolve target fields from current stream settings: a field is bloomed
// only when the operator put it in BOTH `bloom_filter_fields` and
// `index_fields` (bloom is built on top of the tantivy index).
let stream_settings = infra::schema::get_settings(org_id, stream_name, stream_type).await;
let bloom_filter_fields =
infra::schema::get_stream_setting_bloom_filter_fields(&stream_settings);
if bloom_filter_fields.is_empty() {
return Ok(false);
}
let index_fields: HashSet<String> =
infra::schema::get_stream_setting_index_fields(&stream_settings)
.into_iter()
.collect();
let target_fields: Vec<String> = bloom_filter_fields
.into_iter()
.filter(|f| index_fields.contains(f))
.collect();
if target_fields.is_empty() {
return Ok(false);
}
// Pull every file currently in this hour bucket.
let mut files =
infra_file_list::query_for_bloom(org_id, stream_type, stream_name, date_key).await?;
if files.is_empty() {
return Ok(false);
}
// Build blooms only for files without a `.bf` yet (bloom_ver = 0).
//
// **Sub-grouping**: a single hour can hold thousands of files. Since the
// transposed `.bf` holds every file's SBBF in memory before serialize
// (M × B × 32 bytes), one giant `.bf` would OOM the compactor at high
// volume. So we split the new files into chunks of at most
// `max_files_per_bf` and write one `.bf` per chunk, each with its own
// `bloom_ver`. The search side groups by `(date, bloom_ver)`, so it
// naturally reads one block-row per chunk. Total read bytes stay
// `M × 32`; only the request count grows to `ceil(M / max_files_per_bf)`.
//
// Files are sorted by record count first so similar-sized files share a
// chunk — minimizes the uniform-`B` padding waste (B is sized to each
// chunk's max cardinality).
let fpp = cfg.common.bloom_filter_fpp;
let max_files_per_bf = cfg.common.bloom_filter_max_files_per_bf.max(1);
files.sort_by_key(|k| std::cmp::Reverse(k.meta.records));
let base_ver = now_micros();
let chunk_total = files.len().div_ceil(max_files_per_bf);
for (chunk_idx, chunk) in files.chunks(max_files_per_bf).enumerate() {
let bloom_ver = base_ver + chunk_idx as i64;
let bloom_path = bloom_path(org_id, stream_type, stream_name, date_key, bloom_ver);
match build_for_chunk(org_id, bloom_ver, &bloom_path, chunk, &target_fields, fpp).await {
Ok((took, num_blocks, contributing_ids)) => {
log::info!(
"[BLOOM_BUILD] {bloom_path}: wrote chunk {}/{chunk_total}, num_blocks={num_blocks} covering {contributing_ids} files in {took} ms",
chunk_idx + 1,
);
}
Err(e) => {
log::warn!("[BLOOM_BUILD] {bloom_path}: build chunk failed: {e}");
}
}
}
Ok(true)
}
// B for this chunk is sized from the chunk's MAX record count, used as
// a safe NDV upper bound: a file can't hold more distinct values than
// rows. This never under-sizes (no saturation) for the target
// high-cardinality fields where distinct ≈ rows. It over-sizes for
// fields that repeat heavily (distinct ≪ rows) — acceptable; exact
// sizing would read each file's tantivy term count in a pre-pass.
async fn build_for_chunk(
org_id: &str,
bloom_ver: i64,
bf_path: &str,
files: &[FileKey],
target_fields: &[String],
fpp: f64,
) -> Result<(u64, u32, usize)> {
let start = std::time::Instant::now();
let max_records = files
.iter()
.map(|f| f.meta.records.max(0) as u64)
.max()
.unwrap_or(0)
.max(1);
let num_blocks = infra::bloom::num_blocks_for(max_records, fpp);
let mut all_blooms: Vec<FieldBloom> = Vec::new();
let mut contributing_ids: Vec<i64> = Vec::new();
for f in files {
match build_for_file(f, target_fields, num_blocks).await {
Ok(mut blooms) if !blooms.is_empty() => {
all_blooms.append(&mut blooms);
contributing_ids.push(f.id);
}
Ok(_) => {} // no blooms (no index / field absent) — leave at 0
Err(e) => {
log::warn!(
"[BLOOM_BUILD] {bf_path}: skipping {} (bloom build failed): {e}",
f.key
);
}
}
}
if all_blooms.is_empty() {
return Ok((0, 0, 0));
}
// Distinct bloom_ver per chunk (base + idx) → distinct `.bf` path.
let blob: Vec<u8> = BloomWriter::serialize(all_blooms).context("serialize blooms")?;
let bf_account = storage::get_account(org_id, bf_path).unwrap_or_default();
storage::put(&bf_account, bf_path, Bytes::from(blob))
.await
.context("upload blooms")?;
debug_assert!(
contributing_ids.iter().all(|id| *id > 0),
"bloom builder must only stamp file_list rows with assigned ids"
);
infra_file_list::update_bloom_ver(&contributing_ids, bloom_ver)
.await
.context("update bloom ver")?;
let took = start.elapsed().as_millis() as u64;
Ok((took, num_blocks, contributing_ids.len()))
}
async fn build_for_file(
file: &FileKey,
target_fields: &[String],
num_blocks: u32,
) -> anyhow::Result<Vec<FieldBloom>> {
let Some(ttv_file_name) = to_tantivy_name(&file.key) else {
return Ok(Vec::new()); // not an indexable file
};
if file.meta.index_size == 0 {
return Ok(Vec::new()); // no .ttv was emitted
}
let file_account = file.account.clone();
let puffin_dir =
Arc::new(get_tantivy_directory(&file_account, &ttv_file_name, file.meta.index_size).await?);
let footer_cache = FooterCache::from_directory(puffin_dir.clone(), &ttv_file_name).await?;
let cache_dir = CachingDirectory::new_with_cacher(puffin_dir, Arc::new(footer_cache));
let reader_directory: Box<dyn Directory> = Box::new(cache_dir);
let index = tantivy::Index::open(reader_directory).context("open index")?;
// file.id is the file_list row id, assigned by the INSERT that
// happened in `write_file_list` before this build runs. Always > 0
// by the time we get here.
let file_id = file.id as u64;
build_blooms_from_index(&index, file_id, target_fields, num_blocks).await
}
/// Open a tantivy puffin directory for `file_name` in `file_account`.
///
/// Mirrors the search-side `get_tantivy_directory` helper: builds a synthetic
/// `ObjectMeta` (the `.ttv` is immutable, so a fixed `last_modified` is fine)
/// and opens a streaming puffin reader over it.
async fn get_tantivy_directory(
file_account: &str,
file_name: &str,
file_size: i64,
) -> anyhow::Result<PuffinDirReader> {
let file_account = file_account.to_string();
let source = object_store::ObjectMeta {
location: file_name.into(),
last_modified: *config::utils::time::BASE_TIME,
size: file_size as u64,
e_tag: None,
version: None,
};
Ok(PuffinDirReader::from_path(file_account, source).await?)
}
/// Retire any `.bf` whose `bloom_ver` is no longer referenced by a live file
/// in this (stream, `date_key`) bucket.
///
/// `deleted_bloom_vers` is the set of `bloom_ver` values carried by files a
/// merge round just deleted (the caller dedups / drops the 0 sentinel, but we
/// guard again here). For each, we probe `file_list` for any surviving row at
/// that version; if none, the `.bf` is enqueued for deletion via the existing
/// `file_list_deleted` queue.
///
/// Orphan detection is a **cleanup optimization, not correctness**: if the
/// EXISTS probe or the enqueue fails, we log and move on — the stale `.bf`
/// stays until data-retention reaps the day's subtree. Errors never
/// propagate.
async fn cleanup_orphan_blooms(
org_id: &str,
stream_type: StreamType,
stream_name: &str,
date_key: &str,
orphan_blooms: Vec<i64>,
) -> Result<()> {
if orphan_blooms.is_empty() {
return Ok(());
}
let candidates: HashSet<i64> = orphan_blooms.into_iter().collect();
let mut orphans: Vec<FileListDeleted> = Vec::new();
for v_old in candidates {
match infra_file_list::bloom_ver_referenced(
org_id,
stream_type,
stream_name,
date_key,
v_old,
)
.await
{
Ok(true) => {} // still referenced by a live file — keep the `.bf`
Ok(false) => {
let path = bloom_path(org_id, stream_type, stream_name, date_key, v_old);
let account = storage::get_account(org_id, &path).unwrap_or_default();
orphans.push(FileListDeleted {
id: 0,
account,
file: path,
index_file: false, // a `.bf` has no companion `.ttv`
flattened: false,
});
}
Err(e) => {
log::warn!(
"[BLOOM_CLEANUP] {org_id}/{stream_type}/{stream_name}/{date_key}: \
bloom_ver_referenced({v_old}) failed: {e}; leaving `.bf` for retention"
);
}
}
}
if orphans.is_empty() {
return Ok(());
}
let created_at = now_micros();
let n = orphans.len();
if let Err(e) = infra_file_list::batch_add_deleted(org_id, created_at, &orphans).await {
log::warn!(
"[BLOOM_CLEANUP] {org_id}/{stream_type}/{stream_name}/{date_key}: \
enqueue {n} orphan `.bf` for deletion failed: {e}"
);
} else {
log::info!(
"[BLOOM_CLEANUP] {org_id}/{stream_type}/{stream_name}/{date_key}: \
enqueued {n} orphan `.bf` for deletion"
);
}
Ok(())
}

View File

@ -13,14 +13,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! This module contains models that can be serialized and deserialized as JSON
//! for HTTP responses and requests.
//! Bloom-filter build side for compaction.
//!
//! - [`builder`] extracts per-(file, field) SBBFs from a tantivy term dictionary.
//! - [`compact`] is the compactor entry point that owns "which files to bloom" and writes the
//! transposed `.bf` for each hour bucket.
//!
//! The search-side bloom pruner lives in the search crate, while the underlying
//! SBBF format and reader/writer live in `infra::bloom`.
pub use openobserve_api_management::models::action;
#[cfg(feature = "enterprise")]
pub use openobserve_api_management::models::ai;
#[cfg(feature = "cloud")]
pub use openobserve_api_management::models::billings;
#[cfg(feature = "enterprise")]
pub use openobserve_api_management::models::{eval_jobs, providers, score_configs, scorers};
pub use openobserve_api_pipelines::models::pipelines;
mod builder;
pub(crate) mod compact;

View File

@ -32,6 +32,7 @@ use infra::{
use o2_enterprise::enterprise::common::downsampling::get_matching_downsampling_rules;
use tokio::sync::mpsc;
mod bloom;
pub mod deleted;
pub mod dump;
pub mod flatten;

View File

@ -620,7 +620,32 @@ pub async fn merge_by_stream(
orphan_blooms.extend(task.await??);
}
let _ = (is_incremental, orphan_blooms);
// Build bloom filters for the current hour. Failures are non-fatal: files
// left at bloom_ver = 0 fall back to the regular Tantivy search path.
let build_start = std::time::Instant::now();
match crate::bloom::compact::build_for_stream(
org_id,
stream_type,
stream_name,
&date_start,
is_incremental,
orphan_blooms,
)
.await
{
Ok(false) => {}
Ok(true) => {
let build_time = build_start.elapsed().as_millis();
log::info!(
"[COMPACTOR] bloom build for {org_id}/{stream_type}/{stream_name}/{date_start} took: {build_time} ms"
);
}
Err(e) => {
log::warn!(
"[COMPACTOR] bloom build for {org_id}/{stream_type}/{stream_name}/{date_start} failed: {e}"
);
}
}
// update job status
if let Err(e) = infra_file_list::set_job_done(&[job_id]).await {

View File

@ -35,6 +35,7 @@ datafusion.workspace = true
dashmap.workspace = true
dotenv_config.workspace = true
dotenvy.workspace = true
fastdivide.workspace = true
faststr.workspace = true
futures.workspace = true
local-ip-address.workspace = true

View File

@ -52,7 +52,7 @@ pub type RwAHashSet<K> = tokio::sync::RwLock<HashSet<K>>;
pub type RwBTreeMap<K, V> = tokio::sync::RwLock<BTreeMap<K, V>>;
// for DDL commands and migrations
pub const DB_SCHEMA_VERSION: u64 = 55;
pub const DB_SCHEMA_VERSION: u64 = 63;
pub const DB_SCHEMA_KEY: &str = "/db_schema_version/";
// global version variables
@ -632,6 +632,76 @@ pub struct Config {
pub pipeline: Pipeline,
pub health_check: HealthCheck,
pub enrichment_table: EnrichmentTable,
pub slo: Slo,
}
/// Feature 5 — SLO measurement (`alerts_2.md` §6b).
#[derive(Debug, Serialize, EnvConfig, Default)]
pub struct Slo {
// Development is in progress; the default stays false until the feature
// ships. The UI follows this flag rather than duplicating the decision:
// it is published as `slo_enabled` on /config and MainLayout.vue hides the
// SLO menu entry while it is off.
#[env_config(
name = "ZO_SLO_ENABLED",
default = false,
help = "Enable SLO measurement and SLO-based alerts. Set false to switch the feature off entirely: no per-SLO ingest job is scheduled, nothing is written to the slo_slices stream, and the SLO APIs answer 501."
)]
pub enabled: bool,
#[env_config(
name = "ZO_SLO_INGEST_DELAY_SECS",
default = 60,
help = "How far behind now the ingest job reads, so late-arriving data is present before a slice is measured. A slice is never measured until it is this far in the past."
)]
pub ingest_delay_secs: i64,
#[env_config(
name = "ZO_SLO_RECOMPUTE_SLICES",
default = 3,
help = "How many trailing slices each pass recomputes, to pick up data that arrived after those slices were first measured. Re-emitted rows win on revision."
)]
pub recompute_slices: i64,
#[env_config(
name = "ZO_SLO_MIN_COVERAGE",
default = 0.9,
help = "Coverage floor, 0..1. Below this the SLO reads as no-data and its alerts FREEZE rather than resolving — unmeasured time must never read as uptime."
)]
pub min_coverage: f64,
#[env_config(
name = "ZO_SLO_MAX_GROUPS",
default = 500,
help = "Hard cap on status rows per SLO. Group cardinality past this trips GroupOverflow rather than silently truncating."
)]
pub max_groups: i64,
#[env_config(
name = "ZO_SLO_MAX_SLICE_ROWS_PER_ORG",
default = 250000000,
help = "Per-org budget over logical (group, slice) rows. Bounds the SLOs x GROUPS x window product, which is indefensible even where each factor is individually fine."
)]
pub max_slice_rows_per_org: i64,
#[env_config(
name = "ZO_SLO_REVISION_HEADROOM",
default = 1.2,
help = "Multiplier pricing physical excess (late-data re-emissions) over logical rows. Values below 1.0 are clamped; it is a multiplier, not a discount."
)]
pub revision_headroom: f64,
#[env_config(
name = "ZO_SLO_RECONCILE_INTERVAL_SECS",
default = 3600,
help = "How often the running aggregate is rebuilt from the slices. This is the bound on cache drift after a crash, and is load-bearing rather than hygiene."
)]
pub reconcile_interval_secs: i64,
#[env_config(
name = "ZO_SLO_BACKFILL_CHUNK_SECS",
default = 86400,
help = "How much history one backfill chunk covers. One aggregate query per chunk produces every slice in it."
)]
pub backfill_chunk_secs: i64,
#[env_config(
name = "ZO_SLO_MAX_BURN_WINDOW_PAIRS",
default = 8,
help = "Max distinct (long, short) burn-rate window pairs precomputed per SLO per pass. Alerts share these, so the cost is per SLO, not per alert."
)]
pub max_burn_window_pairs: i64,
}
#[derive(Serialize, EnvConfig, Default)]
@ -1507,6 +1577,18 @@ pub struct Common {
pub dashboard_placeholder: String,
#[env_config(name = "ZO_AGGREGATION_TOPK_ENABLED", default = true)]
pub aggregation_topk_enabled: bool,
#[env_config(
name = "ZO_DF_USE_AGG_TOPK_HEAP",
default = true,
help = "Use the heap implementation for eligible aggregate TopK plans"
)]
pub use_agg_topk_heap: bool,
#[env_config(
name = "ZO_DF_TOPK_HEAP_MAX_LIMIT",
default = 500,
help = "Maximum aggregate TopK limit that uses the heap implementation"
)]
pub agg_topk_heap_max_limit: u64,
#[env_config(name = "ZO_SEARCH_INSPECTOR_ENABLED", default = false)]
pub search_inspector_enabled: bool,
#[env_config(name = "ZO_UTF8_VIEW_ENABLED", default = true)]
@ -1753,8 +1835,44 @@ pub struct Limit {
pub http_slow_log_threshold: u64,
#[env_config(name = "ZO_ALERT_SCHEDULE_INTERVAL", default = 10)] // seconds
pub alert_schedule_interval: i64,
#[env_config(
name = "ZO_ALERT_HYBRID_COUNT_THRESHOLD",
default = 100,
help = "Count-based alerts whose row sentinel exceeds this switch to a COUNT(*) decision query plus a 100-row payload sample (alerts_2.md 4.4c). Clamped up to the 100-row floor."
)]
pub alert_hybrid_count_threshold: i64,
#[env_config(name = "ZO_ALERT_SCHEDULE_CONCURRENCY", default = 5)]
pub alert_schedule_concurrency: i64,
#[env_config(
name = "ZO_ALERT_MAX_GROUPS",
default = 500,
help = "Cardinality cap for multi-alerts (alerts_2.md M-6): the most per-group state rows one alert may track. Overflow is evaluated and counted but not persisted beyond the cap, and the true count is surfaced as a warning. 0 = unlimited."
)]
pub alert_max_groups: usize,
#[env_config(
name = "ZO_ALERT_GROUP_DISAPPEARANCE_K",
default = 3,
help = "A multi-alert group unseen for K x the alert's frequency is resolved to Ok (alerts_2.md M-7). Must exceed 1, or a single slow evaluation resolves every group and re-fires it on the next pass."
)]
pub alert_group_disappearance_k: i64,
#[env_config(
name = "ZO_ALERT_GROUP_REAP_GRACE_SECS",
default = 3600,
help = "How long a resolved multi-alert group's state row is retained before deletion (alerts_2.md M-7). Its transition history is kept regardless."
)]
pub alert_group_reap_grace_secs: i64,
#[env_config(
name = "ZO_ALERT_MAX_GROUP_NOTIFICATIONS_PER_EVAL",
default = 0,
help = "Cap on per-group notifications sent by one multi-alert evaluation (alerts_2.md §5.5 MN-8/D48). 0 = unlimited, which is the default because paging per group is the feature's contract and the group cap already bounds the worst case. Dispatch is worst-first, so a cap always delivers the most severe groups; anything dropped is logged."
)]
pub alert_max_group_notifications_per_eval: usize,
#[env_config(
name = "ZO_ALERT_GROUP_SWEEP_INTERVAL",
default = 60,
help = "How often the multi-alert group lifecycle sweep runs, in seconds (alerts_2.md M-7). The sweep only decides fates on elapsed time, so it need not match any alert's frequency. 0 disables it, which stops vanished groups from ever resolving or being reaped."
)]
pub alert_group_sweep_interval: u64,
#[env_config(name = "ZO_ALERT_SCHEDULE_TIMEOUT", default = 90)] // seconds
pub alert_schedule_timeout: i64,
#[env_config(
@ -1810,6 +1928,18 @@ pub struct Limit {
help = "Max backfill jobs pulled per cycle and the backfill worker-pool size. Only used when ZO_SCHEDULER_PER_MODULE_PULLERS=true. Defaults to 1 (smallest budget) so bulk backfills never crowd out latency-sensitive modules."
)]
pub scheduler_backfill_concurrency: i64,
#[env_config(
name = "ZO_SCHEDULER_SLO_CONCURRENCY",
default = 0,
help = "Max SLO SLI-ingest jobs pulled per cycle and the worker-pool size. Only used when ZO_SCHEDULER_PER_MODULE_PULLERS=true. 0 inherits ZO_ALERT_SCHEDULE_CONCURRENCY."
)]
pub scheduler_slo_concurrency: i64,
#[env_config(
name = "ZO_SCHEDULER_SLO_BACKFILL_CONCURRENCY",
default = 1,
help = "Max SLO backfill jobs pulled per cycle and the SLO backfill worker-pool size. Only used when ZO_SCHEDULER_PER_MODULE_PULLERS=true. Defaults to 1 so a bulk historical scan never crowds out latency-sensitive incremental SLI passes."
)]
pub scheduler_slo_backfill_concurrency: i64,
#[env_config(
name = "ZO_SCHEDULER_ANOMALY_CONCURRENCY",
default = 0,
@ -1844,6 +1974,18 @@ pub struct Limit {
help = "Poll cadence in seconds for the backfill puller. Only used when ZO_SCHEDULER_PER_MODULE_PULLERS=true. 0 inherits ZO_ALERT_SCHEDULE_INTERVAL."
)]
pub scheduler_backfill_interval: i64,
#[env_config(
name = "ZO_SCHEDULER_SLO_INTERVAL",
default = 0, // seconds
help = "Poll cadence in seconds for the SLO SLI-ingest puller. Only used when ZO_SCHEDULER_PER_MODULE_PULLERS=true. 0 inherits ZO_ALERT_SCHEDULE_INTERVAL."
)]
pub scheduler_slo_interval: i64,
#[env_config(
name = "ZO_SCHEDULER_SLO_BACKFILL_INTERVAL",
default = 0, // seconds
help = "Poll cadence in seconds for the SLO backfill puller. Only used when ZO_SCHEDULER_PER_MODULE_PULLERS=true. 0 inherits ZO_ALERT_SCHEDULE_INTERVAL."
)]
pub scheduler_slo_backfill_interval: i64,
#[env_config(
name = "ZO_SCHEDULER_ANOMALY_INTERVAL",
default = 0, // seconds
@ -3796,6 +3938,18 @@ pub fn ensure_not_empty(s: &str, name: &str) -> Result<(), anyhow::Error> {
#[cfg(test)]
mod tests {
/// Every `#[env_config]` default must parse.
///
/// The macro reads defaults from a **string literal**, so a Rust digit
/// separator (`86_400`) is not a number to it — it is a `ParseIntError`
/// raised inside `init()`, which panics the process at startup rather
/// than failing anything reviewable. This test is the only cheap guard:
/// it forces every default through the same parse the binary does.
#[test]
fn every_env_config_default_parses() {
let _ = super::Config::init().expect("a default failed to parse");
}
use super::*;
#[test]

View File

@ -0,0 +1,925 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Multi-level thresholds for AGGREGATION alerts — `alerts_2.md` §4.4.
//!
//! Aggregation alerts do NOT use `TriggerCondition.threshold`. Their critical
//! threshold is `Aggregation.having.value` — a `serde_json::Value`, so it may
//! be an int, a float, or a JSON string holding a number. The warning
//! counterpart is `Aggregation.warning_value` (already f64).
//!
//! Classification funnels through `level::evaluate_level_values`, the same core
//! the count path uses, so the two cannot disagree about precedence or
//! boundaries.
use serde_json::Value;
use super::{
Aggregation, Operator,
level::{AlertLevel, compare, evaluate_level_values},
};
/// Why an aggregation's threshold pair could not be used.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AggThresholdError {
/// `having.value` is not a number and not a numeric string.
///
/// Deliberately an error rather than a default: coercing to `0.0` would
/// make every group critical under `>`.
NonNumericCritical,
/// The warning value is not strictly less severe than critical, given the
/// operator's direction.
WarningNotLessSevere,
/// `having.operator` has no severity ordering, so a warning value is
/// meaningless.
OperatorNotOrderable,
}
impl std::fmt::Display for AggThresholdError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NonNumericCritical => {
f.write_str("aggregation threshold (having.value) is not numeric")
}
Self::WarningNotLessSevere => f.write_str(
"aggregation warning value must be less severe than the having threshold",
),
Self::OperatorNotOrderable => f.write_str(
"the aggregation operator has no severity ordering; a warning value is not supported",
),
}
}
}
impl std::error::Error for AggThresholdError {}
/// Coerce an untyped condition value to f64.
///
/// Accepts JSON numbers and numeric strings — the UI has historically submitted
/// condition values as strings. Everything else is rejected.
fn as_f64(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().parse::<f64>().ok(),
_ => None,
}
}
/// Extract `(critical, warning)` for an aggregation alert.
pub fn aggregation_thresholds(agg: &Aggregation) -> Result<(f64, Option<f64>), AggThresholdError> {
let critical = as_f64(&agg.having.value).ok_or(AggThresholdError::NonNumericCritical)?;
Ok((critical, agg.warning_value))
}
/// Classify one aggregate value against an aggregation's thresholds.
///
/// Shares `evaluate_level_values` with the count path — see
/// `test_paths_agree_on_identical_inputs`.
pub fn evaluate_aggregation_level(
actual: f64,
agg: &Aggregation,
) -> Result<Option<AlertLevel>, AggThresholdError> {
let (critical, warning) = aggregation_thresholds(agg)?;
Ok(evaluate_level_values(
actual,
agg.having.operator,
critical,
warning,
))
}
/// Value the SQL `HAVING` clause should filter on (§4.4 option B).
///
/// The clause widens to the **less severe** threshold so every group that could
/// be warning-or-worse is returned, and Rust then classifies each one. Filtering
/// on the critical threshold instead would drop the entire warning band inside
/// the database, where no amount of downstream logic could recover it.
///
/// One query per evaluation, and classification stays in the shared helper.
pub fn having_filter_value(agg: &Aggregation) -> Result<f64, AggThresholdError> {
let (critical, warning) = aggregation_thresholds(agg)?;
Ok(widened_threshold(agg.having.operator, critical, warning))
}
/// Widened comparison value for the "one query, both bands" strategy.
///
/// Returns the LESS severe of the two thresholds, so a single query/filter
/// admits every item that could be warning-or-worse. Shared by the SQL
/// `HAVING` clause and the PromQL expression, which face the same problem.
pub fn widened_threshold(op: Operator, critical: f64, warning: Option<f64>) -> f64 {
let Some(w) = warning else { return critical };
match op {
// Larger is more severe -> the smaller value is the wider net.
Operator::GreaterThan | Operator::GreaterThanEquals => critical.min(w),
// Smaller is more severe -> the larger value is the wider net.
Operator::LessThan | Operator::LessThanEquals => critical.max(w),
// Unorderable operators cannot carry a warning (validation rejects
// them); fall back to critical rather than guessing.
_ => critical,
}
}
/// The two-axis evaluator shared by every alert type whose threshold is
/// applied per ITEM and then gated by an item COUNT.
///
/// Both aggregation alerts (per group, then group count) and PromQL alerts
/// (per series, then series count) have this shape:
///
/// 1. each item's value is classified against critical / warning
/// 2. the alert fires only if enough items matched, per `TriggerCondition`
///
/// Counts are tracked separately per level so an item in the warning band
/// cannot inflate the critical count. With `warning = None` this reduces
/// exactly to the legacy behaviour (G5).
pub fn evaluate_level_over_items(
item_values: &[f64],
op: Operator,
critical: f64,
warning: Option<f64>,
tc: &crate::meta::alerts::TriggerCondition,
) -> Option<AlertLevel> {
let mut critical_items = 0i64;
let mut firing_items = 0i64; // warning-or-worse
for v in item_values {
match evaluate_level_values(*v, op, critical, warning) {
Some(AlertLevel::Critical) => {
critical_items += 1;
firing_items += 1;
}
Some(AlertLevel::Warning) => firing_items += 1,
_ => {}
}
}
if compare(critical_items as f64, tc.operator, tc.threshold as f64) {
return Some(AlertLevel::Critical);
}
if warning.is_some() || tc.warning_threshold.is_some() {
let count_threshold = tc.warning_threshold.unwrap_or(tc.threshold);
if compare(firing_items as f64, tc.operator, count_threshold as f64) {
return Some(AlertLevel::Warning);
}
}
None
}
/// Aggregation wrapper over [`evaluate_level_over_items`]: per-group value
/// classification plus the group-count threshold.
pub fn evaluate_aggregation_alert(
group_values: &[f64],
agg: &Aggregation,
tc: &crate::meta::alerts::TriggerCondition,
) -> Result<Option<AlertLevel>, AggThresholdError> {
let (critical, warning) = aggregation_thresholds(agg)?;
Ok(evaluate_level_over_items(
group_values,
agg.having.operator,
critical,
warning,
tc,
))
}
/// `ORDER BY` fragment that sorts groups **worst-first** for a multi-alert's
/// bounded fetch (`alerts_2.md` §5.3).
///
/// The multi path drops the `HAVING` filter so healthy groups come back too
/// (otherwise a recovering group is indistinguishable from a vanished one), but
/// it still reads a bounded page. Ordering is what makes that page usable: the
/// worst groups are provably inside it, so the rollup level is always exact and
/// the M-6 cap admits the true top of the distribution.
///
/// Buckets by **severity band**, not by raw value. Ordering by the aggregate
/// itself would retain "most extreme within a band", so ordinary jitter between
/// two equally-Critical groups would churn the retained row set every
/// evaluation — the instability `classify_groups`' `(severity_rank desc,
/// group_key asc)` admission contract exists to avoid. Callers append the
/// `group_by` columns as a deterministic tiebreak.
///
/// Requires an orderable operator; `=`/`!=` have no worst-first direction,
/// which is why M-10 refuses them for multi-alerts.
pub fn severity_order_sql(
agg: &Aggregation,
value_alias: &str,
) -> Result<String, AggThresholdError> {
let (critical, warning) = aggregation_thresholds(agg)?;
let op = match agg.having.operator {
Operator::GreaterThan => ">",
Operator::GreaterThanEquals => ">=",
Operator::LessThan => "<",
Operator::LessThanEquals => "<=",
_ => return Err(AggThresholdError::OperatorNotOrderable),
};
let mut sql = format!("CASE WHEN \"{value_alias}\" {op} {critical} THEN 2");
if let Some(w) = warning {
sql.push_str(&format!(" WHEN \"{value_alias}\" {op} {w} THEN 1"));
}
sql.push_str(" ELSE 0 END DESC");
Ok(sql)
}
/// Validate an aggregation's threshold pair (§4.5, applied to
/// `having.operator`).
pub fn validate_aggregation_thresholds(agg: &Aggregation) -> Result<(), AggThresholdError> {
let (critical, warning) = aggregation_thresholds(agg)?;
let Some(warning) = warning else {
// Single-level aggregations stay valid for every operator (G5).
return Ok(());
};
match agg.having.operator {
Operator::GreaterThan | Operator::GreaterThanEquals if warning < critical => Ok(()),
Operator::LessThan | Operator::LessThanEquals if warning > critical => Ok(()),
Operator::GreaterThan
| Operator::GreaterThanEquals
| Operator::LessThan
| Operator::LessThanEquals => Err(AggThresholdError::WarningNotLessSevere),
_ => Err(AggThresholdError::OperatorNotOrderable),
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::meta::alerts::{
AggFunction, Aggregation, Condition, Operator, TriggerCondition,
aggregation_level::{
AggThresholdError, aggregation_thresholds, evaluate_aggregation_alert,
evaluate_aggregation_level, evaluate_level_over_items, having_filter_value,
severity_order_sql, validate_aggregation_thresholds,
},
level::{AlertLevel, evaluate_level_values},
};
fn having(op: Operator, value: serde_json::Value) -> Condition {
Condition {
column: "alert_agg_value".to_string(),
operator: op,
value,
ignore_case: false,
}
}
fn agg(op: Operator, critical: serde_json::Value, warning: Option<f64>) -> Aggregation {
Aggregation {
group_by: None,
function: AggFunction::Avg,
having: having(op, critical),
warning_value: warning,
multi_alert: false,
}
}
// ── having.value extraction ─────────────────────────────────────────────
// `Value` is untyped, so every shape a user or an older payload can produce
// must be handled explicitly. Silent coercion is the dangerous failure:
// a non-numeric threshold read as 0.0 would make EVERY group critical
// under `>`.
#[test]
fn test_extracts_integer_threshold() {
let a = agg(Operator::GreaterThan, json!(100), None);
assert_eq!(aggregation_thresholds(&a).unwrap(), (100.0, None));
}
#[test]
fn test_extracts_float_threshold() {
let a = agg(Operator::GreaterThan, json!(99.5), None);
assert_eq!(aggregation_thresholds(&a).unwrap(), (99.5, None));
}
/// The UI has historically submitted numeric condition values as strings.
#[test]
fn test_extracts_numeric_string_threshold() {
let a = agg(Operator::GreaterThan, json!("100"), None);
assert_eq!(aggregation_thresholds(&a).unwrap(), (100.0, None));
let a = agg(Operator::GreaterThan, json!("99.5"), None);
assert_eq!(aggregation_thresholds(&a).unwrap(), (99.5, None));
}
#[test]
fn test_extracts_negative_threshold() {
let a = agg(Operator::LessThan, json!(-5.5), None);
assert_eq!(aggregation_thresholds(&a).unwrap(), (-5.5, None));
}
#[test]
fn test_non_numeric_threshold_is_an_error_not_zero() {
for bad in [
json!("abc"),
json!(null),
json!(true),
json!([1]),
json!({}),
] {
let a = agg(Operator::GreaterThan, bad.clone(), None);
assert_eq!(
aggregation_thresholds(&a),
Err(AggThresholdError::NonNumericCritical),
"threshold {bad} must fail loudly; coercing to 0.0 would make \
every group critical under `>`"
);
}
}
#[test]
fn test_warning_value_is_carried_through() {
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
assert_eq!(aggregation_thresholds(&a).unwrap(), (100.0, Some(50.0)));
}
#[test]
fn test_absent_warning_value_means_single_level() {
let a = agg(Operator::GreaterThan, json!(100), None);
assert_eq!(aggregation_thresholds(&a).unwrap().1, None);
}
/// Older stored aggregations have no `warning_value` key at all.
#[test]
fn test_aggregation_deserializes_without_warning_value() {
let raw = json!({
"group_by": ["host"],
"function": "avg",
"having": { "column": "alert_agg_value", "operator": ">", "value": 100 }
});
let a: Aggregation = serde_json::from_value(raw).unwrap();
assert_eq!(a.warning_value, None, "absent = single-level, not an error");
}
// ── Classification ──────────────────────────────────────────────────────
#[test]
fn test_critical_takes_precedence_for_aggregations() {
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
assert_eq!(
evaluate_aggregation_level(150.0, &a).unwrap(),
Some(AlertLevel::Critical)
);
}
#[test]
fn test_warning_band_for_aggregations() {
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
assert_eq!(
evaluate_aggregation_level(75.0, &a).unwrap(),
Some(AlertLevel::Warning)
);
}
#[test]
fn test_no_match_for_aggregations() {
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
assert_eq!(evaluate_aggregation_level(10.0, &a).unwrap(), None);
}
#[test]
fn test_fractional_aggregate_values() {
// The whole reason aggregation thresholds are f64: averages are not
// integers.
let a = agg(Operator::GreaterThanEquals, json!(99.5), Some(50.25));
assert_eq!(
evaluate_aggregation_level(99.5, &a).unwrap(),
Some(AlertLevel::Critical)
);
assert_eq!(
evaluate_aggregation_level(50.25, &a).unwrap(),
Some(AlertLevel::Warning)
);
assert_eq!(evaluate_aggregation_level(50.24, &a).unwrap(), None);
}
#[test]
fn test_less_than_direction_for_aggregations() {
// For `<`, critical is the SMALLER number.
let a = agg(Operator::LessThan, json!(10.0), Some(25.0));
assert_eq!(
evaluate_aggregation_level(5.0, &a).unwrap(),
Some(AlertLevel::Critical)
);
assert_eq!(
evaluate_aggregation_level(20.0, &a).unwrap(),
Some(AlertLevel::Warning)
);
assert_eq!(evaluate_aggregation_level(30.0, &a).unwrap(), None);
}
#[test]
fn test_classification_propagates_extraction_errors() {
let a = agg(Operator::GreaterThan, json!("nonsense"), None);
assert_eq!(
evaluate_aggregation_level(1.0, &a),
Err(AggThresholdError::NonNumericCritical)
);
}
// ── The property that keeps the two paths honest ────────────────────────
/// The count path and the aggregation path must classify identical inputs
/// identically. §4.4 warns that a divergence here is silent — both paths
/// keep working, they just disagree about severity.
#[test]
fn test_paths_agree_on_identical_inputs() {
let cases: &[(Operator, f64, Option<f64>)] = &[
(Operator::GreaterThan, 100.0, Some(50.0)),
(Operator::GreaterThanEquals, 100.0, Some(50.0)),
(Operator::LessThan, 10.0, Some(25.0)),
(Operator::LessThanEquals, 10.0, Some(25.0)),
(Operator::GreaterThan, 100.0, None),
];
let actuals = [0.0, 9.9, 10.0, 25.0, 49.9, 50.0, 99.9, 100.0, 150.0];
for (op, critical, warning) in cases {
let a = agg(*op, json!(*critical), *warning);
for actual in actuals {
let via_agg = evaluate_aggregation_level(actual, &a).unwrap();
let via_values = evaluate_level_values(actual, *op, *critical, *warning);
assert_eq!(
via_agg, via_values,
"paths disagree: op={op:?} crit={critical} warn={warning:?} actual={actual}"
);
}
}
}
// ── Validation parity (§4.5 applied to having.operator) ─────────────────
#[test]
fn test_validation_accepts_correct_direction() {
assert!(
validate_aggregation_thresholds(&agg(Operator::GreaterThan, json!(100), Some(50.0)))
.is_ok()
);
assert!(
validate_aggregation_thresholds(&agg(Operator::LessThan, json!(10), Some(25.0)))
.is_ok()
);
}
#[test]
fn test_validation_rejects_wrong_direction() {
let a = agg(Operator::GreaterThan, json!(50), Some(100.0));
assert_eq!(
validate_aggregation_thresholds(&a),
Err(AggThresholdError::WarningNotLessSevere)
);
}
#[test]
fn test_validation_rejects_equal_thresholds() {
let a = agg(Operator::GreaterThan, json!(100), Some(100.0));
assert_eq!(
validate_aggregation_thresholds(&a),
Err(AggThresholdError::WarningNotLessSevere)
);
}
#[test]
fn test_validation_rejects_unorderable_operators() {
for op in [Operator::EqualTo, Operator::NotEqualTo] {
let a = agg(op, json!(100), Some(50.0));
assert_eq!(
validate_aggregation_thresholds(&a),
Err(AggThresholdError::OperatorNotOrderable),
"{op:?} has no severity ordering"
);
}
}
#[test]
fn test_validation_accepts_single_level_for_every_operator() {
// G5: aggregation alerts with no warning value stay valid regardless of
// operator — including the unorderable ones that are legal today.
for op in [
Operator::EqualTo,
Operator::NotEqualTo,
Operator::GreaterThan,
Operator::GreaterThanEquals,
Operator::LessThan,
Operator::LessThanEquals,
] {
let a = agg(op, json!(100), None);
assert!(
validate_aggregation_thresholds(&a).is_ok(),
"{op:?} must stay valid without a warning value"
);
}
}
#[test]
fn test_validation_rejects_non_numeric_critical() {
let a = agg(Operator::GreaterThan, json!("abc"), Some(50.0));
assert_eq!(
validate_aggregation_thresholds(&a),
Err(AggThresholdError::NonNumericCritical)
);
}
// ── Per-group classification (feeds Feature 3, multi-alerts) ──────────────────────────
/// `HAVING` is evaluated per group in the database, so the classifier must
/// work over a set of per-group aggregate values.
#[test]
fn test_per_group_aggregate_values_classify_independently() {
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
let per_group = [("host-a", 150.0), ("host-b", 75.0), ("host-c", 10.0)];
let levels: Vec<_> = per_group
.iter()
.map(|(_, v)| evaluate_aggregation_level(*v, &a).unwrap())
.collect();
assert_eq!(levels[0], Some(AlertLevel::Critical));
assert_eq!(levels[1], Some(AlertLevel::Warning));
assert_eq!(levels[2], None);
}
// ── Group-count threshold (the OTHER axis) ──────────────────────────────
// An aggregation alert has TWO thresholds, and the UI shows both rows:
// 1. `having.value` / `warning_value` — the aggregate VALUE per group ("avg(latency) > 500")
// 2. `trigger_condition.threshold` — how many GROUPS must match ("...for at least 3
// groups")
// Classifying per group without re-applying (2) silently turns "3 groups"
// into "any group".
fn tc(op: Operator, threshold: i64, warning: Option<i64>) -> TriggerCondition {
TriggerCondition {
operator: op,
threshold,
warning_threshold: warning,
..Default::default()
}
}
/// Regression: the group-count threshold must still gate firing.
#[test]
fn test_group_count_threshold_is_still_applied() {
// avg > 100 critical; needs >= 3 groups.
let a = agg(Operator::GreaterThan, json!(100), None);
let t = tc(Operator::GreaterThanEquals, 3, None);
// Only 2 groups over the value threshold -> must NOT fire.
assert_eq!(
evaluate_aggregation_alert(&[150.0, 200.0], &a, &t).unwrap(),
None,
"2 groups cannot satisfy a >= 3 group-count threshold"
);
// 3 groups -> fires.
assert_eq!(
evaluate_aggregation_alert(&[150.0, 200.0, 300.0], &a, &t).unwrap(),
Some(AlertLevel::Critical)
);
}
/// With no warning value the behaviour must be byte-identical to the
/// pre-multi-level implementation: count of HAVING-matching groups against
/// the trigger threshold (G5).
#[test]
fn test_single_level_aggregation_matches_legacy_semantics() {
let a = agg(Operator::GreaterThan, json!(100), None);
let t = tc(Operator::GreaterThanEquals, 2, None);
assert_eq!(
evaluate_aggregation_alert(&[10.0, 20.0], &a, &t).unwrap(),
None
);
assert_eq!(evaluate_aggregation_alert(&[150.0], &a, &t).unwrap(), None);
assert_eq!(
evaluate_aggregation_alert(&[150.0, 160.0], &a, &t).unwrap(),
Some(AlertLevel::Critical)
);
}
/// Warning counts groups that cross the WARNING value; critical counts only
/// those crossing the critical value. A group in the warning band must not
/// inflate the critical count.
#[test]
fn test_warning_and_critical_counts_are_independent() {
// crit avg > 100, warn avg > 50; needs >= 2 groups either way.
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
let t = tc(Operator::GreaterThanEquals, 2, None);
// Two groups in the warning band only -> Warning, not Critical.
assert_eq!(
evaluate_aggregation_alert(&[60.0, 70.0], &a, &t).unwrap(),
Some(AlertLevel::Warning)
);
// One critical + one warning: critical count is 1 (< 2) so it cannot be
// Critical; warning-or-worse count is 2 -> Warning.
assert_eq!(
evaluate_aggregation_alert(&[150.0, 60.0], &a, &t).unwrap(),
Some(AlertLevel::Warning)
);
// Two critical -> Critical.
assert_eq!(
evaluate_aggregation_alert(&[150.0, 160.0], &a, &t).unwrap(),
Some(AlertLevel::Critical)
);
}
/// A separate group-count warning threshold is honoured when present:
/// "critical at 5 groups, warning at 2".
#[test]
fn test_group_count_warning_threshold_is_used_when_set() {
let a = agg(Operator::GreaterThan, json!(100), None);
let t = tc(Operator::GreaterThanEquals, 5, Some(2));
assert_eq!(evaluate_aggregation_alert(&[150.0], &a, &t).unwrap(), None);
assert_eq!(
evaluate_aggregation_alert(&[150.0, 160.0], &a, &t).unwrap(),
Some(AlertLevel::Warning),
"2 groups meets the warning count but not the critical count"
);
let five = [150.0; 5];
assert_eq!(
evaluate_aggregation_alert(&five, &a, &t).unwrap(),
Some(AlertLevel::Critical)
);
}
#[test]
fn test_no_matching_groups_never_fires() {
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
let t = tc(Operator::GreaterThanEquals, 1, None);
assert_eq!(
evaluate_aggregation_alert(&[1.0, 2.0], &a, &t).unwrap(),
None
);
assert_eq!(evaluate_aggregation_alert(&[], &a, &t).unwrap(), None);
}
// ── PromQL shares the same two-axis shape ───────────────────────────────
// PromQL bakes the value threshold into the query itself
// (`(expr) > 500`), then counts matching SERIES against
// `trigger_condition.threshold` — structurally identical to aggregation's
// HAVING + group-count. It therefore uses the same generalized evaluator,
// so the three paths cannot drift apart.
#[test]
fn test_generalized_evaluator_matches_the_aggregation_wrapper() {
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
let t = tc(Operator::GreaterThanEquals, 2, None);
let values = [150.0, 60.0, 10.0];
assert_eq!(
evaluate_level_over_items(&values, Operator::GreaterThan, 100.0, Some(50.0), &t),
evaluate_aggregation_alert(&values, &a, &t).unwrap(),
"the aggregation wrapper must be a thin shim over the shared evaluator"
);
}
#[test]
fn test_promql_series_counting_with_two_levels() {
// crit value > 500, warn > 300; fire when >= 2 series match.
let t = tc(Operator::GreaterThanEquals, 2, None);
// Two series over the WARNING value only -> Warning.
assert_eq!(
evaluate_level_over_items(
&[350.0, 400.0],
Operator::GreaterThan,
500.0,
Some(300.0),
&t
),
Some(AlertLevel::Warning)
);
// Two series over CRITICAL -> Critical.
assert_eq!(
evaluate_level_over_items(
&[600.0, 700.0],
Operator::GreaterThan,
500.0,
Some(300.0),
&t
),
Some(AlertLevel::Critical)
);
// One critical + one warning: critical count is 1 (< 2) -> Warning.
assert_eq!(
evaluate_level_over_items(
&[600.0, 350.0],
Operator::GreaterThan,
500.0,
Some(300.0),
&t
),
Some(AlertLevel::Warning)
);
// Below the series count -> nothing.
assert_eq!(
evaluate_level_over_items(&[600.0], Operator::GreaterThan, 500.0, Some(300.0), &t),
None
);
}
/// With no warning value, PromQL behaviour is unchanged: the query filters
/// at the critical value and the series count decides (G5).
#[test]
fn test_promql_single_level_is_unchanged() {
let t = tc(Operator::GreaterThanEquals, 2, None);
assert_eq!(
evaluate_level_over_items(&[600.0], Operator::GreaterThan, 500.0, None, &t),
None
);
assert_eq!(
evaluate_level_over_items(&[600.0, 700.0], Operator::GreaterThan, 500.0, None, &t),
Some(AlertLevel::Critical)
);
}
/// The widened filter value is the same concept for PromQL: query at the
/// less severe threshold so the warning band comes back.
#[test]
fn test_widened_value_for_promql_style_thresholds() {
use crate::meta::alerts::aggregation_level::widened_threshold;
// `>`: warning is smaller -> query at the warning value.
assert_eq!(
widened_threshold(Operator::GreaterThan, 500.0, Some(300.0)),
300.0
);
// `<`: warning is larger -> query at the warning value.
assert_eq!(
widened_threshold(Operator::LessThan, 10.0, Some(25.0)),
25.0
);
// Single-level: the critical value.
assert_eq!(widened_threshold(Operator::GreaterThan, 500.0, None), 500.0);
}
// ── The widening/classification contract ────────────────────────────────
/// P0 regression guard.
///
/// Widening the HAVING clause is only safe if the caller RE-CLASSIFIES the
/// returned rows. The first implementation widened the SQL but still
/// classified `records.len()` against `TriggerCondition`, so aggregation
/// alerts saw a larger row set and fired MORE often than before the change
/// — a spurious-firing regression, not merely a missing feature.
///
/// This pins the invariant: rows admitted by the widened filter span both
/// severity bands, so row *presence* cannot imply Critical.
#[test]
fn test_widened_filter_admits_rows_that_are_not_critical() {
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
let filter = having_filter_value(&a).unwrap();
// A group that passes the widened SQL filter...
let warning_band_value = 75.0;
assert!(
warning_band_value > filter,
"value must survive the widened HAVING"
);
// ...is NOT critical. Treating its presence as a firing at critical is
// exactly the bug.
assert_eq!(
evaluate_aggregation_level(warning_band_value, &a).unwrap(),
Some(AlertLevel::Warning)
);
}
/// A value below even the widened filter must classify as no-match, so a
/// caller that forgets to re-classify cannot accidentally look correct.
#[test]
fn test_values_below_the_widened_filter_do_not_match() {
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
let filter = having_filter_value(&a).unwrap();
assert_eq!(evaluate_aggregation_level(filter, &a).unwrap(), None);
assert_eq!(evaluate_aggregation_level(filter - 1.0, &a).unwrap(), None);
}
/// The rollup across per-group aggregates takes the most severe group —
/// and the reported value must come from THAT group, so history's
/// "fired at X against Y" refers to one coherent observation.
#[test]
fn test_rollup_reports_the_worst_groups_value() {
use crate::meta::alerts::level::AlertLevel as L;
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
let groups = [("a", 75.0), ("b", 500.0), ("c", 60.0)];
let classified: Vec<_> = groups
.iter()
.filter_map(|(g, v)| {
evaluate_aggregation_level(*v, &a)
.unwrap()
.map(|l| (*g, *v, l))
})
.collect();
let worst = classified
.iter()
.max_by_key(|(_, _, l)| l.severity_rank())
.unwrap();
assert_eq!(worst.0, "b");
assert_eq!(worst.1, 500.0, "the value must come from the worst group");
assert_eq!(worst.2, L::Critical);
}
/// §4.4 option B: the `HAVING` clause widens to the LESS severe threshold
/// so every group that could be warning-or-worse comes back, then Rust
/// classifies. Sizing off the critical threshold would silently drop the
/// whole warning band in the database.
#[test]
fn test_having_widens_to_the_less_severe_threshold() {
// `>`: warning is the smaller number, so filter on it.
let a = agg(Operator::GreaterThan, json!(100), Some(50.0));
assert_eq!(having_filter_value(&a).unwrap(), 50.0);
// `<`: warning is the larger number, so filter on it.
let a = agg(Operator::LessThan, json!(10), Some(25.0));
assert_eq!(having_filter_value(&a).unwrap(), 25.0);
// Single-level: the critical threshold is the filter.
let a = agg(Operator::GreaterThan, json!(100), None);
assert_eq!(having_filter_value(&a).unwrap(), 100.0);
}
// ── §5.3: worst-first ordering for the multi-alert fetch ────────────────
#[test]
fn test_severity_order_ranks_critical_above_warning_above_healthy() {
let a = agg(Operator::GreaterThan, json!(90), Some(80.0));
assert_eq!(
severity_order_sql(&a, "alert_agg_value").unwrap(),
"CASE WHEN \"alert_agg_value\" > 90 THEN 2 \
WHEN \"alert_agg_value\" > 80 THEN 1 ELSE 0 END DESC"
);
}
#[test]
fn test_severity_order_without_a_warning_band_has_two_buckets() {
let a = agg(Operator::GreaterThan, json!(90), None);
assert_eq!(
severity_order_sql(&a, "alert_agg_value").unwrap(),
"CASE WHEN \"alert_agg_value\" > 90 THEN 2 ELSE 0 END DESC"
);
}
#[test]
fn test_severity_order_follows_the_operator_direction() {
// For `<` the WORST group is the smallest, so the comparison — not the
// sort direction — is what flips. Emitting `ASC` on the raw value
// instead would put the healthiest groups first and the cap would
// retain exactly the wrong ones.
let a = agg(Operator::LessThan, json!(10), Some(20.0));
let sql = severity_order_sql(&a, "alert_agg_value").unwrap();
assert_eq!(
sql,
"CASE WHEN \"alert_agg_value\" < 10 THEN 2 \
WHEN \"alert_agg_value\" < 20 THEN 1 ELSE 0 END DESC"
);
assert!(
sql.ends_with("DESC"),
"severity rank always sorts descending; the operator carries the direction"
);
}
#[test]
fn test_severity_order_buckets_rather_than_ranking_raw_values() {
// Two equally-Critical groups must be interchangeable to the sort, so
// ordinary jitter between them cannot churn the retained row set. The
// proof is that the aggregate appears only inside comparisons, never as
// a bare sort key.
let a = agg(Operator::GreaterThan, json!(90), Some(80.0));
let sql = severity_order_sql(&a, "alert_agg_value").unwrap();
assert!(sql.starts_with("CASE WHEN"));
assert!(
!sql.contains("END DESC, \"alert_agg_value\""),
"the raw aggregate must not be a secondary sort key"
);
}
#[test]
fn test_severity_order_rejects_an_unorderable_operator() {
// `=` has no worst-first direction. M-10 refuses these for multi-alerts
// precisely so this is unreachable in practice — but the SQL builder
// must not invent an ordering if it ever is reached.
for op in [Operator::EqualTo, Operator::NotEqualTo] {
let a = agg(op, json!(90), None);
assert!(matches!(
severity_order_sql(&a, "alert_agg_value"),
Err(AggThresholdError::OperatorNotOrderable)
));
}
}
#[test]
fn test_severity_order_rejects_a_non_numeric_threshold() {
let a = agg(Operator::GreaterThan, json!("not a number"), None);
assert!(severity_order_sql(&a, "alert_agg_value").is_err());
}
}

View File

@ -21,7 +21,10 @@ use utoipa::ToSchema;
use crate::{
meta::{
alerts::{QueryCondition, TriggerCondition, deduplication::DeduplicationConfig},
alerts::{
QueryCondition, TriggerCondition, deduplication::DeduplicationConfig,
priority::AlertPriority,
},
stream::StreamType,
triggers::{ScheduledTriggerData, Trigger},
},
@ -101,6 +104,31 @@ pub struct Alert {
pub creates_incident: bool,
#[serde(default)]
pub workflows: Vec<String>,
/// How much humans care about this alert (PT-1). `None` = unset, which is
/// every pre-Feature-2 alert.
///
/// **Mutable** configuration — editable on any update, like `name`.
/// Display + propagation only: it must never influence evaluation,
/// silence, delivery or incident severity (PT-5 / D19).
///
/// `value_type` is required here because the enum serializes as an
/// integer via serde `try_from`/`into`; without it the generated OpenAPI
/// would advertise a string enum and lie about the payload.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<u8>, example = 3)]
pub priority: Option<AlertPriority>,
/// Selection tags (PT-6): bare (`prod`) or `key:value`
/// (`service:checkout`), normalized and validated at save by
/// `tags::normalize_tags`.
///
/// NOT `context_attributes` — that field is free-form KV shipped into
/// notification payloads with no validation. These are the filtering /
/// scoping primitive.
///
/// Skipped when empty so alerts that set no tags serialize exactly as
/// they did before Feature 2 (G5).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
impl MemorySize for Alert {
@ -122,6 +150,8 @@ impl MemorySize for Alert {
+ self.last_edited_by.mem_size()
+ self.deduplication.mem_size()
+ self.workflows.mem_size()
+ self.tags.mem_size()
+ std::mem::size_of::<Option<AlertPriority>>()
}
}
@ -160,6 +190,8 @@ impl Default for Alert {
deduplication: None,
creates_incident: false,
workflows: vec![],
priority: None,
tags: vec![],
}
}
}
@ -245,6 +277,10 @@ pub enum AlertTypeFilter {
Scheduled,
Realtime,
AnomalyDetection,
/// Feature 5 (SA-16). Filters to alerts whose `slo_id` is set — the
/// column, not the JSON payload, which is why it can be a SQL predicate
/// rather than an app-side scan (D60).
Slo,
}
/// Parameters for listing alerts.
@ -277,6 +313,43 @@ pub struct ListAlertsParams {
/// The optional alert type filter. Defaults to `All`.
pub alert_type: AlertTypeFilter,
/// Optional priority filter (PT-3). Multiple values are OR-ed, so
/// `?priority=1&priority=2` returns P1 **or** P2.
///
/// `None` = no filter. `Some(empty)` = the caller asked for priorities but
/// none were valid, which MUST match nothing — collapsing that back to
/// "no filter" would make `?priority=P9` return every alert, the same
/// match-all bug the tag filter guards against.
///
/// Alerts with no priority are excluded whenever a filter is present:
/// "show me the P1s" must not surface unprioritized alerts.
pub priority: Option<Vec<AlertPriority>>,
/// Tag filter (PT-8), **already resolved to alert IDs** by the service
/// layer, which owns the in-memory alert cache the infra layer cannot
/// reach. `None` = no tag filter.
///
/// `Some(empty)` means "no alert carries these tags" and MUST match
/// nothing — collapsing it back to `None` would turn a zero-result filter
/// into a match-all, the same class of bug the filter parser guards
/// against.
pub tag_alert_ids: Option<Vec<String>>,
/// Optional sort column (PT-3). `None` keeps the historical ordering
/// (name, then folder name).
pub sort_by: Option<AlertSortField>,
/// Sort direction; ignored when `sort_by` is `None`.
pub sort_desc: bool,
}
/// Columns the alert list can be sorted by (PT-3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlertSortField {
/// Ascending = most urgent first, because P1 stores as 1.
Priority,
Name,
}
impl ListAlertsParams {
@ -292,9 +365,33 @@ impl ListAlertsParams {
owner: None,
page_size_and_idx: None,
alert_type: AlertTypeFilter::All,
priority: None,
tag_alert_ids: None,
sort_by: None,
sort_desc: false,
}
}
/// Filter by one or more priorities (OR). An empty vec means "matched
/// nothing", NOT "no filter" — see the field docs.
pub fn with_priorities(mut self, priorities: Vec<AlertPriority>) -> Self {
self.priority = Some(priorities);
self
}
/// Filter by a tag-resolved alert-ID set (see `tag_alert_ids`).
pub fn with_tag_alert_ids(mut self, ids: Vec<String>) -> Self {
self.tag_alert_ids = Some(ids);
self
}
/// Sort by a column. Ascending priority = most urgent first (PT-3).
pub fn sorted_by(mut self, field: AlertSortField, desc: bool) -> Self {
self.sort_by = Some(field);
self.sort_desc = desc;
self
}
/// Filter alerts by the given folder ID surrogate key.
pub fn in_folder(mut self, folder_id: &str) -> Self {
self.folder_id = Some(folder_id.to_string());
@ -741,4 +838,161 @@ mod tests {
assert!(obj.contains_key("updated_at"));
assert!(obj.contains_key("deduplication"));
}
// ── Feature 2: list params (PT-3, PT-8) ─────────────────────────────────
#[test]
fn test_list_params_default_to_no_priority_tag_or_sort_filters() {
let p = ListAlertsParams::new("org");
assert_eq!(p.priority, None);
assert_eq!(p.tag_alert_ids, None, "None = no tag filter at all");
assert_eq!(p.sort_by, None, "None keeps the historical ordering");
assert!(!p.sort_desc);
}
#[test]
fn test_priority_filter_accepts_multiple_values_for_or_semantics() {
let p = ListAlertsParams::new("org")
.with_priorities(vec![AlertPriority::P1, AlertPriority::P2]);
assert_eq!(p.priority, Some(vec![AlertPriority::P1, AlertPriority::P2]));
}
/// Same distinction the tag filter needs: "no filter" and "a filter that
/// matched nothing" must not collapse together, or `?priority=P9` returns
/// every alert instead of none.
#[test]
fn test_empty_priority_set_is_distinct_from_no_priority_filter() {
let no_filter = ListAlertsParams::new("org");
assert_eq!(no_filter.priority, None);
let matched_nothing = ListAlertsParams::new("org").with_priorities(vec![]);
assert_eq!(matched_nothing.priority, Some(vec![]));
assert_ne!(no_filter.priority, matched_nothing.priority);
}
/// The distinction that prevents a match-all bug: "no tag filter" (`None`)
/// and "a tag filter that matched nothing" (`Some(vec![])`) must stay
/// different, or a zero-result filter silently returns every alert.
#[test]
fn test_empty_resolved_tag_set_is_distinct_from_no_tag_filter() {
let no_filter = ListAlertsParams::new("org");
assert_eq!(no_filter.tag_alert_ids, None);
let matched_nothing = ListAlertsParams::new("org").with_tag_alert_ids(vec![]);
assert_eq!(matched_nothing.tag_alert_ids, Some(vec![]));
assert_ne!(no_filter.tag_alert_ids, matched_nothing.tag_alert_ids);
}
#[test]
fn test_sort_builder_records_field_and_direction() {
let asc = ListAlertsParams::new("org").sorted_by(AlertSortField::Priority, false);
assert_eq!(asc.sort_by, Some(AlertSortField::Priority));
assert!(!asc.sort_desc);
let desc = ListAlertsParams::new("org").sorted_by(AlertSortField::Name, true);
assert_eq!(desc.sort_by, Some(AlertSortField::Name));
assert!(desc.sort_desc);
}
// ── Feature 2: priority & tags (PT-1, PT-6) ─────────────────────────────
// These test the PRODUCTION `Alert`, unlike the stand-in pattern test in
// `priority.rs` which proves only serde-attribute behaviour.
#[test]
fn test_alert_defaults_have_no_priority_and_no_tags() {
let alert = Alert::default();
assert_eq!(alert.priority, None, "unset is the default, never P1");
assert!(alert.tags.is_empty());
}
/// G5: an alert that configures neither field must serialize EXACTLY as it
/// did before Feature 2 — no new keys, so stored JSON and API payloads are
/// byte-identical for every existing alert.
#[test]
fn test_unset_priority_and_empty_tags_are_omitted_entirely() {
let alert = Alert::default();
let json = serde_json::to_value(&alert).unwrap();
let obj = json.as_object().unwrap();
assert!(
!obj.contains_key("priority"),
"unset priority must not appear"
);
assert!(!obj.contains_key("tags"), "empty tags must not appear");
}
#[test]
fn test_priority_and_tags_round_trip_through_serde() {
let alert = Alert {
priority: Some(AlertPriority::P2),
tags: vec!["prod".to_string(), "service:checkout".to_string()],
..Default::default()
};
let json = serde_json::to_value(&alert).unwrap();
// Integer wire form (D17) — matches the storage column exactly.
assert_eq!(json["priority"], serde_json::json!(2));
assert_eq!(
json["tags"],
serde_json::json!(["prod", "service:checkout"])
);
let back: Alert = serde_json::from_value(json).unwrap();
assert_eq!(back.priority, Some(AlertPriority::P2));
assert_eq!(back.tags, alert.tags);
}
/// PT-1: priority is MUTABLE static configuration. "Static" contrasts it
/// with evaluated state; it does not mean write-once. An edit must be able
/// to raise it, lower it, and clear it back to unset.
#[test]
fn test_priority_is_mutable_including_back_to_unset() {
let mut alert = Alert::default();
alert.priority = Some(AlertPriority::P4);
assert_eq!(alert.priority, Some(AlertPriority::P4));
alert.priority = Some(AlertPriority::P1); // raised
assert_eq!(alert.priority, Some(AlertPriority::P1));
alert.priority = None; // cleared
let json = serde_json::to_value(&alert).unwrap();
assert!(
!json.as_object().unwrap().contains_key("priority"),
"clearing must return to absent, not leave a stale value"
);
}
#[test]
fn test_tags_are_mutable_including_back_to_empty() {
let mut alert = Alert::default();
alert.tags = vec!["prod".to_string()];
alert.tags.clear();
let json = serde_json::to_value(&alert).unwrap();
assert!(!json.as_object().unwrap().contains_key("tags"));
}
/// PT-1/PT-6: unlike the warning family (rejected on realtime by D12),
/// priority and tags are inert metadata and ARE allowed on realtime
/// alerts — excluding them would punch holes in list filtering.
#[test]
fn test_realtime_alerts_may_carry_priority_and_tags() {
let alert = Alert {
is_real_time: true,
priority: Some(AlertPriority::P3),
tags: vec!["prod".to_string()],
..Default::default()
};
let json = serde_json::to_value(&alert).unwrap();
assert_eq!(json["priority"], serde_json::json!(3));
assert_eq!(json["tags"], serde_json::json!(["prod"]));
}
/// Old payloads (no such keys) must still deserialize — the fields are
/// additive.
#[test]
fn test_pre_feature2_payload_still_deserializes() {
let legacy = serde_json::json!({ "name": "old", "org_id": "o" });
let alert: Alert = serde_json::from_value(legacy).unwrap();
assert_eq!(alert.name, "old");
assert_eq!(alert.priority, None);
assert!(alert.tags.is_empty());
}
}

View File

@ -0,0 +1,779 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Composite alerts — Feature 4 of `alerts_2.md`.
//!
//! TODO(composite): the feature is deferred — this module is pure logic with
//! tests and is deliberately not wired into the scheduler, API, or UI yet.
//!
//! Pure logic only: expression parsing/evaluation over child *states*, the
//! stale-child policy (§6.4), and the write-time guards (child counts, cycles).
//! Composites never re-run child queries — that is the whole point of building
//! them on the durable state layer.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::level::AlertLevel;
/// Maximum children per composite (C-1). Bounded so one composite cannot
/// fan out an unbounded state read on every evaluation.
pub const MAX_CHILDREN: usize = 10;
/// Minimum children — a "composite" of one is just the child.
pub const MIN_CHILDREN: usize = 2;
/// Maximum composite nesting depth (D6).
pub const MAX_DEPTH: usize = 2;
/// Staleness multiplier: a child is stale after K x its own frequency (§6.4).
pub const STALE_FREQUENCY_MULTIPLIER: i64 = 3;
/// Boolean expression over child alerts.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompositeExpr {
Child(String),
And(Box<CompositeExpr>, Box<CompositeExpr>),
Or(Box<CompositeExpr>, Box<CompositeExpr>),
Not(Box<CompositeExpr>),
}
/// The slice of a child's state a composite needs.
#[derive(Clone, Debug, PartialEq)]
pub struct ChildState {
pub level: Option<AlertLevel>,
/// When the level was last *computed* (`alert_states.level_at`).
///
/// Deliberately not `last_outcome_at`: a child erroring every minute
/// refreshes its outcome timestamp while its level goes stale, which would
/// keep a long-broken child looking "fresh" to composites.
pub level_at: Option<i64>,
pub frequency_secs: i64,
}
impl ChildState {
fn is_stale(&self, now: i64) -> bool {
match self.level_at {
// Never classified — no basis for a truth value.
None => true,
Some(at) => {
let window = STALE_FREQUENCY_MULTIPLIER
.saturating_mul(self.frequency_secs)
.saturating_mul(1_000_000);
now.saturating_sub(at) > window
}
}
}
}
/// What a composite does with a child whose state has gone stale (§6.4).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum StaleChildPolicy {
/// Trust the frozen state (default).
#[serde(rename = "use_last_state")]
UseLastState,
/// A stale child never satisfies the expression.
#[serde(rename = "treat_as_false")]
TreatAsFalse,
/// Fail-safe for absence-of-heartbeat patterns.
#[serde(rename = "treat_as_true")]
TreatAsTrue,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CompositeError {
/// The expression references a child absent from the supplied state map.
/// Loud failure beats a silent `false` that would never fire.
UnknownChild(String),
TooFewChildren {
got: usize,
min: usize,
},
TooManyChildren {
got: usize,
max: usize,
},
DuplicateChild(String),
/// The reference chain loops; carries the offending path.
Cycle(Vec<String>),
TooDeep {
max: usize,
},
Parse(String),
}
impl std::fmt::Display for CompositeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownChild(c) => write!(f, "unknown child alert `{c}`"),
Self::TooFewChildren { got, min } => {
write!(f, "composite needs at least {min} children, got {got}")
}
Self::TooManyChildren { got, max } => {
write!(f, "composite allows at most {max} children, got {got}")
}
Self::DuplicateChild(c) => write!(f, "child `{c}` referenced more than once"),
Self::Cycle(path) => write!(f, "composite reference cycle: {}", path.join(" -> ")),
Self::TooDeep { max } => write!(f, "composite nesting exceeds depth {max}"),
Self::Parse(m) => write!(f, "invalid expression: {m}"),
}
}
}
impl std::error::Error for CompositeError {}
// ── Parsing ─────────────────────────────────────────────────────────────────
#[derive(Clone, Debug, PartialEq)]
enum Token {
Ident(String),
And,
Or,
Not,
LParen,
RParen,
}
fn tokenize(input: &str) -> Result<Vec<Token>, CompositeError> {
let chars: Vec<char> = input.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match c {
c if c.is_whitespace() => i += 1,
'(' => {
out.push(Token::LParen);
i += 1;
}
')' => {
out.push(Token::RParen);
i += 1;
}
'!' => {
out.push(Token::Not);
i += 1;
}
'&' | '|' => {
if i + 1 < chars.len() && chars[i + 1] == c {
out.push(if c == '&' { Token::And } else { Token::Or });
i += 2;
} else {
return Err(CompositeError::Parse(format!("expected `{c}{c}`")));
}
}
// Operand tokens: alert ksuids in stored expressions, readable
// names in tests and the UI. Braces are accepted so `{ksuid}` is
// valid without changing the grammar.
c if c.is_alphanumeric() || c == '_' || c == '-' || c == '{' || c == '}' => {
let start = i;
while i < chars.len()
&& (chars[i].is_alphanumeric()
|| chars[i] == '_'
|| chars[i] == '-'
|| chars[i] == '{'
|| chars[i] == '}')
{
i += 1;
}
let raw: String = chars[start..i].iter().collect();
out.push(Token::Ident(
raw.trim_matches(|c| c == '{' || c == '}').to_string(),
));
}
other => {
return Err(CompositeError::Parse(format!(
"unexpected character `{other}`"
)));
}
}
}
Ok(out)
}
struct Parser {
tokens: Vec<Token>,
pos: usize,
}
impl Parser {
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
/// or := and ( "||" and )*
fn parse_or(&mut self) -> Result<CompositeExpr, CompositeError> {
let mut lhs = self.parse_and()?;
while matches!(self.peek(), Some(Token::Or)) {
self.pos += 1;
let rhs = self.parse_and()?;
lhs = CompositeExpr::Or(Box::new(lhs), Box::new(rhs));
}
Ok(lhs)
}
/// and := unary ( "&&" unary )* — binds tighter than `||`.
fn parse_and(&mut self) -> Result<CompositeExpr, CompositeError> {
let mut lhs = self.parse_unary()?;
while matches!(self.peek(), Some(Token::And)) {
self.pos += 1;
let rhs = self.parse_unary()?;
lhs = CompositeExpr::And(Box::new(lhs), Box::new(rhs));
}
Ok(lhs)
}
/// unary := "!" unary | atom — binds tighter than `&&`.
fn parse_unary(&mut self) -> Result<CompositeExpr, CompositeError> {
if matches!(self.peek(), Some(Token::Not)) {
self.pos += 1;
return Ok(CompositeExpr::Not(Box::new(self.parse_unary()?)));
}
self.parse_atom()
}
fn parse_atom(&mut self) -> Result<CompositeExpr, CompositeError> {
match self.peek().cloned() {
Some(Token::Ident(name)) => {
self.pos += 1;
Ok(CompositeExpr::Child(name))
}
Some(Token::LParen) => {
self.pos += 1;
let inner = self.parse_or()?;
if !matches!(self.peek(), Some(Token::RParen)) {
return Err(CompositeError::Parse("unclosed `(`".to_string()));
}
self.pos += 1;
Ok(inner)
}
Some(t) => Err(CompositeError::Parse(format!("unexpected token {t:?}"))),
None => Err(CompositeError::Parse(
"unexpected end of expression".to_string(),
)),
}
}
}
/// Parse a composite expression. `&&` binds tighter than `||`, `!` tighter than
/// both — matching every mainstream language, because getting this wrong
/// silently changes the meaning of every composite with no error anywhere.
pub fn parse_expr(input: &str) -> Result<CompositeExpr, CompositeError> {
let tokens = tokenize(input)?;
if tokens.is_empty() {
return Err(CompositeError::Parse("empty expression".to_string()));
}
let mut p = Parser { tokens, pos: 0 };
let expr = p.parse_or()?;
if p.pos != p.tokens.len() {
return Err(CompositeError::Parse(
"trailing tokens after expression".to_string(),
));
}
Ok(expr)
}
// ── Evaluation ──────────────────────────────────────────────────────────────
fn level_truth(level: Option<AlertLevel>, warning_counts_as_firing: bool) -> bool {
match level {
Some(AlertLevel::Critical) => true,
Some(AlertLevel::Warning) => warning_counts_as_firing,
_ => false,
}
}
fn child_truth(
name: &str,
states: &HashMap<String, ChildState>,
warning_counts_as_firing: bool,
stale_policy: StaleChildPolicy,
now: i64,
) -> Result<bool, CompositeError> {
let state = states
.get(name)
.ok_or_else(|| CompositeError::UnknownChild(name.to_string()))?;
if state.is_stale(now) {
return Ok(match stale_policy {
StaleChildPolicy::TreatAsFalse => false,
StaleChildPolicy::TreatAsTrue => true,
// Fall back to the frozen level; a never-classified child yields
// false rather than panicking.
StaleChildPolicy::UseLastState => level_truth(state.level, warning_counts_as_firing),
});
}
Ok(level_truth(state.level, warning_counts_as_firing))
}
/// Evaluate a composite over child states. Never touches child queries.
pub fn evaluate_expr(
expr: &CompositeExpr,
states: &HashMap<String, ChildState>,
warning_counts_as_firing: bool,
stale_policy: StaleChildPolicy,
now: i64,
) -> Result<bool, CompositeError> {
Ok(match expr {
CompositeExpr::Child(name) => {
child_truth(name, states, warning_counts_as_firing, stale_policy, now)?
}
CompositeExpr::Not(inner) => {
!evaluate_expr(inner, states, warning_counts_as_firing, stale_policy, now)?
}
CompositeExpr::And(a, b) => {
evaluate_expr(a, states, warning_counts_as_firing, stale_policy, now)?
&& evaluate_expr(b, states, warning_counts_as_firing, stale_policy, now)?
}
CompositeExpr::Or(a, b) => {
evaluate_expr(a, states, warning_counts_as_firing, stale_policy, now)?
|| evaluate_expr(b, states, warning_counts_as_firing, stale_policy, now)?
}
})
}
/// Level a composite reports.
///
/// OPEN DECISION (D9): a firing composite is always `Critical` — composites
/// carry no thresholds of their own, so there is no basis for an intermediate
/// level.
pub fn result_level(fired: bool) -> AlertLevel {
if fired {
AlertLevel::Critical
} else {
AlertLevel::Ok
}
}
// ── Write-time guards ───────────────────────────────────────────────────────
/// Child count and uniqueness (C-1).
pub fn validate_children(children: &[String]) -> Result<(), CompositeError> {
if children.len() < MIN_CHILDREN {
return Err(CompositeError::TooFewChildren {
got: children.len(),
min: MIN_CHILDREN,
});
}
if children.len() > MAX_CHILDREN {
return Err(CompositeError::TooManyChildren {
got: children.len(),
max: MAX_CHILDREN,
});
}
let mut seen = std::collections::HashSet::new();
for c in children {
if !seen.insert(c) {
return Err(CompositeError::DuplicateChild(c.clone()));
}
}
Ok(())
}
/// Depth of a composite reference subtree. Plain alerts (absent from
/// `existing`) are depth 0; a composite is 1 + its deepest composite child.
fn depth_of(id: &str, existing: &HashMap<String, Vec<String>>) -> usize {
match existing.get(id) {
None => 0,
Some(children) => {
1 + children
.iter()
.map(|c| depth_of(c, existing))
.max()
.unwrap_or(0)
}
}
}
/// Reject reference cycles and over-deep nesting at write time (C-4, D6).
///
/// Detected on write, never at evaluation: a cycle discovered mid-evaluation
/// would be an infinite loop in the scheduler.
pub fn validate_no_cycle(
composite_id: &str,
children: &[String],
existing: &HashMap<String, Vec<String>>,
) -> Result<(), CompositeError> {
// Depth-first search back toward `composite_id`.
fn walk(
current: &str,
target: &str,
existing: &HashMap<String, Vec<String>>,
path: &mut Vec<String>,
) -> Option<Vec<String>> {
if current == target {
path.push(current.to_string());
return Some(path.clone());
}
if path.iter().any(|p| p == current) {
// A pre-existing loop between other composites; stop descending.
return None;
}
path.push(current.to_string());
if let Some(kids) = existing.get(current) {
for k in kids {
if let Some(found) = walk(k, target, existing, path) {
return Some(found);
}
}
}
path.pop();
None
}
for child in children {
let mut path = Vec::new();
if let Some(cycle) = walk(child, composite_id, existing, &mut path) {
return Err(CompositeError::Cycle(cycle));
}
}
// Depth of the composite as it would exist after this write.
let deepest = children
.iter()
.map(|c| depth_of(c, existing))
.max()
.unwrap_or(0);
if 1 + deepest > MAX_DEPTH {
return Err(CompositeError::TooDeep { max: MAX_DEPTH });
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::meta::alerts::{
composite::{
ChildState, CompositeError, CompositeExpr, StaleChildPolicy, evaluate_expr, parse_expr,
validate_children, validate_no_cycle,
},
level::AlertLevel,
};
/// A child whose level was computed recently.
/// NOTE: staleness runs on `level_at` — when the level was last COMPUTED
/// from a successful evaluation — not on any "last evaluation" time. An
/// alert erroring every minute stays fresh on last_outcome_at while its
/// level rots; `level_at` is immune to that (alerts_2.md §6.4/§7.6).
fn fresh(level: AlertLevel) -> ChildState {
ChildState {
level: Some(level),
// 60s ago, inside the K x 60s = 180s staleness window.
level_at: Some(NOW - 60_000_000),
frequency_secs: 60,
}
}
/// A child whose level is far older than K× its frequency.
fn stale(level: AlertLevel) -> ChildState {
ChildState {
level: Some(level),
// 600s ago, well beyond the 180s window.
//
// NOTE: the original fixtures used level_at 1_000 vs 1 against
// NOW = 1_000_000 — a 1ms difference against a 180s threshold, so
// BOTH read as fresh and every staleness test passed vacuously.
level_at: Some(NOW - 600_000_000),
frequency_secs: 60,
}
}
fn states(pairs: &[(&str, ChildState)]) -> HashMap<String, ChildState> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
/// Microseconds. Large enough that the fixtures below stay positive.
const NOW: i64 = 1_000_000_000;
// ── Expression parsing ──────────────────────────────────────────────────
#[test]
fn test_parse_single_child() {
let e = parse_expr("a").unwrap();
assert_eq!(e, CompositeExpr::Child("a".to_string()));
}
#[test]
fn test_parse_and_or_not() {
assert!(parse_expr("a && b").is_ok());
assert!(parse_expr("a || b").is_ok());
assert!(parse_expr("!a").is_ok());
assert!(parse_expr("(a && b) || !c").is_ok());
}
#[test]
fn test_parse_rejects_malformed_expressions() {
for bad in ["a &&", "&& b", "(a && b", "a b", "", "a && && b", "!"] {
assert!(
parse_expr(bad).is_err(),
"expression {bad:?} must be rejected"
);
}
}
#[test]
fn test_and_binds_tighter_than_or() {
// `a || b && c` must parse as `a || (b && c)`, matching every
// mainstream language. Getting this wrong silently changes semantics.
//
// The operand values must DISCRIMINATE between the two parses:
// a=true, b=false, c=false
// a || (b && c) = true || false = TRUE <- correct
// (a || b) && c = true && false = FALSE <- wrong precedence
// An earlier version used a=false,b=true,c=false, where both parses
// yield false — it passed regardless of precedence.
let states = states(&[
("a", fresh(AlertLevel::Critical)),
("b", fresh(AlertLevel::Ok)),
("c", fresh(AlertLevel::Ok)),
]);
let e = parse_expr("a || b && c").unwrap();
assert!(
evaluate_expr(&e, &states, true, StaleChildPolicy::UseLastState, NOW).unwrap(),
"must be true; a false result means OR bound tighter than AND"
);
}
#[test]
fn test_not_binds_tighter_than_and() {
// `!a && b` is `(!a) && b`, not `!(a && b)`.
// a=true, b=true -> (!a) && b = false ; !(a && b) = false (same)
// a=false, b=true -> (!a) && b = TRUE ; !(a && b) = TRUE (same)
// a=true, b=false -> (!a) && b = false ; !(a && b) = TRUE <- differs
let s = states(&[
("a", fresh(AlertLevel::Critical)),
("b", fresh(AlertLevel::Ok)),
]);
let e = parse_expr("!a && b").unwrap();
assert!(
!evaluate_expr(&e, &s, true, StaleChildPolicy::UseLastState, NOW).unwrap(),
"must be false; a true result means NOT was applied to the whole AND"
);
}
#[test]
fn test_parentheses_override_precedence() {
let states = states(&[
("a", fresh(AlertLevel::Critical)),
("b", fresh(AlertLevel::Ok)),
("c", fresh(AlertLevel::Ok)),
]);
// (a || b) && c => true && false => false
let e = parse_expr("(a || b) && c").unwrap();
assert!(!evaluate_expr(&e, &states, true, StaleChildPolicy::UseLastState, NOW).unwrap());
}
// ── C-3: child truth mapping ────────────────────────────────────────────
#[test]
fn test_critical_child_is_true() {
let s = states(&[("a", fresh(AlertLevel::Critical))]);
let e = parse_expr("a").unwrap();
assert!(evaluate_expr(&e, &s, true, StaleChildPolicy::UseLastState, NOW).unwrap());
}
#[test]
fn test_ok_child_is_false() {
let s = states(&[("a", fresh(AlertLevel::Ok))]);
let e = parse_expr("a").unwrap();
assert!(!evaluate_expr(&e, &s, true, StaleChildPolicy::UseLastState, NOW).unwrap());
}
/// D5: default is that Warning counts as firing.
#[test]
fn test_warning_child_truth_follows_the_flag() {
let s = states(&[("a", fresh(AlertLevel::Warning))]);
let e = parse_expr("a").unwrap();
assert!(
evaluate_expr(&e, &s, true, StaleChildPolicy::UseLastState, NOW).unwrap(),
"warning_counts_as_firing = true (default)"
);
assert!(
!evaluate_expr(&e, &s, false, StaleChildPolicy::UseLastState, NOW).unwrap(),
"warning_counts_as_firing = false"
);
}
#[test]
fn test_negation_inverts_child_truth() {
let s = states(&[("a", fresh(AlertLevel::Ok))]);
let e = parse_expr("!a").unwrap();
assert!(evaluate_expr(&e, &s, true, StaleChildPolicy::UseLastState, NOW).unwrap());
}
#[test]
fn test_realistic_composite_expression() {
// (disk_full && high_latency) || db_down
let s = states(&[
("disk_full", fresh(AlertLevel::Critical)),
("high_latency", fresh(AlertLevel::Ok)),
("db_down", fresh(AlertLevel::Critical)),
]);
let e = parse_expr("(disk_full && high_latency) || db_down").unwrap();
assert!(evaluate_expr(&e, &s, true, StaleChildPolicy::UseLastState, NOW).unwrap());
}
// ── §6.4: stale-child policy ────────────────────────────────────────────
#[test]
fn test_stale_child_use_last_state_keeps_its_truth() {
let s = states(&[("a", stale(AlertLevel::Critical))]);
let e = parse_expr("a").unwrap();
assert!(
evaluate_expr(&e, &s, true, StaleChildPolicy::UseLastState, NOW).unwrap(),
"default policy trusts the frozen state"
);
}
#[test]
fn test_stale_child_treat_as_false() {
let s = states(&[("a", stale(AlertLevel::Critical))]);
let e = parse_expr("a").unwrap();
assert!(!evaluate_expr(&e, &s, true, StaleChildPolicy::TreatAsFalse, NOW).unwrap());
}
#[test]
fn test_stale_child_treat_as_true_supports_heartbeat_patterns() {
let s = states(&[("a", stale(AlertLevel::Ok))]);
let e = parse_expr("a").unwrap();
assert!(
evaluate_expr(&e, &s, true, StaleChildPolicy::TreatAsTrue, NOW).unwrap(),
"fail-safe: absence of a heartbeat should be able to fire"
);
}
#[test]
fn test_never_evaluated_child_uses_the_stale_policy() {
// A child that has never run has no level at all — distinct from Ok.
let never = ChildState {
level: None,
level_at: None,
frequency_secs: 60,
};
let s = states(&[("a", never)]);
let e = parse_expr("a").unwrap();
assert!(!evaluate_expr(&e, &s, true, StaleChildPolicy::TreatAsFalse, NOW).unwrap());
assert!(evaluate_expr(&e, &s, true, StaleChildPolicy::TreatAsTrue, NOW).unwrap());
assert!(
!evaluate_expr(&e, &s, true, StaleChildPolicy::UseLastState, NOW).unwrap(),
"no last state to use → falls back to false rather than panicking"
);
}
#[test]
fn test_missing_child_state_is_an_error_not_a_silent_false() {
// A referenced child with no entry at all means the caller assembled
// the state map wrongly. Failing loudly beats a silent false.
let s = states(&[("a", fresh(AlertLevel::Critical))]);
let e = parse_expr("a && missing").unwrap();
let err = evaluate_expr(&e, &s, true, StaleChildPolicy::UseLastState, NOW).unwrap_err();
assert_eq!(err, CompositeError::UnknownChild("missing".to_string()));
}
// ── C-1: child-count limits ─────────────────────────────────────────────
#[test]
fn test_requires_at_least_two_children() {
let err = validate_children(&["a".to_string()]).unwrap_err();
assert_eq!(err, CompositeError::TooFewChildren { got: 1, min: 2 });
}
#[test]
fn test_accepts_between_two_and_ten_children() {
for n in 2..=10 {
let kids: Vec<String> = (0..n).map(|i| format!("a{i}")).collect();
assert!(
validate_children(&kids).is_ok(),
"{n} children must be valid"
);
}
}
#[test]
fn test_rejects_more_than_ten_children() {
let kids: Vec<String> = (0..11).map(|i| format!("a{i}")).collect();
let err = validate_children(&kids).unwrap_err();
assert_eq!(err, CompositeError::TooManyChildren { got: 11, max: 10 });
}
#[test]
fn test_rejects_duplicate_children() {
let kids = vec!["a".to_string(), "a".to_string()];
assert_eq!(
validate_children(&kids).unwrap_err(),
CompositeError::DuplicateChild("a".to_string())
);
}
// ── C-4: cycle rejection ────────────────────────────────────────────────
#[test]
fn test_rejects_direct_self_reference() {
let err = validate_no_cycle("c1", &["c1".to_string()], &HashMap::new()).unwrap_err();
assert_eq!(err, CompositeError::Cycle(vec!["c1".to_string()]));
}
#[test]
fn test_rejects_transitive_cycle() {
// c1 -> c2 -> c1
let mut existing = HashMap::new();
existing.insert("c2".to_string(), vec!["c1".to_string()]);
let err = validate_no_cycle("c1", &["c2".to_string()], &existing).unwrap_err();
assert!(
matches!(err, CompositeError::Cycle(_)),
"a transitive cycle must be rejected at write time, not discovered at eval"
);
}
#[test]
fn test_accepts_acyclic_composite_of_composite() {
// c1 -> c2 -> (plain alerts). Depth 2, no cycle (D6).
let mut existing = HashMap::new();
existing.insert("c2".to_string(), vec![]);
assert!(validate_no_cycle("c1", &["c2".to_string()], &existing).is_ok());
}
/// D6: depth beyond 2 is rejected in v1.
#[test]
fn test_rejects_depth_greater_than_two() {
// c1 -> c2 -> c3 is depth 3.
let mut existing = HashMap::new();
existing.insert("c2".to_string(), vec!["c3".to_string()]);
existing.insert("c3".to_string(), vec![]);
let err = validate_no_cycle("c1", &["c2".to_string()], &existing).unwrap_err();
assert_eq!(err, CompositeError::TooDeep { max: 2 });
}
// ── Composite result maps to a level ────────────────────────────────────
/// OPEN DECISION (not covered by alerts_2.md): a firing composite is
/// reported as Critical, never Warning — composites have no thresholds of
/// their own, so there is no basis for an intermediate level. If composites
/// should instead inherit the worst child level (so a warning-only
/// composite reports Warning), this is the test to change.
#[test]
fn test_composite_result_maps_to_a_level() {
use crate::meta::alerts::composite::result_level;
assert_eq!(result_level(true), AlertLevel::Critical);
assert_eq!(result_level(false), AlertLevel::Ok);
}
}

View File

@ -283,6 +283,22 @@ impl DeduplicationConfig {
}
}
/// Whether an existing dedup reservation should suppress this notification
/// (`alerts_2.md` §5.5 MN-6).
///
/// Deduplication currently records a fingerprint as *seen* the moment a row
/// passes — before the notification is sent — so a send that then fails is
/// suppressed as a duplicate on the retry, and the page is lost for the whole
/// dedup window. The `notification_sent` column already exists for exactly
/// this and has never been read.
///
/// The rule: a reservation suppresses only once it has been **confirmed** by a
/// successful delivery. An unconfirmed reservation inside the window is a
/// delivery that never landed, so the next evaluation must be allowed through.
pub fn reservation_suppresses(notification_sent: bool, within_window: bool) -> bool {
within_window && notification_sent
}
#[cfg(test)]
mod tests {
use super::*;
@ -670,4 +686,45 @@ mod tests {
fn test_default_group_wait() {
assert_eq!(default_group_wait(), 30);
}
// ── §5.5 MN-6: reserve, then confirm on successful delivery ─────────────
#[test]
fn test_an_unconfirmed_reservation_does_not_suppress() {
// THE bug this rule exists for. The previous evaluation reserved the
// fingerprint and its send then failed, so nothing was delivered.
// Suppressing here would convert one transient webhook error into a
// page silently lost for the whole dedup window.
assert!(!reservation_suppresses(false, true));
}
#[test]
fn test_a_confirmed_reservation_inside_the_window_suppresses() {
// The ordinary case dedup exists for: it really was delivered.
assert!(reservation_suppresses(true, true));
}
#[test]
fn test_an_expired_reservation_never_suppresses() {
// Outside the window nothing suppresses, confirmed or not — otherwise
// a delivered alert could never fire again.
assert!(!reservation_suppresses(true, false));
assert!(!reservation_suppresses(false, false));
}
#[test]
fn test_confirmation_is_required_not_merely_preferred() {
// Stated as the invariant rather than a case list: within the window,
// suppression must track confirmation exactly. An implementation that
// ignored `notification_sent` (today's behaviour) fails this.
for within in [true, false] {
for sent in [true, false] {
assert_eq!(
reservation_suppresses(sent, within),
within && sent,
"within_window={within}, notification_sent={sent}"
);
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -388,9 +388,17 @@ pub struct IncidentAlert {
pub incident_id: String,
pub alert_id: String,
pub alert_name: String,
#[serde(default)]
pub alert_kind: AlertKind,
pub alert_fired_at: i64,
pub correlation_reason: CorrelationReason,
pub created_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<std::collections::HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detected_source: Option<String>,
}
/// Incident with its alerts (for detail view)
@ -497,6 +505,13 @@ pub enum IncidentCorrelationOutcome {
incident_id: String,
service_name: String,
},
/// The alert re-fired at a HIGHER severity than the incident currently
/// carries (Warning→Critical, T-8). The incident's severity was upgraded
/// and a notification must be sent — an escalation is never a repeat.
SeverityEscalated {
incident_id: String,
service_name: String,
},
}
impl IncidentCorrelationOutcome {
@ -504,7 +519,8 @@ impl IncidentCorrelationOutcome {
match self {
Self::NewIncidentCreated { incident_id, .. }
| Self::NewAlertTypeJoined { incident_id, .. }
| Self::ExistingAlertRepeated { incident_id, .. } => incident_id,
| Self::ExistingAlertRepeated { incident_id, .. }
| Self::SeverityEscalated { incident_id, .. } => incident_id,
}
}
@ -512,7 +528,8 @@ impl IncidentCorrelationOutcome {
match self {
Self::NewIncidentCreated { service_name, .. }
| Self::NewAlertTypeJoined { service_name, .. }
| Self::ExistingAlertRepeated { service_name, .. } => service_name,
| Self::ExistingAlertRepeated { service_name, .. }
| Self::SeverityEscalated { service_name, .. } => service_name,
}
}
}
@ -533,6 +550,72 @@ fn default_true() -> bool {
true
}
/// Whether an incident's member alert is an OpenObserve alert or an
/// externally-ingested one (External Alert Sources feature).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AlertKind {
#[default]
Internal,
External,
}
impl AlertKind {
pub fn as_str(&self) -> &'static str {
match self {
AlertKind::Internal => "internal",
AlertKind::External => "external",
}
}
pub fn from_stored(s: &str) -> Self {
match s {
"external" => AlertKind::External,
_ => AlertKind::Internal,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ExternalAlertStatus {
#[default]
Firing,
Resolved,
}
/// One normalized alert event from an external source (Grafana, Alertmanager,
/// generic JSON). Produced by the normalizers, consumed by persistence +
/// correlation. Self-contained: `raw` keeps the per-alert slice of the
/// original payload.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ExternalAlertEvent {
pub status: ExternalAlertStatus,
pub dedup_key: String,
pub title: String,
pub severity: IncidentSeverity,
pub labels: HashMap<String, String>,
/// Source event time (startsAt/endsAt) in epoch micros — NOT receipt time.
pub event_ts: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_url: Option<String>,
#[serde(default)]
pub raw: serde_json::Value,
}
/// Built-in default severity mapping (spec §4.9: "no JSON for humans").
/// critical→P1, error/major→P2, warning/minor→P3, info→P4, P1..P4 passthrough,
/// anything else → P3.
pub fn map_external_severity(s: &str) -> IncidentSeverity {
match s.to_ascii_lowercase().as_str() {
"critical" | "crit" | "fatal" | "p1" => IncidentSeverity::P1,
"error" | "major" | "high" | "p2" => IncidentSeverity::P2,
"warning" | "warn" | "minor" | "p3" => IncidentSeverity::P3,
"info" | "low" | "ok" | "p4" => IncidentSeverity::P4,
_ => IncidentSeverity::P3,
}
}
/// Statistics for incidents dashboard
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct IncidentStats {
@ -2123,4 +2206,111 @@ mod tests {
IncidentEventType::AIAnalysisComplete
));
}
#[test]
fn test_alert_kind_serde_and_stored() {
assert_eq!(
serde_json::to_string(&AlertKind::External).unwrap(),
"\"external\""
);
assert_eq!(AlertKind::from_stored("external"), AlertKind::External);
assert_eq!(AlertKind::from_stored("internal"), AlertKind::Internal);
assert_eq!(AlertKind::from_stored("garbage"), AlertKind::Internal); // safe default
assert_eq!(AlertKind::External.as_str(), "external");
}
#[test]
fn test_incident_alert_external_fields_serde() {
let alert = IncidentAlert {
incident_id: "inc1".to_string(),
alert_id: "ext1".to_string(),
alert_name: "External Alert".to_string(),
alert_kind: AlertKind::External,
alert_fired_at: 1000,
correlation_reason: CorrelationReason::AlertId,
created_at: 1000,
source_url: Some("https://example.com/alert/1".to_string()),
labels: Some(std::collections::HashMap::from([(
"service".to_string(),
"checkout".to_string(),
)])),
detected_source: Some("pagerduty".to_string()),
};
let json = serde_json::to_value(&alert).unwrap();
assert_eq!(json["alert_kind"], "external");
assert_eq!(json["source_url"], "https://example.com/alert/1");
assert_eq!(json["detected_source"], "pagerduty");
let round_tripped: IncidentAlert = serde_json::from_value(json).unwrap();
assert_eq!(round_tripped.alert_kind, AlertKind::External);
assert_eq!(
round_tripped.source_url,
Some("https://example.com/alert/1".to_string())
);
}
#[test]
fn test_incident_alert_legacy_json_defaults_to_internal() {
// Legacy stored/older payloads have no alert_kind or external fields at all.
let legacy_json = serde_json::json!({
"incident_id": "inc1",
"alert_id": "alert1",
"alert_name": "My Alert",
"alert_fired_at": 1000,
"correlation_reason": "alert_id",
"created_at": 1000,
});
let alert: IncidentAlert = serde_json::from_value(legacy_json).unwrap();
assert_eq!(alert.alert_kind, AlertKind::Internal);
assert_eq!(alert.source_url, None);
assert_eq!(alert.labels, None);
assert_eq!(alert.detected_source, None);
}
#[test]
fn test_external_alert_status_serde() {
assert_eq!(
serde_json::to_string(&ExternalAlertStatus::Firing).unwrap(),
"\"firing\""
);
let s: ExternalAlertStatus = serde_json::from_str("\"resolved\"").unwrap();
assert_eq!(s, ExternalAlertStatus::Resolved);
}
#[test]
fn test_map_external_severity() {
assert_eq!(map_external_severity("critical"), IncidentSeverity::P1);
assert_eq!(map_external_severity("CRITICAL"), IncidentSeverity::P1);
assert_eq!(map_external_severity("error"), IncidentSeverity::P2);
assert_eq!(map_external_severity("warning"), IncidentSeverity::P3);
assert_eq!(map_external_severity("info"), IncidentSeverity::P4);
assert_eq!(map_external_severity("p2"), IncidentSeverity::P2);
assert_eq!(
map_external_severity("unknown-things"),
IncidentSeverity::P3
); // default
}
#[test]
fn test_external_alert_event_serde_roundtrip() {
let ev = ExternalAlertEvent {
status: ExternalAlertStatus::Firing,
dedup_key: "abc123".into(),
title: "High CPU".into(),
severity: IncidentSeverity::P1,
labels: std::collections::HashMap::from([(
"namespace".to_string(),
"prod".to_string(),
)]),
event_ts: 1_722_300_000_000_000,
source_url: Some("https://grafana.example/alerting/x".into()),
raw: serde_json::json!({"k": "v"}),
};
let json = serde_json::to_string(&ev).unwrap();
let back: ExternalAlertEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back.dedup_key, "abc123");
assert_eq!(back.severity, IncidentSeverity::P1);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -30,9 +30,18 @@ use crate::{
},
};
pub mod aggregation_level;
pub mod alert;
pub mod composite;
pub mod deduplication;
pub mod dispatch;
pub mod grouping;
pub mod incidents;
pub mod level;
pub mod priority;
pub mod state;
pub mod state_level;
pub mod tags;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, PartialEq, Default)]
#[serde(default)]
@ -42,7 +51,19 @@ pub struct TriggerCondition {
#[serde(default)]
pub operator: Operator, // >=
#[serde(default)]
pub threshold: i64, // 3 times
pub threshold: i64, // 3 times = CRITICAL level
/// Warning threshold, sharing `operator` with `threshold` — one operator
/// for both levels, no mixed directions (T-2). `None` = single-level
/// alert, i.e. exactly the legacy behaviour.
/// Validated by `level::validate_thresholds` — "less severe" is
/// direction-dependent, so this is NOT simply `< threshold`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning_threshold: Option<i64>,
/// Whether a Warning-level match delivers a notification (D11).
/// `None` = true — warnings notify unless explicitly opted out.
/// Persisted in `trigger_thresholds`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notify_on_warning: Option<bool>,
/// (seconds)
#[serde(default)]
pub frequency: i64, // 1 minute
@ -321,11 +342,89 @@ impl TriggerCondition {
}
}
impl TriggerCondition {
/// How long a group of this alert may go unobserved before M-7 resolves it,
/// in microseconds, measured from `last_seen`.
///
/// Two schedule shapes, and they cannot share an implementation. A fixed
/// frequency is a constant, so `K × frequency` is the whole answer. A cron
/// alert's numeric `frequency` is **not** the cadence it runs at, and the
/// gap between consecutive fires is not even constant (weekday-only,
/// monthly, DST), so its deadline has to be read off the schedule itself —
/// anchored to this row's `last_seen` so it cannot drift between sweeps.
pub fn group_resolve_threshold_micros(&self, last_seen: i64, k: i64) -> i64 {
use crate::meta::alerts::grouping::{
cron_resolve_threshold_micros, resolve_threshold_micros,
};
if self.frequency_type != FrequencyType::Cron {
return resolve_threshold_micros(self.frequency, k);
}
// An unparseable expression or an out-of-range timestamp must never
// resolve a live group: saturating high leaves the row alone until the
// schedule can be read, while any finite fallback would resolve groups
// on a cadence nobody configured.
let (Ok(schedule), Some(anchor)) = (
Schedule::from_str(&self.cron),
chrono::DateTime::from_timestamp_micros(last_seen),
) else {
return i64::MAX;
};
// Same DST-aware offset resolution as `get_next_trigger_time_*`, so the
// sweep and the scheduler agree on when this alert actually fires.
let offset_minutes = match get_timezone_from_string(self.timezone.as_deref(), 0) {
Ok(tz) => get_offset_minutes_from_tz(&tz, anchor),
Err(_) => 0,
};
let Some(tz_offset) = FixedOffset::east_opt(offset_minutes * 60) else {
return i64::MAX;
};
let anchored = anchor.with_timezone(&tz_offset);
let occurrences = schedule
.after(&anchored)
.take(k.max(0) as usize)
.map(|d| d.timestamp_micros());
cron_resolve_threshold_micros(last_seen, occurrences, k)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct TriggerEvalResults {
pub data: Option<Vec<Map<String, Value>>>,
pub end_time: i64,
pub query_took: Option<i64>,
/// Severity of the matched threshold (`alerts_2.md` Feature 1).
/// `None` when nothing matched, or for evaluations with no level axis.
/// Always `Some` when `data` is `Some` for a condition-bearing module.
pub level: Option<level::AlertLevel>,
/// The value that was compared — row count, or the aggregate for
/// aggregation alerts. Recorded on the trigger record (T-9) so history can
/// show "112 vs 100". For count alerts this is a LOWER BOUND once the
/// search cap is reached (`alerts_2.md` §7.5).
pub actual_value: Option<f64>,
/// Which group/series produced `actual_value` ("host=b,region=eu"), for
/// grouped aggregation and PromQL alerts (T-9). `None` for count alerts —
/// a row count has no group identity.
pub group_label: Option<String>,
/// True when `actual_value` is a LOWER BOUND, not exact: the legacy
/// SingleQuery count path fetched exactly its cap, so the true count may
/// be higher (§7.5). History renders a `≥` prefix. Hybrid evaluations are
/// always exact.
pub value_is_lower_bound: bool,
/// Per-group view of this evaluation — `Some` only for an alert that opted
/// in to multi-alerts (M-9). `None` puts state persistence on exactly the
/// path it took before this feature existed.
///
/// Classification happens during evaluation rather than at persist time
/// because that is where the aggregation's own thresholds are in scope:
/// `TriggerCondition.threshold` is the group-COUNT gate for an aggregation
/// alert, so classifying against it there would compare aggregates to a
/// count.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_classification: Option<grouping::GroupClassification>,
}
#[derive(Clone, Default, Debug, Serialize, Deserialize, ToSchema, PartialEq)]
@ -359,6 +458,44 @@ pub struct QueryCondition {
pub sql: Option<String>,
pub promql: Option<String>, // (cpu usage / cpu total)
pub promql_condition: Option<Condition>, // value >= 80
/// WARNING value for the PromQL condition (alerts_2.md Feature 1).
///
/// A sibling field rather than a member of `Condition`, which is shared by
/// every filter in the product and must not grow alert-specific knobs.
/// Shares `promql_condition.operator` with critical. `None` = single-level.
///
/// Lenient deserialization: this arrives through
/// `CreateAlertRequestBody`'s `#[serde(flatten)]`, which buffers via
/// `Value`, where `arbitrary_precision` makes a number a map (D61).
/// Without it a FRACTIONAL warning is rejected while an integer one works
/// — found by end-to-end testing.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::meta::slo::lenient_f64::deserialize_opt"
)]
pub promql_warning_value: Option<f64>,
/// Per-group alerting for a PromQL alert (M-9), where a "group" is one
/// returned SERIES.
///
/// A sibling of `promql_condition` rather than a member of
/// [`Aggregation::multi_alert`], because a PromQL alert has no
/// `aggregation` at all — its grouping is expressed in the PromQL itself
/// (`sum by (pod) (…)`), not in a `group_by` column list.
///
/// The group key is the series' FULL label set. That is what a series'
/// identity already is in Prometheus, and it means the expression stays
/// the single place grouping is decided — a separate label picker could
/// only ever disagree with the `by (…)` clause beside it.
///
/// `#[serde(default)]` carries the same backward-compatibility guarantee
/// as its aggregation counterpart: an alert stored before this field
/// existed deserializes to `false` and keeps its collapsed evaluation
/// byte-for-byte. Nothing is inferred from the query returning several
/// series — that would change paging cadence for every existing PromQL
/// alert that happens to be unaggregated.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub promql_multi_alert: bool,
pub aggregation: Option<Aggregation>,
#[serde(default)]
pub vrl_function: Option<String>,
@ -366,6 +503,46 @@ pub struct QueryCondition {
pub search_event_type: Option<SearchEventType>,
#[serde(default)]
pub multi_time_range: Option<Vec<CompareHistoricData>>,
/// Feature 5 (D42): the SLO condition, when `query_type` is `Slo`.
///
/// Persisted in its own `alerts.query_slo_condition` column following the
/// `query_aggregation` precedent — deliberately not `trigger_thresholds`,
/// whose documented scope is threshold and level configuration only (D1).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slo_condition: Option<crate::meta::slo::condition::SloCondition>,
}
impl QueryCondition {
/// Whether this alert evaluates and pages per group.
///
/// The aggregation and PromQL families store the opt-in in different places
/// because they define a group differently — an aggregation alert by its
/// `group_by` columns, a PromQL alert by each returned series' labels.
/// Every caller downstream of evaluation cares only about the answer, so
/// asking through one accessor is what keeps a family from being silently
/// honoured by only half the dispatch path.
///
/// **SLO alerts answer `false`, and that is correct — do not "fix" it.**
/// `SloCondition.multi_alert` exists (SA-13) and `slo::evaluate` does read
/// every group when it is set, but only to pick the WORST one:
/// `evaluate_slo_alert` collapses the results into a single `level` +
/// `group_label` and never populates `group_classification`. Per-group
/// state rows are written only when a classification exists
/// (`persist_alert_run_state`), so an SLO alert has none, and SA-13's
/// "reuses Feature 3 verbatim" is not yet implemented.
///
/// Returning `true` here would therefore switch OFF the alert-level
/// delivery decision — silence windows, the escalation baseline,
/// `notify_on_warning` — with nothing per-group to replace it, because no
/// per-group dispatch runs. That is a regression, not a fix. When SA-13
/// lands, this arm changes at the same time as the dispatch that justifies
/// it. Pinned by `test_slo_alerts_deliberately_answer_false`.
pub fn multi_alert_enabled(&self) -> bool {
match self.query_type {
QueryType::PromQL => self.promql_multi_alert,
_ => self.aggregation.as_ref().is_some_and(|a| a.multi_alert),
}
}
}
impl MemorySize for QueryCondition {
@ -528,7 +705,36 @@ impl IntoIterator for ConditionList {
pub struct Aggregation {
pub group_by: Option<Vec<String>>,
pub function: AggFunction,
/// CRITICAL threshold. `having.value` is untyped (`serde_json::Value`) and
/// may be an int, a float, or a numeric string.
pub having: Condition,
/// WARNING threshold, sharing `having.operator` and `having.column` with
/// critical (alerts_2.md §4.4). `None` = single-level aggregation alert,
/// i.e. exactly the legacy behaviour. Stored as f64 because aggregate
/// values (averages, percentiles) are not integers.
/// Same lenient deserialization as `promql_warning_value`, for the same
/// reason (D61) — aggregate warnings are the field most likely to be
/// fractional.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::meta::slo::lenient_f64::deserialize_opt"
)]
pub warning_value: Option<f64>,
/// Opt-in to per-group evaluation — multi-alerts, `alerts_2.md` M-9/D26.
///
/// `#[serde(default)]` is the whole backward-compatibility guarantee: an
/// aggregation stored before this field existed cannot contain it, so it
/// deserializes to `false` and the alert keeps its legacy collapsed
/// evaluation byte-for-byte. Nothing is inferred from `group_by` being
/// present — that would silently change paging cadence and reset silence
/// fingerprints for every existing grouped alert.
///
/// Validated by [`grouping::validate_multi_alert`] (M-10): requires a
/// non-empty `group_by`, an orderable `having.operator`, and "any group"
/// count gates.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub multi_alert: bool,
}
impl MemorySize for Aggregation {
@ -613,6 +819,12 @@ pub enum QueryType {
SQL,
#[serde(rename = "promql")]
PromQL,
/// Feature 5 (D28). An SLO alert is an ordinary `alerts` row whose
/// condition reads precomputed SLO status rather than running a query —
/// which is what lets five alerts on one SLO cost five cheap status reads
/// and ZERO extra raw-data scans.
#[serde(rename = "slo")]
Slo,
}
impl std::fmt::Display for QueryType {
@ -621,6 +833,7 @@ impl std::fmt::Display for QueryType {
QueryType::Custom => write!(f, "custom"),
QueryType::SQL => write!(f, "sql"),
QueryType::PromQL => write!(f, "promql"),
QueryType::Slo => write!(f, "slo"),
}
}
}
@ -631,6 +844,7 @@ impl From<&str> for QueryType {
"custom" => QueryType::Custom,
"sql" => QueryType::SQL,
"promql" => QueryType::PromQL,
"slo" => QueryType::Slo,
_ => QueryType::Custom,
}
}
@ -2182,6 +2396,155 @@ mod test {
assert!(AggFunction::try_from("unknown").is_err());
}
// ── multi_alert_enabled: one question, two storage locations ────────────
fn agg_with_multi(multi: bool) -> Aggregation {
Aggregation {
group_by: Some(vec!["host".to_string()]),
function: AggFunction::Avg,
having: Condition {
column: "alert_agg_value".to_string(),
operator: Operator::GreaterThan,
value: serde_json::json!(90),
ignore_case: false,
},
warning_value: None,
multi_alert: multi,
}
}
#[test]
fn test_multi_alert_enabled_reads_the_aggregation_for_sql_alerts() {
let q = QueryCondition {
query_type: QueryType::SQL,
aggregation: Some(agg_with_multi(true)),
..Default::default()
};
assert!(q.multi_alert_enabled());
}
#[test]
fn test_multi_alert_enabled_reads_the_promql_flag_for_promql_alerts() {
let q = QueryCondition {
query_type: QueryType::PromQL,
promql_multi_alert: true,
..Default::default()
};
assert!(q.multi_alert_enabled());
}
/// The cross-family trap. A PromQL alert carrying a stray aggregation must
/// NOT be treated as per-group: its rows have no `group_by` columns, so the
/// aggregation extractor would hand every series the same empty label set
/// and collapse them into one group.
#[test]
fn test_a_promql_alert_ignores_an_aggregation_multi_alert_flag() {
let q = QueryCondition {
query_type: QueryType::PromQL,
promql_multi_alert: false,
aggregation: Some(agg_with_multi(true)),
..Default::default()
};
assert!(!q.multi_alert_enabled());
}
/// And the mirror: a SQL alert must not be switched on by the PromQL flag,
/// which nothing in its evaluation path would honour.
#[test]
fn test_a_sql_alert_ignores_the_promql_multi_alert_flag() {
let q = QueryCondition {
query_type: QueryType::SQL,
promql_multi_alert: true,
aggregation: Some(agg_with_multi(false)),
..Default::default()
};
assert!(!q.multi_alert_enabled());
}
/// SLO alerts must answer `false` until SA-13 actually lands.
///
/// `slo_condition.multi_alert` reads every group, but only so
/// `evaluate_slo_alert` can pick the worst; it never builds a
/// `GroupClassification`, and group state rows are written only when one
/// exists. So there is no per-group dispatch for an SLO alert — and
/// answering `true` would switch off the ALERT-level delivery decision
/// (silence, escalation baseline, notify_on_warning) with nothing to
/// replace it. This test is the tripwire for anyone who reads
/// `SloCondition.multi_alert` and assumes the accessor is missing a case.
#[test]
fn test_slo_alerts_deliberately_answer_false() {
let q = QueryCondition {
query_type: QueryType::Slo,
aggregation: None,
..Default::default()
};
assert!(
!q.multi_alert_enabled(),
"turning this true silences SLO alerts: it disables alert-level \
delivery gating, and no per-group dispatch runs for QueryType::Slo. \
Land SA-13's dispatch first, in the same change."
);
}
#[test]
fn test_multi_alert_enabled_is_false_by_default_for_every_query_type() {
for qt in [
QueryType::Custom,
QueryType::SQL,
QueryType::PromQL,
QueryType::Slo,
] {
let q = QueryCondition {
query_type: qt.clone(),
..Default::default()
};
assert!(!q.multi_alert_enabled(), "{qt} defaulted to enabled");
}
}
// ── Upgrade safety for the new flag ─────────────────────────────────────
/// THE guarantee (M-9/D26): every PromQL alert already stored was
/// serialized without this field. If it read back as anything but `false`,
/// all of them would switch to per-series evaluation on deploy — different
/// paging cadence, and every in-flight silence fingerprint invalidated.
#[test]
fn test_promql_multi_alert_defaults_to_false_when_absent_from_stored_json() {
let stored = serde_json::json!({
"type": "promql",
"promql": "up == 0",
});
let parsed: QueryCondition = serde_json::from_value(stored).expect("deserializable");
assert!(!parsed.promql_multi_alert);
assert!(!parsed.multi_alert_enabled());
}
/// `skip_serializing_if` keeps the field out of the payload when off, so
/// turning the feature on never rewrites rows that are not using it.
#[test]
fn test_promql_multi_alert_is_omitted_from_json_when_off() {
let q = QueryCondition {
query_type: QueryType::PromQL,
promql_multi_alert: false,
..Default::default()
};
let v = serde_json::to_value(&q).expect("serializable");
assert!(v.get("promql_multi_alert").is_none(), "{v}");
}
#[test]
fn test_promql_multi_alert_round_trips_when_on() {
let q = QueryCondition {
query_type: QueryType::PromQL,
promql_multi_alert: true,
..Default::default()
};
let v = serde_json::to_value(&q).expect("serializable");
assert_eq!(v.get("promql_multi_alert"), Some(&serde_json::json!(true)));
let back: QueryCondition = serde_json::from_value(v).expect("deserializable");
assert!(back.promql_multi_alert);
}
#[test]
fn test_query_type_display() {
assert_eq!(QueryType::Custom.to_string(), "custom");

View File

@ -0,0 +1,362 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Alert priority — Feature 2 (PT-1, PT-2).
//!
//! A **third** axis, distinct from the two Feature 1 established:
//!
//! * `RunOutcome` — did the evaluation fire?
//! * `AlertLevel` — how bad is it *right now*? (evaluated state)
//! * `AlertPriority` — how much do humans care about this alert? (**mutable** static configuration
//! — "static" contrasts it with *evaluated* state, it does NOT mean write-once: priority is
//! editable on any update, like name or description. PT-1.)
//!
//! Priority is **display + propagation only**: it filters/sorts the alert
//! list and is exposed to notification templates so receivers can route on
//! it. It must never influence evaluation, silence, delivery, or incident
//! severity — that is a separate, explicitly opted-into extension.
//!
//! Deliberately NOT `IncidentSeverity` (P1P4): different scale, different
//! concept, different lifecycle.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// How much humans care about this alert. P1 is the most urgent.
///
/// Serialized as an **integer** (15), which is both the storage
/// representation and the API shape; `Display` renders the familiar `"P3"`
/// form for UI and template substitution.
// `Ord` is deliberately NOT derived. Declaration order would make `P1 < P5`
// true, so a bare `a.priority > b.priority` at a call site reads exactly
// backwards ("greater" = less urgent). Sorting happens in SQL (PT-3), so
// nothing here needs it; compare via `is_more_urgent_than` or `to_i32`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(try_from = "u8", into = "u8")]
// NOTE: the wire form is an integer (see the serde attribute above). utoipa
// only accepts `value_type` at the FIELD level, so the `Alert.priority` field
// carries `#[schema(value_type = u8)]` — without it the generated OpenAPI
// would advertise a string enum and lie about the payload.
pub enum AlertPriority {
P1,
P2,
P3,
P4,
P5,
}
/// Why a priority value was rejected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PriorityError {
/// Not one of the five defined priorities.
OutOfRange(String),
}
impl AlertPriority {
/// Durable storage id for the `alerts.priority` column.
///
/// These are persisted — **never reorder or reuse**. P1 = 1 so that a
/// plain `ORDER BY priority ASC` surfaces the most urgent alerts first.
pub fn to_i32(&self) -> i32 {
match self {
Self::P1 => 1,
Self::P2 => 2,
Self::P3 => 3,
Self::P4 => 4,
Self::P5 => 5,
}
}
/// Inverse of [`Self::to_i32`]; `None` for any value outside 1..=5.
pub fn from_i32(v: i32) -> Option<Self> {
match v {
1 => Some(Self::P1),
2 => Some(Self::P2),
3 => Some(Self::P3),
4 => Some(Self::P4),
5 => Some(Self::P5),
_ => None,
}
}
/// `"P1"`..`"P5"` — the form shown in the UI and substituted into
/// notification templates.
pub fn as_str(&self) -> &'static str {
match self {
Self::P1 => "P1",
Self::P2 => "P2",
Self::P3 => "P3",
Self::P4 => "P4",
Self::P5 => "P5",
}
}
/// True when `self` is more urgent than `other` (P1 is most urgent).
///
/// Named rather than relying on `Ord`, because the natural integer
/// ordering is *inverted* with respect to urgency and a bare `<` at a
/// call site would read exactly backwards.
pub fn is_more_urgent_than(&self, other: Self) -> bool {
// Smaller storage id = more urgent (P1 = 1). This is the one place
// that inversion is allowed to be written out.
self.to_i32() < other.to_i32()
}
}
impl std::fmt::Display for AlertPriority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::fmt::Display for PriorityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OutOfRange(v) => {
write!(f, "invalid alert priority `{v}`: expected P1 through P5")
}
}
}
}
impl std::error::Error for PriorityError {}
impl std::str::FromStr for AlertPriority {
type Err = PriorityError;
/// Accepts `"P1"`/`"p1"` and the bare `"1"`, so query parameters and
/// template inputs both parse without the caller guessing which form.
fn from_str(s: &str) -> Result<Self, Self::Err> {
let token = s.trim();
// Accept an optional `P`/`p` prefix, then require exactly one digit
// in range. `strip_prefix` on a char slice keeps "PP1" and "P" from
// sneaking through, since what remains must still match exactly.
let digits = token.strip_prefix(['P', 'p']).unwrap_or(token);
match digits {
"1" => Ok(Self::P1),
"2" => Ok(Self::P2),
"3" => Ok(Self::P3),
"4" => Ok(Self::P4),
"5" => Ok(Self::P5),
// Report the TRIMMED token: the user sees what was parsed, not
// their incidental whitespace.
_ => Err(PriorityError::OutOfRange(token.to_string())),
}
}
}
impl TryFrom<u8> for AlertPriority {
type Error = PriorityError;
fn try_from(v: u8) -> Result<Self, Self::Error> {
Self::from_i32(v as i32).ok_or_else(|| PriorityError::OutOfRange(v.to_string()))
}
}
impl From<AlertPriority> for u8 {
fn from(p: AlertPriority) -> u8 {
p.to_i32() as u8
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
const ALL: [AlertPriority; 5] = [
AlertPriority::P1,
AlertPriority::P2,
AlertPriority::P3,
AlertPriority::P4,
AlertPriority::P5,
];
/// PT-2: storage ids are durable. This test exists to FAIL loudly if
/// anyone renumbers the enum — every persisted row would silently change
/// meaning.
#[test]
fn test_storage_ids_are_pinned_and_never_reordered() {
assert_eq!(AlertPriority::P1.to_i32(), 1);
assert_eq!(AlertPriority::P2.to_i32(), 2);
assert_eq!(AlertPriority::P3.to_i32(), 3);
assert_eq!(AlertPriority::P4.to_i32(), 4);
assert_eq!(AlertPriority::P5.to_i32(), 5);
}
#[test]
fn test_storage_id_round_trips() {
for p in ALL {
assert_eq!(AlertPriority::from_i32(p.to_i32()), Some(p));
}
}
/// Unset priority is represented by a NULL column / `None`, never by a
/// sentinel id — so 0 and 6 must not decode.
#[test]
fn test_out_of_range_ids_do_not_decode() {
for v in [-1, 0, 6, 99] {
assert_eq!(AlertPriority::from_i32(v), None, "id {v} must not decode");
}
}
/// P1 = 1 is deliberate: `ORDER BY priority ASC` must put the most urgent
/// alerts at the top of the list without a CASE expression (PT-3).
#[test]
fn test_ascending_storage_id_means_descending_urgency() {
let mut ids: Vec<i32> = ALL.iter().map(|p| p.to_i32()).collect();
ids.sort();
let sorted: Vec<AlertPriority> = ids
.into_iter()
.map(|i| AlertPriority::from_i32(i).unwrap())
.collect();
assert_eq!(sorted[0], AlertPriority::P1, "most urgent must sort first");
assert_eq!(sorted[4], AlertPriority::P5, "least urgent must sort last");
}
#[test]
fn test_urgency_comparison_is_not_the_integer_order() {
assert!(AlertPriority::P1.is_more_urgent_than(AlertPriority::P3));
assert!(!AlertPriority::P3.is_more_urgent_than(AlertPriority::P1));
// Equal priorities are not "more urgent" than each other.
assert!(!AlertPriority::P2.is_more_urgent_than(AlertPriority::P2));
// Guard against a naive `self < other` implementation: P1 has the
// SMALLEST id but the HIGHEST urgency.
assert!(AlertPriority::P1.to_i32() < AlertPriority::P5.to_i32());
assert!(AlertPriority::P1.is_more_urgent_than(AlertPriority::P5));
}
/// PT-4: the `"P3"` form is what reaches templates and the UI.
#[test]
fn test_display_uses_the_p_form() {
assert_eq!(AlertPriority::P1.as_str(), "P1");
assert_eq!(AlertPriority::P5.to_string(), "P5");
for p in ALL {
assert_eq!(p.to_string(), p.as_str());
}
}
#[test]
fn test_parses_both_p_form_and_bare_integer() {
assert_eq!(AlertPriority::from_str("P1").unwrap(), AlertPriority::P1);
assert_eq!(AlertPriority::from_str("p2").unwrap(), AlertPriority::P2);
assert_eq!(AlertPriority::from_str("3").unwrap(), AlertPriority::P3);
assert_eq!(AlertPriority::from_str(" P4 ").unwrap(), AlertPriority::P4);
}
#[test]
fn test_parse_rejects_junk_and_names_the_offender() {
for bad in ["", "P0", "P6", "0", "6", "banana", "P", "-1", "PP1", "1.0"] {
let err = AlertPriority::from_str(bad).unwrap_err();
// Match the BACKTICK-QUOTED form. A bare `contains(bad)` is
// satisfiable by the message's own "expected P1 through P5" text
// for inputs like "P", which made this assertion vacuous.
assert!(
err.to_string().contains(&format!("`{bad}`")),
"error for `{bad}` must name the offending value, got: {err}"
);
}
}
/// Defect #4: the reported value is the TRIMMED token, so the user sees
/// what was actually parsed rather than their incidental whitespace.
#[test]
fn test_parse_error_reports_the_trimmed_token() {
let err = AlertPriority::from_str(" P9 ").unwrap_err();
assert_eq!(err, PriorityError::OutOfRange("P9".to_string()));
}
/// Pins the equivalence that makes SQL `ORDER BY priority ASC` correct.
///
/// NOTE: this does NOT — and cannot — enforce that `Ord` stays underived.
/// Stable Rust has no negative trait bound, so re-adding the derive would
/// fail nothing here. The prohibition lives as a comment on the type;
/// this test only guards the id/urgency relationship it rests on.
#[test]
fn test_smaller_storage_id_means_more_urgent() {
// If `Ord` were derived, this would be the tempting (and wrong) way to
// ask "is a more urgent than b" — it must be written explicitly.
assert!(AlertPriority::P1.is_more_urgent_than(AlertPriority::P2));
assert_eq!(
AlertPriority::P1.to_i32() < AlertPriority::P2.to_i32(),
AlertPriority::P1.is_more_urgent_than(AlertPriority::P2),
"smaller id == more urgent; this equivalence is what makes SQL ORDER BY ASC correct"
);
}
/// Serde is the INTEGER form: it matches the storage column, so the API
/// and the DB cannot drift apart.
///
/// Every value is exercised because serde goes through `TryFrom<u8>` /
/// `From<AlertPriority> for u8` — implementations SEPARATE from
/// `to_i32`/`from_i32`. Testing one value would leave a wrong mapping for
/// P1, P4 or P5 undetected.
#[test]
fn test_serializes_as_an_integer_for_every_value() {
for (p, n) in ALL.iter().zip(1u8..=5) {
let json = serde_json::to_string(p).unwrap();
assert_eq!(json, n.to_string(), "{p:?} must serialize as {n}");
let back: AlertPriority = serde_json::from_str(&n.to_string()).unwrap();
assert_eq!(back, *p, "{n} must deserialize to {p:?}");
}
}
/// The serde path and the storage path must agree — they are different
/// code, and a divergence would silently corrupt rows on write.
#[test]
fn test_serde_and_storage_conversions_agree() {
for p in ALL {
let via_serde: u8 = p.into();
assert_eq!(
i32::from(via_serde),
p.to_i32(),
"serde and storage ids disagree for {p:?}"
);
}
}
#[test]
fn test_serde_rejects_out_of_range() {
assert!(serde_json::from_str::<AlertPriority>("0").is_err());
assert!(serde_json::from_str::<AlertPriority>("6").is_err());
}
/// Pins the serde PATTERN that `Alert.priority` must adopt: `None`
/// round-trips as *absent*, never as `0` or `null`.
///
/// SCOPE WARNING: this uses a stand-in struct, so it proves nothing about
/// the real `Alert` — it passes today precisely BECAUSE `Alert` has no
/// `priority` field yet. The production contract (field present, correct
/// default, survives an update round-trip) belongs to the storage/CRUD
/// layer and is tracked separately. Do not read this as covering it.
#[test]
fn test_option_pattern_omits_unset_priority() {
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Holder {
#[serde(default, skip_serializing_if = "Option::is_none")]
priority: Option<AlertPriority>,
}
let none = Holder { priority: None };
assert_eq!(serde_json::to_string(&none).unwrap(), "{}");
let parsed: Holder = serde_json::from_str("{}").unwrap();
assert_eq!(parsed, none);
let some = Holder {
priority: Some(AlertPriority::P2),
};
assert_eq!(serde_json::to_string(&some).unwrap(), r#"{"priority":2}"#);
}
}

View File

@ -0,0 +1,607 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Durable per-alert run state — Part IV of `alerts.md`.
//!
//! This module holds the *pure* decision logic that governs the `alert_states`
//! and `alert_state_transitions` tables. The tables themselves live in
//! `infra::table::alert_states`; everything that decides **whether** and **what**
//! to write lives here so it is unit-testable without a database.
use serde::{Deserialize, Serialize};
use crate::meta::{alerts::level::AlertLevel, self_reporting::usage::RunOutcome};
/// `group_key` of the per-alert rollup row. Grouped monitors additionally get
/// one row per label set; the rollup row is what list views read.
pub const ROLLUP_GROUP_KEY: &str = "";
/// Current run state for one `(alert_id, group_key)` pair.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AlertState {
pub alert_id: String,
pub group_key: String,
/// `None` = never evaluated. Distinct from any real outcome.
pub last_outcome: Option<RunOutcome>,
pub last_outcome_at: Option<i64>,
/// When `last_outcome` last *changed*. Stable across repeated same-outcome runs.
pub since: Option<i64>,
// ── Level axis (alerts_2.md §7.2) ───────────────────────────────────────
// Independent of the outcome axis above: `firing -> notify_failed` moves
// `since` while the level (and `level_since`) stay put.
/// Severity of the last successful classification. `None` = no level
/// (single-level legacy alert, or never classified).
pub level: Option<AlertLevel>,
/// When `level` last *changed* — powers "critical for 20 minutes".
pub level_since: Option<i64>,
/// When `level` was last *computed* from a successful evaluation.
/// Freshness, not change-time: composite staleness (§6.4) runs on this, so
/// an alert erroring every minute cannot look fresh while its level rots.
pub level_at: Option<i64>,
/// Last evaluation that actually *included* this group (M-7).
///
/// A separate clock from `last_outcome_at` on purpose. Resolving a vanished
/// group records a real outcome at the resolution time, so `last_outcome_at`
/// must advance — but the group was not seen then, so `last_seen` must not.
/// Overloading one field for both would either make the recovery row claim
/// a timestamp at which the group was still firing, or reset the
/// disappearance clock so the row could never be reaped.
pub last_seen: Option<i64>,
/// Rendered labels for UI and templates (M-4). `None` on the rollup row.
pub group_labels: Option<String>,
/// **Rollup row only**: the true number of groups the last evaluation
/// observed, before the M-6 cap truncated them. `None` on group rows.
///
/// Persisted rather than recomputed because the cap-overflow warning has to
/// render from the stored row on list and detail views, long after the
/// evaluation that produced it. The retained row count cannot substitute —
/// it is post-cap, so an overflowing alert would report "500 of 500" and be
/// indistinguishable from one that never overflowed.
pub groups_observed: Option<usize>,
/// **Rollup row only**: how many of `groups_observed` were firing
/// (warning-or-worse), before the M-6 cap. `None` on group rows.
///
/// Counted pre-cap for the same reason as `groups_observed`, and stored
/// separately from it because the "N of M groups firing" chip cannot be
/// derived from the retained rows: past the cap those are truncated, so
/// counting them under-reports exactly when the number matters most.
pub groups_firing: Option<usize>,
/// Whether `groups_observed` is a `≥` lower bound rather than exact — the
/// bounded fetch page came back full, so more groups may exist below it
/// (§5.3). `None` = written before this was tracked.
///
/// Persisted rather than recomputed: exactness cannot be recovered later
/// from the count and a cap that is mutable config.
pub groups_observed_is_lower_bound: Option<bool>,
/// Whether `groups_firing` is a `≥` lower bound.
///
/// Tracked separately from `groups_observed_is_lower_bound` because the two
/// genuinely diverge: a full page that reached healthy groups has seen
/// *every* firing group (the fetch is severity-ordered), so the firing
/// count is exact while the observed count is not.
pub groups_firing_is_lower_bound: Option<bool>,
// ── Per-group delivery state (alerts_2.md §5.5 MN-2) ────────────────────
// §7.1's `delivery_decision` fed per group. Deliberately NOT named
// `delivery_silenced_until`: that is the ScheduledTriggerData field
// non-multi alerts keep using — the state row is the per-group home.
//
// Written ONLY by the delivery callbacks (`dispatch::delivery_success_update`),
// never by evaluation: `apply_outcome` carries both forward untouched. That
// one-writer rule is what makes MN-6 hold — a failed send leaves them
// unadvanced, so the group re-qualifies on the next evaluation.
/// Per-group silence window: suppress same-level re-delivery until this
/// instant (micros). `None` = not silenced.
pub silenced_until: Option<i64>,
/// The level of this group's last *successful* delivery — what
/// `delivery_decision` measures escalation against.
pub last_notified_level: Option<AlertLevel>,
}
impl AlertState {
/// A row that has never been evaluated.
pub fn empty(alert_id: &str, group_key: &str) -> Self {
Self {
alert_id: alert_id.to_string(),
group_key: group_key.to_string(),
last_outcome: None,
last_outcome_at: None,
since: None,
level: None,
level_since: None,
level_at: None,
last_seen: None,
group_labels: None,
groups_observed: None,
groups_firing: None,
groups_observed_is_lower_bound: None,
groups_firing_is_lower_bound: None,
silenced_until: None,
last_notified_level: None,
}
}
/// True when the last recorded outcome was a firing one. `None` (never
/// evaluated) is not firing.
pub fn is_firing(&self) -> bool {
self.last_outcome.as_ref().is_some_and(|o| o.is_firing())
}
}
/// An append-only state change, written to `alert_state_transitions`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StateTransition {
pub alert_id: String,
pub group_key: String,
/// `None` on the first evaluation of an alert.
pub from_outcome: Option<RunOutcome>,
pub to_outcome: RunOutcome,
/// Level before/after. `None` when the alert has no level axis, or when
/// this transition was driven purely by an outcome change.
pub from_level: Option<AlertLevel>,
pub to_level: Option<AlertLevel>,
pub at: i64,
/// Observed value at transition time — the source for per-group history
/// (M-8, §7.2). `None` where no value was observed, which includes the
/// disappearance transition: a group that stopped being returned has no
/// value, and recording 0 would render as a real measurement.
pub value: Option<f64>,
/// Rendered labels, duplicated from the state row on purpose: the state row
/// is reaped after the grace period (M-7) while transitions are retained,
/// and `group_key` is a hash. Without this, history outlives the only thing
/// that could say which host it was about.
pub group_labels: Option<String>,
}
/// What `apply_outcome` decided to do about an observed evaluation result.
#[derive(Clone, Debug, PartialEq)]
pub struct StateUpdate {
/// The row to persist. `None` means "write nothing" — the observation is
/// not allowed to overwrite existing state.
pub state: Option<AlertState>,
/// Emitted only when the outcome actually changed.
pub transition: Option<StateTransition>,
}
impl StateUpdate {
/// A decision to persist nothing at all.
pub fn noop() -> Self {
Self {
state: None,
transition: None,
}
}
pub fn is_noop(&self) -> bool {
self.state.is_none() && self.transition.is_none()
}
}
/// Whether an observed outcome is allowed to overwrite stored state.
///
/// `Skipped` means the alert was never evaluated (silenced, paused, org
/// deleting). Letting it overwrite would erase a real firing state, so skipped
/// runs are dropped entirely.
pub fn should_persist(outcome: &RunOutcome) -> bool {
!matches!(outcome, RunOutcome::Skipped)
}
/// Fold an observed outcome into the previous state.
///
/// `alert_id` and `group_key` are always supplied by the caller — they must not
/// be recovered from `prev`, which is `None` on an alert's first ever
/// evaluation. Deriving them would write identity-less rows that can never be
/// joined back to their alert.
///
/// - `Skipped` observations are dropped (see [`should_persist`]).
/// - A changed outcome moves `since` and emits a transition.
/// - A repeated outcome refreshes `last_outcome_at` but leaves `since` alone and emits no
/// transition — this is what keeps writes transition-bounded.
pub fn apply_outcome(
alert_id: &str,
group_key: &str,
prev: Option<&AlertState>,
outcome: RunOutcome,
level: Option<AlertLevel>,
at: i64,
) -> StateUpdate {
if !should_persist(&outcome) {
return StateUpdate::noop();
}
debug_assert!(
!alert_id.is_empty(),
"apply_outcome requires a non-empty alert_id"
);
let alert_id = alert_id.to_string();
let group_key = group_key.to_string();
// ── Outcome axis ────────────────────────────────────────────────────────
let previous_outcome = prev.and_then(|p| p.last_outcome.clone());
let outcome_changed = previous_outcome.as_ref() != Some(&outcome);
let since = if outcome_changed {
Some(at)
} else {
prev.and_then(|p| p.since).or(Some(at))
};
// ── Level axis (independent of the above) ───────────────────────────────
let previous_level = prev.and_then(|p| p.level);
let (level, level_since, level_at, level_changed) = match level {
// No level computed this run — e.g. a query error. Carry the whole
// level axis forward untouched, including freshness: an evaluation
// that observed nothing must not make the level look fresh.
None => (
previous_level,
prev.and_then(|p| p.level_since),
prev.and_then(|p| p.level_at),
false,
),
Some(new_level) => {
let changed = previous_level != Some(new_level);
let level_since = if changed {
Some(at)
} else {
prev.and_then(|p| p.level_since).or(Some(at))
};
// Freshness always advances on a successful classification.
(Some(new_level), level_since, Some(at), changed)
}
};
let state = AlertState {
alert_id: alert_id.clone(),
group_key: group_key.clone(),
last_outcome: Some(outcome.clone()),
last_outcome_at: Some(at),
since,
level,
level_since,
level_at,
// An observation, by definition, saw the group.
last_seen: Some(at),
group_labels: prev.and_then(|p| p.group_labels.clone()),
// Set by the per-group planner on the rollup row only.
groups_observed: prev.and_then(|p| p.groups_observed),
groups_firing: prev.and_then(|p| p.groups_firing),
groups_observed_is_lower_bound: prev.and_then(|p| p.groups_observed_is_lower_bound),
groups_firing_is_lower_bound: prev.and_then(|p| p.groups_firing_is_lower_bound),
// Delivery state is the callbacks' to write; evaluation only carries it.
silenced_until: prev.and_then(|p| p.silenced_until),
last_notified_level: prev.and_then(|p| p.last_notified_level),
};
// A change on EITHER axis is a transition — an escalation while still
// `firing` must be recorded, as must a delivery failure at a steady level.
let transition = (outcome_changed || level_changed).then_some(StateTransition {
alert_id,
group_key,
from_outcome: previous_outcome,
to_outcome: outcome,
from_level: previous_level,
to_level: level,
at,
// Filled by the per-group planner, which is the only caller that has
// an observed value and a label set.
value: None,
group_labels: None,
});
StateUpdate {
state: Some(state),
transition,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn prev(outcome: RunOutcome, at: i64, since: i64) -> AlertState {
AlertState {
alert_id: "alert-1".to_string(),
group_key: ROLLUP_GROUP_KEY.to_string(),
last_outcome: Some(outcome),
last_outcome_at: Some(at),
since: Some(since),
level: None,
level_since: None,
level_at: None,
last_seen: Some(at),
group_labels: None,
groups_observed: None,
groups_firing: None,
groups_observed_is_lower_bound: None,
groups_firing_is_lower_bound: None,
silenced_until: None,
last_notified_level: None,
}
}
#[test]
fn test_rollup_group_key_is_empty_string() {
assert_eq!(ROLLUP_GROUP_KEY, "");
}
#[test]
fn test_empty_state_is_never_evaluated() {
let s = AlertState::empty("alert-1", ROLLUP_GROUP_KEY);
assert_eq!(s.last_outcome, None);
assert_eq!(s.last_outcome_at, None);
assert_eq!(s.since, None);
assert!(!s.is_firing());
}
#[test]
fn test_is_firing_tracks_run_outcome() {
assert!(prev(RunOutcome::Firing, 10, 10).is_firing());
// notify_failed still fired — see RunOutcome::is_firing.
assert!(prev(RunOutcome::NotifyFailed, 10, 10).is_firing());
assert!(!prev(RunOutcome::Normal, 10, 10).is_firing());
assert!(!prev(RunOutcome::Error, 10, 10).is_firing());
}
// ── should_persist ──────────────────────────────────────────────────────
#[test]
fn test_should_persist_rejects_only_skipped() {
assert!(!should_persist(&RunOutcome::Skipped));
assert!(should_persist(&RunOutcome::Firing));
assert!(should_persist(&RunOutcome::Normal));
assert!(should_persist(&RunOutcome::Succeeded));
assert!(should_persist(&RunOutcome::Error));
assert!(should_persist(&RunOutcome::NotifyFailed));
}
// ── apply_outcome ───────────────────────────────────────────────────────
#[test]
fn test_skipped_never_overwrites_existing_state() {
let existing = prev(RunOutcome::Firing, 100, 100);
let update = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&existing),
RunOutcome::Skipped,
None,
200,
);
assert!(
update.is_noop(),
"a silenced run must not erase a firing state"
);
}
#[test]
fn test_skipped_on_fresh_alert_writes_nothing() {
let update = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
None,
RunOutcome::Skipped,
None,
100,
);
assert!(update.is_noop());
}
#[test]
fn test_first_evaluation_creates_state_and_transition() {
let update = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
None,
RunOutcome::Firing,
None,
100,
);
let state = update.state.expect("first evaluation must persist state");
assert_eq!(state.last_outcome, Some(RunOutcome::Firing));
assert_eq!(state.last_outcome_at, Some(100));
assert_eq!(state.since, Some(100));
assert_eq!(state.group_key, ROLLUP_GROUP_KEY);
// Identity must come from the caller, not from `prev` (which is None
// here). An empty alert_id would orphan the row permanently.
assert_eq!(
state.alert_id, "alert-1",
"first-evaluation rows must carry their alert_id"
);
let t = update
.transition
.expect("first evaluation is a transition from nothing");
assert_eq!(t.from_outcome, None);
assert_eq!(t.to_outcome, RunOutcome::Firing);
assert_eq!(t.at, 100);
assert_eq!(t.alert_id, "alert-1");
}
#[test]
fn test_repeated_same_outcome_emits_no_transition() {
let existing = prev(RunOutcome::Normal, 100, 100);
let update = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&existing),
RunOutcome::Normal,
None,
200,
);
let state = update.state.expect("state should still refresh");
assert_eq!(
state.last_outcome_at,
Some(200),
"last_outcome_at tracks the latest run"
);
assert_eq!(
state.since,
Some(100),
"since must NOT move when the outcome is unchanged"
);
assert!(
update.transition.is_none(),
"unchanged outcome must not write a transition row"
);
}
#[test]
fn test_outcome_change_moves_since_and_emits_transition() {
let existing = prev(RunOutcome::Normal, 100, 50);
let update = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&existing),
RunOutcome::Firing,
None,
200,
);
let state = update.state.unwrap();
assert_eq!(state.last_outcome, Some(RunOutcome::Firing));
assert_eq!(state.since, Some(200), "since moves on a real change");
let t = update.transition.expect("changed outcome must transition");
assert_eq!(t.from_outcome, Some(RunOutcome::Normal));
assert_eq!(t.to_outcome, RunOutcome::Firing);
assert_eq!(t.at, 200);
}
#[test]
fn test_recovery_transition_is_recorded() {
let existing = prev(RunOutcome::Firing, 100, 100);
let update = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&existing),
RunOutcome::Normal,
None,
300,
);
let t = update
.transition
.expect("firing -> normal is the recovery event");
assert_eq!(t.from_outcome, Some(RunOutcome::Firing));
assert_eq!(t.to_outcome, RunOutcome::Normal);
assert!(!update.state.unwrap().is_firing());
}
/// `firing` -> `notify_failed` is a change in outcome even though both are
/// firing states, so it transitions.
#[test]
fn test_firing_to_notify_failed_transitions() {
let existing = prev(RunOutcome::Firing, 100, 100);
let update = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&existing),
RunOutcome::NotifyFailed,
None,
200,
);
let state = update.state.unwrap();
assert!(state.is_firing(), "notify_failed is still a firing state");
assert_eq!(state.since, Some(200));
assert!(update.transition.is_some());
}
#[test]
fn test_state_preserves_identity_from_previous_row() {
let existing = prev(RunOutcome::Normal, 100, 100);
let update = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&existing),
RunOutcome::Firing,
None,
200,
);
let state = update.state.unwrap();
assert_eq!(state.alert_id, "alert-1");
assert_eq!(state.group_key, ROLLUP_GROUP_KEY);
let t = update.transition.unwrap();
assert_eq!(t.alert_id, "alert-1");
assert_eq!(t.group_key, ROLLUP_GROUP_KEY);
}
#[test]
fn test_evaluation_carries_delivery_state_forward_untouched() {
// §5.5 MN-2/MN-6: delivery state has ONE writer — the delivery
// callbacks. If `apply_outcome` reset these on every evaluation, each
// run would erase the silence window and the alert would page every
// cycle regardless of silence; if it defaulted them on a fresh clone,
// same result.
let mut previous = prev(RunOutcome::Firing, 100, 100);
previous.silenced_until = Some(9_999);
previous.last_notified_level = Some(AlertLevel::Critical);
let update = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&previous),
RunOutcome::Firing,
Some(AlertLevel::Critical),
200,
);
let state = update.state.expect("an observed run writes state");
assert_eq!(state.silenced_until, Some(9_999));
assert_eq!(state.last_notified_level, Some(AlertLevel::Critical));
}
#[test]
fn test_per_group_state_is_keyed_independently() {
let pod_a = AlertState {
alert_id: "alert-1".to_string(),
group_key: "pod=a".to_string(),
last_outcome: Some(RunOutcome::Firing),
last_outcome_at: Some(100),
since: Some(100),
level: None,
level_since: None,
level_at: None,
last_seen: Some(100),
group_labels: None,
groups_observed: None,
groups_firing: None,
groups_observed_is_lower_bound: None,
groups_firing_is_lower_bound: None,
silenced_until: None,
last_notified_level: None,
};
let update = apply_outcome(
"alert-1",
"pod=a",
Some(&pod_a),
RunOutcome::Normal,
None,
200,
);
let state = update.state.unwrap();
assert_eq!(
state.group_key, "pod=a",
"per-group rows must keep their own key"
);
assert_eq!(update.transition.unwrap().group_key, "pod=a");
}
}

View File

@ -0,0 +1,298 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Level tracking on alert state — §7.2 of `alerts_2.md`.
//!
//! TDD: tests only. Extends the shipped `alerts::state` module (Part IV of
//! `alerts.md`) with the level axis. `apply_outcome` gains a level parameter
//! and `AlertState` gains `level` + `level_since`.
#[cfg(test)]
mod tests {
use crate::meta::{
alerts::{
level::AlertLevel,
state::{AlertState, ROLLUP_GROUP_KEY, apply_outcome},
},
self_reporting::usage::RunOutcome,
};
/// Existing state at a given outcome+level, with independent `since` values.
fn existing(
outcome: RunOutcome,
outcome_since: i64,
level: AlertLevel,
level_since: i64,
) -> AlertState {
AlertState {
alert_id: "alert-1".to_string(),
group_key: ROLLUP_GROUP_KEY.to_string(),
last_outcome: Some(outcome),
last_outcome_at: Some(outcome_since),
since: Some(outcome_since),
level: Some(level),
level_since: Some(level_since),
// Freshness: when the level was last COMPUTED (§7.6). Starts equal
// to level_since; diverges as same-level runs refresh it.
level_at: Some(level_since),
last_seen: Some(outcome_since),
group_labels: None,
groups_observed: None,
groups_firing: None,
groups_observed_is_lower_bound: None,
groups_firing_is_lower_bound: None,
silenced_until: None,
last_notified_level: None,
}
}
// ── The two axes must move independently ────────────────────────────────
// This is the defect caught reviewing alerts_2.md: reusing one `since`
// column for both outcome and level silently resets "critical for 20
// minutes" whenever the OUTCOME changes but the level does not.
#[test]
fn test_outcome_change_at_same_level_does_not_move_level_since() {
// firing -> notify_failed: the delivery broke, the severity did not.
let prev = existing(RunOutcome::Firing, 100, AlertLevel::Critical, 100);
let u = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&prev),
RunOutcome::NotifyFailed,
Some(AlertLevel::Critical),
500,
);
let s = u.state.expect("outcome change must persist");
assert_eq!(
s.since,
Some(500),
"outcome `since` moves on outcome change"
);
assert_eq!(
s.level_since,
Some(100),
"level_since must NOT move — it has been Critical since 100"
);
}
#[test]
fn test_level_change_at_same_outcome_moves_only_level_since() {
// Warning -> Critical while still `firing`: escalation.
let prev = existing(RunOutcome::Firing, 100, AlertLevel::Warning, 200);
let u = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&prev),
RunOutcome::Firing,
Some(AlertLevel::Critical),
600,
);
let s = u.state.expect("level change must persist");
assert_eq!(
s.since,
Some(100),
"outcome is unchanged (still firing), so its `since` holds"
);
assert_eq!(s.level_since, Some(600), "level_since moves on escalation");
assert_eq!(s.level, Some(AlertLevel::Critical));
}
#[test]
fn test_first_evaluation_sets_both_since_values() {
let u = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
None,
RunOutcome::Firing,
Some(AlertLevel::Warning),
100,
);
let s = u.state.unwrap();
assert_eq!(s.since, Some(100));
assert_eq!(s.level_since, Some(100));
assert_eq!(s.level_at, Some(100), "first computation sets freshness");
assert_eq!(s.level, Some(AlertLevel::Warning));
assert_eq!(s.alert_id, "alert-1", "identity comes from the caller");
}
// ── Transitions carry the level change ──────────────────────────────────
#[test]
fn test_escalation_emits_a_transition_with_both_levels() {
let prev = existing(RunOutcome::Firing, 100, AlertLevel::Warning, 100);
let u = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&prev),
RunOutcome::Firing,
Some(AlertLevel::Critical),
300,
);
let t = u
.transition
.expect("a level change is a transition even when the outcome is unchanged");
assert_eq!(t.from_level, Some(AlertLevel::Warning));
assert_eq!(t.to_level, Some(AlertLevel::Critical));
assert_eq!(t.at, 300);
}
#[test]
fn test_recovery_to_ok_emits_a_transition() {
let prev = existing(RunOutcome::Firing, 100, AlertLevel::Critical, 100);
let u = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&prev),
RunOutcome::Normal,
Some(AlertLevel::Ok),
900,
);
let t = u.transition.expect("recovery is a transition");
assert_eq!(t.from_level, Some(AlertLevel::Critical));
assert_eq!(t.to_level, Some(AlertLevel::Ok));
assert_eq!(u.state.unwrap().level_since, Some(900));
}
#[test]
fn test_repeated_same_level_and_outcome_emits_no_transition() {
// The transition-bounded write property from Part IV must survive the
// level axis: a steady Critical writes no new transition rows.
let prev = existing(RunOutcome::Firing, 100, AlertLevel::Critical, 100);
let u = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&prev),
RunOutcome::Firing,
Some(AlertLevel::Critical),
700,
);
assert!(
u.transition.is_none(),
"unchanged outcome AND level must not write a transition"
);
let s = u.state.expect("state still refreshes");
assert_eq!(s.last_outcome_at, Some(700), "freshness advances");
assert_eq!(s.since, Some(100));
assert_eq!(s.level_since, Some(100));
}
// ── skipped still never overwrites (Part IV rule, now with a level) ─────
#[test]
fn test_skipped_does_not_erase_level() {
let prev = existing(RunOutcome::Firing, 100, AlertLevel::Critical, 100);
let u = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&prev),
RunOutcome::Skipped,
None,
900,
);
assert!(
u.is_noop(),
"a silenced run must not erase a Critical level any more than it erases a firing outcome"
);
}
// ── Level is optional: single-level alerts pre-Feature-1 ────────────────
#[test]
fn test_none_level_is_accepted_for_alerts_without_levels() {
// Alerts written before Feature 1, or non-condition modules, have no
// level. That must not be conflated with Ok.
let u = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
None,
RunOutcome::Firing,
None,
100,
);
let s = u.state.unwrap();
assert_eq!(s.level, None, "no level is distinct from AlertLevel::Ok");
assert_eq!(s.level_since, None);
}
// ── level_at: the freshness clock composites depend on (§7.6) ───────────
#[test]
fn test_error_outcome_preserves_level_and_does_not_refresh_level_at() {
// A query error made no valid observation: the level axis must be
// completely untouched — value, since, AND freshness. If level_at were
// refreshed here, a child erroring every minute would look "fresh" to
// composites while its level rots (the exact bug §6.4 guards against).
let prev = existing(RunOutcome::Firing, 100, AlertLevel::Critical, 100);
let u = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&prev),
RunOutcome::Error,
None, // the caller has no level to offer — the query failed
900,
);
let s = u.state.expect("the outcome axis still records the error");
assert_eq!(s.last_outcome, Some(RunOutcome::Error));
assert_eq!(s.since, Some(900), "outcome changed firing -> error");
assert_eq!(
s.level,
Some(AlertLevel::Critical),
"error must not erase the last known level"
);
assert_eq!(s.level_since, Some(100));
assert_eq!(
s.level_at,
Some(100),
"freshness must NOT advance on an evaluation that computed nothing"
);
}
#[test]
fn test_successful_same_level_run_refreshes_level_at_only() {
// Steady Critical: freshness advances, change-time does not.
let prev = existing(RunOutcome::Firing, 100, AlertLevel::Critical, 100);
let u = apply_outcome(
"alert-1",
ROLLUP_GROUP_KEY,
Some(&prev),
RunOutcome::Firing,
Some(AlertLevel::Critical),
700,
);
let s = u.state.unwrap();
assert_eq!(s.level_at, Some(700), "recomputed this run -> fresh");
assert_eq!(s.level_since, Some(100), "unchanged level -> since holds");
assert!(u.transition.is_none());
}
#[test]
fn test_is_firing_reads_outcome_not_level() {
// `AlertState::is_firing` predates levels and must keep answering the
// OUTCOME question, so existing callers do not silently change meaning.
let s = existing(RunOutcome::Firing, 100, AlertLevel::Warning, 100);
assert!(s.is_firing());
let s = existing(RunOutcome::Normal, 100, AlertLevel::Ok, 100);
assert!(!s.is_firing());
}
}

View File

@ -0,0 +1,634 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Alert tags — Feature 2 (PT-6, PT-7, PT-8).
//!
//! Tags are the **selection primitive**: normalized, validated, and used to
//! filter the alert list and (later) to scope composite membership and
//! muting. They are NOT `context_attributes` — that field is free-form KV
//! shipped into notification payloads, with no validation and a different
//! purpose. The two stay separate.
//!
//! Format: bare (`prod`) or `key:value` (`service:checkout`).
//!
//! **Unicode-aware (D22).** We have international users, so `café`,
//! `münchen` and `日本` are valid tags: a tag starts with a Unicode *letter*
//! and its body is Unicode alphanumerics plus `_ - . / :`.
//!
//! Normalization **repairs** rather than rejects wherever the intent is
//! unambiguous — case and surrounding whitespace are fixed silently, because
//! rejecting `Prod` would be a papercut with no upside. Anything that changes
//! meaning (illegal characters, a leading digit, over-length) is an error
//! that names the offending tag.
//!
//! **Deliberate divergence from Datadog (D22):** they silently rewrite
//! unsupported characters to `_`. We reject, because a silent rewrite changes
//! the tag's *selector identity* — a filter that used to match quietly stops
//! matching. Import-time normalization belongs in the importer, where a
//! transformation is expected and can be reported to the user.
use std::collections::HashSet;
/// Longest single tag, in **characters** (`chars().count()`), not bytes.
///
/// With Unicode tags (D22) the two differ — `日本語` is 3 characters but 9
/// bytes — so byte-length would silently impose a much shorter limit on
/// non-Latin scripts.
pub const MAX_TAG_LEN: usize = 200;
/// Most tags on one alert, measured on what would be STORED (i.e. after
/// de-duplication). Our own operational cap — unbounded tag lists are a
/// payload and UI hazard, and nothing legitimate needs more.
pub const MAX_TAGS: usize = 64;
/// Hard bound on the RAW input length, checked before any normalization.
///
/// [`MAX_TAGS`] is measured after de-duplication, which means a caller could
/// otherwise force unbounded lowercasing/validating work with a giant list of
/// duplicates before being rejected. Generous enough that no legitimate
/// request trips it.
pub const MAX_INPUT_TAGS: usize = MAX_TAGS * 10;
/// Why a tag list was rejected. Every variant carries the offending tag so
/// the API can tell the user exactly which one to fix (PT-7).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TagError {
/// Does not begin with a Unicode letter.
MustStartWithLetter(String),
/// Contains a character that is neither a Unicode alphanumeric nor one of
/// `_ - . / :`.
IllegalCharacter { tag: String, ch: char },
/// Longer than [`MAX_TAG_LEN`] CHARACTERS (not bytes).
TooLong { tag: String, len: usize },
/// More than [`MAX_TAGS`] tags would be stored (counted AFTER
/// de-duplication, so the number is what the user would end up with).
TooMany(usize),
/// The raw input exceeded [`MAX_INPUT_TAGS`] before normalization.
TooManyRaw(usize),
}
impl std::fmt::Display for TagError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MustStartWithLetter(t) => {
write!(f, "tag `{t}` must start with a letter")
}
Self::IllegalCharacter { tag, ch } => {
write!(
f,
"tag `{tag}` contains an illegal character `{ch}`; allowed: letters, digits, and _ - . / :"
)
}
Self::TooLong { tag, len } => {
write!(
f,
"tag `{tag}` is {len} characters; the maximum is {MAX_TAG_LEN}"
)
}
Self::TooMany(n) => {
write!(f, "{n} distinct tags; the maximum is {MAX_TAGS} per alert")
}
Self::TooManyRaw(n) => {
write!(
f,
"{n} tags supplied; refusing to process more than {MAX_INPUT_TAGS} entries"
)
}
}
}
}
impl std::error::Error for TagError {}
/// Normalize and validate a tag list for storage (PT-7).
///
/// Repairs silently: lowercases, trims surrounding whitespace, drops entries
/// that are empty once trimmed (a trailing comma in the UI is not an error),
/// and de-duplicates **after** normalization, keeping first-seen order so the
/// stored list is stable and predictable.
///
/// Errors on anything that would change meaning, naming the offending tag.
///
/// **Validation order is part of the contract** so error messages are stable:
///
/// 1. raw input bound ([`MAX_INPUT_TAGS`]) — cheapest, before any work;
/// 2. per tag, in order: trim → drop-if-empty → Unicode-lowercase → must-start-with-a-letter →
/// charset → character length. Structural problems are reported before length, because "you used
/// a `!`" is more actionable than "your 300-character string is too long";
/// 3. de-duplicate;
/// 4. stored-count cap ([`MAX_TAGS`]).
pub fn normalize_tags(tags: &[String]) -> Result<Vec<String>, TagError> {
// 1. Raw bound first — before any per-entry work, so a duplicate-stuffed payload cannot force
// unbounded normalization (the MAX_TAGS cap is measured post-dedup and would be reached too
// late).
if tags.len() > MAX_INPUT_TAGS {
return Err(TagError::TooManyRaw(tags.len()));
}
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for raw in tags {
// 2. Repairs that cannot change meaning.
let trimmed = raw.trim();
if trimmed.is_empty() {
continue; // separator artifact, not a user error
}
// Unicode lowercase, NOT full case folding: folding maps `ß` -> `ss`,
// silently rewriting the user's tag and changing its selector
// identity (D22).
let tag = trimmed.to_lowercase();
validate_tag(&tag)?;
// 3. De-duplicate, first-seen order wins so storage is stable.
if seen.insert(tag.clone()) {
out.push(tag);
}
}
// 4. Cap what would be STORED, so a list that only exceeds it before de-duplication still
// saves.
if out.len() > MAX_TAGS {
return Err(TagError::TooMany(out.len()));
}
Ok(out)
}
/// Per-tag rules, applied in the documented order: structural problems are
/// reported before length, because "you used a `!`" points at the real fix.
fn validate_tag(tag: &str) -> Result<(), TagError> {
match tag.chars().next() {
// Unicode letter (D22) — `café`, `münchen`, `日本` all qualify.
Some(c) if c.is_alphabetic() => {}
_ => return Err(TagError::MustStartWithLetter(tag.to_string())),
}
for ch in tag.chars() {
// "Unicode" means letters and digits, not "any codepoint": symbols
// and emoji stay illegal.
if !(ch.is_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | ':')) {
return Err(TagError::IllegalCharacter {
tag: tag.to_string(),
ch,
});
}
}
// CHARACTERS, not bytes — byte length would give non-Latin scripts a
// fraction of the allowance.
let len = tag.chars().count();
if len > MAX_TAG_LEN {
return Err(TagError::TooLong {
tag: tag.to_string(),
len,
});
}
Ok(())
}
/// Normalize tags for a FILTER (`?tags=a,b`) — lenient counterpart of
/// [`normalize_tags`].
///
/// A filter must never 400. An unparseable or unknown tag simply matches
/// nothing, which is the honest answer to "show me alerts tagged `!!!`".
/// This applies the same case/whitespace repair so a user typing `Prod` in
/// the URL still matches the stored `prod` — the bug this exists to prevent
/// is a filter that silently returns zero rows because the caller forgot to
/// normalize.
///
/// **Invalid tokens are KEPT, never dropped.** Dropping them would be a
/// privilege-escalating bug rather than a nicety: `?tags=!!!` would normalize
/// to an EMPTY filter, and an empty filter matches *every* alert — so a
/// request that should return nothing would return everything. A preserved
/// `!!!` cannot match any stored tag (storage rejects that character), which
/// yields the correct empty result. Only blank entries — separator artifacts
/// from `?tags=a,,b` — are removed.
pub fn normalize_filter_tags(tags: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for raw in tags {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue; // `?tags=a,,b` — separator artifact
}
// NOTE: deliberately no `validate_tag` call. Keeping an invalid token
// is what makes `?tags=!!!` match nothing instead of everything.
let tag = trimmed.to_lowercase();
if seen.insert(tag.clone()) {
out.push(tag);
}
}
out
}
/// AND semantics for the list filter (PT-8): every requested tag must be
/// present on the alert. An empty filter matches everything.
///
/// Both sides are expected to be normalized already — stored tags by
/// [`normalize_tags`] at save, the filter by [`normalize_filter_tags`] at
/// parse. This is a pure containment check.
pub fn matches_all_tags(alert_tags: &[String], filter: &[String]) -> bool {
// AND: every requested tag must be present. Whole-token equality, never
// substring — `service` must not match `service:checkout` (D20).
filter
.iter()
.all(|wanted| alert_tags.iter().any(|have| have == wanted))
}
#[cfg(test)]
mod tests {
use super::*;
fn v(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
// ── Accepted shapes ─────────────────────────────────────────────────────
#[test]
fn test_bare_and_key_value_tags_are_both_valid() {
let out = normalize_tags(&v(&["prod", "service:checkout"])).unwrap();
assert_eq!(out, v(&["prod", "service:checkout"]));
}
#[test]
fn test_full_legal_punctuation_set_is_accepted() {
// _ - . / : — all of it, in one tag.
let tag = "web.server-01/prod:v2_x";
let out = normalize_tags(&v(&[tag])).unwrap();
assert_eq!(out, v(&[tag]));
}
// ── Unicode (D22) ───────────────────────────────────────────────────────
/// We have international users: accented Latin, Cyrillic and CJK tags are
/// first-class, not an edge case.
#[test]
fn test_unicode_letters_are_valid_tags() {
for tag in ["café", "münchen", "日本", "team:москва", "ключ:значение"]
{
assert!(
normalize_tags(&v(&[tag])).is_ok(),
"`{tag}` must be a valid tag under D22"
);
}
}
#[test]
fn test_unicode_tag_may_start_with_a_non_ascii_letter() {
assert_eq!(
normalize_tags(&v(&["日本:tokyo"])).unwrap(),
v(&["日本:tokyo"])
);
}
/// Case handling is Unicode-aware, so dedup works across scripts.
#[test]
fn test_unicode_case_is_lowercased_and_deduplicated() {
let out = normalize_tags(&v(&["CAFÉ", "café", "Café"])).unwrap();
assert_eq!(out, v(&["café"]), "one distinct tag after lowercasing");
}
/// Unicode **lowercasing**, deliberately NOT full case folding (D22).
///
/// Full folding maps `ß` → `ss`, so `Straße` would be stored as
/// `strasse` — silently rewriting what the user typed, which is exactly
/// the selector-identity change D22 rejects. `CAFÉ`/`café` alone cannot
/// tell the two policies apart; this case can.
#[test]
fn test_lowercasing_is_not_full_case_folding() {
let sharp_s = normalize_tags(&v(&["Straße"])).unwrap();
assert_eq!(sharp_s, v(&["straße"]), "ß must be preserved, not expanded");
let expanded = normalize_tags(&v(&["STRASSE"])).unwrap();
assert_eq!(expanded, v(&["strasse"]));
assert_ne!(
sharp_s, expanded,
"these are DISTINCT tags; folding them together would rewrite the user's tag"
);
}
/// Symbols and emoji are still illegal — "Unicode" means letters and
/// digits, not "anything".
#[test]
fn test_unicode_does_not_mean_symbols_are_allowed() {
for bad in ["emoji🔥", "a→b", "a✓"] {
assert!(
matches!(
normalize_tags(&v(&[bad])).unwrap_err(),
TagError::IllegalCharacter { .. }
),
"`{bad}` must still be rejected"
);
}
}
/// The length limit counts CHARACTERS. Measured in bytes, a 200-character
/// CJK tag would be ~600 and get rejected — silently giving non-Latin
/// scripts a third of the allowance.
#[test]
fn test_length_limit_counts_characters_not_bytes() {
let cjk = format!("{}", "".repeat(MAX_TAG_LEN - 1));
assert_eq!(cjk.chars().count(), MAX_TAG_LEN);
assert!(cjk.len() > MAX_TAG_LEN, "precondition: bytes exceed chars");
assert!(
normalize_tags(&v(&[&cjk])).is_ok(),
"200 characters must pass regardless of byte length"
);
let over = format!("{}", "".repeat(MAX_TAG_LEN));
match normalize_tags(&v(&[&over])).unwrap_err() {
TagError::TooLong { len, .. } => {
assert_eq!(len, MAX_TAG_LEN + 1, "len must be a CHARACTER count")
}
other => panic!("expected TooLong, got {other:?}"),
}
}
#[test]
fn test_empty_input_is_valid_and_yields_no_tags() {
assert_eq!(normalize_tags(&[]).unwrap(), Vec::<String>::new());
}
// ── Silent repairs ──────────────────────────────────────────────────────
#[test]
fn test_case_is_normalized_rather_than_rejected() {
let out = normalize_tags(&v(&["Prod", "SERVICE:Checkout"])).unwrap();
assert_eq!(out, v(&["prod", "service:checkout"]));
}
#[test]
fn test_surrounding_whitespace_is_trimmed() {
let out = normalize_tags(&v(&[" prod ", "\tenv:dev\n"])).unwrap();
assert_eq!(out, v(&["prod", "env:dev"]));
}
/// A trailing comma in the tag input yields an empty entry; that is a UI
/// artifact, not a user error.
#[test]
fn test_blank_entries_are_dropped_not_rejected() {
let out = normalize_tags(&v(&["prod", "", " ", "dev"])).unwrap();
assert_eq!(out, v(&["prod", "dev"]));
}
#[test]
fn test_duplicates_collapse_after_normalization_keeping_first_order() {
let out = normalize_tags(&v(&["prod", "Prod", " PROD ", "dev", "prod"])).unwrap();
assert_eq!(out, v(&["prod", "dev"]), "first-seen order must be stable");
}
// ── Rejections, each naming the offending tag ───────────────────────────
#[test]
fn test_tag_must_start_with_a_letter() {
for bad in [
"1prod", "_prod", ":prod", "-prod", ".prod", "/prod", "1日本", "٣test",
] {
let err = normalize_tags(&v(&[bad])).unwrap_err();
assert_eq!(err, TagError::MustStartWithLetter(bad.to_string()));
assert!(
err.to_string().contains(bad),
"message must name the tag, got: {err}"
);
}
}
#[test]
fn test_illegal_characters_are_rejected() {
for bad in ["prod!", "a b", "emoji🔥", "a,b", "a=b", "a#b"] {
let err = normalize_tags(&v(&[bad])).unwrap_err();
match &err {
TagError::IllegalCharacter { tag, .. } => assert_eq!(tag, bad),
other => panic!("expected IllegalCharacter for `{bad}`, got {other:?}"),
}
assert!(err.to_string().contains(bad));
}
}
/// A comma is the list separator in `?tags=a,b`; allowing it inside a tag
/// would make the filter ambiguous.
#[test]
fn test_comma_is_illegal_because_it_separates_the_filter_list() {
assert!(matches!(
normalize_tags(&v(&["a,b"])).unwrap_err(),
TagError::IllegalCharacter { .. }
));
}
#[test]
fn test_length_boundary_is_inclusive() {
let at_limit = format!("a{}", "b".repeat(MAX_TAG_LEN - 1));
assert_eq!(at_limit.len(), MAX_TAG_LEN);
assert!(normalize_tags(&v(&[&at_limit])).is_ok(), "200 chars is OK");
let over = format!("a{}", "b".repeat(MAX_TAG_LEN));
match normalize_tags(&v(&[&over])).unwrap_err() {
TagError::TooLong { len, .. } => assert_eq!(len, MAX_TAG_LEN + 1),
other => panic!("expected TooLong, got {other:?}"),
}
}
#[test]
fn test_tag_count_cap_is_inclusive() {
let ok: Vec<String> = (0..MAX_TAGS).map(|i| format!("tag{i}")).collect();
assert_eq!(normalize_tags(&ok).unwrap().len(), MAX_TAGS);
// 70 raw entries that collapse to 65 distinct tags. Using 65 UNIQUE
// inputs would leave the count ambiguous — input-count and stored-count
// would both be 65, so the test would pass under either reading.
let mut too_many: Vec<String> = (0..MAX_TAGS + 1).map(|i| format!("tag{i}")).collect();
for i in 0..5 {
too_many.push(format!("TAG{i}")); // duplicates after lowercasing
}
assert_eq!(too_many.len(), MAX_TAGS + 6);
assert_eq!(
normalize_tags(&too_many).unwrap_err(),
TagError::TooMany(MAX_TAGS + 1),
"the count must be what would be STORED (65), not what was sent (70)"
);
}
/// The cap applies to what is STORED, so a list that only exceeds it
/// before de-duplication must pass.
#[test]
fn test_cap_is_measured_after_deduplication() {
let mut dupes: Vec<String> = (0..MAX_TAGS).map(|i| format!("tag{i}")).collect();
dupes.push("tag0".to_string());
dupes.push("TAG1".to_string());
let out = normalize_tags(&dupes).expect("duplicates must not trip the cap");
assert_eq!(out.len(), MAX_TAGS);
}
// ── Validation ORDER is part of the contract (defect #4) ────────────────
/// A tag that is BOTH over-length and illegal must report the illegal
/// character: "you used a `!`" is actionable, "your 300-char string is
/// too long" sends the user to fix the wrong thing.
#[test]
fn test_structural_errors_are_reported_before_length() {
let bad = format!("a{}!", "b".repeat(MAX_TAG_LEN));
match normalize_tags(&v(&[&bad])).unwrap_err() {
TagError::IllegalCharacter { ch, .. } => assert_eq!(ch, '!'),
other => panic!("expected IllegalCharacter to win over TooLong, got {other:?}"),
}
}
/// Leading-character is checked before charset, so `1a!` names the more
/// fundamental problem rather than the incidental `!`.
#[test]
fn test_start_check_precedes_charset_check() {
assert_eq!(
normalize_tags(&v(&["1a!"])).unwrap_err(),
TagError::MustStartWithLetter("1a!".to_string())
);
}
/// An over-cap list containing an invalid tag reports the INVALID TAG:
/// trimming the list would not make the bad tag legal.
#[test]
fn test_invalid_tag_is_reported_before_the_stored_count_cap() {
let mut many: Vec<String> = (0..MAX_TAGS + 1).map(|i| format!("tag{i}")).collect();
many.push("bad!".to_string());
assert!(matches!(
normalize_tags(&many).unwrap_err(),
TagError::IllegalCharacter { .. }
));
}
/// ...but an absurd RAW payload is refused before any of that work.
#[test]
fn test_raw_input_bound_precedes_all_per_tag_work() {
let huge: Vec<String> = (0..MAX_INPUT_TAGS + 1)
.map(|_| "bad!".to_string())
.collect();
assert_eq!(
normalize_tags(&huge).unwrap_err(),
TagError::TooManyRaw(MAX_INPUT_TAGS + 1),
"a giant payload must be refused before per-tag validation runs"
);
}
#[test]
fn test_raw_bound_is_inclusive_and_dedup_still_applies_under_it() {
// MAX_INPUT_TAGS identical entries: passes the raw bound, dedupes to 1.
let at_bound: Vec<String> = (0..MAX_INPUT_TAGS).map(|_| "prod".to_string()).collect();
assert_eq!(normalize_tags(&at_bound).unwrap(), v(&["prod"]));
}
// ── Lenient filter parsing (defect #5) ──────────────────────────────────
/// The filter path must never error — an unknown or malformed tag simply
/// matches nothing.
#[test]
fn test_filter_parsing_is_lenient_where_saving_is_strict() {
let out = normalize_filter_tags(&v(&["Prod", " env:DEV ", "", "!!!"]));
// Case/whitespace repaired identically to the save path...
assert!(out.contains(&"prod".to_string()));
assert!(out.contains(&"env:dev".to_string()));
// ...blanks dropped, and the malformed entry is NOT an error...
assert!(!out.contains(&"".to_string()));
// ...but the malformed entry is RETAINED as a token (see below).
assert!(
out.contains(&"!!!".to_string()),
"invalid tokens must survive normalization, got {out:?}"
);
}
/// REGRESSION GUARD for a privilege-escalating bug: if the filter parser
/// DROPPED invalid tokens, `?tags=!!!` would become the empty filter —
/// and the empty filter matches every alert. A request that must return
/// nothing would instead return everything.
#[test]
fn test_invalid_only_filter_matches_nothing_not_everything() {
let stored = normalize_tags(&v(&["prod", "service:checkout"])).unwrap();
let filter = normalize_filter_tags(&v(&["!!!"]));
assert!(
!filter.is_empty(),
"must not collapse to the match-all filter"
);
assert!(
!matches_all_tags(&stored, &filter),
"an invalid-only filter must match nothing"
);
}
/// The AND semantics must also hold when one term is valid and one is not.
#[test]
fn test_mixed_valid_and_invalid_filter_matches_nothing() {
let stored = normalize_tags(&v(&["prod"])).unwrap();
let filter = normalize_filter_tags(&v(&["prod", "!!!"]));
assert!(
!matches_all_tags(&stored, &filter),
"one unsatisfiable term must fail the whole AND filter"
);
}
/// Blanks ARE dropped — `?tags=a,,b` is a separator artifact, not a
/// request for a tag named "". An all-blank filter is "no filter".
#[test]
fn test_blank_only_filter_is_treated_as_no_filter() {
let filter = normalize_filter_tags(&v(&["", " ", ""]));
assert!(filter.is_empty());
assert!(matches_all_tags(&v(&["prod"]), &filter));
}
/// The bug this pairing exists to prevent: a user types `Prod` in the URL
/// and gets zero rows because the stored form is `prod`.
#[test]
fn test_filter_normalization_makes_mixed_case_urls_match_stored_tags() {
let stored = normalize_tags(&v(&["Prod", "Service:Checkout"])).unwrap();
let filter = normalize_filter_tags(&v(&["PROD", "service:CHECKOUT"]));
assert!(
matches_all_tags(&stored, &filter),
"stored {stored:?} should match filter {filter:?}"
);
}
// ── Filtering (PT-8) ────────────────────────────────────────────────────
#[test]
fn test_filter_requires_every_tag_and_semantics() {
let alert = v(&["prod", "service:checkout", "team:payments"]);
assert!(matches_all_tags(&alert, &v(&["prod"])));
assert!(matches_all_tags(&alert, &v(&["prod", "team:payments"])));
// AND, not OR: one missing tag fails the whole filter.
assert!(!matches_all_tags(&alert, &v(&["prod", "env:staging"])));
assert!(!matches_all_tags(&alert, &v(&["nope"])));
}
#[test]
fn test_empty_filter_matches_everything() {
assert!(matches_all_tags(&v(&["prod"]), &[]));
assert!(matches_all_tags(&[], &[]));
}
#[test]
fn test_untagged_alert_matches_no_non_empty_filter() {
assert!(!matches_all_tags(&[], &v(&["prod"])));
}
/// Tags are exact tokens, not substrings: `service:checkout` must not be
/// matched by `service` or by `checkout`.
#[test]
fn test_filter_matches_whole_tags_not_substrings() {
let alert = v(&["service:checkout"]);
assert!(!matches_all_tags(&alert, &v(&["service"])));
assert!(!matches_all_tags(&alert, &v(&["checkout"])));
assert!(matches_all_tags(&alert, &v(&["service:checkout"])));
}
}

View File

@ -43,6 +43,7 @@ pub mod service_graph;
pub mod service_streams;
pub mod session;
pub mod short_url;
pub mod slo;
pub mod sql;
pub mod stream;
pub mod synthetics;

View File

@ -277,7 +277,7 @@ impl ReportingRunner {
#[cfg(test)]
mod tests {
use tokio::time::Duration;
use usage::{TriggerData, TriggerDataStatus, TriggerDataType, UsageData, UsageEvent};
use usage::{RunOutcome, TriggerData, TriggerDataType, UsageData, UsageEvent};
use super::*;
@ -361,7 +361,7 @@ mod tests {
next_run_at: 1234567890,
is_realtime: true,
is_silenced: false,
status: TriggerDataStatus::Completed,
status: RunOutcome::Succeeded,
start_time: 1234567890,
end_time: 1234567890,
retries: 0,
@ -380,6 +380,12 @@ mod tests {
dedup_count: None,
grouped: None,
group_size: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
level: None,
group_label: None,
value_is_lower_bound: None,
};
let result = queue
@ -395,7 +401,7 @@ mod tests {
assert_eq!(data.org, "test_org");
assert_eq!(data.module, TriggerDataType::Alert);
assert_eq!(data.key, "test_key");
assert_eq!(data.status, TriggerDataStatus::Completed);
assert_eq!(data.status, RunOutcome::Succeeded);
}
_ => panic!("Expected Trigger data"),
}
@ -895,7 +901,7 @@ mod tests {
next_run_at: 1234567890,
is_realtime: true,
is_silenced: false,
status: TriggerDataStatus::Completed,
status: RunOutcome::Succeeded,
start_time: 1234567890,
end_time: 1234567890,
retries: 0,
@ -914,6 +920,12 @@ mod tests {
dedup_count: None,
grouped: None,
group_size: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
level: None,
group_label: None,
value_is_lower_bound: None,
};
let error_data = error::ErrorData {
@ -963,7 +975,7 @@ mod tests {
next_run_at: 1234567890,
is_realtime: true,
is_silenced: false,
status: TriggerDataStatus::Completed,
status: RunOutcome::Succeeded,
start_time: 1234567890,
end_time: 1234567890,
retries: 0,
@ -982,6 +994,12 @@ mod tests {
dedup_count: None,
grouped: None,
group_size: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
level: None,
group_label: None,
value_is_lower_bound: None,
};
let trigger_data2 = TriggerData {
@ -1021,7 +1039,7 @@ mod tests {
next_run_at: 1234567890,
is_realtime: true,
is_silenced: false,
status: TriggerDataStatus::Completed,
status: RunOutcome::Succeeded,
start_time: 1234567890,
end_time: 1234567890,
retries: 0,
@ -1040,6 +1058,12 @@ mod tests {
dedup_count: None,
grouped: None,
group_size: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
level: None,
group_label: None,
value_is_lower_bound: None,
};
// Should succeed when queue has space

View File

@ -49,16 +49,173 @@ pub fn is_reserved_self_reporting_stream(stream_name: &str) -> bool {
RESERVED_SELF_REPORTING_STREAMS.contains(&stream_name)
}
/// Every reserved internal stream, self-reporting or otherwise.
///
/// `slo_slices` is not self-reporting — it is measurement data for Feature 5 —
/// but it needs the identical protection, and for the identical reason: a user
/// write into it would corrupt the numbers an SLO reports. It is listed
/// separately rather than folded into [`RESERVED_SELF_REPORTING_STREAMS`] so
/// that array keeps meaning what its name says.
pub const RESERVED_INTERNAL_STREAMS: [&str; 6] = [
USAGE_STREAM,
STATS_STREAM,
TRIGGERS_STREAM,
ERROR_STREAM,
DATA_RETENTION_USAGE_STREAM,
crate::meta::slo::stream::SLO_SLICES_STREAM,
];
/// Returns true if `stream_name` is reserved for internal writes of any kind.
///
/// This is the predicate the create/delete/ingest guards should use. Internal
/// writers bypass it the same way self-reporting does — via the
/// `IngestionRequest::Usage` channel, for which `should_report_usage()` is
/// false.
pub fn is_reserved_internal_stream(stream_name: &str) -> bool {
RESERVED_INTERNAL_STREAMS.contains(&stream_name)
}
/// Outcome of a single scheduled evaluation — "did it fire?".
///
/// Part III of `alerts.md`. Replaces the former `TriggerDataStatus`, whose
/// `Completed` variant collided by name with `TriggerStatus::Completed` (the
/// job-queue state) while meaning something entirely different.
///
/// Condition-bearing modules (alerts, derived streams, anomaly detection) use
/// [`RunOutcome::Firing`] / [`RunOutcome::Normal`]; modules with no condition
/// (reports, workflows, synthetics) use [`RunOutcome::Succeeded`].
///
/// The legacy vocabulary is accepted on **read** via serde aliases so that
/// `TriggerData` records written by an older build — which are read back as a
/// typed struct from the on-disk self-reporting queue — do not panic the ingest
/// task after an upgrade. Legacy values are never written. Because `completed`
/// was module-dependent it aliases to the neutral `Succeeded` and is corrected
/// by [`TriggerData::normalize_legacy_outcome`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum TriggerDataStatus {
#[serde(rename = "completed")]
Completed,
#[serde(rename = "failed")]
Failed,
#[serde(rename = "condition_not_satisfied")]
ConditionNotSatisfied,
pub enum RunOutcome {
/// Condition matched — the alert triggered and the notification was sent.
#[serde(rename = "firing")]
Firing,
/// Evaluated cleanly; nothing to alert on.
#[serde(rename = "normal", alias = "condition_not_satisfied")]
Normal,
/// Non-condition modules: report sent, derived stream written.
#[serde(rename = "succeeded", alias = "completed")]
Succeeded,
/// The evaluation itself failed (query error, timeout).
#[serde(rename = "error", alias = "failed")]
Error,
/// Never evaluated — silenced, paused, org deleting.
#[serde(rename = "skipped")]
Skipped,
/// Condition matched but delivery failed. Still a firing state: without
/// this, a webhook outage silently undercounts firings.
#[serde(rename = "notify_failed")]
NotifyFailed,
}
impl RunOutcome {
/// True when the alert actually triggered. `NotifyFailed` counts — the
/// condition matched, only delivery failed.
pub fn is_firing(&self) -> bool {
matches!(self, Self::Firing | Self::NotifyFailed)
}
/// Durable integer form, stored in `alert_states.last_outcome` (Part IV).
///
/// These values are persisted — never reorder or reuse them.
pub fn to_i32(&self) -> i32 {
match self {
Self::Firing => 0,
Self::Normal => 1,
Self::Succeeded => 2,
Self::Error => 3,
Self::Skipped => 4,
Self::NotifyFailed => 5,
}
}
pub fn from_i32(v: i32) -> Option<Self> {
match v {
0 => Some(Self::Firing),
1 => Some(Self::Normal),
2 => Some(Self::Succeeded),
3 => Some(Self::Error),
4 => Some(Self::Skipped),
5 => Some(Self::NotifyFailed),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Firing => "firing",
Self::Normal => "normal",
Self::Succeeded => "succeeded",
Self::Error => "error",
Self::Skipped => "skipped",
Self::NotifyFailed => "notify_failed",
}
}
}
impl std::fmt::Display for RunOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Parse `anomalies_found` out of an anomaly run's `success_response` JSON.
/// A missing or unparseable payload counts as zero — never as firing.
fn anomalies_found(success_response: Option<&str>) -> i64 {
success_response
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v["anomalies_found"].as_i64())
.unwrap_or(0)
}
/// Map a raw `status` string from the `triggers` stream onto a [`RunOutcome`].
///
/// Handles both vocabularies, so it works across the retention window in which
/// pre- and post-cutover rows coexist. Returns `None` for values that belong to
/// neither.
///
/// This is the read-side migration described in Part III of `alerts.md`. It is
/// temporary: once legacy rows age out of the triggers stream's retention
/// window, only the passthrough branch is reachable and the rest can be deleted.
pub fn normalize_outcome(
raw: &str,
module: &TriggerDataType,
success_response: Option<&str>,
) -> Option<RunOutcome> {
match raw.to_lowercase().as_str() {
// ── current vocabulary ──
"firing" => Some(RunOutcome::Firing),
"normal" => Some(RunOutcome::Normal),
"succeeded" => Some(RunOutcome::Succeeded),
"error" => Some(RunOutcome::Error),
"skipped" => Some(RunOutcome::Skipped),
"notify_failed" => Some(RunOutcome::NotifyFailed),
// ── legacy vocabulary ──
"condition_not_satisfied" => Some(RunOutcome::Normal),
"failed" => Some(RunOutcome::Error),
"completed" => Some(match module {
// Anomaly rows stored `completed` whenever detection RAN, even with
// zero anomalies — the count lives in `success_response`.
TriggerDataType::AnomalyDetection => {
if anomalies_found(success_response) > 0 {
RunOutcome::Firing
} else {
RunOutcome::Normal
}
}
m if m.is_condition_bearing() => RunOutcome::Firing,
_ => RunOutcome::Succeeded,
}),
_ => None,
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -81,6 +238,21 @@ pub enum TriggerDataType {
Workflow,
#[serde(rename = "synthetics")]
Synthetics,
#[serde(rename = "slo")]
Slo,
#[serde(rename = "slo_backfill")]
SloBackfill,
}
impl TriggerDataType {
/// Whether this module evaluates a condition, and so can meaningfully be
/// "firing". Modules without one report [`RunOutcome::Succeeded`] instead.
pub fn is_condition_bearing(&self) -> bool {
matches!(
self,
Self::Alert | Self::DerivedStream | Self::AnomalyDetection
)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -93,7 +265,7 @@ pub struct TriggerData {
pub next_run_at: i64,
pub is_realtime: bool,
pub is_silenced: bool,
pub status: TriggerDataStatus,
pub status: RunOutcome,
pub start_time: i64,
pub end_time: i64,
pub retries: i32,
@ -119,6 +291,33 @@ pub struct TriggerData {
pub grouped: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_size: Option<i32>,
// ── Value context (T-9, alerts_2.md §7.5) ───────────────────────────────
// Lets history answer "fired at 112 against threshold 100" from the stream
// alone. `#[serde(default)]` is load-bearing: records written before these
// fields existed must still deserialize.
/// Observed value: row count for count alerts, `alert_agg_value` for
/// aggregation alerts. For count alerts this is a LOWER BOUND once the
/// search cap is reached.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actual_value: Option<f64>,
/// The threshold that matched. `None` on `normal` rows.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_value: Option<f64>,
/// Operator, so the record reads standalone ("112 >= 100").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_operator: Option<String>,
/// Computed `AlertLevel` as i32 — `Ok` on normal rows, never absent for a
/// level-bearing evaluation. `None` for non-condition modules and
/// error/skipped runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<i32>,
/// Which group produced `actual_value` (worst group; D8).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_label: Option<String>,
/// True when `actual_value` is a LOWER BOUND (legacy SingleQuery count
/// fetch hit its cap, §7.5) — history renders "≥ N". Absent = exact.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_is_lower_bound: Option<bool>,
}
impl Default for TriggerData {
@ -131,7 +330,7 @@ impl Default for TriggerData {
next_run_at: 0,
is_realtime: false,
is_silenced: false,
status: TriggerDataStatus::Completed,
status: RunOutcome::Succeeded,
start_time: 0,
end_time: 0,
retries: 0,
@ -150,6 +349,12 @@ impl Default for TriggerData {
dedup_count: None,
grouped: None,
group_size: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
level: None,
group_label: None,
value_is_lower_bound: None,
}
}
}
@ -172,7 +377,7 @@ impl TriggerData {
next_run_at: 0,
is_realtime: false,
is_silenced: false,
status: TriggerDataStatus::Completed,
status: RunOutcome::Succeeded,
start_time: 0,
end_time: 0,
retries: 0,
@ -192,9 +397,38 @@ impl TriggerData {
dedup_count: Some(0),
grouped: Some(false),
group_size: Some(0),
actual_value: Some(0.0),
threshold_value: Some(0.0),
threshold_operator: Some(String::new()),
level: Some(0),
group_label: Some(String::new()),
value_is_lower_bound: Some(false),
}
}
/// Correct a legacy `completed` outcome that was read in through the
/// [`RunOutcome::Succeeded`] serde alias.
///
/// Call this after deserializing a `TriggerData` that may have been written
/// by an older build (see `self_reporting::persistence`). Safe and
/// idempotent: current code never writes `Succeeded` for a condition-bearing
/// module, so a `Succeeded` on one of those can only be a legacy record.
pub fn normalize_legacy_outcome(&mut self) {
if self.status != RunOutcome::Succeeded || !self.module.is_condition_bearing() {
return;
}
self.status = match self.module {
TriggerDataType::AnomalyDetection => {
if anomalies_found(self.success_response.as_deref()) > 0 {
RunOutcome::Firing
} else {
RunOutcome::Normal
}
}
_ => RunOutcome::Firing,
};
}
/// Returns all field names for TriggerData struct by introspecting a sample instance.
///
/// This is primarily used for testing/validation. For schema creation, use
@ -652,6 +886,549 @@ impl From<FileMeta> for RequestStats {
}
}
#[cfg(test)]
mod run_outcome_tests {
use super::*;
// ── Serialization: the wire vocabulary ──────────────────────────────────
#[test]
fn test_run_outcome_serialization() {
assert_eq!(
serde_json::to_string(&RunOutcome::Firing).unwrap(),
"\"firing\""
);
assert_eq!(
serde_json::to_string(&RunOutcome::Normal).unwrap(),
"\"normal\""
);
assert_eq!(
serde_json::to_string(&RunOutcome::Succeeded).unwrap(),
"\"succeeded\""
);
assert_eq!(
serde_json::to_string(&RunOutcome::Error).unwrap(),
"\"error\""
);
assert_eq!(
serde_json::to_string(&RunOutcome::Skipped).unwrap(),
"\"skipped\""
);
assert_eq!(
serde_json::to_string(&RunOutcome::NotifyFailed).unwrap(),
"\"notify_failed\""
);
}
#[test]
fn test_run_outcome_deserialization_roundtrip() {
for outcome in [
RunOutcome::Firing,
RunOutcome::Normal,
RunOutcome::Succeeded,
RunOutcome::Error,
RunOutcome::Skipped,
RunOutcome::NotifyFailed,
] {
let s = serde_json::to_string(&outcome).unwrap();
let back: RunOutcome = serde_json::from_str(&s).unwrap();
assert_eq!(outcome, back, "roundtrip failed for {outcome:?}");
}
}
/// The old vocabulary MUST still deserialize. `TriggerData` is read back as a
/// typed struct from the on-disk self-reporting queue
/// (`persistence.rs:121`, an `.unwrap()`), so records written by a previous
/// build must not panic the ingest task after an upgrade.
#[test]
fn test_run_outcome_accepts_legacy_values_leniently() {
assert_eq!(
serde_json::from_str::<RunOutcome>("\"condition_not_satisfied\"").unwrap(),
RunOutcome::Normal
);
assert_eq!(
serde_json::from_str::<RunOutcome>("\"failed\"").unwrap(),
RunOutcome::Error
);
// `completed` is module-dependent, so it lands on the neutral variant and
// is corrected by `TriggerData::normalize_legacy_outcome`.
assert_eq!(
serde_json::from_str::<RunOutcome>("\"completed\"").unwrap(),
RunOutcome::Succeeded
);
}
/// Legacy values must never be *written* — aliases are read-only.
#[test]
fn test_run_outcome_never_serializes_legacy_values() {
for outcome in [RunOutcome::Normal, RunOutcome::Error, RunOutcome::Succeeded] {
let s = serde_json::to_string(&outcome).unwrap();
assert!(
!["\"completed\"", "\"failed\"", "\"condition_not_satisfied\""]
.contains(&s.as_str()),
"{outcome:?} must not serialize to a legacy value, got {s}"
);
}
}
/// A full legacy `TriggerData` payload must survive the round trip that
/// `persistence.rs` performs, and land on the right outcome per module.
#[test]
fn test_trigger_data_normalize_legacy_outcome() {
// Condition-bearing module: legacy `completed` really meant "fired".
let mut td = TriggerData {
module: TriggerDataType::Alert,
status: serde_json::from_str("\"completed\"").unwrap(),
..Default::default()
};
td.normalize_legacy_outcome();
assert_eq!(td.status, RunOutcome::Firing);
// Anomaly with no anomalies found is NOT firing.
let mut td = TriggerData {
module: TriggerDataType::AnomalyDetection,
status: serde_json::from_str("\"completed\"").unwrap(),
success_response: Some(r#"{"anomalies_found":0}"#.to_string()),
..Default::default()
};
td.normalize_legacy_outcome();
assert_eq!(td.status, RunOutcome::Normal);
let mut td = TriggerData {
module: TriggerDataType::AnomalyDetection,
status: serde_json::from_str("\"completed\"").unwrap(),
success_response: Some(r#"{"anomalies_found":7}"#.to_string()),
..Default::default()
};
td.normalize_legacy_outcome();
assert_eq!(td.status, RunOutcome::Firing);
// Non-condition module: `succeeded` is correct and must be left alone.
let mut td = TriggerData {
module: TriggerDataType::Report,
status: serde_json::from_str("\"completed\"").unwrap(),
..Default::default()
};
td.normalize_legacy_outcome();
assert_eq!(td.status, RunOutcome::Succeeded);
}
/// The fixup must be idempotent and must not corrupt new-vocabulary records.
#[test]
fn test_normalize_legacy_outcome_is_idempotent_and_safe() {
for outcome in [
RunOutcome::Firing,
RunOutcome::Normal,
RunOutcome::Error,
RunOutcome::Skipped,
RunOutcome::NotifyFailed,
] {
let mut td = TriggerData {
module: TriggerDataType::Alert,
status: outcome.clone(),
..Default::default()
};
td.normalize_legacy_outcome();
assert_eq!(td.status, outcome, "fixup must not alter {outcome:?}");
td.normalize_legacy_outcome();
assert_eq!(td.status, outcome, "fixup must be idempotent");
}
}
// ── is_firing: the defect fix from Part III ─────────────────────────────
#[test]
fn test_is_firing() {
assert!(RunOutcome::Firing.is_firing());
// The whole point of `notify_failed`: the alert DID fire, delivery did
// not. It must still count toward firing totals.
assert!(RunOutcome::NotifyFailed.is_firing());
assert!(!RunOutcome::Normal.is_firing());
assert!(!RunOutcome::Succeeded.is_firing());
assert!(!RunOutcome::Error.is_firing());
assert!(!RunOutcome::Skipped.is_firing());
}
// ── Integer mapping: needed for the Part IV `last_outcome INT` column ────
/// These integers are DURABLE — they are what lands in the Part IV
/// `last_outcome INT` column. Pin the literal values: a roundtrip-only test
/// still passes if the variants are reordered, at which point every stored
/// row silently changes meaning.
#[test]
fn test_run_outcome_i32_values_are_pinned() {
assert_eq!(RunOutcome::Firing.to_i32(), 0);
assert_eq!(RunOutcome::Normal.to_i32(), 1);
assert_eq!(RunOutcome::Succeeded.to_i32(), 2);
assert_eq!(RunOutcome::Error.to_i32(), 3);
assert_eq!(RunOutcome::Skipped.to_i32(), 4);
assert_eq!(RunOutcome::NotifyFailed.to_i32(), 5);
assert_eq!(RunOutcome::from_i32(0), Some(RunOutcome::Firing));
assert_eq!(RunOutcome::from_i32(1), Some(RunOutcome::Normal));
assert_eq!(RunOutcome::from_i32(2), Some(RunOutcome::Succeeded));
assert_eq!(RunOutcome::from_i32(3), Some(RunOutcome::Error));
assert_eq!(RunOutcome::from_i32(4), Some(RunOutcome::Skipped));
assert_eq!(RunOutcome::from_i32(5), Some(RunOutcome::NotifyFailed));
}
#[test]
fn test_run_outcome_i32_roundtrip() {
for outcome in [
RunOutcome::Firing,
RunOutcome::Normal,
RunOutcome::Succeeded,
RunOutcome::Error,
RunOutcome::Skipped,
RunOutcome::NotifyFailed,
] {
let n = outcome.to_i32();
assert_eq!(
RunOutcome::from_i32(n),
Some(outcome.clone()),
"i32 roundtrip failed for {outcome:?} (got {n})"
);
}
}
#[test]
fn test_run_outcome_from_i32_rejects_unknown() {
assert_eq!(RunOutcome::from_i32(-1), None);
assert_eq!(RunOutcome::from_i32(99), None);
}
// ── Condition-bearing modules ───────────────────────────────────────────
#[test]
fn test_is_condition_bearing() {
assert!(TriggerDataType::Alert.is_condition_bearing());
assert!(TriggerDataType::DerivedStream.is_condition_bearing());
assert!(TriggerDataType::AnomalyDetection.is_condition_bearing());
assert!(!TriggerDataType::Report.is_condition_bearing());
assert!(!TriggerDataType::CachedReport.is_condition_bearing());
assert!(!TriggerDataType::Workflow.is_condition_bearing());
assert!(!TriggerDataType::Synthetics.is_condition_bearing());
assert!(!TriggerDataType::Backfill.is_condition_bearing());
assert!(!TriggerDataType::AnomalyDetectionTraining.is_condition_bearing());
}
// ── normalize_outcome: the read-side migration (Part III) ───────────────
#[test]
fn test_normalize_legacy_completed_alert_is_firing() {
assert_eq!(
normalize_outcome("completed", &TriggerDataType::Alert, None),
Some(RunOutcome::Firing)
);
}
#[test]
fn test_normalize_legacy_completed_derived_stream_is_firing() {
assert_eq!(
normalize_outcome("completed", &TriggerDataType::DerivedStream, None),
Some(RunOutcome::Firing)
);
}
/// Anomaly rows store `completed` whenever detection RAN, even with zero
/// anomalies (`handlers.rs:198`). The count lives in `success_response`, so
/// the normalizer must parse it — mirroring `history.rs:362`.
#[test]
fn test_normalize_legacy_completed_anomaly_with_anomalies_is_firing() {
assert_eq!(
normalize_outcome(
"completed",
&TriggerDataType::AnomalyDetection,
Some(r#"{"anomalies_found":3}"#),
),
Some(RunOutcome::Firing)
);
}
#[test]
fn test_normalize_legacy_completed_anomaly_without_anomalies_is_normal() {
assert_eq!(
normalize_outcome(
"completed",
&TriggerDataType::AnomalyDetection,
Some(r#"{"anomalies_found":0}"#),
),
Some(RunOutcome::Normal)
);
}
/// A missing or unparseable `success_response` must not be read as firing.
#[test]
fn test_normalize_legacy_completed_anomaly_missing_response_is_normal() {
assert_eq!(
normalize_outcome("completed", &TriggerDataType::AnomalyDetection, None),
Some(RunOutcome::Normal)
);
assert_eq!(
normalize_outcome(
"completed",
&TriggerDataType::AnomalyDetection,
Some("not json"),
),
Some(RunOutcome::Normal)
);
}
#[test]
fn test_normalize_legacy_completed_non_condition_module_is_succeeded() {
assert_eq!(
normalize_outcome("completed", &TriggerDataType::Report, None),
Some(RunOutcome::Succeeded)
);
assert_eq!(
normalize_outcome("completed", &TriggerDataType::Workflow, None),
Some(RunOutcome::Succeeded)
);
}
#[test]
fn test_normalize_legacy_condition_not_satisfied_is_normal() {
assert_eq!(
normalize_outcome("condition_not_satisfied", &TriggerDataType::Alert, None),
Some(RunOutcome::Normal)
);
}
#[test]
fn test_normalize_legacy_failed_is_error() {
assert_eq!(
normalize_outcome("failed", &TriggerDataType::Alert, None),
Some(RunOutcome::Error)
);
}
#[test]
fn test_normalize_legacy_skipped_unchanged() {
assert_eq!(
normalize_outcome("skipped", &TriggerDataType::Alert, None),
Some(RunOutcome::Skipped)
);
}
/// Post-cutover rows already carry the new vocabulary and must pass through.
#[test]
fn test_normalize_new_vocabulary_passthrough() {
for (raw, expected) in [
("firing", RunOutcome::Firing),
("normal", RunOutcome::Normal),
("succeeded", RunOutcome::Succeeded),
("error", RunOutcome::Error),
("skipped", RunOutcome::Skipped),
("notify_failed", RunOutcome::NotifyFailed),
] {
assert_eq!(
normalize_outcome(raw, &TriggerDataType::Alert, None),
Some(expected),
"passthrough failed for {raw}"
);
}
}
#[test]
fn test_normalize_is_case_insensitive() {
assert_eq!(
normalize_outcome("COMPLETED", &TriggerDataType::Alert, None),
Some(RunOutcome::Firing)
);
assert_eq!(
normalize_outcome("Firing", &TriggerDataType::Alert, None),
Some(RunOutcome::Firing)
);
}
#[test]
fn test_normalize_unknown_returns_none() {
assert_eq!(normalize_outcome("", &TriggerDataType::Alert, None), None);
assert_eq!(
normalize_outcome("banana", &TriggerDataType::Alert, None),
None
);
}
// ── TriggerData still serializes its outcome under `status` ─────────────
/// Part III deliberately keeps the stream FIELD name `status` and changes
/// only the values — no schema change. Guard that.
#[test]
fn test_trigger_data_field_is_still_named_status() {
let td = TriggerData {
status: RunOutcome::Firing,
..Default::default()
};
let v = serde_json::to_value(&td).unwrap();
assert_eq!(v.get("status").and_then(|s| s.as_str()), Some("firing"));
assert!(
v.get("outcome").is_none(),
"must NOT introduce an `outcome` field on the triggers stream"
);
}
#[test]
fn test_reflection_sample_still_exposes_status_field() {
let names = TriggerData::get_field_names();
assert!(names.contains(&"status".to_string()));
assert!(!names.contains(&"outcome".to_string()));
}
// ── T-9: value context on the trigger record (alerts_2.md §7.5) ─────────
// "Fired at 112 against threshold 100" must be reconstructable from the
// triggers stream alone. Today both values exist only as notification
// template variables and never reach the stream.
#[test]
fn test_trigger_data_records_actual_and_threshold() {
let td = TriggerData {
module: TriggerDataType::Alert,
status: RunOutcome::Firing,
actual_value: Some(112.0),
threshold_value: Some(100.0),
threshold_operator: Some(">=".to_string()),
level: Some(2), // AlertLevel::Critical
..Default::default()
};
let v = serde_json::to_value(&td).unwrap();
assert_eq!(v.get("actual_value").and_then(|x| x.as_f64()), Some(112.0));
assert_eq!(
v.get("threshold_value").and_then(|x| x.as_f64()),
Some(100.0)
);
assert_eq!(
v.get("threshold_operator").and_then(|x| x.as_str()),
Some(">=")
);
assert_eq!(v.get("level").and_then(|x| x.as_i64()), Some(2));
}
/// A healthy run must still record what it observed — that is what makes
/// "how close did we get?" answerable from history. Per T-10 (as revised):
/// normal rows display actual value + Ok; only firing/warning rows display
/// a threshold.
#[test]
fn test_normal_runs_record_actual_value_and_level_ok() {
let td = TriggerData {
module: TriggerDataType::Alert,
status: RunOutcome::Normal,
actual_value: Some(12.0),
threshold_value: None,
// Level is the COMPUTED level: Ok (0) for a level-bearing normal
// run — never absent. `None` is reserved for non-condition modules
// and error/skipped runs (alerts_2.md §7.5).
level: Some(0),
..Default::default()
};
assert_eq!(td.actual_value, Some(12.0));
assert_eq!(
td.threshold_value, None,
"no threshold matched, so none is recorded (T-10: rendered without one)"
);
assert_eq!(td.level, Some(0), "normal rows carry level = Ok, not None");
}
#[test]
fn test_value_fields_are_optional_and_omitted_when_unset() {
// Non-condition modules (reports, workflows) have no values to record;
// the fields must not bloat every record.
let td = TriggerData {
module: TriggerDataType::Report,
status: RunOutcome::Succeeded,
..Default::default()
};
let v = serde_json::to_value(&td).unwrap();
assert!(v.get("actual_value").is_none());
assert!(v.get("threshold_value").is_none());
assert!(v.get("level").is_none());
}
/// The stream schema is generated by reflection, so new fields must appear
/// in the reflection sample or fresh orgs get a schema without them.
#[test]
fn test_reflection_sample_includes_the_value_context_fields() {
let names = TriggerData::get_field_names();
for f in [
"actual_value",
"threshold_value",
"threshold_operator",
"level",
"group_label",
] {
assert!(
names.contains(&f.to_string()),
"reflection sample must include `{f}` or new orgs get a schema without it"
);
}
}
/// D8: one record per evaluation, carrying the worst group's context.
#[test]
fn test_group_label_identifies_which_group_produced_the_value() {
let td = TriggerData {
module: TriggerDataType::Alert,
status: RunOutcome::Firing,
actual_value: Some(500.0),
threshold_value: Some(100.0),
group_label: Some("host=b".to_string()),
..Default::default()
};
assert_eq!(td.group_label.as_deref(), Some("host=b"));
}
#[test]
fn test_legacy_records_without_value_context_still_deserialize() {
// Rows written before T-9 have none of these fields.
let legacy = r#"{
"_timestamp": 0, "org": "o", "module": "alert", "key": "k",
"next_run_at": 0, "is_realtime": false, "is_silenced": false,
"status": "firing", "start_time": 0, "end_time": 0, "retries": 0,
"error": null, "success_response": null, "is_partial": null,
"delay_in_secs": null, "evaluation_took_in_secs": null,
"source_node": null, "query_took": null, "scheduler_trace_id": null,
"time_in_queue_ms": null
}"#;
let td: TriggerData = serde_json::from_str(legacy).unwrap();
assert_eq!(td.actual_value, None);
assert_eq!(td.level, None);
}
}
#[cfg(test)]
mod reserved_stream_tests {
use super::*;
#[test]
fn slo_slices_is_reserved() {
// Not self-reporting, but it needs the identical protection: a user
// write into it would corrupt the numbers an SLO reports.
assert!(is_reserved_internal_stream(
crate::meta::slo::stream::SLO_SLICES_STREAM
));
assert!(!is_reserved_self_reporting_stream(
crate::meta::slo::stream::SLO_SLICES_STREAM
));
}
#[test]
fn every_self_reporting_stream_is_also_an_internal_stream() {
for s in RESERVED_SELF_REPORTING_STREAMS {
assert!(is_reserved_internal_stream(s), "{s} lost its protection");
}
}
#[test]
fn an_ordinary_stream_name_is_not_reserved() {
for s in ["logs", "default", "slo", "slices", "slo_slice"] {
assert!(!is_reserved_internal_stream(s), "{s} wrongly reserved");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -793,45 +1570,9 @@ mod tests {
assert!(!UsageType::Retention.is_function());
}
#[test]
fn test_trigger_data_status_serialization() {
assert_eq!(
serde_json::to_string(&TriggerDataStatus::Completed).unwrap(),
"\"completed\""
);
assert_eq!(
serde_json::to_string(&TriggerDataStatus::Failed).unwrap(),
"\"failed\""
);
assert_eq!(
serde_json::to_string(&TriggerDataStatus::ConditionNotSatisfied).unwrap(),
"\"condition_not_satisfied\""
);
assert_eq!(
serde_json::to_string(&TriggerDataStatus::Skipped).unwrap(),
"\"skipped\""
);
}
#[test]
fn test_trigger_data_status_deserialization() {
assert_eq!(
serde_json::from_str::<TriggerDataStatus>("\"completed\"").unwrap(),
TriggerDataStatus::Completed
);
assert_eq!(
serde_json::from_str::<TriggerDataStatus>("\"failed\"").unwrap(),
TriggerDataStatus::Failed
);
assert_eq!(
serde_json::from_str::<TriggerDataStatus>("\"condition_not_satisfied\"").unwrap(),
TriggerDataStatus::ConditionNotSatisfied
);
assert_eq!(
serde_json::from_str::<TriggerDataStatus>("\"skipped\"").unwrap(),
TriggerDataStatus::Skipped
);
}
// NOTE: `TriggerDataStatus` serialization/deserialization tests were replaced
// by the `run_outcome_tests` module above when the enum became `RunOutcome`
// (Part III of alerts.md).
#[test]
fn test_trigger_data_type_serialization() {
@ -883,7 +1624,7 @@ mod tests {
next_run_at: 1234567890,
is_realtime: true,
is_silenced: false,
status: TriggerDataStatus::Completed,
status: RunOutcome::Succeeded,
start_time: 1234567890,
end_time: 1234567890,
retries: 0,
@ -902,6 +1643,12 @@ mod tests {
dedup_count: None,
grouped: None,
group_size: None,
actual_value: None,
threshold_value: None,
threshold_operator: None,
level: None,
group_label: None,
value_is_lower_bound: None,
};
let json = serde_json::to_string(&trigger_data).unwrap();

View File

@ -0,0 +1,374 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The per-org slice budget — S-14, D57.
//!
//! `ZO_SLO_MAX_PER_ORG` (200) and `ZO_SLO_MAX_GROUPS` (500) are each
//! defensible alone; their **product is not** — 200 × 500 groups over the
//! 97-day horizon is billions of rows. So the binding limit is on the product,
//! and it is charged three ways that a naive design gets wrong:
//!
//! * **Reservations, not estimates.** A save-time check is bypassable by organic group growth: a
//! 1-group SLO can grow to thousands with no further save to check it at.
//! * **Residuals, not instant release.** A superseded generation's rows and a deleted SLO's rows
//! stay in the stream until the horizon, so releasing the charge at delete time lets
//! create/backfill/delete cycles exceed the budget arbitrarily.
//! * **Logical rows with priced headroom.** The stream also holds late-data re-emissions, so the
//! budget is over *logical* `(group, slice)` rows with a revision multiplier — not a physical-row
//! invariant the formula cannot honestly deliver.
/// Stream retention horizon: max window (90d) + 7d margin (D57).
pub const RETENTION_HORIZON_SECS: i64 = 97 * 86_400;
/// Reserved groups for an SLO (S-14a).
///
/// An ungrouped SLO reserves exactly **1** — it can never grow a second
/// series, and an unconditional floor would charge it ~1.79M rows, letting 200
/// ungrouped SLOs exceed the whole default budget.
pub fn groups_reserved(is_grouped: bool, groups_estimate: i64, hard_cap: i64) -> i64 {
if !is_grouped {
// An ungrouped SLO can never grow a second series. An unconditional
// floor here charged it ~1.79M rows, which let 200 ungrouped SLOs
// exceed the whole default budget.
return 1;
}
(2 * groups_estimate).clamp(64, hard_cap)
}
/// Logical slice rows a reservation costs at the retention horizon, including
/// the revision headroom multiplier.
pub fn rows_charged(groups_reserved: i64, slice_interval_secs: i64, revision_headroom: f64) -> i64 {
if slice_interval_secs <= 0 {
return 0;
}
// Priced at the retention HORIZON, not the SLO's own window: retention is
// a stream property, so a 7-day SLO's slices still live 97 days (D57).
let slices = RETENTION_HORIZON_SECS / slice_interval_secs;
((groups_reserved * slices) as f64 * revision_headroom) as i64
}
/// A charge's lifecycle state (S-14c).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChargeState {
/// The SLO/generation is live and writing.
Active,
/// Superseded or deleted; the rows persist until they age out.
Residual { expires_at: i64 },
}
/// One row of `slo_budget_charges`.
#[derive(Debug, Clone, PartialEq)]
pub struct BudgetCharge {
pub slo_id: String,
pub generation: i32,
pub rows_charged: i64,
pub state: ChargeState,
}
/// Convert a charge to a residual expiring one horizon after its last write.
pub fn to_residual(charge: BudgetCharge, last_write_secs: i64) -> BudgetCharge {
// Releasing at delete time would let create/backfill/delete cycles exceed
// the budget arbitrarily: the rows persist to the horizon regardless.
BudgetCharge {
state: ChargeState::Residual {
expires_at: last_write_secs + RETENTION_HORIZON_SECS,
},
..charge
}
}
/// Total rows an org currently owes: active reservations plus unexpired
/// residuals.
pub fn org_usage(charges: &[BudgetCharge], now_secs: i64) -> i64 {
charges
.iter()
.filter(|c| match c.state {
ChargeState::Active => true,
ChargeState::Residual { expires_at } => now_secs < expires_at,
})
.map(|c| c.rows_charged)
.sum()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BudgetError {
/// The org would exceed its slice budget. Carries the arithmetic so the
/// 400 can show it.
OrgBudgetExceeded {
requested: i64,
in_use: i64,
limit: i64,
},
/// The preflight estimate is above the hard cardinality cap.
HardCapExceeded { estimate: i64, hard_cap: i64 },
}
/// Whether a new charge fits.
pub fn can_admit(
charges: &[BudgetCharge],
requested_rows: i64,
limit: i64,
now_secs: i64,
) -> Result<(), BudgetError> {
let in_use = org_usage(charges, now_secs);
if in_use + requested_rows > limit {
return Err(BudgetError::OrgBudgetExceeded {
requested: requested_rows,
in_use,
limit,
});
}
Ok(())
}
/// Whether the ingest job may raise a reservation in place, or must trip
/// `GroupOverflow` (S-14b).
pub fn can_raise_reservation(
charges: &[BudgetCharge],
slo_id: &str,
generation: i32,
new_rows: i64,
limit: i64,
now_secs: i64,
) -> bool {
// The raise REPLACES this charge rather than adding to it — summing would
// double-count the SLO's own reservation and trip overflow early.
let others: Vec<BudgetCharge> = charges
.iter()
.filter(|c| !(c.slo_id == slo_id && c.generation == generation))
.cloned()
.collect();
can_admit(&others, new_rows, limit, now_secs).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
const HEADROOM: f64 = 1.5;
const HARD_CAP: i64 = 10_000;
fn active(id: &str, generation: i32, rows: i64) -> BudgetCharge {
BudgetCharge {
slo_id: id.into(),
generation,
rows_charged: rows,
state: ChargeState::Active,
}
}
// ---- reservations ------------------------------------------------------
/// S-14a: the fix for the unconditional 64-floor, which charged every
/// ungrouped SLO ~1.79M rows.
#[test]
fn an_ungrouped_slo_reserves_exactly_one_group() {
assert_eq!(groups_reserved(false, 1, HARD_CAP), 1);
assert_eq!(
groups_reserved(false, 500, HARD_CAP),
1,
"an ungrouped SLO can never grow a second series"
);
}
#[test]
fn a_grouped_slo_reserves_double_the_estimate() {
assert_eq!(groups_reserved(true, 200, HARD_CAP), 400);
}
#[test]
fn a_grouped_reservation_has_a_floor_of_sixty_four() {
assert_eq!(groups_reserved(true, 5, HARD_CAP), 64);
assert_eq!(groups_reserved(true, 1, HARD_CAP), 64);
}
#[test]
fn a_grouped_reservation_is_clamped_at_the_hard_cap() {
assert_eq!(groups_reserved(true, 9_000, HARD_CAP), HARD_CAP);
}
#[test]
fn two_hundred_ungrouped_slos_fit_comfortably_in_the_default_budget() {
let per_slo = rows_charged(groups_reserved(false, 1, HARD_CAP), 300, HEADROOM);
let total = per_slo * 200;
assert!(
total < 250_000_000,
"200 ungrouped SLOs charged {total}, over the 250M default"
);
}
// ---- row arithmetic ----------------------------------------------------
#[test]
fn rows_are_charged_at_the_horizon_not_the_slo_window() {
// D57: retention is global, so a 7-day SLO's slices still live 97 days.
let rows = rows_charged(1, 300, 1.0);
assert_eq!(rows, RETENTION_HORIZON_SECS / 300);
assert_eq!(rows, 27_936);
}
#[test]
fn a_finer_slice_interval_charges_proportionally_more() {
assert_eq!(rows_charged(1, 60, 1.0), rows_charged(1, 300, 1.0) * 5);
}
#[test]
fn the_headroom_multiplier_prices_late_data_re_emissions() {
let bare = rows_charged(10, 300, 1.0);
let with_headroom = rows_charged(10, 300, 1.5);
assert_eq!(with_headroom, (bare as f64 * 1.5) as i64);
}
#[test]
fn a_five_hundred_group_slo_charges_the_documented_magnitude() {
// §6b.4d: ~14M logical rows steady state at the horizon.
let rows = rows_charged(500, 300, 1.0);
assert!(
(13_000_000..15_000_000).contains(&rows),
"expected ~14M, got {rows}"
);
}
// ---- admission ---------------------------------------------------------
#[test]
fn a_charge_that_fits_is_admitted() {
assert!(can_admit(&[active("a", 1, 100)], 50, 1_000, 0).is_ok());
}
#[test]
fn a_charge_that_would_exceed_the_limit_is_rejected_with_the_arithmetic() {
let err = can_admit(&[active("a", 1, 900)], 200, 1_000, 0).unwrap_err();
assert_eq!(
err,
BudgetError::OrgBudgetExceeded {
requested: 200,
in_use: 900,
limit: 1_000
}
);
}
#[test]
fn admission_is_exact_at_the_limit() {
assert!(can_admit(&[active("a", 1, 900)], 100, 1_000, 0).is_ok());
assert!(can_admit(&[active("a", 1, 900)], 101, 1_000, 0).is_err());
}
// ---- residuals: the create/delete cycling bypass ------------------------
#[test]
fn a_deleted_slo_becomes_a_residual_rather_than_releasing_immediately() {
let charge = to_residual(active("a", 1, 100), 1_000);
assert_eq!(
charge.state,
ChargeState::Residual {
expires_at: 1_000 + RETENTION_HORIZON_SECS
}
);
}
#[test]
fn an_unexpired_residual_still_counts_against_the_org() {
let charges = vec![to_residual(active("a", 1, 500), 1_000)];
assert_eq!(org_usage(&charges, 1_000 + 86_400), 500);
}
#[test]
fn an_expired_residual_stops_counting() {
let charges = vec![to_residual(active("a", 1, 500), 1_000)];
assert_eq!(org_usage(&charges, 1_000 + RETENTION_HORIZON_SECS + 1), 0);
}
/// The bypass residuals exist to close: without them, create → backfill →
/// delete in a loop consumes unbounded storage while the budget reads
/// zero.
#[test]
fn create_delete_cycling_cannot_exceed_the_budget() {
let mut charges = Vec::new();
let mut now = 0;
for i in 0..10 {
let c = active(&format!("slo{i}"), 1, 200);
// Admission must see the residuals from every prior cycle.
let admitted = can_admit(&charges, 200, 1_000, now).is_ok();
if admitted {
charges.push(to_residual(c, now));
}
now += 3_600;
}
let live = org_usage(&charges, now);
assert!(
live <= 1_000,
"cycling accumulated {live} rows against a 1,000 limit"
);
assert!(
charges.len() < 10,
"some creates must have been rejected; {} were admitted",
charges.len()
);
}
/// The other bypass: repeated computation edits, each leaving a full
/// superseded generation behind.
#[test]
fn repeated_generation_bumps_accumulate_residual_charges() {
let mut charges = vec![];
for generation in 1..=3 {
charges.push(to_residual(
active("a", generation, 300),
(generation as i64) * 60,
));
}
assert_eq!(
org_usage(&charges, 1_000),
900,
"all three generations' rows are still in the stream"
);
}
#[test]
fn a_superseded_generation_and_its_successor_are_both_charged() {
let charges = vec![to_residual(active("a", 1, 300), 100), active("a", 2, 300)];
assert_eq!(org_usage(&charges, 200), 600);
}
// ---- runtime reservation raises ----------------------------------------
#[test]
fn a_reservation_can_be_raised_when_the_org_has_headroom() {
let charges = vec![active("a", 1, 100)];
assert!(can_raise_reservation(&charges, "a", 1, 300, 1_000, 0));
}
#[test]
fn a_reservation_cannot_be_raised_past_the_org_limit() {
let charges = vec![active("a", 1, 100), active("b", 1, 800)];
assert!(
!can_raise_reservation(&charges, "a", 1, 500, 1_000, 0),
"raising a to 500 would total 1300 against a 1000 limit"
);
}
/// The raise must replace the SLO's own charge, not add to it — otherwise
/// growth double-counts and trips overflow early.
#[test]
fn raising_replaces_the_slos_existing_charge() {
let charges = vec![active("a", 1, 900)];
assert!(
can_raise_reservation(&charges, "a", 1, 950, 1_000, 0),
"950 replaces 900; it does not sum to 1850"
);
}
}

View File

@ -0,0 +1,321 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Row-budget arithmetic (`alerts_2.md` §6b.4d, S-14).
//!
//! The limit exists because `SLOs × GROUPS × window` is indefensible even
//! where each factor is individually fine: 100 SLOs × 500 groups × 90 days at
//! 5-minute slices is 1.3 **billion** rows, and every factor in that product
//! passes its own limit. So the budget is on the product.
//!
//! Two rules that look like details and are not:
//!
//! * **Priced at the horizon, not the window.** Slices live until retention regardless of how long
//! the SLO's window is, so a 7-day SLO occupies the same storage as a 90-day one. Pricing by
//! window would let an org buy unlimited storage by declaring short windows.
//! * **Physical, not logical.** Late data and recomputes re-emit rows; the dedupe happens at read
//! time, not in storage. `ZO_SLO_REVISION_HEADROOM` prices that.
/// The retention horizon slices are priced against: the longest window plus
/// the 7-day grace that keeps a just-expired window readable.
pub const SLICE_HORIZON_SECS: i64 = 97 * 86_400;
/// Why a reservation could not be granted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BudgetError {
/// The org's budget is already committed. Carries the arithmetic, because
/// "quota exceeded" without numbers is unactionable — §6b.4d requires the
/// rejection to show its working.
OrgBudgetExceeded {
requested: i64,
active: i64,
residual: i64,
cap: i64,
},
/// The reservation itself is larger than any org could hold.
RequestExceedsCap { requested: i64, cap: i64 },
}
impl std::fmt::Display for BudgetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OrgBudgetExceeded {
requested,
active,
residual,
cap,
} => write!(
f,
"SLO would reserve {requested} slice rows; the org already holds \
{active} active + {residual} residual of a {cap} row budget \
({} free)",
(cap - active - residual).max(0)
),
Self::RequestExceedsCap { requested, cap } => write!(
f,
"SLO would reserve {requested} slice rows, more than the entire \
{cap} row org budget"
),
}
}
}
impl std::error::Error for BudgetError {}
/// How many groups an SLO reserves.
///
/// `1` when ungrouped. Otherwise **twice** the preflight estimate, floored at
/// 64 and capped: the doubling is headroom for organic group growth, because
/// a reservation that exactly matched today's cardinality would trip the
/// moment one new region appeared.
pub fn groups_reserved(is_grouped: bool, estimate: Option<i64>, hard_cap: i64) -> i64 {
if !is_grouped {
return 1;
}
let doubled = estimate.unwrap_or(0).saturating_mul(2);
doubled.clamp(64, hard_cap.max(64))
}
/// Logical rows an SLO reserves: groups × slices-to-the-horizon × revision
/// headroom.
///
/// Priced at the **horizon**, not the SLO's window — see the module note.
pub fn rows_for_reservation(
groups_reserved: i64,
slice_interval_secs: i64,
revision_headroom: f64,
) -> i64 {
if slice_interval_secs <= 0 {
return 0;
}
let slices = SLICE_HORIZON_SECS / slice_interval_secs;
let logical = groups_reserved.saturating_mul(slices);
// Headroom below 1.0 would under-price; it is a multiplier for
// re-emissions, not a discount.
let headroom = revision_headroom.max(1.0);
((logical as f64) * headroom).ceil() as i64
}
/// Whether `requested` fits in the org's remaining budget.
///
/// Residual rows count against the cap. They are real storage — a superseded
/// generation's slices persist to the horizon whether or not anything reads
/// them — and not charging them would make delete-and-recreate an unlimited
/// storage loophole (S-14c).
pub fn check_headroom(
requested: i64,
active: i64,
residual: i64,
cap: i64,
) -> Result<(), BudgetError> {
if requested > cap {
return Err(BudgetError::RequestExceedsCap { requested, cap });
}
if active.saturating_add(residual).saturating_add(requested) > cap {
return Err(BudgetError::OrgBudgetExceeded {
requested,
active,
residual,
cap,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const CAP: i64 = 250_000_000;
// ===================== reservation sizing =============================
#[test]
fn an_ungrouped_slo_reserves_one_group() {
assert_eq!(groups_reserved(false, None, 500), 1);
// Even if an estimate somehow came back — ungrouped is one series.
assert_eq!(groups_reserved(false, Some(400), 500), 1);
}
/// The doubling is headroom for organic growth: a reservation that exactly
/// matched today's cardinality would trip the first time a new region
/// appeared.
#[test]
fn a_grouped_slo_reserves_twice_its_estimate() {
assert_eq!(groups_reserved(true, Some(100), 500), 200);
}
#[test]
fn a_small_estimate_is_floored_at_64() {
assert_eq!(groups_reserved(true, Some(3), 500), 64);
assert_eq!(groups_reserved(true, Some(0), 500), 64);
// No preflight result is not a licence to reserve nothing.
assert_eq!(groups_reserved(true, None, 500), 64);
}
#[test]
fn a_large_estimate_is_capped() {
assert_eq!(groups_reserved(true, Some(10_000), 500), 500);
}
/// The floor must survive a hard cap set below it, or a misconfigured
/// deployment would reserve less than the minimum.
#[test]
fn the_floor_wins_over_an_absurdly_low_cap() {
assert_eq!(groups_reserved(true, Some(100), 10), 64);
}
#[test]
fn a_huge_estimate_does_not_overflow() {
assert_eq!(groups_reserved(true, Some(i64::MAX), 500), 500);
}
// ===================== row pricing ====================================
/// The §6b.4d figure: an ungrouped 5-minute SLO is negligible.
#[test]
fn an_ungrouped_five_minute_slo_is_cheap() {
// 97d / 5min = 27,936 slices.
assert_eq!(rows_for_reservation(1, 300, 1.0), 27_936);
}
#[test]
fn a_one_minute_slo_costs_five_times_a_five_minute_one() {
assert_eq!(
rows_for_reservation(1, 60, 1.0),
rows_for_reservation(1, 300, 1.0) * 5
);
}
/// Pricing by window rather than horizon would let an org buy unlimited
/// storage by declaring short windows — the slices live to the horizon
/// either way.
#[test]
fn the_price_does_not_depend_on_the_slo_window() {
// There is no window parameter, and that is the point. Pinned as a
// test so adding one is a deliberate act.
let seven_day_slo = rows_for_reservation(64, 300, 1.0);
let ninety_day_slo = rows_for_reservation(64, 300, 1.0);
assert_eq!(seven_day_slo, ninety_day_slo);
}
#[test]
fn revision_headroom_multiplies_and_rounds_up() {
let base = rows_for_reservation(1, 300, 1.0);
assert_eq!(
rows_for_reservation(1, 300, 1.5),
(base as f64 * 1.5).ceil() as i64
);
}
/// Headroom prices re-emissions. A value below 1.0 would under-price them,
/// so it is clamped rather than honoured.
#[test]
fn headroom_below_one_does_not_discount() {
assert_eq!(
rows_for_reservation(10, 300, 0.5),
rows_for_reservation(10, 300, 1.0)
);
}
#[test]
fn a_nonsensical_slice_interval_prices_at_zero_rather_than_dividing_by_it() {
assert_eq!(rows_for_reservation(10, 0, 1.0), 0);
assert_eq!(rows_for_reservation(10, -300, 1.0), 0);
}
/// The product this whole budget exists to bound (§6b.4d): each factor
/// passes its own limit, and together they are 1.3 billion rows.
#[test]
fn the_indefensible_product_is_rejected() {
let per_slo = rows_for_reservation(500, 300, 1.0);
let hundred_slos = per_slo * 100;
assert!(
hundred_slos > CAP,
"100 x 500 groups x 97d @5m = {hundred_slos}, which must not fit \
in a {CAP} row budget"
);
}
// ===================== headroom check =================================
#[test]
fn a_reservation_that_fits_is_granted() {
assert_eq!(check_headroom(1_000, 500, 500, CAP), Ok(()));
}
#[test]
fn a_reservation_that_exactly_fills_the_budget_is_granted() {
assert_eq!(check_headroom(10, 80, 10, 100), Ok(()));
}
#[test]
fn one_row_past_the_budget_is_rejected() {
assert_eq!(
check_headroom(11, 80, 10, 100),
Err(BudgetError::OrgBudgetExceeded {
requested: 11,
active: 80,
residual: 10,
cap: 100
})
);
}
/// Residual rows are real storage. Not charging them would make
/// delete-and-recreate an unlimited storage loophole (S-14c).
#[test]
fn residual_rows_count_against_the_budget() {
assert!(check_headroom(50, 0, 0, 100).is_ok());
assert!(
check_headroom(50, 0, 60, 100).is_err(),
"a residual charge was ignored"
);
}
#[test]
fn a_request_larger_than_the_whole_budget_says_so_specifically() {
assert_eq!(
check_headroom(500, 0, 0, 100),
Err(BudgetError::RequestExceedsCap {
requested: 500,
cap: 100
})
);
}
/// "Quota exceeded" without numbers is unactionable — §6b.4d requires the
/// rejection to show its arithmetic.
#[test]
fn the_rejection_shows_its_arithmetic() {
let msg = check_headroom(11, 80, 10, 100).unwrap_err().to_string();
for part in ["11", "80", "10", "100"] {
assert!(msg.contains(part), "{msg} omits {part}");
}
}
#[test]
fn the_free_figure_never_goes_negative_in_the_message() {
// An org already over budget (a cap lowered under it) must still get a
// sensible message rather than "-40 free".
let msg = check_headroom(10, 90, 50, 100).unwrap_err().to_string();
assert!(msg.contains("0 free"), "{msg}");
}
#[test]
fn saturating_arithmetic_survives_absurd_inputs() {
assert!(check_headroom(i64::MAX, i64::MAX, i64::MAX, CAP).is_err());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,427 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Coverage, and the refusal to fabricate uptime — §6b.4b, S-8, SA-17, SA-18.
//!
//! This is the module that decides whether an SLO alert may change state at
//! all. Its single job is to distinguish **"we measured, and it was fine"**
//! from **"we did not measure"**, because collapsing those two is the worst
//! failure this feature can have: a search outage would read as "no errors
//! observed" and recover every burn-rate alert in the org.
//!
//! Deliberate divergence from Datadog, which counts missing data in a Time
//! Slice SLO as uptime (D34).
//!
//! For an `alert`-type SLI the same question is asked of a *source alert's*
//! evaluations rather than of a query — see [`evaluation_is_measured`].
use crate::meta::self_reporting::usage::RunOutcome;
/// Coverage as a fraction in `[0, 1]`: observed slices over *expected* slices,
/// where expected comes from the aligned grid — never from what a query
/// happened to return.
pub fn coverage(observed_slices: i64, expected_slices: i64) -> f64 {
if expected_slices <= 0 {
// Nothing was expected, so nothing was covered. Reporting 1.0 here
// would make an empty window look fully measured.
return 0.0;
}
// Clamped: duplicate rows must never report more than fully covered.
(observed_slices as f64 / expected_slices as f64).clamp(0.0, 1.0)
}
/// One window's worth of aggregated slices, as read from the status row.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WindowRead {
pub good: f64,
pub total: f64,
pub observed_slices: i64,
pub expected_slices: i64,
}
/// Why an evaluation could not observe anything. Each maps to "freeze the
/// level" — never to a recovery.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnobservedReason {
/// Coverage for this window is under the floor (S-8).
BelowCoverageFloor,
/// The window is covered but carries no events, so the SLI is undefined
/// (SA-18). Usually itself an incident.
ZeroTotal,
/// The SLO's watermark has not advanced recently enough to trust (SA-14).
StaleWatermark,
}
/// The result of trying to observe a window.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Observation {
/// A real measurement — the SLI over the window.
Observed { sli: f64 },
/// Nothing was measured. The caller must leave `level`, `level_since` and
/// `level_at` untouched (§7.6).
Unobserved(UnobservedReason),
}
impl Observation {
pub fn is_observed(&self) -> bool {
matches!(self, Self::Observed { .. })
}
pub fn sli(&self) -> Option<f64> {
match self {
Self::Observed { sli } => Some(*sli),
Self::Unobserved(_) => None,
}
}
}
/// Decide whether a window read is a measurement.
///
/// `watermark_stale` is computed by [`super::window::watermark_is_stale`] and
/// passed in so this stays a pure decision over already-gathered facts.
pub fn observe(read: WindowRead, coverage_floor: f64, watermark_stale: bool) -> Observation {
// Precedence is fixed and deliberate. Staleness first: if the data is not
// current, nothing about it is a measurement, whatever its coverage says.
// Then coverage: we cannot claim a window was empty if we did not measure
// it. Only then emptiness.
if watermark_stale {
return Observation::Unobserved(UnobservedReason::StaleWatermark);
}
if coverage(read.observed_slices, read.expected_slices) < coverage_floor {
return Observation::Unobserved(UnobservedReason::BelowCoverageFloor);
}
match super::math::sli(read.good, read.total) {
Some(sli) => Observation::Observed { sli },
None => Observation::Unobserved(UnobservedReason::ZeroTotal),
}
}
/// Whether one evaluation of a **source alert** counts as a measurement, for
/// an `alert`-type SLI (S-16, D65).
///
/// This is not a new classification — it is exactly §7.6's rule for which
/// outcomes refresh `level_at`, i.e. which evaluations actually computed a
/// level. An `alert` SLI's coverage is the fraction of slices containing at
/// least one such record in the `triggers` stream.
///
/// The cases that are *absent* rather than negative matter just as much: a
/// paused or disabled alert publishes **no record at all** (the scheduler
/// returns before publishing), so its slices have no evidence of measurement
/// and fall through gap-fill as uncovered.
pub fn evaluation_is_measured(outcome: &RunOutcome) -> bool {
match outcome {
// Computed a level — the same set §7.6 refreshes `level_at` for.
RunOutcome::Firing | RunOutcome::Normal | RunOutcome::NotifyFailed => true,
// Observed nothing: the query failed, the run was skipped, or the
// outcome belongs to a non-condition module.
RunOutcome::Error | RunOutcome::Skipped | RunOutcome::Succeeded => false,
}
}
/// Whether an SLO's overall status should read as `NoData` (S-8).
pub fn is_no_data(read: WindowRead, coverage_floor: f64) -> bool {
coverage(read.observed_slices, read.expected_slices) < coverage_floor
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meta::self_reporting::usage::RunOutcome;
const FLOOR: f64 = 0.8;
fn full(good: f64, total: f64) -> WindowRead {
WindowRead {
good,
total,
observed_slices: 100,
expected_slices: 100,
}
}
// ---- coverage arithmetic ----------------------------------------------
#[test]
fn full_coverage_is_one() {
assert_eq!(coverage(100, 100), 1.0);
}
#[test]
fn no_observations_is_zero_coverage() {
assert_eq!(coverage(0, 100), 0.0);
}
#[test]
fn partial_coverage_is_the_fraction() {
assert!((coverage(71, 100) - 0.71).abs() < 1e-9);
}
#[test]
fn coverage_of_an_empty_window_is_zero_not_a_divide_by_zero() {
assert_eq!(coverage(0, 0), 0.0);
}
#[test]
fn coverage_is_clamped_at_one() {
// Defensive: duplicate rows must never report >100% covered.
assert_eq!(coverage(150, 100), 1.0);
}
// ---- the floor ---------------------------------------------------------
#[test]
fn a_well_covered_window_is_observed() {
let obs = observe(full(999.0, 1000.0), FLOOR, false);
assert!(obs.is_observed());
assert!((obs.sli().unwrap() - 99.9).abs() < 1e-9);
}
#[test]
fn coverage_exactly_at_the_floor_is_observed() {
let read = WindowRead {
observed_slices: 80,
expected_slices: 100,
..full(999.0, 1000.0)
};
assert!(observe(read, FLOOR, false).is_observed());
}
#[test]
fn coverage_below_the_floor_is_unobserved() {
let read = WindowRead {
observed_slices: 71,
expected_slices: 100,
..full(999.0, 1000.0)
};
assert_eq!(
observe(read, FLOOR, false),
Observation::Unobserved(UnobservedReason::BelowCoverageFloor)
);
}
/// The headline failure mode. A search outage leaves a window that looks
/// perfect — every slice it *did* see was good — and must NOT be read as a
/// recovery.
#[test]
fn a_search_outage_never_reads_as_a_healthy_window() {
let outage = WindowRead {
good: 50.0,
total: 50.0, // everything observed was perfect
observed_slices: 5,
expected_slices: 100,
};
let obs = observe(outage, FLOOR, false);
assert!(
!obs.is_observed(),
"a 5%-covered window must never report a 100% SLI"
);
}
// ---- zero total (SA-18) ------------------------------------------------
#[test]
fn a_covered_window_with_no_events_is_unobserved_not_healthy() {
let idle = WindowRead {
good: 0.0,
total: 0.0,
observed_slices: 100,
expected_slices: 100,
};
assert_eq!(
observe(idle, FLOOR, false),
Observation::Unobserved(UnobservedReason::ZeroTotal),
"traffic stopping is usually the incident, not a recovery"
);
}
#[test]
fn zero_total_is_never_reported_as_a_zero_burn_rate() {
let idle = WindowRead {
good: 0.0,
total: 0.0,
observed_slices: 100,
expected_slices: 100,
};
assert_eq!(observe(idle, FLOOR, false).sli(), None);
}
// ---- stale watermark (SA-14) -------------------------------------------
#[test]
fn a_stale_watermark_makes_the_window_unobserved() {
assert_eq!(
observe(full(999.0, 1000.0), FLOOR, true),
Observation::Unobserved(UnobservedReason::StaleWatermark)
);
}
#[test]
fn staleness_wins_over_good_coverage() {
// Perfect coverage but a frozen watermark: the data is stale, so
// nothing about it is a current measurement.
let obs = observe(full(1000.0, 1000.0), FLOOR, true);
assert!(!obs.is_observed());
}
// ---- precedence --------------------------------------------------------
/// When several reasons apply the caller only needs "unobserved", but the
/// reason drives the UI copy, so the precedence must be stable.
#[test]
fn unobserved_reasons_have_a_deterministic_precedence() {
let bad = WindowRead {
good: 0.0,
total: 0.0,
observed_slices: 1,
expected_slices: 100,
};
// Stale watermark is the most fundamental: the data is not current at
// all, so it outranks coverage and emptiness.
assert_eq!(
observe(bad, FLOOR, true),
Observation::Unobserved(UnobservedReason::StaleWatermark)
);
// Without staleness, coverage outranks zero-total: we cannot even say
// the window was empty if we did not measure it.
assert_eq!(
observe(bad, FLOOR, false),
Observation::Unobserved(UnobservedReason::BelowCoverageFloor)
);
}
// ---- NoData status -----------------------------------------------------
#[test]
fn no_data_tracks_the_coverage_floor() {
assert!(!is_no_data(full(1.0, 1.0), FLOOR));
assert!(is_no_data(
WindowRead {
observed_slices: 10,
expected_slices: 100,
..full(1.0, 1.0)
},
FLOOR
));
}
// ---- alert-SLI measurement availability (S-16, D65) --------------------
/// The outcomes that computed a level are exactly the ones that count as
/// measurement — the same partition §7.6 uses for `level_at`.
#[test]
fn evaluations_that_computed_a_level_count_as_measured() {
for outcome in [
RunOutcome::Firing,
RunOutcome::Normal,
RunOutcome::NotifyFailed,
] {
assert!(
evaluation_is_measured(&outcome),
"{outcome:?} computed a level and must count as measured"
);
}
}
/// A query failure observed nothing, so it cannot contribute uptime — the
/// same reason §7.6 refuses to refresh `level_at` on an error.
#[test]
fn an_errored_evaluation_is_not_a_measurement() {
assert!(!evaluation_is_measured(&RunOutcome::Error));
}
#[test]
fn a_skipped_evaluation_is_not_a_measurement() {
assert!(!evaluation_is_measured(&RunOutcome::Skipped));
}
/// An interval in which the source alert was **paused** has no records at
/// all, so it must read as reduced coverage rather than as uptime. This is
/// the case S-16 exists for: pausing an alert is a routine operator
/// action, and counting that time as good would silently inflate the SLO
/// for as long as the pause lasted.
#[test]
fn a_paused_interval_reduces_coverage_rather_than_counting_as_uptime() {
// 100 slices expected; the source alert was paused for 40 of them, so
// those produced no trigger records at all.
let read = WindowRead {
good: 60.0,
total: 60.0, // every measured slice was healthy
observed_slices: 60,
expected_slices: 100,
};
assert!((coverage(60, 100) - 0.6).abs() < 1e-9);
assert_eq!(
observe(read, FLOOR, false),
Observation::Unobserved(UnobservedReason::BelowCoverageFloor),
"a 60%-covered window must not report the 100% SLI of the slices \
that happened to be measured"
);
}
/// The same, for intervals the alert *ran* but failed to evaluate. These
/// DO produce records, so they must be excluded by status rather than by
/// absence — a coverage rule keyed only on "is there a record" would count
/// them.
#[test]
fn an_errored_interval_reduces_coverage_rather_than_counting_as_uptime() {
let measured = [
RunOutcome::Normal,
RunOutcome::Error,
RunOutcome::Error,
RunOutcome::Normal,
]
.iter()
.filter(|o| evaluation_is_measured(o))
.count();
assert_eq!(measured, 2, "the errored slices must not be counted");
let read = WindowRead {
good: 2.0,
total: 2.0,
observed_slices: measured as i64,
expected_slices: 4,
};
assert!(
!observe(read, FLOOR, false).is_observed(),
"half the window unmeasured must freeze, not report 100%"
);
}
// ---- per-window independence (SA-17) -----------------------------------
/// A 30-day window can be well covered while the last hour is a hole — and
/// the last hour is exactly what a burn-rate alert is about. Each window is
/// therefore gated on its OWN coverage.
#[test]
fn a_well_covered_long_window_does_not_vouch_for_a_broken_short_one() {
let long = WindowRead {
good: 43_000.0,
total: 43_200.0,
observed_slices: 43_100,
expected_slices: 43_200,
};
let short = WindowRead {
good: 4.0,
total: 4.0,
observed_slices: 1,
expected_slices: 5,
};
assert!(observe(long, FLOOR, false).is_observed());
assert!(
!observe(short, FLOOR, false).is_observed(),
"the short window must be judged on its own coverage"
);
}
}

View File

@ -0,0 +1,391 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Definition generations — §6b.4a, S-13, D43, D59.
//!
//! Slice identity is `(slo_id, group_key, slice_start)`, which says nothing
//! about *what was being measured*. Change the `good_expr`, the stream, the
//! comparator, the slice interval or the grouping, and the next pass starts
//! writing slices that mean something different from the ones already in the
//! window — which would then be summed together for up to 90 days, producing a
//! number that describes no definition that ever existed.
//!
//! **Generation is also the writing epoch (D59).** Two drafts tried to make
//! A → B → A cheap by reusing a matching prior generation; each spawned a
//! crop of correctness machinery (sealed per-epoch commit marks, status
//! re-seeding, cross-epoch revision ordering) serving exactly one feature —
//! fast revert. Cut. A computation-affecting edit, *including a revert*,
//! always mints a fresh generation and rebuilds. That leaves one `reset_time`,
//! one pair of committed marks, one `rev` space, and only the current
//! generation readable.
//!
//! `target` is deliberately **not** computation-affecting: it is applied at
//! read time (D56), so editing it never invalidates a slice.
use super::SloDefinition;
/// A stable hash over exactly the computation-affecting fields.
///
/// Canonical form — sorted keys, expressions re-rendered from their AST — so
/// cosmetic re-edits (whitespace in a predicate) hash equal.
pub fn definition_hash(definition: &SloDefinition) -> String {
use std::hash::{Hash, Hasher};
// Canonical form first: serialize, then re-parse into a BTreeMap-backed
// value so key order cannot affect the hash, and normalize the free-text
// expressions so a whitespace-only edit does not read as a semantic one.
let mut value = crate::utils::json::to_value(definition).unwrap_or(serde_json::Value::Null);
canonicalize(&mut value);
let canonical = serde_json::to_string(&value).unwrap_or_default();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
canonical.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// Collapse insignificant whitespace inside every string, recursively.
///
/// A full AST re-render belongs at the query-safety boundary
/// (`parse_predicate`), which is where a fragment is validated. This is the
/// cheap half of the same idea: it stops reformatting from triggering a
/// 90-day rebuild without pretending to understand SQL.
fn canonicalize(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) => {
*s = s.split_whitespace().collect::<Vec<_>>().join(" ");
}
serde_json::Value::Array(items) => items.iter_mut().for_each(canonicalize),
serde_json::Value::Object(map) => map.iter_mut().for_each(|(_, v)| canonicalize(v)),
_ => {}
}
}
/// Whether an edit requires a fresh generation and a rebuild.
pub fn requires_new_generation(old: &SloDefinition, new: &SloDefinition) -> bool {
// Every field of SloDefinition is computation-affecting by construction —
// `target` is deliberately not a member (D56). So the hash IS the test.
definition_hash(old) != definition_hash(new)
}
/// Whether a writer whose pass began at `writer_generation` may still commit
/// against the SLO's current generation (§6b.4b CAS fence).
///
/// Delivery of a columnar batch can outlive the generation that ordered it; a
/// late commit must fail rather than advance the new generation's marks with
/// the old generation's arithmetic.
pub fn writer_may_commit(writer_generation: i32, current_generation: i32) -> bool {
// Equality, not `>=`: a mismatch in either direction means the writer's
// arithmetic does not describe the current definition.
writer_generation == current_generation
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meta::{
alerts::Operator,
slo::{CountSource, QueryLanguage, SliConfig, SloDefinition},
};
fn count_def() -> SloDefinition {
SloDefinition {
sli_config: SliConfig::Count {
source: CountSource::SingleQuery {
stream: "requests".into(),
stream_type: "logs".into(),
scope: Some("service = 'checkout'".into()),
good_expr: "status_code < 500".into(),
},
},
group_by: None,
window_secs: 30 * 86_400,
slice_interval_secs: 60,
}
}
fn time_slice_def() -> SloDefinition {
SloDefinition {
sli_config: SliConfig::TimeSlice {
stream: "http_metrics".into(),
stream_type: "metrics".into(),
query_language: QueryLanguage::Sql,
query: "SELECT p95(duration_ms) AS zo_slo_value".into(),
scope: None,
comparator: Operator::LessThan,
threshold: 500.0,
absent_is_bad: false,
},
group_by: Some(vec!["region".into()]),
window_secs: 7 * 86_400,
slice_interval_secs: 300,
}
}
// ---- hashing -----------------------------------------------------------
#[test]
fn the_same_definition_hashes_the_same() {
assert_eq!(definition_hash(&count_def()), definition_hash(&count_def()));
}
#[test]
fn different_definitions_hash_differently() {
assert_ne!(
definition_hash(&count_def()),
definition_hash(&time_slice_def())
);
}
#[test]
fn the_hash_is_stable_across_calls_and_nonempty() {
let h = definition_hash(&count_def());
assert!(!h.is_empty());
for _ in 0..5 {
assert_eq!(definition_hash(&count_def()), h);
}
}
// ---- what forces a rebuild ---------------------------------------------
#[test]
fn an_identical_definition_needs_no_new_generation() {
assert!(!requires_new_generation(&count_def(), &count_def()));
}
#[test]
fn changing_the_good_predicate_forces_a_rebuild() {
let mut new = count_def();
new.sli_config = SliConfig::Count {
source: CountSource::SingleQuery {
stream: "requests".into(),
stream_type: "logs".into(),
scope: Some("service = 'checkout'".into()),
good_expr: "status_code < 400".into(),
},
};
assert!(requires_new_generation(&count_def(), &new));
}
#[test]
fn changing_the_scope_forces_a_rebuild() {
let mut new = count_def();
new.sli_config = SliConfig::Count {
source: CountSource::SingleQuery {
stream: "requests".into(),
stream_type: "logs".into(),
scope: Some("service = 'cart'".into()),
good_expr: "status_code < 500".into(),
},
};
assert!(requires_new_generation(&count_def(), &new));
}
#[test]
fn changing_the_stream_forces_a_rebuild() {
let mut new = count_def();
new.sli_config = SliConfig::Count {
source: CountSource::SingleQuery {
stream: "other_requests".into(),
stream_type: "logs".into(),
scope: Some("service = 'checkout'".into()),
good_expr: "status_code < 500".into(),
},
};
assert!(requires_new_generation(&count_def(), &new));
}
#[test]
fn changing_the_slice_interval_forces_a_rebuild() {
let mut new = count_def();
new.slice_interval_secs = 300;
assert!(requires_new_generation(&count_def(), &new));
}
#[test]
fn changing_the_window_forces_a_rebuild() {
let mut new = count_def();
new.window_secs = 90 * 86_400;
assert!(requires_new_generation(&count_def(), &new));
}
#[test]
fn changing_the_grouping_forces_a_rebuild() {
let mut new = count_def();
new.group_by = Some(vec!["region".into()]);
assert!(requires_new_generation(&count_def(), &new));
}
#[test]
fn changing_the_sli_type_forces_a_rebuild() {
assert!(requires_new_generation(&count_def(), &time_slice_def()));
}
#[test]
fn changing_a_time_slice_comparator_or_threshold_forces_a_rebuild() {
let base = time_slice_def();
let mut cmp_changed = base.clone();
cmp_changed.sli_config = SliConfig::TimeSlice {
stream: "http_metrics".into(),
stream_type: "metrics".into(),
query_language: QueryLanguage::Sql,
query: "SELECT p95(duration_ms) AS zo_slo_value".into(),
scope: None,
comparator: Operator::LessThanEquals,
threshold: 500.0,
absent_is_bad: false,
};
assert!(requires_new_generation(&base, &cmp_changed));
let mut threshold_changed = base.clone();
threshold_changed.sli_config = SliConfig::TimeSlice {
stream: "http_metrics".into(),
stream_type: "metrics".into(),
query_language: QueryLanguage::Sql,
query: "SELECT p95(duration_ms) AS zo_slo_value".into(),
scope: None,
comparator: Operator::LessThan,
threshold: 250.0,
absent_is_bad: false,
};
assert!(requires_new_generation(&base, &threshold_changed));
}
/// D56/S-13: the target is applied at read time, so it is deliberately
/// absent from `SloDefinition` and cannot force a rebuild. This test
/// documents that the type system enforces it.
#[test]
fn the_target_is_not_part_of_the_computation_affecting_definition() {
// If `target` were ever added to SloDefinition this would stop
// compiling, which is the point.
let def = count_def();
let json = serde_json::to_value(&def).unwrap();
assert!(
json.get("target").is_none(),
"target must not be computation-affecting (D56)"
);
}
/// D59: reverts rebuild. A → B → A produces a *third* generation, not a
/// reuse of the first — that is the whole simplification.
#[test]
fn a_revert_still_requires_a_new_generation() {
let a = count_def();
let mut b = count_def();
b.slice_interval_secs = 300;
assert!(requires_new_generation(&a, &b), "A -> B");
assert!(
requires_new_generation(&b, &a),
"B -> A must ALSO rebuild; generation reuse was cut (D59)"
);
}
/// The hash remains useful for diagnostics — a revert IS recognisable as
/// returning to a previous definition — it simply no longer drives
/// generation reuse (D59). This asserts the round trip, which the
/// duplicate-of-an-earlier-test it replaced did not.
#[test]
fn a_revert_is_recognisable_by_hash_even_though_it_still_rebuilds() {
let a = count_def();
let mut b = count_def();
b.slice_interval_secs = 300;
let (ha, hb) = (definition_hash(&a), definition_hash(&b));
assert_ne!(ha, hb, "A and B are different definitions");
// Revert to A: the hash comes back, but the rebuild still happens.
let a_again = count_def();
assert_eq!(definition_hash(&a_again), ha, "the hash round-trips");
assert!(
requires_new_generation(&b, &a_again),
"recognising the revert must NOT short-circuit the rebuild"
);
}
// ---- AST canonicalization ----------------------------------------------
fn count_def_with_scope(scope: &str) -> SloDefinition {
let mut d = count_def();
d.sli_config = SliConfig::Count {
source: CountSource::SingleQuery {
stream: "requests".into(),
stream_type: "logs".into(),
scope: Some(scope.into()),
good_expr: "status_code < 500".into(),
},
};
d
}
/// §6b.4a promises the hash is over the **canonical** form — expressions
/// re-rendered from their AST — so that reformatting a predicate does not
/// masquerade as a semantic change and trigger a 90-day rebuild.
#[test]
fn whitespace_only_predicate_edits_hash_identically() {
let a = count_def_with_scope("service = 'checkout'");
let b = count_def_with_scope("service = 'checkout'");
assert_eq!(
definition_hash(&a),
definition_hash(&b),
"reformatting must not look like a semantic change"
);
}
#[test]
fn whitespace_only_predicate_edits_do_not_force_a_rebuild() {
let a = count_def_with_scope("service = 'checkout'");
let b = count_def_with_scope(" service = 'checkout' ");
assert!(
!requires_new_generation(&a, &b),
"a cosmetic edit must not rebuild 90 days of history"
);
}
#[test]
fn a_semantic_predicate_edit_still_forces_a_rebuild() {
let a = count_def_with_scope("service = 'checkout'");
let b = count_def_with_scope("service = 'cart'");
assert!(requires_new_generation(&a, &b));
}
/// Canonicalization must not go so far as to erase meaning: these differ.
#[test]
fn logically_distinct_predicates_do_not_collide() {
let a = count_def_with_scope("a = 1 AND b = 2");
let b = count_def_with_scope("a = 1 OR b = 2");
assert_ne!(definition_hash(&a), definition_hash(&b));
}
// ---- the CAS fence -----------------------------------------------------
#[test]
fn a_writer_on_the_current_generation_may_commit() {
assert!(writer_may_commit(4, 4));
}
/// The stale-writer case: an ingest or backfill pass that finishes after a
/// computation edit landed must NOT advance the new generation's marks.
#[test]
fn a_writer_from_a_superseded_generation_may_not_commit() {
assert!(!writer_may_commit(3, 4));
}
#[test]
fn a_writer_from_the_future_may_not_commit_either() {
// Defensive: should be impossible, but a mismatch in either direction
// means the writer's arithmetic does not match the current definition.
assert!(!writer_may_commit(5, 4));
}
}

View File

@ -0,0 +1,413 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Grouped SLOs — the exact overall row and the two-tier cap (S-9, S-10, D46).
//!
//! The contradiction this module resolves: S-9 requires the overall row to
//! aggregate **every** group, while S-10 keeps at most a few hundred per-group
//! rows. Both are satisfied because the overall is computed **in the engine
//! over the full group set** and the cap truncates only which *per-group* rows
//! are persisted. Getting that wrong resolves in the direction that inflates
//! the SLO of the worst service.
//!
//! The other subtlety is the time-slice overall. It is not a plain
//! `MIN(good_flag)` over returned groups — that is blind to groups that
//! returned nothing. It is a **three-valued** MIN over the *expected* set,
//! ordered `Bad < Unknown < Good`, so a proven violation beats an unmeasured
//! group and an all-good-but-incomplete slice is uncovered rather than good.
/// A group's verdict for one slice, ordered by "how much it constrains the
/// overall". `Bad` wins because a proven violation is a violation regardless
/// of what else went unmeasured.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum SliceVerdict {
/// The group was measured and violated the condition.
Bad,
/// The group was expected but produced no value — unmeasured.
Unknown,
/// The group was measured and satisfied the condition.
Good,
}
/// The overall verdict for a time-slice SLO's slice: the three-valued MIN over
/// the **expected group set**.
///
/// Takes keyed observations and expected keys rather than a slice plus a
/// count. A count cannot tell expected `{a, b}` from observed `{a, a}` or
/// `{a, c}` — both have length 2 and would report `Good` while `b` went
/// entirely unmeasured, inflating the exact overall row that S-9 depends on.
///
/// * every expected key missing from `observed` contributes `Unknown`
/// * duplicate observations of one key collapse to their **worst** verdict
/// * keys observed but not expected (a group appearing mid-window, before the active set catches
/// up) do not fabricate absences and do not vote
pub fn overall_time_slice(
observed: &[(String, SliceVerdict)],
expected: &[String],
) -> SliceVerdict {
use std::collections::HashMap;
// Duplicates of one key collapse to their WORST verdict — a disagreeing
// duplicate must not be resolved optimistically.
let mut by_key: HashMap<&str, SliceVerdict> = HashMap::new();
for (key, verdict) in observed {
by_key
.entry(key.as_str())
.and_modify(|v| *v = (*v).min(*verdict))
.or_insert(*verdict);
}
if expected.is_empty() {
// Nothing was expected, so "every group was good" is unprovable rather
// than vacuously true.
return SliceVerdict::Unknown;
}
// Three-valued MIN over the EXPECTED set. A key observed but not expected
// neither votes nor fabricates an absence: the active set can lag a
// newly-appeared group by one pass.
expected
.iter()
.map(|key| {
by_key
.get(key.as_str())
.copied()
.unwrap_or(SliceVerdict::Unknown)
})
.min()
.unwrap_or(SliceVerdict::Unknown)
}
/// The overall good/total for a **count** SLO: a straight sum across every
/// group, which is exactly the ungrouped query (S-9).
pub fn overall_count(groups: &[(f64, f64)]) -> (f64, f64) {
// Sum, not a mean of ratios: the overall must be traffic-weighted, or a
// tiny perfect group would drag a huge broken one up.
groups
.iter()
.fold((0.0, 0.0), |(g, t), (good, total)| (g + good, t + total))
}
/// A candidate for the per-group status roster.
#[derive(Debug, Clone, PartialEq)]
pub struct RosterCandidate {
pub group_key: String,
/// Window SLI, aggregated from the persisted slices by the election pass.
/// Lower is worse.
pub sli: f64,
}
/// Elect the per-group status roster, worst-SLI-first with a deterministic
/// tie-break (S-10 tier 2).
///
/// This is possible only because tier 1 persists slices for *every* observed
/// group: ranking a group needs its window SLI, which needs slices that a
/// naive "cap the slices" design would already have discarded (D55).
pub fn elect_roster(mut candidates: Vec<RosterCandidate>, cap: usize) -> Vec<String> {
// Worst SLI first; ties broken on the group key so the roster is stable
// between elections and cannot churn which groups may page.
candidates.sort_by(|a, b| {
a.sli
.partial_cmp(&b.sli)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.group_key.cmp(&b.group_key))
});
candidates
.into_iter()
.take(cap)
.map(|c| c.group_key)
.collect()
}
/// Whether the observed group count has crossed the hard cap, which freezes
/// per-group tracking while leaving the overall exact (S-10).
pub fn is_group_overflow(observed_groups: i64, hard_cap: i64) -> bool {
observed_groups > hard_cap
}
#[cfg(test)]
mod tests {
use super::*;
fn cand(key: &str, sli: f64) -> RosterCandidate {
RosterCandidate {
group_key: key.into(),
sli,
}
}
// ---- verdict ordering --------------------------------------------------
#[test]
fn verdicts_are_ordered_bad_then_unknown_then_good() {
assert!(SliceVerdict::Bad < SliceVerdict::Unknown);
assert!(SliceVerdict::Unknown < SliceVerdict::Good);
}
// ---- the three-valued overall -----------------------------------------
fn obs(pairs: &[(&str, SliceVerdict)]) -> Vec<(String, SliceVerdict)> {
pairs.iter().map(|(k, v)| ((*k).to_string(), *v)).collect()
}
fn keys(ks: &[&str]) -> Vec<String> {
ks.iter().map(|k| (*k).to_string()).collect()
}
#[test]
fn all_groups_good_makes_the_slice_good() {
let o = obs(&[
("a", SliceVerdict::Good),
("b", SliceVerdict::Good),
("c", SliceVerdict::Good),
]);
assert_eq!(
overall_time_slice(&o, &keys(&["a", "b", "c"])),
SliceVerdict::Good
);
}
#[test]
fn any_bad_group_makes_the_slice_bad() {
let o = obs(&[
("a", SliceVerdict::Good),
("b", SliceVerdict::Bad),
("c", SliceVerdict::Good),
]);
assert_eq!(
overall_time_slice(&o, &keys(&["a", "b", "c"])),
SliceVerdict::Bad
);
}
#[test]
fn an_absent_group_makes_an_otherwise_good_slice_uncovered() {
let o = obs(&[("a", SliceVerdict::Good), ("b", SliceVerdict::Good)]);
assert_eq!(
overall_time_slice(&o, &keys(&["a", "b", "c"])),
SliceVerdict::Unknown,
"c was expected and never reported — `all groups good` is unprovable"
);
}
/// A count-based signature could not catch this: two observations of `a`
/// and none of `b` has the same length as one each.
#[test]
fn a_duplicated_group_does_not_stand_in_for_a_missing_one() {
let o = obs(&[("a", SliceVerdict::Good), ("a", SliceVerdict::Good)]);
assert_eq!(
overall_time_slice(&o, &keys(&["a", "b"])),
SliceVerdict::Unknown,
"b is unmeasured however many times a reported"
);
}
/// Likewise an unexpected key must not fill an expected one's slot.
#[test]
fn an_unexpected_group_does_not_stand_in_for_a_missing_one() {
let o = obs(&[("a", SliceVerdict::Good), ("zz", SliceVerdict::Good)]);
assert_eq!(
overall_time_slice(&o, &keys(&["a", "b"])),
SliceVerdict::Unknown
);
}
#[test]
fn duplicate_observations_of_one_key_collapse_to_the_worst() {
let o = obs(&[("a", SliceVerdict::Good), ("a", SliceVerdict::Bad)]);
assert_eq!(
overall_time_slice(&o, &keys(&["a"])),
SliceVerdict::Bad,
"a disagreeing duplicate must not be resolved optimistically"
);
}
/// The invariant break a previous PRD draft had: if absence dominated
/// badness, a bad group plus an absent sibling would make the slice
/// *uncovered*, excluding it from the overall's denominator and letting
/// the overall read HIGHER than the group.
#[test]
fn a_proven_violation_beats_an_unmeasured_sibling() {
let o = obs(&[("a", SliceVerdict::Bad)]);
assert_eq!(
overall_time_slice(&o, &keys(&["a", "b"])),
SliceVerdict::Bad,
"absence must not mask a violation"
);
}
#[test]
fn the_overall_never_exceeds_the_worst_group() {
// Ten slices; group A is bad in slice 3; group B is absent in slice 3.
let expected = keys(&["a", "b"]);
let mut a_good = 0;
let mut overall_good = 0;
let mut overall_measured = 0;
for i in 0..10 {
let o = if i == 3 {
obs(&[("a", SliceVerdict::Bad)])
} else {
obs(&[("a", SliceVerdict::Good), ("b", SliceVerdict::Good)])
};
if o.iter().any(|(k, v)| k == "a" && *v == SliceVerdict::Good) {
a_good += 1;
}
match overall_time_slice(&o, &expected) {
SliceVerdict::Good => {
overall_good += 1;
overall_measured += 1;
}
SliceVerdict::Bad => overall_measured += 1,
SliceVerdict::Unknown => {}
}
}
let a_sli = a_good as f64 / 10.0;
let overall_sli = overall_good as f64 / overall_measured as f64;
assert!(
overall_sli <= a_sli,
"overall {overall_sli} exceeded group A {a_sli} — S-9 invariant broken"
);
}
#[test]
fn an_empty_expected_set_is_unknown_not_good() {
assert_eq!(overall_time_slice(&[], &[]), SliceVerdict::Unknown);
}
#[test]
fn every_group_absent_is_unknown() {
assert_eq!(
overall_time_slice(&[], &keys(&["a", "b", "c"])),
SliceVerdict::Unknown
);
}
/// A group that reported before the active set caught up must not make the
/// slice uncovered.
#[test]
fn extra_observations_do_not_fabricate_absences() {
let o = obs(&[
("a", SliceVerdict::Good),
("b", SliceVerdict::Good),
("c", SliceVerdict::Good),
]);
assert_eq!(
overall_time_slice(&o, &keys(&["a", "b"])),
SliceVerdict::Good
);
}
#[test]
fn an_unexpected_group_does_not_vote_bad() {
// `zz` is not in the active set; its verdict must not drag the overall
// down before the roster admits it.
let o = obs(&[("a", SliceVerdict::Good), ("zz", SliceVerdict::Bad)]);
assert_eq!(overall_time_slice(&o, &keys(&["a"])), SliceVerdict::Good);
}
// ---- count overall -----------------------------------------------------
#[test]
fn count_overall_sums_every_group() {
let (good, total) = overall_count(&[(90.0, 100.0), (8.0, 10.0), (1.0, 1.0)]);
assert_eq!((good, total), (99.0, 111.0));
}
/// S-9: the count overall is `Σgood / Σtotal`, which weights by traffic —
/// NOT the mean of the per-group ratios.
#[test]
fn count_overall_is_traffic_weighted_not_an_average_of_ratios() {
// A tiny perfect group must not drag a huge broken one up.
let (good, total) = overall_count(&[(0.0, 1_000_000.0), (1.0, 1.0)]);
let overall = 100.0 * good / total;
let mean_of_ratios: f64 = (0.0 + 100.0) / 2.0;
assert!(overall < 0.01);
assert!(
(mean_of_ratios - 50.0).abs() < 1e-9 && overall < mean_of_ratios,
"averaging ratios would report 50%"
);
}
#[test]
fn count_overall_of_nothing_is_zero_zero() {
assert_eq!(overall_count(&[]), (0.0, 0.0));
}
// ---- roster election ---------------------------------------------------
#[test]
fn the_roster_keeps_the_worst_groups() {
let out = elect_roster(
vec![
cand("a", 99.9),
cand("b", 98.0),
cand("c", 99.99),
cand("d", 95.0),
],
2,
);
assert_eq!(out, vec!["d".to_string(), "b".to_string()]);
}
#[test]
fn the_roster_is_capped() {
let cands: Vec<_> = (0..100)
.map(|i| cand(&format!("g{i:03}"), 99.0 - i as f64 * 0.01))
.collect();
assert_eq!(elect_roster(cands, 10).len(), 10);
}
#[test]
fn a_roster_smaller_than_the_cap_keeps_everything() {
let out = elect_roster(vec![cand("a", 99.0), cand("b", 98.0)], 500);
assert_eq!(out.len(), 2);
}
/// Determinism matters: an unstable roster would churn which groups can
/// page between elections.
#[test]
fn ties_break_deterministically_on_the_group_key() {
let out = elect_roster(
vec![cand("zebra", 99.0), cand("alpha", 99.0), cand("mike", 99.0)],
2,
);
assert_eq!(out, vec!["alpha".to_string(), "mike".to_string()]);
}
#[test]
fn election_is_order_independent() {
let forward = elect_roster(vec![cand("a", 99.0), cand("b", 98.0), cand("c", 97.0)], 2);
let reverse = elect_roster(vec![cand("c", 97.0), cand("b", 98.0), cand("a", 99.0)], 2);
assert_eq!(forward, reverse);
}
#[test]
fn a_zero_cap_elects_nobody() {
assert!(elect_roster(vec![cand("a", 1.0)], 0).is_empty());
}
// ---- overflow ----------------------------------------------------------
#[test]
fn observed_groups_under_the_hard_cap_do_not_overflow() {
assert!(!is_group_overflow(9_999, 10_000));
assert!(!is_group_overflow(10_000, 10_000));
}
#[test]
fn observed_groups_past_the_hard_cap_overflow() {
assert!(is_group_overflow(10_001, 10_000));
}
}

View File

@ -0,0 +1,164 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Deserializing `f64` under `serde_json/arbitrary_precision` (D61).
//!
//! The workspace enables `serde_json`'s `arbitrary_precision` feature
//! (`Cargo.toml`). That feature changes how a number is represented **inside a
//! buffered `Value`**: instead of an f64 it becomes a one-key map,
//! `{"$serde_json::private::Number": "14.4"}`. Nothing buffers in the common
//! path, so this is invisible until something does — and then a plain
//! `f64` field fails with `invalid type: map, expected f64`.
//!
//! Two things buffer:
//!
//! * an **internally-tagged enum**, which is why `SliConfig` and `CountSource` use adjacent tagging
//! instead (D61);
//! * **`#[serde(flatten)]`**, which is how an `SloCondition` reaches this code — it arrives nested
//! inside `CreateAlertRequestBody`'s flattened `alert` field.
//!
//! The second was found by end-to-end testing, not by review: every unit test
//! deserialized `SloCondition` directly, where no buffering happens and a
//! plain `f64` works fine.
use serde::{Deserialize, Deserializer, de::Error};
/// Deserialize an `f64` that may have been buffered into an
/// arbitrary-precision map.
pub fn deserialize<'de, D>(d: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
match Lenient::deserialize(d)? {
Lenient::Num(v) => Ok(v),
Lenient::Buffered(v) => from_value(&v).ok_or_else(|| D::Error::custom("expected a number")),
}
}
/// The `Option` form, for fields like `warning`.
pub fn deserialize_opt<'de, D>(d: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
match Option::<Lenient>::deserialize(d)? {
None => Ok(None),
Some(Lenient::Num(v)) => Ok(Some(v)),
Some(Lenient::Buffered(v)) => Ok(Some(
from_value(&v).ok_or_else(|| D::Error::custom("expected a number"))?,
)),
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Lenient {
/// The ordinary path — no buffering happened.
Num(f64),
/// A buffered value, which under `arbitrary_precision` is a map.
Buffered(serde_json::Value),
}
/// Pull an f64 out of a `Value`, including the arbitrary-precision map form.
fn from_value(v: &serde_json::Value) -> Option<f64> {
if let Some(n) = v.as_f64() {
return Some(n);
}
// `{"$serde_json::private::Number": "14.4"}` — the private key is not
// matched by name because it is private and could change; any one-key map
// whose value parses as a number is accepted instead.
let obj = v.as_object()?;
if obj.len() != 1 {
return None;
}
let raw = obj.values().next()?;
raw.as_str()
.and_then(|s| s.parse::<f64>().ok())
.or_else(|| raw.as_f64())
}
#[cfg(test)]
mod tests {
use serde::Deserialize;
use super::*;
#[derive(Debug, Deserialize, PartialEq)]
struct Holder {
#[serde(deserialize_with = "deserialize")]
critical: f64,
#[serde(default, deserialize_with = "deserialize_opt")]
warning: Option<f64>,
}
/// The ordinary path must keep working exactly as before.
#[test]
fn a_plain_number_deserializes() {
let h: Holder = serde_json::from_str(r#"{"critical": 14.4, "warning": 6}"#).unwrap();
assert_eq!(h.critical, 14.4);
assert_eq!(h.warning, Some(6.0));
}
#[test]
fn an_absent_option_is_none() {
let h: Holder = serde_json::from_str(r#"{"critical": 1.0}"#).unwrap();
assert_eq!(h.warning, None);
}
#[test]
fn an_explicit_null_option_is_none() {
let h: Holder = serde_json::from_str(r#"{"critical": 1.0, "warning": null}"#).unwrap();
assert_eq!(h.warning, None);
}
/// The failure this module exists for. Reproduces the buffering that
/// `#[serde(flatten)]` performs: the JSON is parsed to a `Value` first,
/// which under `arbitrary_precision` turns every number into a map.
#[test]
fn a_number_buffered_through_value_deserializes() {
let v: serde_json::Value =
serde_json::from_str(r#"{"critical": 14.4, "warning": 6}"#).unwrap();
let h: Holder = serde_json::from_value(v).expect("must survive buffering");
assert_eq!(h.critical, 14.4);
assert_eq!(h.warning, Some(6.0));
}
/// The end-to-end shape: a struct reached through a flatten, which is how
/// an SloCondition arrives inside CreateAlertRequestBody.
#[test]
fn a_number_inside_a_flattened_struct_deserializes() {
#[derive(Debug, Deserialize)]
struct Outer {
name: String,
#[serde(flatten)]
inner: Holder,
}
let o: Outer =
serde_json::from_str(r#"{"name":"a","critical": 14.4, "warning": 6}"#).unwrap();
assert_eq!(o.name, "a");
assert_eq!(o.inner.critical, 14.4);
assert_eq!(o.inner.warning, Some(6.0));
}
#[test]
fn a_string_is_still_rejected() {
assert!(serde_json::from_str::<Holder>(r#"{"critical": "high"}"#).is_err());
}
#[test]
fn an_integer_widens() {
let h: Holder = serde_json::from_str(r#"{"critical": 14}"#).unwrap();
assert_eq!(h.critical, 14.0);
}
}

View File

@ -0,0 +1,369 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The normative SLO math — `alerts_2.md` §6b.6a.
//!
//! Stated once, here, and derived everywhere else. Every number the UI shows
//! and every threshold comparison an SLO alert makes comes through these
//! functions.
//!
//! ```text
//! sli(R) = 100 × Σgood / Σtotal undefined if Σtotal = 0 (SA-18)
//! error_rate(R) = 100 sli(R)
//! burn_rate(R) = error_rate(R) / (100 target)
//! max_burn_rate = 100 / (100 target) the SA-6 cap
//!
//! over the full window W:
//! consumed% = 100 × burn_rate(W)
//! remaining% = 100 consumed%
//! ```
//!
//! The last identity matters: **error-budget consumption and burn rate are the
//! same quantity at two scalings**, differing only in the window they are read
//! over. That is why one evaluator serves both SLO-alert kinds.
/// SLI as a percentage. `None` when there is nothing to divide by — a covered
/// window with zero events is *not* 100% and *not* 0%, it is undefined, and
/// SA-18 turns that into "unobserved" rather than a recovery.
pub fn sli(good: f64, total: f64) -> Option<f64> {
// `<= 0` rather than `== 0`: a negative total is corruption, not a 0% SLI.
// NaN is checked explicitly — it must not reach the division either.
if total.is_nan() || total <= 0.0 {
return None;
}
Some(100.0 * good / total)
}
/// Error rate as a percentage: the complement of the SLI.
pub fn error_rate(sli: f64) -> f64 {
100.0 - sli
}
/// Burn rate: observed error rate divided by the budgeted error rate.
///
/// 1.0 means the budget lands exactly at the window's end. 14.4 over a 30-day
/// SLO exhausts it in about two days.
pub fn burn_rate(sli: f64, target: f64) -> f64 {
error_rate(sli) / (100.0 - target)
}
/// The largest burn rate physically reachable for a target — `1/(1 target)`.
/// A threshold above this needs an error rate over 100% and can never fire
/// (SA-6).
pub fn max_burn_rate(target: f64) -> f64 {
// Identically `burn_rate(0.0, target)` — the burn rate of a totally failed
// window. Written out so the SA-6 cap reads as its own idea.
100.0 / (100.0 - target)
}
/// Percentage of the error budget consumed over the full window.
/// Identically `100 × burn_rate(window)`.
pub fn error_budget_consumed(sli_window: f64, target: f64) -> f64 {
100.0 * burn_rate(sli_window, target)
}
/// Percentage of the error budget remaining — **signed**. A blown budget reads
/// negative and is rendered that way; it is never clamped to zero (S-6).
pub fn error_budget_remaining(sli_window: f64, target: f64) -> f64 {
100.0 - error_budget_consumed(sli_window, target)
}
/// How long the window's budget lasts at a sustained burn rate:
/// `window / burn`. `None` when the burn rate is zero or negative — the budget
/// is not being consumed at all.
pub fn time_to_exhaust_secs(window_secs: i64, burn: f64) -> Option<i64> {
// NaN is checked explicitly — it must not reach the division.
if burn.is_nan() || burn <= 0.0 {
return None;
}
Some((window_secs as f64 / burn) as i64)
}
#[cfg(test)]
mod tests {
use super::*;
/// The §9 accuracy gate is 0.001 percentage points; these are closed-form
/// identities, so the tests hold themselves several orders tighter.
const EPS: f64 = 1e-9;
/// Absolute for small values, **relative** for large ones.
///
/// A flat `1e-9` was tighter than f64 can represent for the larger
/// quantities here: `max_burn_rate(99.99)` is `9999.999999994885`, off by
/// 5.1e-9 purely from `100.0 - 99.99` not being exact. That is a
/// representation limit, not an arithmetic error — the relative error is
/// 5e-13 — and it is well inside the §9 gate. Asserting a flat epsilon
/// against a four-digit multiplier was the mistake.
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= EPS.max(b.abs() * 1e-12)
}
// ---- sli ---------------------------------------------------------------
#[test]
fn sli_is_the_good_fraction_as_a_percentage() {
assert!(close(sli(999.0, 1000.0).unwrap(), 99.9));
assert!(close(sli(1.0, 2.0).unwrap(), 50.0));
}
#[test]
fn sli_of_a_perfect_window_is_exactly_one_hundred() {
assert_eq!(sli(1000.0, 1000.0), Some(100.0));
}
#[test]
fn sli_of_a_totally_failed_window_is_exactly_zero() {
assert_eq!(sli(0.0, 1000.0), Some(0.0));
}
/// SA-18: a covered window with no events is *undefined*, not zero and not
/// a hundred. Returning a number here is how "traffic stopped entirely"
/// silently clears a page.
#[test]
fn sli_is_undefined_when_total_is_zero() {
assert_eq!(sli(0.0, 0.0), None);
}
#[test]
fn sli_is_undefined_for_a_negative_total() {
// Defensive: totals are counts or seconds and cannot be negative; if
// one ever is, that is corruption, not a 0% SLI.
assert_eq!(sli(0.0, -1.0), None);
}
// ---- error_rate --------------------------------------------------------
#[test]
fn error_rate_is_the_complement_of_the_sli() {
assert!(close(error_rate(99.9), 0.1));
assert!(close(error_rate(100.0), 0.0));
assert!(close(error_rate(0.0), 100.0));
}
// ---- burn_rate ---------------------------------------------------------
/// The §6b.6a sanity checks, encoded.
#[test]
fn burn_rate_is_zero_for_a_perfect_sli() {
assert!(close(burn_rate(100.0, 99.9), 0.0));
}
#[test]
fn burn_rate_is_one_when_the_sli_sits_exactly_on_target() {
assert!(close(burn_rate(99.9, 99.9), 1.0));
assert!(close(burn_rate(99.0, 99.0), 1.0));
assert!(close(burn_rate(95.0, 95.0), 1.0));
}
#[test]
fn burn_rate_of_a_totally_failed_window_equals_the_max() {
assert!(close(burn_rate(0.0, 99.9), max_burn_rate(99.9)));
assert!(close(burn_rate(0.0, 99.0), max_burn_rate(99.0)));
}
/// Datadog's headline example: 14.4 over a 30-day SLO.
#[test]
fn burn_rate_matches_the_datadog_worked_example() {
// A 99.9% target has a 0.1% budget. An observed error rate of 1.44%
// is 14.4× the budgeted rate.
assert!(close(burn_rate(100.0 - 1.44, 99.9), 14.4));
}
#[test]
fn burn_rate_scales_linearly_with_the_error_rate() {
let a = burn_rate(99.0, 99.9); // 1% errors
let b = burn_rate(98.0, 99.9); // 2% errors
assert!(close(b, a * 2.0));
}
// ---- max_burn_rate -----------------------------------------------------
#[test]
fn max_burn_rate_is_the_reciprocal_of_the_budget() {
assert!(close(max_burn_rate(99.0), 100.0));
assert!(close(max_burn_rate(99.9), 1000.0));
assert!(close(max_burn_rate(99.99), 10_000.0));
assert!(close(max_burn_rate(90.0), 10.0));
}
/// SA-6's direction, which an earlier PRD draft had inverted: TIGHTENING a
/// target RAISES the ceiling, so it can never strand an existing
/// threshold. Loosening is the dangerous direction.
#[test]
fn tightening_the_target_raises_the_max_burn_rate() {
assert!(max_burn_rate(99.9) > max_burn_rate(99.0));
}
#[test]
fn loosening_the_target_lowers_the_max_burn_rate() {
// 99.9 -> 99.0 drops the ceiling from 1000 to 100, which can strand a
// saved threshold of 500 as permanently unfireable.
let before = max_burn_rate(99.9);
let after = max_burn_rate(99.0);
assert!(after < before);
assert!(500.0 <= before && 500.0 > after);
}
#[test]
fn no_reachable_burn_rate_exceeds_the_max() {
for target in [90.0, 99.0, 99.9, 99.99] {
for sli_v in [0.0, 1.0, 50.0, 99.0, 100.0] {
assert!(
burn_rate(sli_v, target) <= max_burn_rate(target) + EPS,
"burn({sli_v}, {target}) exceeded the max"
);
}
}
}
// ---- error budget ------------------------------------------------------
#[test]
fn a_perfect_window_consumes_no_budget_and_leaves_all_of_it() {
assert!(close(error_budget_consumed(100.0, 99.9), 0.0));
assert!(close(error_budget_remaining(100.0, 99.9), 100.0));
}
#[test]
fn sitting_exactly_on_target_consumes_the_whole_budget() {
assert!(close(error_budget_consumed(99.9, 99.9), 100.0));
assert!(close(error_budget_remaining(99.9, 99.9), 0.0));
}
/// S-6: remaining is SIGNED. Clamping it to zero hides how deep the hole
/// is, which is the number an SRE actually needs.
#[test]
fn a_blown_budget_reports_negative_remaining() {
// 99.8% against a 99.9% target = twice the budget spent.
let remaining = error_budget_remaining(99.8, 99.9);
assert!(remaining < 0.0, "expected negative, got {remaining}");
assert!(close(remaining, -100.0));
}
#[test]
fn consumed_and_remaining_always_sum_to_one_hundred() {
for (sli_v, target) in [(100.0, 99.9), (99.95, 99.9), (99.9, 99.9), (99.5, 99.9)] {
let sum = error_budget_consumed(sli_v, target) + error_budget_remaining(sli_v, target);
assert!(close(sum, 100.0), "sli={sli_v} target={target} sum={sum}");
}
}
/// The identity that lets one evaluator serve both alert kinds: an
/// error-budget alert is a burn-rate read over the whole window, ×100.
#[test]
fn consumed_is_exactly_one_hundred_times_the_window_burn_rate() {
for (sli_v, target) in [(99.95, 99.9), (99.5, 99.9), (98.0, 99.0), (100.0, 95.0)] {
assert!(
close(
error_budget_consumed(sli_v, target),
100.0 * burn_rate(sli_v, target)
),
"identity failed for sli={sli_v} target={target}"
);
}
}
// ---- time to exhaust ---------------------------------------------------
#[test]
fn budget_lasts_exactly_the_window_at_burn_rate_one() {
let window = 30 * 86_400;
assert_eq!(time_to_exhaust_secs(window, 1.0), Some(window));
}
/// Datadog's stated example: 14.4 exhausts a 30-day budget in ~2 days.
#[test]
fn burn_rate_fourteen_point_four_exhausts_thirty_days_in_about_two() {
let secs = time_to_exhaust_secs(30 * 86_400, 14.4).unwrap();
let days = secs as f64 / 86_400.0;
assert!((days - 2.083).abs() < 0.01, "got {days} days");
}
#[test]
fn a_higher_burn_rate_exhausts_the_budget_sooner() {
let w = 30 * 86_400;
assert!(time_to_exhaust_secs(w, 14.4).unwrap() < time_to_exhaust_secs(w, 3.0).unwrap());
}
#[test]
fn a_zero_burn_rate_never_exhausts_the_budget() {
assert_eq!(time_to_exhaust_secs(30 * 86_400, 0.0), None);
}
#[test]
fn a_negative_burn_rate_never_exhausts_the_budget() {
assert_eq!(time_to_exhaust_secs(30 * 86_400, -1.0), None);
}
// ---- cross-checks ------------------------------------------------------
/// Every suggested Datadog row should spend the documented fraction of the
/// budget over its long window.
///
/// Derived through `burn_rate` and `error_budget_consumed` rather than from
/// literals: an earlier version of this test did the arithmetic inline and
/// would have passed with every function in this module returning zero.
#[test]
fn datadog_suggested_rows_consume_the_documented_budget_fraction() {
// (slo window days, burn, long window hours, documented budget %)
let rows: [(f64, f64, f64, f64); 9] = [
(30.0, 14.4, 1.0, 2.0),
(30.0, 6.0, 6.0, 5.0),
(30.0, 3.0, 24.0, 10.0),
(7.0, 16.8, 1.0, 10.0),
(7.0, 5.6, 6.0, 20.0),
(7.0, 2.8, 24.0, 40.0),
(90.0, 21.6, 1.0, 1.0),
(90.0, 10.8, 6.0, 3.0),
(90.0, 4.5, 24.0, 5.0),
];
let target = 99.9;
for (window_days, burn, long_hours, budget_pct) in rows {
// Invert the burn rate to the SLI that would produce it, then push
// that SLI back through the real functions.
let sli_v = 100.0 - burn * (100.0 - target);
assert!(
close(burn_rate(sli_v, target), burn),
"burn_rate did not round-trip for {burn}"
);
// Budget spent over the long window = consumed% × (long / window).
let consumed_over_window = error_budget_consumed(sli_v, target);
let fraction = consumed_over_window * (long_hours / 24.0) / window_days;
assert!(
(fraction - budget_pct).abs() < 1e-6,
"burn {burn} over {long_hours}h of a {window_days}d SLO spends {fraction:.4}%, \
Datadog documents {budget_pct}%"
);
}
}
/// The exhaustion helper must agree with the same rows: window ÷ burn.
#[test]
fn datadog_suggested_rows_exhaust_the_budget_over_their_long_window() {
// At burn B, the budget lasts window/B. Firing after `long` hours means
// the fraction spent is long / (window/B) — the check above, restated
// through time_to_exhaust_secs.
let window_secs = 30 * 86_400;
let secs = time_to_exhaust_secs(window_secs, 14.4).unwrap();
let spent_in_one_hour = 3600.0 / secs as f64;
assert!(
(spent_in_one_hour * 100.0 - 2.0).abs() < 0.01,
"expected ~2% of the budget in 1h, got {:.3}%",
spent_in_one_hour * 100.0
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,672 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Slice rows: dedupe, gap-fill, and the one clamp that matters — §6b.4a/§6b.4b.
//!
//! **Slices publish at-least-once, like every other stream in the product**
//! (D64). There is no transactional publication protocol here, and that is a
//! deliberate reversal of three earlier designs. The reasoning, in short:
//!
//! * A torn batch's rows are **not corrupt** — they were computed from real data by the query that
//! would have run anyway. The only thing that did not happen is their delta being folded into the
//! running aggregate.
//! * The running aggregate is a **cache**, rebuilt from slices by reconciliation. So a torn batch
//! causes cache drift, which self-heals.
//! * Alerts read the cache (`slo_status.burn_windows`, §6b.4c), never slices directly, so an
//! unaccounted slice cannot page anyone.
//! * A partially-written batch shows up as **reduced coverage**, and coverage gating — which exists
//! for search outages anyway — already bounds the damage: above the floor it is bounded by
//! definition, below it the alert freezes.
//! * The trailing-K recompute re-emits any slice near a crash on the next pass regardless.
//!
//! What survives from those designs, because each earns its place for a reason
//! unrelated to torn batches:
//!
//! * **The watermark**, a forward clamp only. Not a commit barrier — it stops readers seeing the
//! *currently filling* slice, which for a time-slice SLI would classify against a partial bucket
//! and then flip.
//! * **Latest-revision-wins dedupe**, needed for late data. `MAX(good)` is *wrong* here: a
//! recomputed time-slice can flip good → bad, and MAX would keep the stale 300 — a failure that
//! only ever over-reports uptime.
//! * **Type-specific gap-fill** (D48): "no rows in the bucket" is an observation of zero traffic
//! for a count SLI and an absence of measurement for a time-slice one.
//! * **Ingest-boundary validation**, so non-finite values never reach a slice.
use super::SliType;
/// One row of the `slo_slices` stream.
#[derive(Debug, Clone, PartialEq)]
pub struct SliceRow {
pub slo_id: String,
/// The definition this slice was measured under — and, since D59, also the
/// writing epoch. Reads filter to the current generation only.
pub definition_generation: i32,
/// `""` is the exact overall row (S-9).
pub group_key: String,
pub slice_start: i64,
/// `f64` **units**: events for a count SLI, seconds for time-slice and
/// alert-based. Unifying on units is what lets one evaluator serve all
/// three.
pub good: f64,
pub total: f64,
/// Monotonic within the generation; higher wins on the same key. Exists
/// for late-data re-emission, not for publication ordering.
pub rev: i64,
}
/// Whether a row is visible to readers.
///
/// One clamp: the row must belong to the current generation and start strictly
/// before the watermark. Three earlier designs added a backward barrier here —
/// per-writer committed marks, an abandoned-batch set, a write-ahead manifest
/// — to hide rows from batches that never committed. D64 removed all of it:
/// those rows are valid measurements whose delta was never folded into the
/// cache, the cache is rebuilt by reconciliation, and alerts read the cache
/// rather than slices.
pub fn is_visible(row: &SliceRow, watermark_end: i64, generation: i32) -> bool {
row.definition_generation == generation && row.slice_start < watermark_end
}
/// Collapse duplicate revisions of the same key, keeping the **latest
/// revision** (never the max value).
///
/// Input need not be sorted. Output is ascending by `(group_key, slice_start)`
/// so downstream aggregation is deterministic.
pub fn dedupe_latest_rev(rows: Vec<SliceRow>) -> Vec<SliceRow> {
use std::collections::BTreeMap;
// BTreeMap so the output order is deterministic by (group_key,
// slice_start) without a separate sort.
let mut best: BTreeMap<(String, i64), SliceRow> = BTreeMap::new();
for row in rows {
let key = (row.group_key.clone(), row.slice_start);
match best.get(&key) {
// Strictly greater: an equal revision must not flip the winner, or
// the result would depend on input order.
Some(existing) if existing.rev >= row.rev => {}
_ => {
best.insert(key, row);
}
}
}
best.into_values().collect()
}
/// Clamp and dedupe — the canonical read path.
pub fn visible_slices(rows: Vec<SliceRow>, watermark_end: i64, generation: i32) -> Vec<SliceRow> {
// Clamp BEFORE dedupe: a row from another generation must not win a key
// just because its revision is higher.
let visible = rows
.into_iter()
.filter(|r| is_visible(r, watermark_end, generation))
.collect();
dedupe_latest_rev(visible)
}
/// What the ingest job emits for a bucket the query did not return.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GapFill {
/// Emit `good = 0, total = 0` — a real observation of zero traffic, which
/// counts as covered.
CoveredZero,
/// Emit `good = 0, total = interval` — the slice was PROVEN empty and
/// absence is the failure (`absent_is_bad`). Covered, and fully bad.
CoveredBad,
/// Emit nothing — the slice is a gap and reduces coverage.
Nothing,
}
/// How a missing bucket is interpreted for a specific SLO definition.
///
/// Refines [`gap_fill_policy`]: the per-TYPE answer (D48) holds except where
/// the definition itself says otherwise — a time-slice SLO with
/// `absent_is_bad` treats a proven-empty bucket as downtime rather than as a
/// gap. Callers holding a config should ask here; the type-level function
/// remains for callers that have only the discriminant.
pub fn gap_fill_policy_for(sli: &crate::meta::slo::SliConfig) -> GapFill {
if let crate::meta::slo::SliConfig::TimeSlice {
absent_is_bad: true,
..
} = sli
{
return GapFill::CoveredBad;
}
gap_fill_policy(sli.sli_type())
}
/// How a missing bucket is interpreted, per SLI type (D48).
pub fn gap_fill_policy(sli_type: SliType) -> GapFill {
match sli_type {
// "No rows" is a real observation of zero traffic.
SliType::Count => GapFill::CoveredZero,
// The aggregate had no input, so there is no value to compare.
SliType::TimeSlice => GapFill::Nothing,
// Coverage comes from the triggers stream instead (S-16, D65).
SliType::Alert => GapFill::Nothing,
}
}
/// Fill the buckets a successful query did not return, per the type's policy.
///
/// `observed` are the rows the query produced; `expected_starts` is the full
/// aligned grid for the pass. Only applies to a **successful** query — a
/// failed one emits nothing for any type, which is what coverage is for.
pub fn fill_gaps(
observed: Vec<SliceRow>,
expected_starts: &[i64],
group_keys: &[String],
template: &SliceRow,
sli_type: SliType,
) -> Vec<SliceRow> {
if gap_fill_policy(sli_type) == GapFill::Nothing {
return observed;
}
use std::collections::HashSet;
let present: HashSet<(String, i64)> = observed
.iter()
.map(|r| (r.group_key.clone(), r.slice_start))
.collect();
let mut out = observed;
for group_key in group_keys {
for &slice_start in expected_starts {
if present.contains(&(group_key.clone(), slice_start)) {
continue;
}
out.push(SliceRow {
group_key: group_key.clone(),
slice_start,
good: 0.0,
total: 0.0,
..template.clone()
});
}
}
out
}
/// Whether a recomputed slice should be re-emitted (D55 write-on-change).
///
/// Re-emitting unconditionally makes the trailing-K recompute cost ~K physical
/// rows per logical slice forever — at documented defaults that was ~8 billion
/// rows over a 90-day window.
pub fn should_emit(previous: Option<(f64, f64)>, recomputed: (f64, f64)) -> bool {
let Some((prev_good, prev_total)) = previous else {
return true;
};
// `same` rather than `!=` so an unchanged NaN compares as unchanged;
// `NaN != NaN` would make the slice churn on every pass forever.
let same = |a: f64, b: f64| a == b || (a.is_nan() && b.is_nan());
!(same(prev_good, recomputed.0) && same(prev_total, recomputed.1))
}
/// Which lane wrote a batch.
///
/// Still meaningful for **scheduling** — bulk backfill runs in its own
/// concurrency lane so it cannot starve latency-sensitive incremental passes
/// (D58) — but no longer for visibility. Both lanes write ordinary slices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Writer {
Incremental,
Backfill,
}
/// Why an observation may not be persisted as a slice.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ObservationError {
/// `NaN` or `±inf`. Poisons every downstream aggregate, comparison and
/// serialized status value.
NotFinite { field: &'static str, value: f64 },
/// Counts and seconds cannot be negative.
Negative { field: &'static str, value: f64 },
/// More good units than total — the SLI would exceed 100%.
GoodExceedsTotal { good: f64, total: f64 },
}
/// Reject a non-finite or incoherent observation at the **ingest boundary**,
/// before it can reach a slice row.
///
/// Deliberately not `should_emit`'s job: making write-on-change tolerate `NaN`
/// stops the row churning, but still lets invalid data into the stream, where
/// it silently corrupts every window aggregate that touches it.
pub fn validate_observation(good: f64, total: f64) -> Result<(), ObservationError> {
for (field, value) in [("good", good), ("total", total)] {
if !value.is_finite() {
return Err(ObservationError::NotFinite { field, value });
}
if value < 0.0 {
return Err(ObservationError::Negative { field, value });
}
}
if good > total {
return Err(ObservationError::GoodExceedsTotal { good, total });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn row(group: &str, start: i64, good: f64, total: f64, rev: i64) -> SliceRow {
SliceRow {
slo_id: "slo1".into(),
definition_generation: 1,
group_key: group.into(),
slice_start: start,
good,
total,
rev,
}
}
const WATERMARK: i64 = 10_000;
// ---- the forward clamp -------------------------------------------------
#[test]
fn rows_before_the_watermark_are_visible() {
assert!(is_visible(&row("", 9_700, 1.0, 1.0, 1), WATERMARK, 1));
}
/// The one thing the watermark is for: the currently-filling slice must
/// not be published. A bucket that is 10% full reads as 90% less traffic,
/// and for a time-slice SLI it can classify bad and then flip good.
#[test]
fn the_currently_filling_slice_is_not_visible() {
assert!(!is_visible(&row("", 10_000, 1.0, 1.0, 1), WATERMARK, 1));
assert!(!is_visible(&row("", 10_300, 1.0, 1.0, 1), WATERMARK, 1));
}
#[test]
fn rows_from_another_generation_are_invisible() {
let mut old = row("", 9_000, 1.0, 1.0, 99);
old.definition_generation = 1;
assert!(!is_visible(&old, WATERMARK, 2));
}
/// D64, stated as a test so the reversal is not silently undone: a row
/// whose batch never committed IS visible. It is a real measurement whose
/// delta was not folded into the cache; reconciliation repairs the cache,
/// and alerts read the cache rather than slices. Re-adding a backward
/// barrier would break this test, which is the intent.
#[test]
fn an_unaccounted_row_is_visible_because_publication_is_at_least_once() {
assert!(
is_visible(&row("", 9_000, 1.0, 1.0, 5), WATERMARK, 1),
"at-least-once publication: slices are not gated on a commit record"
);
}
// ---- dedupe: latest revision wins -------------------------------------
#[test]
fn dedupe_keeps_the_highest_revision() {
let out = dedupe_latest_rev(vec![
row("a", 100, 5.0, 10.0, 1),
row("a", 100, 7.0, 12.0, 2),
]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].good, 7.0);
assert_eq!(out[0].total, 12.0);
}
#[test]
fn dedupe_is_order_independent() {
let ascending = dedupe_latest_rev(vec![
row("a", 100, 5.0, 10.0, 1),
row("a", 100, 7.0, 12.0, 2),
]);
let descending = dedupe_latest_rev(vec![
row("a", 100, 7.0, 12.0, 2),
row("a", 100, 5.0, 10.0, 1),
]);
assert_eq!(ascending, descending);
}
/// D54, the one that matters: a recomputed time-slice can flip good→bad.
/// `MAX(good)` would keep the stale 300 and over-report uptime.
#[test]
fn dedupe_lets_a_recomputed_slice_flip_from_good_to_bad() {
let out = dedupe_latest_rev(vec![
row("a", 100, 300.0, 300.0, 1), // good slice
row("a", 100, 0.0, 300.0, 2), // late data flipped it bad
]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].good, 0.0, "MAX would wrongly keep 300 here");
}
#[test]
fn dedupe_keys_on_group_and_slice_independently() {
let out = dedupe_latest_rev(vec![
row("a", 100, 1.0, 1.0, 1),
row("b", 100, 2.0, 2.0, 1),
row("a", 200, 3.0, 3.0, 1),
]);
assert_eq!(out.len(), 3);
}
#[test]
fn dedupe_returns_deterministic_order() {
let out = dedupe_latest_rev(vec![
row("b", 200, 1.0, 1.0, 1),
row("a", 300, 1.0, 1.0, 1),
row("a", 100, 1.0, 1.0, 1),
]);
let keys: Vec<_> = out
.iter()
.map(|r| (r.group_key.clone(), r.slice_start))
.collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(keys, sorted);
}
#[test]
fn dedupe_of_nothing_is_nothing() {
assert!(dedupe_latest_rev(vec![]).is_empty());
}
// ---- the composed read path -------------------------------------------
#[test]
fn visible_slices_clamps_then_dedupes() {
let rows = vec![
row("a", 9_000, 1.0, 2.0, 1), // superseded
row("a", 9_000, 5.0, 9.0, 2), // the later revision
row("a", 10_500, 9.0, 9.0, 1), // at/after the watermark
];
let out = visible_slices(rows, WATERMARK, 1);
assert_eq!(out.len(), 1, "the unclosed slice is clamped away");
assert_eq!(
out[0].good, 5.0,
"the later revision wins — there is no commit record to gate on (D64)"
);
}
/// Order matters: clamping must happen BEFORE dedupe, or a row from
/// another generation could win a key on revision alone and then be
/// filtered out, leaving the key empty.
#[test]
fn clamping_happens_before_dedupe_not_after() {
let mut foreign = row("a", 9_000, 99.0, 99.0, 9);
foreign.definition_generation = 2;
let current = row("a", 9_000, 1.0, 1.0, 1);
let out = visible_slices(vec![foreign, current], WATERMARK, 1);
assert_eq!(out.len(), 1);
assert_eq!(
out[0].good, 1.0,
"a higher revision in the wrong generation must not consume the key"
);
}
#[test]
fn visible_slices_excludes_other_generations() {
let mut old = row("a", 9_000, 1.0, 1.0, 5);
old.definition_generation = 1;
let mut new = row("a", 9_000, 2.0, 2.0, 1);
new.definition_generation = 2;
let out = visible_slices(vec![old, new], WATERMARK, 2);
assert_eq!(out.len(), 1);
assert_eq!(out[0].definition_generation, 2);
assert_eq!(
out[0].good, 2.0,
"a higher rev in an older generation must never win"
);
}
// ---- gap-fill policy ---------------------------------------------------
/// D48: the two types disagree, and the disagreement is the point.
#[test]
fn count_treats_a_missing_bucket_as_measured_zero_traffic() {
assert_eq!(gap_fill_policy(SliType::Count), GapFill::CoveredZero);
}
#[test]
fn time_slice_treats_a_missing_bucket_as_unmeasured() {
assert_eq!(gap_fill_policy(SliType::TimeSlice), GapFill::Nothing);
}
#[test]
fn alert_sli_takes_coverage_from_the_ledger_not_gap_fill() {
assert_eq!(gap_fill_policy(SliType::Alert), GapFill::Nothing);
}
// ---- gap-fill behaviour ------------------------------------------------
#[test]
fn count_fills_missing_buckets_with_covered_zeros() {
let template = row("", 0, 0.0, 0.0, 1);
let out = fill_gaps(
vec![row("a", 300, 5.0, 10.0, 1)],
&[0, 300, 600],
&["a".to_string()],
&template,
SliType::Count,
);
assert_eq!(out.len(), 3, "every bucket in the grid is emitted");
let filled: Vec<_> = out.iter().filter(|r| r.slice_start != 300).collect();
assert!(filled.iter().all(|r| r.good == 0.0 && r.total == 0.0));
}
#[test]
fn time_slice_leaves_missing_buckets_absent() {
let template = row("", 0, 0.0, 0.0, 1);
let out = fill_gaps(
vec![row("a", 300, 300.0, 300.0, 1)],
&[0, 300, 600],
&["a".to_string()],
&template,
SliType::TimeSlice,
);
assert_eq!(out.len(), 1, "gaps stay gaps");
assert_eq!(out[0].slice_start, 300);
}
#[test]
fn gap_fill_covers_every_group_not_just_the_ones_that_reported() {
let template = row("", 0, 0.0, 0.0, 1);
let out = fill_gaps(
vec![row("a", 0, 1.0, 1.0, 1)],
&[0],
&["a".to_string(), "b".to_string()],
&template,
SliType::Count,
);
assert_eq!(out.len(), 2);
assert!(out.iter().any(|r| r.group_key == "b" && r.total == 0.0));
}
#[test]
fn gap_fill_preserves_observed_values() {
let template = row("", 0, 0.0, 0.0, 1);
let out = fill_gaps(
vec![row("a", 0, 7.0, 9.0, 3)],
&[0, 300],
&["a".to_string()],
&template,
SliType::Count,
);
let observed = out.iter().find(|r| r.slice_start == 0).unwrap();
assert_eq!((observed.good, observed.total), (7.0, 9.0));
}
// ---- write-on-change ---------------------------------------------------
#[test]
fn a_first_observation_is_always_emitted() {
assert!(should_emit(None, (1.0, 2.0)));
}
#[test]
fn an_unchanged_recompute_is_not_re_emitted() {
assert!(!should_emit(Some((1.0, 2.0)), (1.0, 2.0)));
}
#[test]
fn a_changed_recompute_is_re_emitted() {
assert!(should_emit(Some((1.0, 2.0)), (1.0, 3.0)));
assert!(should_emit(Some((1.0, 2.0)), (0.0, 2.0)));
}
/// The steady-state claim behind the §6b.4d volume numbers, exercised over
/// a realistic trailing-recompute window rather than by repetition: only
/// the slice whose value actually moved is re-emitted.
#[test]
fn a_recompute_pass_emits_only_the_slices_that_changed() {
let previous = [(0, (10.0, 10.0)), (300, (9.0, 10.0)), (600, (10.0, 10.0))];
let recomputed = [(0, (10.0, 10.0)), (300, (9.0, 11.0)), (600, (10.0, 10.0))];
let emitted: Vec<i64> = previous
.iter()
.zip(recomputed.iter())
.filter(|((_, prev), (_, now))| should_emit(Some(*prev), *now))
.map(|((start, _), _)| *start)
.collect();
assert_eq!(emitted, vec![300], "only the slice that gained late data");
}
/// Defence in depth only: `NaN` must be rejected at the ingest boundary
/// (see `validate_observation`), but if one ever reaches the trailing
/// buffer it must not make the slice churn forever — `NaN != NaN` under a
/// naive equality check would defeat write-on-change entirely.
#[test]
fn a_nan_value_does_not_re_emit_forever() {
assert!(
!should_emit(Some((f64::NAN, 10.0)), (f64::NAN, 10.0)),
"an unchanged NaN must compare as unchanged"
);
}
// ---- ingest-boundary validation ---------------------------------------
#[test]
fn a_coherent_observation_is_accepted() {
assert_eq!(validate_observation(5.0, 10.0), Ok(()));
assert_eq!(
validate_observation(0.0, 0.0),
Ok(()),
"zero traffic is valid"
);
assert_eq!(validate_observation(10.0, 10.0), Ok(()));
}
#[test]
fn non_finite_observations_are_rejected_before_persistence() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(
matches!(
validate_observation(bad, 10.0),
Err(ObservationError::NotFinite { .. })
),
"good={bad} accepted"
);
assert!(
matches!(
validate_observation(1.0, bad),
Err(ObservationError::NotFinite { .. })
),
"total={bad} accepted"
);
}
}
#[test]
fn negative_observations_are_rejected() {
assert!(matches!(
validate_observation(-1.0, 10.0),
Err(ObservationError::Negative { .. })
));
assert!(matches!(
validate_observation(1.0, -10.0),
Err(ObservationError::Negative { .. })
));
}
/// More good units than total would put the SLI above 100% and the burn
/// rate below zero.
#[test]
fn more_good_than_total_is_rejected() {
assert_eq!(
validate_observation(11.0, 10.0),
Err(ObservationError::GoodExceedsTotal {
good: 11.0,
total: 10.0
})
);
}
}
/// Tests for the config-aware gap-fill policy — the seam `absent_is_bad`
/// turns on. Written before the function exists.
#[cfg(test)]
mod gap_fill_policy_for_tests {
use super::*;
use crate::meta::{
alerts::Operator,
slo::{CountSource, QueryLanguage, SliConfig},
};
fn ts(absent_is_bad: bool) -> SliConfig {
SliConfig::TimeSlice {
stream: "s".into(),
stream_type: "logs".into(),
query_language: QueryLanguage::Sql,
query: "count(*)".into(),
scope: None,
comparator: Operator::GreaterThanEquals,
threshold: 1.0,
absent_is_bad,
}
}
/// The freshness semantics: a slice the search proved empty is BAD, not a
/// gap. Only the POLICY changes — a failed search still writes nothing
/// for every type, because gap fill runs only after a successful query.
#[test]
fn an_absent_is_bad_time_slice_fills_covered_bad() {
assert_eq!(gap_fill_policy_for(&ts(true)), GapFill::CoveredBad);
}
/// Off keeps S-8 exactly: absence is a gap, coverage falls, the SLO
/// freezes rather than inventing downtime.
#[test]
fn a_plain_time_slice_still_fills_nothing() {
assert_eq!(gap_fill_policy_for(&ts(false)), GapFill::Nothing);
}
#[test]
fn count_and_alert_policies_are_unchanged() {
let count = SliConfig::Count {
source: CountSource::SingleQuery {
stream: "s".into(),
stream_type: "logs".into(),
scope: None,
good_expr: "ok".into(),
},
};
assert_eq!(gap_fill_policy_for(&count), GapFill::CoveredZero);
assert_eq!(
gap_fill_policy_for(&SliConfig::Alert {
alert_id: "a".into()
}),
GapFill::Nothing
);
// The type-level answer stays for callers that have no config.
assert_eq!(gap_fill_policy(SliType::Count), GapFill::CoveredZero);
assert_eq!(gap_fill_policy(SliType::TimeSlice), GapFill::Nothing);
}
}

View File

@ -0,0 +1,335 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The read-time view of an SLO's measurement (`alerts_2.md` §6b.4c, D56).
//!
//! Nothing here is stored. `slo_status` keeps only **target-free** raw
//! counts, and every number a user sees is derived from those plus the
//! *current* target — which is what lets a target edit take effect instantly
//! instead of invalidating 90 days of measurement.
//!
//! The other rule this type enforces is that **unmeasured time never reads as
//! uptime** (D34). Below the coverage floor the view reports `no_data` and
//! leaves the derived figures `None`, rather than reporting an SLI computed
//! from a fraction of the window as though it described all of it.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use super::math::{burn_rate, error_budget_remaining, sli, time_to_exhaust_secs};
/// What the UI and the API render for one SLO or one group.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct SloStatusView {
pub group_key: String,
/// Fraction of the window actually measured, 0..1.
pub coverage: f64,
/// True when coverage is below the floor. Every derived figure is `None`
/// in that case — the SLO is frozen, not healthy and not breached.
pub no_data: bool,
/// Percentage, 0..100. `None` when frozen or not yet measured.
pub sli: Option<f64>,
/// **Percentage** of the error budget still unspent, not a fraction.
/// Negative once the budget is overspent, which is meaningful and
/// deliberately not clamped: "-80% remaining" is what a user needs to see
/// when they have burned 180% of the budget.
pub error_budget_remaining: Option<f64>,
/// Current burn rate — multiples of the budget-neutral rate.
pub burn_rate: Option<f64>,
/// Seconds until the budget is exhausted at the current burn. `None` when
/// the burn is at or below neutral, because nothing is being exhausted.
pub time_to_exhaust_secs: Option<i64>,
pub good: f64,
pub total: f64,
pub covered_slices: i64,
pub computed_at: Option<i64>,
}
impl SloStatusView {
/// Derive the view from raw counts and the current target.
#[allow(clippy::too_many_arguments)]
pub fn derive(
group_key: String,
good: Option<f64>,
total: Option<f64>,
covered_slices: Option<i64>,
expected_slices: i64,
target: f64,
window_secs: i64,
coverage_floor: f64,
computed_at: Option<i64>,
) -> Self {
let good = good.unwrap_or(0.0);
let total = total.unwrap_or(0.0);
let covered = covered_slices.unwrap_or(0);
let coverage = if expected_slices > 0 {
(covered as f64 / expected_slices as f64).clamp(0.0, 1.0)
} else {
0.0
};
// Nothing measured yet is NOT the same as measured-and-empty. A brand
// new SLO must not render as 0% available.
let unmeasured = covered_slices.is_none() || covered == 0;
// A covered window with ZERO events has no SLI either — the ratio is
// undefined, not 0% (SA-18). `coverage::observe` already treats this
// as `Unobserved(ZeroTotal)`; this is the read path agreeing with it.
// Without this the view reports `no_data: false` alongside a null SLI,
// an inconsistent state the UI renders as "measured" with an em dash.
let sli_pct_raw = sli(good, total);
let no_data = unmeasured || coverage < coverage_floor || sli_pct_raw.is_none();
let sli_pct = if no_data { None } else { sli_pct_raw };
let (budget, burn, ttl) = match sli_pct {
Some(s) => {
let remaining = error_budget_remaining(s, target);
let b = burn_rate(s, target);
(
Some(remaining),
Some(b),
time_to_exhaust_secs(window_secs, b),
)
}
None => (None, None, None),
};
Self {
group_key,
coverage,
no_data,
sli: sli_pct,
error_budget_remaining: budget,
burn_rate: burn,
time_to_exhaust_secs: ttl,
good,
total,
covered_slices: covered,
computed_at,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn view(
good: Option<f64>,
total: Option<f64>,
covered: Option<i64>,
expected: i64,
) -> SloStatusView {
SloStatusView::derive(
String::new(),
good,
total,
covered,
expected,
99.0,
30 * 86_400,
0.9,
Some(100),
)
}
#[test]
fn a_healthy_slo_derives_its_sli_and_budget() {
let v = view(Some(999.0), Some(1000.0), Some(100), 100);
assert!(!v.no_data);
assert_eq!(v.sli, Some(99.9));
// Target 99% allows 1% errors; 0.1% used leaves 90% of the budget.
// A PERCENTAGE, matching `math::error_budget_remaining` — not 0.9.
let remaining = v.error_budget_remaining.unwrap();
assert!((remaining - 90.0).abs() < 1e-9, "got {remaining}");
}
/// The property the whole coverage mechanism exists for (D34).
#[test]
fn unmeasured_time_never_reads_as_uptime() {
// Half the window measured, all of it good.
let v = view(Some(50.0), Some(50.0), Some(50), 100);
assert!(v.no_data, "50% coverage read as a real measurement");
assert_eq!(v.sli, None, "an SLI was reported from half a window");
assert_eq!(v.error_budget_remaining, None);
assert_eq!(v.burn_rate, None);
}
#[test]
fn coverage_exactly_at_the_floor_is_measured() {
let v = view(Some(90.0), Some(90.0), Some(90), 100);
assert!(!v.no_data, "the floor is inclusive");
assert_eq!(v.sli, Some(100.0));
}
#[test]
fn one_slice_below_the_floor_freezes() {
let v = view(Some(89.0), Some(89.0), Some(89), 100);
assert!(v.no_data);
}
/// "Not yet measured" and "measured as zero" are different, and a UI that
/// conflates them shows a brand-new SLO as 0% available.
#[test]
fn a_brand_new_slo_is_no_data_not_zero_percent() {
let v = view(None, None, None, 100);
assert!(v.no_data);
assert_eq!(v.sli, None);
assert_eq!(v.coverage, 0.0);
}
#[test]
fn a_fully_measured_but_empty_window_is_still_no_data() {
// covered = 0 means nothing was observed, even if expected > 0.
let v = view(Some(0.0), Some(0.0), Some(0), 100);
assert!(v.no_data);
}
/// A window with plenty of COVERAGE but no events at all. The SLI is
/// undefined, not 0% (SA-18), so the view must say no_data rather than
/// report "measured" with a null SLI — an inconsistent pair the UI would
/// render as a measured SLO showing an em dash.
///
/// Found by end-to-end testing: gap-filled zero-traffic slices produce
/// exactly this shape, and every unit test had fed it non-zero totals.
#[test]
fn a_covered_window_with_zero_events_is_no_data() {
let v = view(Some(0.0), Some(0.0), Some(95), 100);
assert!(v.coverage > 0.9, "the window IS covered");
assert!(
v.no_data,
"covered but empty reported as measured — sli would be null"
);
assert_eq!(v.sli, None);
assert_eq!(v.error_budget_remaining, None);
assert_eq!(v.burn_rate, None);
}
/// The pair must never disagree: a null SLI and `no_data: false` together
/// is the inconsistency this guards.
#[test]
fn no_data_and_a_null_sli_always_agree() {
for (good, total, covered) in [
(Some(0.0), Some(0.0), Some(95)),
(Some(99.0), Some(100.0), Some(95)),
(None, None, None),
(Some(0.0), Some(100.0), Some(95)),
(Some(1.0), Some(1.0), Some(5)),
] {
let v = view(good, total, covered, 100);
assert_eq!(
v.sli.is_none(),
v.no_data,
"sli={:?} disagrees with no_data={} for {good:?}/{total:?}/{covered:?}",
v.sli,
v.no_data
);
}
}
/// D56: the target is applied at READ time, so the same stored counts
/// yield different budgets under different targets — with no rebuild.
#[test]
fn the_target_is_applied_at_read_time() {
let counts = (Some(999.0), Some(1000.0), Some(100));
let lenient = SloStatusView::derive(
String::new(),
counts.0,
counts.1,
counts.2,
100,
99.0,
30 * 86_400,
0.9,
None,
);
let strict = SloStatusView::derive(
String::new(),
counts.0,
counts.1,
counts.2,
100,
99.95,
30 * 86_400,
0.9,
None,
);
assert_eq!(lenient.sli, strict.sli, "the SLI is target-free");
assert_ne!(
lenient.error_budget_remaining, strict.error_budget_remaining,
"the budget must move with the target"
);
assert!(
strict.error_budget_remaining.unwrap() < 0.0,
"99.9% against a 99.95% target has overspent its budget"
);
}
/// Negative remaining budget is meaningful and deliberately not clamped:
/// "180% consumed" is what a user needs to see.
#[test]
fn an_overspent_budget_reports_a_negative_remainder() {
let v = SloStatusView::derive(
String::new(),
Some(980.0),
Some(1000.0),
Some(100),
100,
99.0,
30 * 86_400,
0.9,
None,
);
let remaining = v.error_budget_remaining.unwrap();
assert!(remaining < 0.0, "got {remaining}");
}
#[test]
fn a_burn_at_or_below_neutral_never_exhausts() {
// Exactly on target: burn = 1.0, so the budget lasts exactly the
// window and nothing is being exhausted early.
let v = SloStatusView::derive(
String::new(),
Some(990.0),
Some(1000.0),
Some(100),
100,
99.0,
30 * 86_400,
0.9,
None,
);
assert_eq!(v.burn_rate, Some(1.0));
// No errors at all: burn 0, nothing to exhaust.
let perfect = view(Some(1000.0), Some(1000.0), Some(100), 100);
assert_eq!(perfect.burn_rate, Some(0.0));
assert_eq!(perfect.time_to_exhaust_secs, None);
}
#[test]
fn coverage_is_clamped_to_one() {
// More slices than expected (a late-data re-emission miscount) must
// not report 130% coverage.
let v = view(Some(1.0), Some(1.0), Some(130), 100);
assert_eq!(v.coverage, 1.0);
}
#[test]
fn a_zero_expected_window_does_not_divide_by_zero() {
let v = view(Some(1.0), Some(1.0), Some(0), 0);
assert_eq!(v.coverage, 0.0);
assert!(v.no_data);
}
}

View File

@ -0,0 +1,160 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The `slo_slices` reserved stream (`alerts_2.md` §6b.8, D32).
//!
//! Slices are a **stream**, not a meta-store table, for the reason the volume
//! makes obvious: 90 days × 5-minute slices × groups × SLOs is timeseries
//! data, and the meta store is SQLite in local deployments. `triggers` is the
//! precedent — reserved name, schema by reflection over the Rust struct,
//! written by the job that produces it.
//!
//! Every row carries `definition_generation`. Readers filter on it, which is
//! what makes a generation bump a clean break rather than a migration: the old
//! epoch's slices simply stop being visible and age out with retention.
use serde::{Deserialize, Serialize};
/// The reserved stream name. Registered in
/// [`crate::meta::self_reporting::usage::RESERVED_INTERNAL_STREAMS`], which is
/// what stops a user creating, ingesting into, or deleting it.
pub const SLO_SLICES_STREAM: &str = "slo_slices";
/// One measured slice.
///
/// `good`/`total` are stored raw rather than as a ratio: the running aggregate
/// sums them, and summing pre-divided ratios would weight a slice with 3
/// events the same as one with 30,000.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SloSliceRow {
/// Ingest timestamp — when the slice was *written*, not what it measures.
/// `slice_start` is the measurement time, and the two differ by the
/// evaluation delay and by however late the data arrived.
pub _timestamp: i64,
pub org: String,
pub slo_id: String,
/// The epoch this measurement belongs to. Readers filter on it (D59).
pub definition_generation: i32,
/// `""` is the ungrouped / rollup series (S-9).
pub group_key: String,
/// Display labels for the group, denormalized so a reader does not need
/// the definition to render a chart.
pub group_labels: String,
/// Aligned to the slice grid. THE measurement time.
pub slice_start: i64,
pub good: f64,
pub total: f64,
/// Monotonic per `(slo_id, generation, group_key, slice_start)`. Late data
/// and recomputes produce a higher revision, and readers keep the highest
/// (D54).
///
/// This is for **dedupe**, not publication ordering — there is no
/// publication protocol to order (D64).
pub rev: i64,
}
impl SloSliceRow {
/// A fully-populated sample used to infer the Arrow schema, so every field
/// exists from the first write rather than appearing as data happens to
/// contain it. Mirrors `TriggerData::init_for_reflection`.
///
/// Every field must be non-null and of its real type here: a field left
/// empty would be inferred as `Utf8` and then conflict with the first real
/// numeric write.
pub fn init_for_reflection() -> Self {
Self {
_timestamp: 1,
org: "org".to_string(),
slo_id: "slo".to_string(),
definition_generation: 1,
group_key: "group".to_string(),
group_labels: "label".to_string(),
slice_start: 1,
good: 1.0,
total: 1.0,
rev: 1,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_reflection_sample_populates_every_field() {
// A field left at its default would be inferred with the wrong Arrow
// type and then conflict with the first real write.
let v = serde_json::to_value(SloSliceRow::init_for_reflection()).unwrap();
let obj = v.as_object().unwrap();
assert_eq!(obj.len(), 10, "a field was added without a sample value");
for (k, val) in obj {
assert!(!val.is_null(), "{k} is null in the reflection sample");
if let Some(s) = val.as_str() {
assert!(!s.is_empty(), "{k} is empty in the reflection sample");
}
}
}
/// `good` and `total` must infer as floats. If the sample used whole
/// numbers they could infer as integers, and the first fractional write
/// would then conflict with the stored schema.
#[test]
fn the_numeric_fields_are_floats_in_the_sample() {
let v = serde_json::to_value(SloSliceRow::init_for_reflection()).unwrap();
assert!(v["good"].is_f64(), "good must infer as a float");
assert!(v["total"].is_f64(), "total must infer as a float");
}
#[test]
fn a_slice_row_round_trips() {
let row = SloSliceRow {
_timestamp: 1_700_000_000_000_000,
org: "acme".into(),
slo_id: "slo1".into(),
definition_generation: 3,
group_key: "region=eu".into(),
group_labels: "region: eu".into(),
slice_start: 1_700_000_000,
good: 98.0,
total: 100.0,
rev: 2,
};
let json = serde_json::to_string(&row).unwrap();
assert_eq!(serde_json::from_str::<SloSliceRow>(&json).unwrap(), row);
}
/// The stored field names are a wire contract with the reader SQL. A
/// rename here silently breaks every query in §6b.4.
#[test]
fn the_field_names_are_pinned() {
let v = serde_json::to_value(SloSliceRow::init_for_reflection()).unwrap();
for field in [
"_timestamp",
"org",
"slo_id",
"definition_generation",
"group_key",
"group_labels",
"slice_start",
"good",
"total",
"rev",
] {
assert!(v.get(field).is_some(), "field `{field}` was renamed");
}
}
}

View File

@ -0,0 +1,582 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Slice and window arithmetic — `alerts_2.md` §6b.4a.
//!
//! Two rules carry the whole ingest path:
//!
//! 1. **The range is `[start, end)` and `end` is the last *completed* slice.** A query with only
//! `_timestamp >= watermark` publishes the currently-open slice: a bucket that is 10% full reads
//! as 90% less traffic, and for a time-slice SLI a half-filled slice can classify bad and then
//! flip good on the next pass.
//! 2. **Everything is aligned in UTC and computed in absolute seconds.** "30 days" is 30 × 86,400
//! s, not a calendar month — which is exactly why calendar windows are a v1 non-goal rather than
//! a rounding detail.
/// Align a timestamp down to the start of its slice.
pub fn align_down(ts_secs: i64, slice_interval_secs: i64) -> i64 {
if slice_interval_secs <= 0 {
return ts_secs;
}
// `div_euclid`, not `/`: integer division truncates toward zero, so a
// negative timestamp would align *up* and land inside the wrong bucket.
ts_secs.div_euclid(slice_interval_secs) * slice_interval_secs
}
/// Align a timestamp **up** to the next slice boundary, leaving an exact
/// boundary alone.
fn align_up(ts_secs: i64, slice_interval_secs: i64) -> i64 {
if slice_interval_secs <= 0 {
return ts_secs;
}
let down = align_down(ts_secs, slice_interval_secs);
if down == ts_secs {
down
} else {
down + slice_interval_secs
}
}
/// A closed ingest range: `[start, end)`, both aligned to the slice grid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IngestRange {
/// Inclusive lower bound, aligned.
pub start: i64,
/// **Exclusive** upper bound, aligned — the start of the first slice that
/// is *not* yet complete.
pub end: i64,
}
impl IngestRange {
/// Number of slices the range covers.
pub fn slice_count(&self, slice_interval_secs: i64) -> i64 {
if slice_interval_secs <= 0 {
return 0;
}
(self.end - self.start).max(0) / slice_interval_secs
}
/// Every aligned `slice_start` in the range, ascending.
pub fn slice_starts(&self, slice_interval_secs: i64) -> Vec<i64> {
if slice_interval_secs <= 0 {
return Vec::new();
}
let mut out = Vec::new();
let mut s = self.start;
while s < self.end {
out.push(s);
s += slice_interval_secs;
}
out
}
}
/// Inputs to the range computation, named so the call sites cannot transpose
/// them.
#[derive(Debug, Clone, Copy)]
pub struct IngestRangeParams {
/// Wall clock at the start of the pass.
pub now_secs: i64,
/// The current published watermark; `None` on the very first pass of a
/// generation.
pub watermark_end: Option<i64>,
pub slice_interval_secs: i64,
/// Settle time for normal ingestion lag before a slice is closed
/// (`ZO_SLO_INGEST_DELAY_SECS`).
pub ingest_delay_secs: i64,
/// How many trailing slices to recompute for late data
/// (`ZO_SLO_RECOMPUTE_SLICES`).
pub recompute_slices: i64,
/// Floor for the first pass of a generation — never scan before this.
pub generation_reset_time: i64,
}
/// Compute the pass's `[start, end)`.
///
/// ```text
/// end = align_down(now - ingest_delay) the last COMPLETE slice
/// start = min(watermark_end, align_down(now) - K × slice)
/// start = max(start, generation_reset_time)
/// ```
///
/// `None` — the pass does nothing — **iff no new slice has closed**, i.e.
/// `end <= watermark_end` (or the degenerate `end <= start`).
///
/// That condition is the whole reason this returns an `Option`, and it needs
/// stating because the obvious alternative is wrong in both directions. With
/// `K > 0` the range *always* reaches back K slices, so `start < end` holds
/// unconditionally and a `end <= start` test alone could never fire — the job
/// would re-query the same trailing slices on every scheduler tick, at up to
/// `slice_interval / cadence` times the necessary query load. Gating on "did a
/// slice close" costs nothing in late-data coverage: the trailing K slices are
/// still recomputed at the next slice boundary, which is the soonest any of
/// their values could matter.
///
/// A watermark ahead of `now` (clock skew) therefore also yields `None`, which
/// is the safe response — never a backwards range.
pub fn ingest_range(params: IngestRangeParams) -> Option<IngestRange> {
let slice = params.slice_interval_secs;
if slice <= 0 {
return None;
}
// The last COMPLETE slice: never the one still filling.
let end = align_down(params.now_secs - params.ingest_delay_secs, slice);
// Nothing new has closed — do not re-query the same trailing slices on
// every tick. A watermark ahead of `now` (clock skew) lands here too,
// which is the safe response.
if let Some(watermark) = params.watermark_end
&& end <= watermark
{
return None;
}
// Reach back K slices for late data. On the first pass of a generation
// there is no watermark to reach back from.
let reach_back = align_down(params.now_secs, slice) - params.recompute_slices * slice;
let start = match params.watermark_end {
Some(watermark) => watermark.min(reach_back),
None => params.generation_reset_time,
};
// Never scan before the generation began, and keep `start` on the grid:
// an unaligned start would make the histogram emit a `slice_start` below
// it, i.e. a bucket that partly predates the generation.
let floor = align_up(params.generation_reset_time, slice);
let start = align_down(start, slice).max(floor);
if end <= start {
return None;
}
Some(IngestRange { start, end })
}
/// How many slices a read window *should* contain — the denominator of
/// coverage. Derived from the aligned grid, never from what a query returned.
pub fn expected_slices(from_secs: i64, to_secs: i64, slice_interval_secs: i64) -> i64 {
if slice_interval_secs <= 0 {
return 0;
}
(to_secs - from_secs).max(0) / slice_interval_secs
}
/// The `[from, to)` a read window covers, anchored at the watermark rather
/// than the wall clock (SA-14).
pub fn read_window(watermark_end: i64, window_secs: i64) -> (i64, i64) {
(watermark_end - window_secs, watermark_end)
}
/// Whether a watermark is too old to trust — `now > watermark + K × slice`
/// (SA-14). A stale watermark means the evaluation is unobserved, not that the
/// SLO recovered.
pub fn watermark_is_stale(
now_secs: i64,
watermark_end: i64,
slice_interval_secs: i64,
stale_k: i64,
) -> bool {
now_secs > watermark_end + stale_k * slice_interval_secs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meta::slo::{SLICE_60_SECS, SLICE_300_SECS};
const MIN: i64 = 60;
const FIVE_MIN: i64 = 300;
fn params(now: i64, watermark: Option<i64>, slice: i64) -> IngestRangeParams {
IngestRangeParams {
now_secs: now,
watermark_end: watermark,
slice_interval_secs: slice,
ingest_delay_secs: 60,
recompute_slices: 3,
generation_reset_time: 0,
}
}
// ---- alignment ---------------------------------------------------------
#[test]
fn align_down_snaps_to_the_slice_grid() {
assert_eq!(align_down(1_000_000, FIVE_MIN), 999_900);
assert_eq!(align_down(1_000_000, MIN), 1_000_000 - 40);
}
#[test]
fn align_down_is_idempotent() {
let aligned = align_down(1_234_567, FIVE_MIN);
assert_eq!(align_down(aligned, FIVE_MIN), aligned);
}
#[test]
fn align_down_leaves_an_exact_boundary_alone() {
assert_eq!(align_down(3600, FIVE_MIN), 3600);
assert_eq!(align_down(0, FIVE_MIN), 0);
}
// ---- the exclusive upper bound ----------------------------------------
/// The defect this rule exists to prevent: publishing the slice that is
/// still filling.
#[test]
fn range_never_includes_the_currently_open_slice() {
// now sits 100s into the slice that starts at 3600.
let now = 3600 + 100;
let r = ingest_range(IngestRangeParams {
ingest_delay_secs: 0,
..params(now, Some(3000), FIVE_MIN)
})
.unwrap();
assert_eq!(r.end, 3600, "end must be the START of the open slice");
assert!(r.end <= now);
}
#[test]
fn range_end_is_aligned() {
let r = ingest_range(params(1_234_567, Some(1_200_000), FIVE_MIN)).unwrap();
assert_eq!(align_down(r.end, FIVE_MIN), r.end);
}
#[test]
fn ingest_delay_pushes_the_end_back_by_whole_slices() {
let now = 7200; // exactly on a slice boundary
let no_delay = ingest_range(IngestRangeParams {
ingest_delay_secs: 0,
..params(now, Some(3600), FIVE_MIN)
})
.unwrap();
let delayed = ingest_range(IngestRangeParams {
ingest_delay_secs: 60,
..params(now, Some(3600), FIVE_MIN)
})
.unwrap();
assert_eq!(no_delay.end, 7200);
assert_eq!(delayed.end, 6900, "a 60s delay drops the last slice");
}
/// The pass is a no-op while no new slice has closed — otherwise a job
/// running faster than its slice interval re-queries the same trailing
/// slices every tick.
#[test]
fn range_is_none_when_no_new_slice_has_closed() {
// end = align(7300 - 60) = 7200, which the watermark already covers.
assert_eq!(ingest_range(params(7300, Some(7200), FIVE_MIN)), None);
}
#[test]
fn range_is_some_again_as_soon_as_one_slice_closes() {
// Same watermark, one slice later: end = align(7600-60) = 7500.
let r = ingest_range(params(7600, Some(7200), FIVE_MIN)).unwrap();
assert_eq!(r.end, 7500);
}
#[test]
fn range_is_none_when_the_watermark_is_ahead_of_now() {
// Clock skew must never produce a backwards range.
assert_eq!(ingest_range(params(3600, Some(99_999), FIVE_MIN)), None);
}
// ---- the trailing recompute window ------------------------------------
#[test]
fn range_reaches_back_k_slices_for_late_data() {
let r = ingest_range(params(10_000, Some(9000), FIVE_MIN)).unwrap();
// now - delay = 9940 -> end = 9900; align(now) - 3 slices = 9000,
// and the watermark is also 9000, so start is 9000.
assert_eq!(r.end, 9900);
assert_eq!(r.start, 9000, "3 slices back from align(now)");
}
/// The recompute window must actually re-cover already-published slices,
/// otherwise late data is never picked up.
#[test]
fn the_recompute_window_reaches_behind_the_watermark() {
// A watermark well ahead of align(now) - K*slice: start is pulled back.
let r = ingest_range(params(10_000, Some(9_800), FIVE_MIN)).unwrap();
assert!(
r.start < 9_800,
"start {} must reach behind the watermark for late data",
r.start
);
assert_eq!(r.start, 9_000);
}
#[test]
fn recompute_never_reaches_past_the_generation_reset() {
let r = ingest_range(IngestRangeParams {
generation_reset_time: 9600,
..params(10_000, Some(9000), FIVE_MIN)
})
.unwrap();
assert!(
r.start >= 9600,
"must not scan before the generation started: {}",
r.start
);
}
/// An unaligned reset would put `start` inside a bucket, so the histogram
/// emits a `slice_start` BELOW `start` — which then falls on the backfill
/// side of `reset_time` and is judged against the wrong committed mark.
#[test]
fn an_unaligned_generation_reset_still_yields_an_aligned_start() {
let r = ingest_range(IngestRangeParams {
generation_reset_time: 9601,
..params(11_000, Some(9_000), FIVE_MIN)
})
.unwrap();
assert_eq!(
align_down(r.start, FIVE_MIN),
r.start,
"start {} is not on the slice grid",
r.start
);
assert!(
r.start >= 9601,
"start {} would scan data from before the generation began",
r.start
);
}
#[test]
fn an_unaligned_reset_never_emits_a_slice_owned_by_the_other_writer() {
let reset = 9601;
let r = ingest_range(IngestRangeParams {
generation_reset_time: reset,
..params(11_000, Some(9_000), FIVE_MIN)
})
.unwrap();
for s in r.slice_starts(FIVE_MIN) {
assert!(
s >= reset,
"slice_start {s} is below reset_time {reset}, so the backfill \
writer's mark would judge an incremental row"
);
}
}
#[test]
fn the_first_pass_of_a_generation_starts_at_the_reset_time() {
let r = ingest_range(IngestRangeParams {
watermark_end: None,
generation_reset_time: 9000,
..params(10_000, None, FIVE_MIN)
})
.unwrap();
assert_eq!(r.start, 9000);
}
#[test]
fn zero_recompute_starts_exactly_at_the_watermark() {
let r = ingest_range(IngestRangeParams {
recompute_slices: 0,
..params(10_000, Some(9000), FIVE_MIN)
})
.unwrap();
assert_eq!(r.start, 9000);
}
// ---- slice enumeration -------------------------------------------------
#[test]
fn slice_count_is_the_half_open_length() {
let r = IngestRange {
start: 0,
end: 1500,
};
assert_eq!(r.slice_count(FIVE_MIN), 5);
}
#[test]
fn slice_starts_are_ascending_aligned_and_exclude_the_end() {
let r = IngestRange {
start: 600,
end: 1800,
};
let starts = r.slice_starts(FIVE_MIN);
assert_eq!(starts, vec![600, 900, 1200, 1500]);
assert!(!starts.contains(&1800), "end is exclusive");
}
#[test]
fn an_empty_range_enumerates_nothing() {
let r = IngestRange {
start: 900,
end: 900,
};
assert_eq!(r.slice_count(FIVE_MIN), 0);
assert!(r.slice_starts(FIVE_MIN).is_empty());
}
// ---- agreement with the engine's bucketing -----------------------------
/// `histogram()` is rewritten to `date_bin(interval, source, origin)` with
/// **origin = 2001-01-01T00:00:00Z**, not the Unix epoch
/// (`rewrite_histogram.rs:250`). `align_down` aligns to the epoch. The two
/// therefore agree only when the origin is itself a whole number of
/// intervals from the epoch.
///
/// It is — for the intervals we allow. 2001-01-01 is 978,307,200 s after
/// the epoch, which 60 and 300 both divide exactly. But that is a
/// coincidence of the constant, not a property of the design: a 7-minute
/// interval (420 s) leaves a remainder of 360 and the two bucketings drift
/// apart by a minute, which would silently misalign every slice against
/// the coverage grid.
///
/// So this is pinned rather than assumed. If a new slice interval is ever
/// added, this test is what says whether the engine agrees with us.
#[test]
fn every_legal_slice_interval_agrees_with_the_histogram_origin() {
/// 2001-01-01T00:00:00Z, from `rewrite_histogram.rs`.
const DATE_BIN_ORIGIN_SECS: i64 = 978_307_200;
for slice in [SLICE_60_SECS, SLICE_300_SECS] {
assert_eq!(
DATE_BIN_ORIGIN_SECS % slice,
0,
"slice interval {slice}s does not divide the date_bin origin, so \
histogram() buckets would not line up with align_down"
);
}
}
/// The same statement as a behavioural check: for a legal interval, our
/// bucket and the engine's are the same number.
#[test]
fn align_down_matches_date_bin_for_legal_intervals() {
const ORIGIN: i64 = 978_307_200;
let date_bin = |ts: i64, slice: i64| ORIGIN + (ts - ORIGIN).div_euclid(slice) * slice;
for slice in [SLICE_60_SECS, SLICE_300_SECS] {
for ts in [0, 1_000_000_000, 1_753_000_000, 2_000_000_123] {
assert_eq!(
align_down(ts, slice),
date_bin(ts, slice),
"align_down and date_bin disagree at ts={ts}, slice={slice}"
);
}
}
}
/// The negative case, so the test above cannot pass vacuously: an interval
/// that does NOT divide the origin really does drift.
#[test]
fn an_interval_that_does_not_divide_the_origin_would_drift() {
const ORIGIN: i64 = 978_307_200;
let slice = 420; // 7 minutes — not a legal SLO slice, deliberately
let date_bin = ORIGIN + (1_000_000_000 - ORIGIN).div_euclid(slice) * slice;
assert_ne!(
align_down(1_000_000_000, slice),
date_bin,
"if these ever agree, the guard above has stopped meaning anything"
);
}
// ---- expected slices (the coverage denominator) ------------------------
#[test]
fn expected_slices_counts_the_grid_not_the_data() {
assert_eq!(expected_slices(0, 3600, FIVE_MIN), 12);
assert_eq!(expected_slices(0, 3600, MIN), 60);
}
#[test]
fn a_thirty_day_window_has_the_documented_slice_count() {
// The §6b.4d volume table: 8,640 slices per 30 days at 5-min.
assert_eq!(expected_slices(0, 30 * 86_400, FIVE_MIN), 8_640);
assert_eq!(expected_slices(0, 30 * 86_400, MIN), 43_200);
}
#[test]
fn a_ninety_day_window_at_five_minutes_is_the_documented_row_count() {
assert_eq!(expected_slices(0, 90 * 86_400, FIVE_MIN), 25_920);
}
#[test]
fn expected_slices_of_an_empty_window_is_zero() {
assert_eq!(expected_slices(1000, 1000, FIVE_MIN), 0);
}
/// These are `pub` entry points, so a zero interval must be defined rather
/// than a divide-by-zero panic — validate_slo rejects it upstream, but the
/// functions must not be a landmine for a caller that skips validation.
#[test]
fn a_zero_slice_interval_does_not_panic() {
assert_eq!(align_down(1234, 0), 1234);
assert_eq!(expected_slices(0, 3600, 0), 0);
let r = IngestRange { start: 0, end: 900 };
assert_eq!(r.slice_count(0), 0);
assert!(r.slice_starts(0).is_empty());
}
#[test]
fn a_negative_slice_interval_does_not_panic() {
assert_eq!(align_down(1234, -60), 1234);
assert_eq!(expected_slices(0, 3600, -60), 0);
}
#[test]
fn expected_slices_never_goes_negative() {
assert_eq!(expected_slices(2000, 1000, FIVE_MIN), 0);
}
// ---- read windows are anchored at the watermark ------------------------
/// SA-14: an alert that anchors at `now()` reads a window missing its most
/// recent slice — systematically optimistic, and worst during an incident.
#[test]
fn read_window_ends_at_the_watermark_not_the_clock() {
let (from, to) = read_window(9_000, 3600);
assert_eq!(to, 9_000);
assert_eq!(from, 9_000 - 3600);
}
#[test]
fn read_window_length_is_exactly_the_window() {
let (from, to) = read_window(1_000_000, 30 * 86_400);
assert_eq!(to - from, 30 * 86_400);
}
// ---- watermark staleness ----------------------------------------------
#[test]
fn a_fresh_watermark_is_not_stale() {
assert!(!watermark_is_stale(10_000, 9_900, FIVE_MIN, 3));
}
#[test]
fn a_watermark_older_than_k_slices_is_stale() {
// K=3 at 5-min slices = 900s of tolerance.
assert!(!watermark_is_stale(10_000, 9_150, FIVE_MIN, 3));
assert!(watermark_is_stale(10_000, 9_000, FIVE_MIN, 3));
}
#[test]
fn staleness_scales_with_the_slice_interval() {
// The same absolute gap is stale at 1-min slices but fine at 5-min.
let gap_secs = 500;
assert!(watermark_is_stale(10_000, 10_000 - gap_secs, MIN, 3));
assert!(!watermark_is_stale(10_000, 10_000 - gap_secs, FIVE_MIN, 3));
}
#[test]
fn a_future_watermark_is_never_stale() {
assert!(!watermark_is_stale(10_000, 20_000, FIVE_MIN, 3));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -74,7 +74,12 @@ pub mod session {
.collect()
}
pub fn trace_ids_sql(stream: &str, session_columns: &[String], session_id: &str) -> String {
pub fn trace_ids_sql(
stream: &str,
session_columns: &[String],
session_id: &str,
ingest_cutoff_us: Option<i64>,
) -> String {
let escaped_session_id = escape_sql_string(session_id);
let predicate = session_columns
.iter()
@ -84,14 +89,27 @@ pub mod session {
format!(
"SELECT trace_id, min({}) as zo_sql_timestamp \
FROM {} \
WHERE ({predicate}) \
WHERE ({predicate}){} \
GROUP BY trace_id \
ORDER BY zo_sql_timestamp DESC, trace_id ASC",
quote_identifier(TIMESTAMP_COL_NAME),
quote_identifier(stream),
ingest_cutoff_predicate(ingest_cutoff_us),
)
}
/// An ingest-time upper bound appended to evidence queries: rows ingested
/// at or after the cutoff are excluded, so hydration cannot mix evidence
/// that arrived after an evaluation run's frozen boundary into that run.
fn ingest_cutoff_predicate(ingest_cutoff_us: Option<i64>) -> String {
ingest_cutoff_us.map_or_else(String::new, |cutoff_us| {
format!(
" AND {} < {cutoff_us}",
quote_identifier(crate::O2_INGEST_TS_COL_NAME)
)
})
}
pub fn trace_ids_from_hits(hits: &[Value]) -> Vec<String> {
let mut seen = HashSet::with_capacity(hits.len());
let mut trace_ids = Vec::with_capacity(hits.len());
@ -116,11 +134,16 @@ pub mod session {
format!("{} IN ({values})", quote_identifier("trace_id"))
}
pub fn span_rows_sql(stream: &str, trace_ids: &[String]) -> String {
pub fn span_rows_sql(
stream: &str,
trace_ids: &[String],
ingest_cutoff_us: Option<i64>,
) -> String {
format!(
"SELECT * FROM {} WHERE {} ORDER BY {} ASC, _o2_ingest_ts ASC, trace_id ASC, span_id ASC",
"SELECT * FROM {} WHERE {}{} ORDER BY {} ASC, _o2_ingest_ts ASC, trace_id ASC, span_id ASC",
quote_identifier(stream),
trace_id_predicate(trace_ids),
ingest_cutoff_predicate(ingest_cutoff_us),
quote_identifier(TIMESTAMP_COL_NAME),
)
}
@ -165,17 +188,37 @@ pub mod session {
"llm_session_id".to_string(),
],
"session-'1",
None,
);
assert!(sql.contains("\"gen_ai_conversation_id\" = 'session-''1'"));
assert!(!sql.contains("_o2_ingest_ts"));
assert!(sql.contains("OR \"llm_session_id\" = 'session-''1'"));
assert!(sql.contains("GROUP BY trace_id"));
assert!(sql.contains("ORDER BY zo_sql_timestamp DESC, trace_id ASC"));
}
#[test]
fn evidence_queries_apply_the_frozen_ingest_cutoff() {
let sql = trace_ids_sql(
"traces",
&["llm_session_id".to_string()],
"session-1",
Some(1_000_000),
);
assert!(sql.contains("AND \"_o2_ingest_ts\" < 1000000 GROUP BY"));
let sql = span_rows_sql("traces", &["abc-123".to_string()], Some(1_000_000));
assert!(sql.contains("AND \"_o2_ingest_ts\" < 1000000 ORDER BY"));
}
#[test]
fn phase_two_selects_all_rows_for_discovered_traces() {
let sql = span_rows_sql("traces", &["abc-123".to_string(), "def-456".to_string()]);
let sql = span_rows_sql(
"traces",
&["abc-123".to_string(), "def-456".to_string()],
None,
);
assert!(sql.contains("SELECT *"));
assert!(sql.contains("\"trace_id\" IN ('abc-123', 'def-456')"));

View File

@ -38,6 +38,16 @@ pub enum TriggerModule {
QueryRecommendations,
Backfill,
AnomalyDetection,
// APPEND ONLY. The implicit discriminant is the value stored in
// `scheduled_jobs.module` (this enum is persisted via `sqlx::Type` +
// `#[repr(i32)]`, not by name), so inserting a variant above this line
// silently remaps every existing row to a different module.
/// SLI ingest — one job per enabled SLO, cadence = its slice interval.
Slo,
/// Bulk historical fill. Its own lane, because a bulk scan sharing a
/// concurrency budget with latency-sensitive incremental passes would
/// starve them (§6b.9).
SloBackfill,
}
impl std::fmt::Display for TriggerModule {
@ -49,6 +59,8 @@ impl std::fmt::Display for TriggerModule {
Self::QueryRecommendations => write!(f, "query_recommendations"),
Self::Backfill => write!(f, "backfill"),
Self::AnomalyDetection => write!(f, "anomaly_detection"),
Self::Slo => write!(f, "slo"),
Self::SloBackfill => write!(f, "slo_backfill"),
}
}
}
@ -99,6 +111,20 @@ pub struct ScheduledTriggerData {
pub last_satisfied_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub backfill_job: Option<BackfillJob>,
// ── Multi-level silence (alerts_2.md §7.1) ──────────────────────────────
// For alerts WITH a warning threshold, silence stops suppressing
// *evaluation* and suppresses only *delivery* — otherwise a
// Warning→Critical escalation during a silence window can never be
// observed. Single-level alerts keep the legacy behaviour (next_run_at is
// pushed forward) and never set these.
/// Deliver nothing until this timestamp, unless the level escalates.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivery_silenced_until: Option<i64>,
/// Severity of the last DELIVERED notification (`AlertLevel::to_i32`).
/// Escalation is measured against this, not against the previous
/// evaluation — otherwise a flap down and back up would re-notify.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_notified_level: Option<i32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -165,6 +191,23 @@ mod tests {
TriggerModule::AnomalyDetection.to_string(),
"anomaly_detection"
);
assert_eq!(TriggerModule::Slo.to_string(), "slo");
assert_eq!(TriggerModule::SloBackfill.to_string(), "slo_backfill");
}
/// The discriminant IS the stored value. A variant inserted above an
/// existing one would remap every `scheduled_jobs` row to a different
/// module — silently, with no migration to catch it.
#[test]
fn trigger_module_discriminants_are_pinned() {
assert_eq!(TriggerModule::Report as i32, 0);
assert_eq!(TriggerModule::Alert as i32, 1);
assert_eq!(TriggerModule::DerivedStream as i32, 2);
assert_eq!(TriggerModule::QueryRecommendations as i32, 3);
assert_eq!(TriggerModule::Backfill as i32, 4);
assert_eq!(TriggerModule::AnomalyDetection as i32, 5);
assert_eq!(TriggerModule::Slo as i32, 6);
assert_eq!(TriggerModule::SloBackfill as i32, 7);
}
#[test]
@ -174,6 +217,8 @@ mod tests {
tolerance: 42,
last_satisfied_at: Some(999),
backfill_job: None,
delivery_silenced_until: None,
last_notified_level: None,
};
data.reset();
assert!(data.period_end_time.is_none());
@ -197,6 +242,8 @@ mod tests {
tolerance: 10,
last_satisfied_at: Some(9_999_999),
backfill_job: None,
delivery_silenced_until: None,
last_notified_level: None,
};
let json = data.to_json_string();
let restored = ScheduledTriggerData::from_json_string(&json).unwrap();
@ -357,6 +404,8 @@ mod tests {
period_end_time: Some(500),
tolerance: 5,
last_satisfied_at: None,
delivery_silenced_until: None,
last_notified_level: None,
backfill_job: Some(BackfillJob {
current_position: 42,
deletion_status: DeletionStatus::Pending,

View File

@ -163,6 +163,46 @@ pub static INGEST_PARQUET_FILES: Lazy<IntGaugeVec> = Lazy::new(|| {
)
.expect("Metric created")
});
/// Checks waiting to be leased, per location and pool.
///
/// The other half of queue-lag visibility. A result record carries `scheduled_ts`
/// and `started_ts`, so the delay of work that RAN is already derivable — but a
/// check nobody leased produces no record at all, so the backlog it sits in is
/// invisible from the results side by construction. Without this, "the queue is
/// backed up" and "concurrency fixed it" are both unfalsifiable.
pub static SYNTHETICS_PENDING_JOBS: Lazy<IntGaugeVec> = Lazy::new(|| {
IntGaugeVec::new(
Opts::new(
"synthetics_pending_jobs",
"Number of synthetics checks pending lease.".to_owned() + HELP_SUFFIX,
)
.namespace(NAMESPACE)
.const_labels(create_const_labels()),
&["location", "pool"],
)
.expect("Metric created")
});
/// Age of the oldest check still waiting, in seconds, per location and pool.
///
/// Reported alongside the count because they fail differently: a large count that
/// drains every tick is throughput, while a small count whose oldest entry keeps
/// ageing is a location that has stopped being served at all — one agent down, or
/// no agent ever polling that pool. The count alone cannot tell those apart.
pub static SYNTHETICS_OLDEST_PENDING_AGE_SECONDS: Lazy<IntGaugeVec> = Lazy::new(|| {
IntGaugeVec::new(
Opts::new(
"synthetics_oldest_pending_age_seconds",
"Age of the oldest synthetics check pending lease, in seconds.".to_owned()
+ HELP_SUFFIX,
)
.namespace(NAMESPACE)
.const_labels(create_const_labels()),
&["location", "pool"],
)
.expect("Metric created")
});
pub static INGEST_PACK_FILES: Lazy<IntGaugeVec> = Lazy::new(|| {
IntGaugeVec::new(
Opts::new(
@ -1952,6 +1992,71 @@ pub static QUEUE_OLDEST_MESSAGE_AGE_SECONDS: Lazy<IntGaugeVec> = Lazy::new(|| {
.expect("Metric created")
});
pub static EVAL_SCHEDULER_PENDING_TARGETS: Lazy<IntGaugeVec> = Lazy::new(|| {
IntGaugeVec::new(
Opts::new(
"eval_scheduler_pending_targets",
"Current in-flight (pending) evaluation targets tracked by the eval scheduler, summed across the organization's streams",
)
.namespace(NAMESPACE)
.const_labels(create_const_labels()),
&["organization"],
)
.expect("Metric created")
});
pub static EVAL_SCHEDULER_PENDING_MEMORY_BYTES: Lazy<IntGaugeVec> = Lazy::new(|| {
IntGaugeVec::new(
Opts::new(
"eval_scheduler_pending_memory_bytes",
"Accounted bytes of pending evaluation targets, and the configured limit (state=used, limit)",
)
.namespace(NAMESPACE)
.const_labels(create_const_labels()),
&["state"],
)
.expect("Metric created")
});
pub static EVAL_SCHEDULER_FORCED_READY_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
IntCounterVec::new(
Opts::new(
"eval_scheduler_forced_ready_total",
"Total pending evaluation targets force-evaluated early because the pending memory budget was exceeded",
)
.namespace(NAMESPACE)
.const_labels(create_const_labels()),
&["organization"],
)
.expect("Metric created")
});
pub static EVAL_SCHEDULER_EVICTED_EVIDENCE_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
IntCounterVec::new(
Opts::new(
"eval_scheduler_evicted_evidence_total",
"Evaluation evidence dropped by pending-memory budget enforcement (kind=orphan: unbound session evidence whose session evaluation may be lost until a restart replays it; kind=binding: trace-to-session bindings). Sustained increases mean the budget does not match the workload",
)
.namespace(NAMESPACE)
.const_labels(create_const_labels()),
&["organization", "kind"],
)
.expect("Metric created")
});
pub static EVAL_SCHEDULER_WATERMARK_LAG_SECONDS: Lazy<IntGaugeVec> = Lazy::new(|| {
IntGaugeVec::new(
Opts::new(
"eval_scheduler_watermark_lag_seconds",
"Lag in seconds between the eval scheduler scan cursor and the committed (persisted) watermark, worst case across the organization's streams",
)
.namespace(NAMESPACE)
.const_labels(create_const_labels()),
&["organization"],
)
.expect("Metric created")
});
fn register_metrics(registry: &Registry) {
// http latency
registry
@ -1985,6 +2090,12 @@ fn register_metrics(registry: &Registry) {
registry
.register(Box::new(INGEST_PARQUET_FILES.clone()))
.expect("Metric registered");
registry
.register(Box::new(SYNTHETICS_PENDING_JOBS.clone()))
.expect("Metric registered");
registry
.register(Box::new(SYNTHETICS_OLDEST_PENDING_AGE_SECONDS.clone()))
.expect("Metric registered");
registry
.register(Box::new(INGEST_PACK_FILES.clone()))
.expect("Metric registered");
@ -2452,6 +2563,22 @@ fn register_metrics(registry: &Registry) {
registry
.register(Box::new(QUEUE_OLDEST_MESSAGE_AGE_SECONDS.clone()))
.expect("Metric registered");
// eval scheduler pending-target metrics
registry
.register(Box::new(EVAL_SCHEDULER_PENDING_TARGETS.clone()))
.expect("Metric registered");
registry
.register(Box::new(EVAL_SCHEDULER_PENDING_MEMORY_BYTES.clone()))
.expect("Metric registered");
registry
.register(Box::new(EVAL_SCHEDULER_FORCED_READY_TOTAL.clone()))
.expect("Metric registered");
registry
.register(Box::new(EVAL_SCHEDULER_EVICTED_EVIDENCE_TOTAL.clone()))
.expect("Metric registered");
registry
.register(Box::new(EVAL_SCHEDULER_WATERMARK_LAG_SECONDS.clone()))
.expect("Metric registered");
}
pub fn create_const_labels() -> HashMap<String, String> {
@ -2569,6 +2696,8 @@ mod tests {
let _ = INGEST_ERRORS.clone();
let _ = INGEST_WAL_USED_BYTES.clone();
let _ = INGEST_PARQUET_FILES.clone();
let _ = SYNTHETICS_PENDING_JOBS.clone();
let _ = SYNTHETICS_OLDEST_PENDING_AGE_SECONDS.clone();
let _ = INGEST_PACK_FILES.clone();
let _ = INGEST_PACK_SEGMENTS.clone();
let _ = INGEST_WAL_WRITE_BYTES.clone();

View File

@ -117,10 +117,10 @@ pub const PREBUILT_TEMPLATE_PREFIX: &str = "prebuilt_";
/// `prebuilt_<type>` where `<type>` is a registered prebuilt destination type
/// (slack, opsgenie, servicenow, ...).
///
/// Lives next to `get_prebuilt_template` so the public API surface
/// (`handler::http::models`), the service-layer guards
/// (`service::alerts::templates`), and any future caller all derive the
/// "is system template" answer from the same source of truth.
/// Lives next to `get_prebuilt_template` so the API models
/// (`openobserve_api_management::models`), the service-layer guards
/// (`openobserve_core::alerts::templates`), and any future caller all derive
/// the "is system template" answer from the same source of truth.
///
/// User-created templates whose names happen to start with `prebuilt_` but
/// don't match a registered type stay freely editable and deletable.

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,7 @@ use parquet::{
basic::{Compression, Encoding},
file::{metadata::KeyValue, properties::WriterProperties},
};
use serde::{Deserialize, Serialize};
use vortex::{
VortexSessionDefault,
array::{ArrayRef, VortexSessionExecute},
@ -44,7 +45,31 @@ use vortex::{
session::VortexSession,
};
use crate::{FileFormat, config::*, ider, meta::stream::FileMeta};
use crate::{FileFormat, config::*, ider, meta::stream::FileMeta, utils::json};
/// Key of the vortex metadata segment carrying the o2 [`FileMeta`].
pub const VORTEX_FILE_META_KEY: &str = "o2_file_meta";
/// Same four fields [`new_parquet_writer`] writes into the parquet footer.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(default)]
struct VortexFileMeta {
min_ts: i64,
max_ts: i64,
records: i64,
original_size: i64,
}
/// Encode `metadata` for the [`VORTEX_FILE_META_KEY`] segment.
pub fn encode_vortex_file_meta(metadata: &FileMeta) -> Vec<u8> {
json::to_vec(&VortexFileMeta {
min_ts: metadata.min_ts,
max_ts: metadata.max_ts,
records: metadata.records,
original_size: metadata.original_size,
})
.expect("file meta is always serializable")
}
pub fn new_parquet_writer<'a>(
buf: &'a mut Vec<u8>,
@ -496,6 +521,31 @@ mod tests {
assert_eq!(read_metadata.original_size, metadata.original_size);
}
#[test]
fn test_encode_decode_vortex_file_meta() {
let metadata = FileMeta {
min_ts: -1,
max_ts: i64::MAX,
records: 7,
original_size: 8,
compressed_size: 9,
index_size: 10,
bloom_ver: 11,
flattened: true,
};
let decoded: VortexFileMeta =
json::from_slice(&encode_vortex_file_meta(&metadata)).unwrap();
assert_eq!(decoded.min_ts, metadata.min_ts);
assert_eq!(decoded.max_ts, metadata.max_ts);
assert_eq!(decoded.records, metadata.records);
assert_eq!(decoded.original_size, metadata.original_size);
// unknown and missing keys are tolerated
let decoded: VortexFileMeta = json::from_slice(br#"{"min_ts":5,"future":true}"#).unwrap();
assert_eq!(decoded.min_ts, 5);
assert_eq!(decoded.max_ts, 0);
}
#[test]
fn test_parse_file_key_columns() {
let key = "files/default/logs/olympics/2022/10/03/10/6982652937134804993_1.parquet";

File diff suppressed because it is too large Load Diff

View File

@ -28,25 +28,150 @@ use config::{
use infra::table::entity::alert_dedup_state;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
/// Append the evaluated level to a fingerprint for MULTI-LEVEL alerts.
///
/// A Warning batch and a Critical batch must never share a dedup identity —
/// otherwise deduplication discards the Warning→Critical escalation that the
/// scheduler explicitly allowed through silence (§7.1). Single-level alerts
/// keep their legacy fingerprints byte-for-byte, so existing dedup state is
/// not invalidated on upgrade. The `|level:` separator is deliberately not
/// the `,dim=val` shape, so dimension parsing never mistakes it for a field.
fn with_level_component(
base: String,
alert: &Alert,
level: Option<config::meta::alerts::level::AlertLevel>,
) -> String {
let multi_level = alert.trigger_condition.warning_threshold.is_some()
|| alert
.query_condition
.aggregation
.as_ref()
.is_some_and(|a| a.warning_value.is_some())
|| alert.query_condition.promql_warning_value.is_some();
match (multi_level, level) {
(true, Some(l)) => format!("{base}|level:{l}"),
_ => base,
}
}
/// Calculate fingerprint for an alert result row
///
/// Delegates to enterprise implementation.
/// Delegates to enterprise implementation; the evaluated level is appended as
/// an implicit component for multi-level alerts (see `with_level_component`).
pub fn calculate_fingerprint(
alert: &Alert,
result_row: &Map<String, Value>,
config: &DeduplicationConfig,
org_config: Option<&GlobalDeduplicationConfig>,
semantic_groups: &[config::meta::correlation::FieldAlias],
level: Option<config::meta::alerts::level::AlertLevel>,
) -> String {
o2_enterprise::enterprise::alerts::dedup::calculate_fingerprint(
let base = o2_enterprise::enterprise::alerts::dedup::calculate_fingerprint(
alert,
result_row,
config,
org_config,
semantic_groups,
);
// Level and group are the two IMPLICIT fingerprint components (M-5). The
// group one is derived from the row itself rather than passed in, so every
// caller gets it — there is no way to compute a fingerprint for a
// multi-alert row and accidentally leave the group out, which would let
// one group's notification dedup away another's.
//
// `with_group_component` returns the base unchanged for an ungrouped alert
// and for the rollup key, so existing fingerprints stay byte-identical and
// no live silence window is invalidated by the upgrade.
let with_level = with_level_component(base, alert, level);
config::meta::alerts::grouping::with_group_component(
with_level,
row_group_key(alert, result_row).as_deref(),
)
}
/// This row's group identity, for the M-5 fingerprint component.
///
/// `None` for anything that is not an opted-in multi-alert, which is what
/// keeps every pre-existing alert's fingerprint unchanged.
fn row_group_key(alert: &Alert, row: &Map<String, Value>) -> Option<String> {
if !alert.query_condition.multi_alert_enabled() {
return None;
}
// Same extractors the evaluation and dispatch use, so the fingerprint's
// notion of "which group" cannot drift from the state row's. Which one
// applies is decided by the family, exactly as it is in dispatch: reading
// `aggregation.group_by` for a PromQL alert yields no columns and hence no
// group component, which would give every SERIES the same fingerprint and
// let one series' notification dedup away another's — the precise failure
// the note above this function warns about.
let labels = match alert.query_condition.query_type {
config::meta::alerts::QueryType::PromQL => {
config::meta::alerts::dispatch::promql_series_labels(row)
}
_ => {
let group_by = alert
.query_condition
.aggregation
.as_ref()?
.group_by
.as_ref()?;
config::meta::alerts::dispatch::row_group_labels(row, group_by)
}
};
Some(config::meta::alerts::grouping::group_key(&labels))
}
/// One fingerprint reserved by a dedup pass, tagged with the group whose
/// delivery must confirm it (§5.5 MN-4/MN-6). Group A's send can succeed
/// while B's fails; confirming B on A's success would suppress B's retry as
/// a duplicate for the rest of the window.
pub struct ReservedFingerprint {
pub fingerprint: String,
/// `None` for anything that is not an opted-in multi-alert, whose single
/// send confirms every reservation.
pub group_key: Option<String>,
}
/// What one pass of [`apply_deduplication`] decided.
pub struct DeduplicationOutcome {
/// The rows that survived deduplication and should be notified on.
pub rows: Vec<Map<String, Value>>,
/// Whether deduplication actually ran (it is opt-in per alert).
pub applied: bool,
/// Fingerprints RESERVED by this pass — recorded as seen but not yet
/// confirmed as delivered (§5.5 MN-6).
///
/// The caller must pass these to [`confirm_notification_sent`] once the
/// notification actually goes out — per group for a multi-alert. Until it
/// does, they do not suppress: that is what lets a failed send be retried
/// instead of being swallowed as a duplicate for the rest of the window.
pub reserved: Vec<ReservedFingerprint>,
}
/// Confirm reservations whose notification was delivered (§5.5 MN-6).
///
/// Called only after a successful send. Anything left unconfirmed is treated
/// as a delivery that never happened, so the next evaluation is allowed
/// through — no retry bookkeeping required.
pub async fn confirm_notification_sent(
db: &DatabaseConnection,
fingerprints: &[String],
) -> Result<(), sea_orm::DbErr> {
if fingerprints.is_empty() {
return Ok(());
}
alert_dedup_state::Entity::update_many()
.col_expr(
alert_dedup_state::Column::NotificationSent,
sea_orm::sea_query::Expr::value(true),
)
.filter(alert_dedup_state::Column::Fingerprint.is_in(fingerprints.to_vec()))
.exec(db)
.await?;
Ok(())
}
/// Get or create deduplication state
pub async fn get_dedup_state(
db: &DatabaseConnection,
@ -161,11 +286,19 @@ pub async fn apply_deduplication(
db: &DatabaseConnection,
alert: &Alert,
result_rows: Vec<Map<String, Value>>,
) -> Result<(Vec<Map<String, Value>>, bool), sea_orm::DbErr> {
level: Option<config::meta::alerts::level::AlertLevel>,
) -> Result<DeduplicationOutcome, sea_orm::DbErr> {
// Check if per-alert deduplication is enabled
let dedup_config = match &alert.deduplication {
Some(config) if config.enabled => config,
_ => return Ok((result_rows, false)), // Deduplication disabled, return all rows
// Deduplication disabled, return all rows
_ => {
return Ok(DeduplicationOutcome {
rows: result_rows,
applied: false,
reserved: Vec::new(),
});
}
};
// Get semantic groups from system_settings — the single source of truth
@ -185,9 +318,14 @@ pub async fn apply_deduplication(
dedup_config,
org_config.as_ref(),
&semantic_groups,
level,
)
.await
.map(|result| (result, true))
.map(|(rows, reserved)| DeduplicationOutcome {
rows,
applied: true,
reserved,
})
}
/// Enterprise implementation of apply_deduplication
@ -198,7 +336,8 @@ async fn apply_deduplication_impl(
dedup_config: &DeduplicationConfig,
org_config: Option<&GlobalDeduplicationConfig>,
semantic_groups: &[config::meta::correlation::FieldAlias],
) -> Result<Vec<Map<String, Value>>, sea_orm::DbErr> {
level: Option<config::meta::alerts::level::AlertLevel>,
) -> Result<(Vec<Map<String, Value>>, Vec<ReservedFingerprint>), sea_orm::DbErr> {
let now = o2_enterprise::enterprise::alerts::dedup::current_timestamp_micros();
let alert_id = alert.get_unique_key();
let org_id = &alert.org_id;
@ -210,14 +349,34 @@ async fn apply_deduplication_impl(
);
let mut deduplicated_rows = Vec::new();
// Reserved but unconfirmed until the notification actually lands.
let mut reserved = Vec::new();
for row in result_rows {
let fingerprint =
calculate_fingerprint(alert, &row, dedup_config, org_config, semantic_groups);
let fingerprint = calculate_fingerprint(
alert,
&row,
dedup_config,
org_config,
semantic_groups,
level,
);
// Check if this fingerprint exists and is within time window
// A reservation suppresses only once a delivery has CONFIRMED it
// (§5.5 MN-6). Recording the fingerprint when a row merely *passes*
// dedup — which is what happens below — means a send that then fails
// is suppressed as a duplicate on every later evaluation while
// `last_seen_at` keeps extending the window: one transient webhook
// error swallows the page for the whole window. `notification_sent`
// has existed on this table since the feature shipped and was never
// read; it is the confirm flag.
let should_send = match get_dedup_state(db, &fingerprint).await? {
Some(existing_state) if is_within_window(&existing_state, time_window_minutes) => {
Some(existing_state)
if config::meta::alerts::deduplication::reservation_suppresses(
existing_state.notification_sent,
is_within_window(&existing_state, time_window_minutes),
) =>
{
// Within window - update occurrence count but don't send
if let Err(e) = save_dedup_state(
db,
@ -300,9 +459,143 @@ async fn apply_deduplication_impl(
fingerprint
);
// The reservation carries its group so the caller can confirm it
// against that group's OWN delivery outcome, not a sibling's.
reserved.push(ReservedFingerprint {
group_key: row_group_key(alert, &row),
fingerprint,
});
deduplicated_rows.push(row);
}
}
Ok(deduplicated_rows)
Ok((deduplicated_rows, reserved))
}
#[cfg(test)]
mod tests {
use config::meta::alerts::{
AggFunction, Aggregation, Condition, Operator, QueryType, alert::Alert,
};
use serde_json::{Map, Value, json};
use super::row_group_key;
fn row(pairs: &[(&str, Value)]) -> Map<String, Value> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
fn promql_alert(multi: bool) -> Alert {
let mut a = Alert::default();
a.query_condition.query_type = QueryType::PromQL;
a.query_condition.promql = Some("sum by (pod) (rate(errors[5m]))".into());
a.query_condition.promql_multi_alert = multi;
a
}
fn agg_alert(multi: bool) -> Alert {
let mut a = Alert::default();
a.query_condition.query_type = QueryType::SQL;
a.query_condition.aggregation = Some(Aggregation {
group_by: Some(vec!["host".into()]),
function: AggFunction::Avg,
having: Condition {
column: "alert_agg_value".into(),
operator: Operator::GreaterThan,
value: json!(90),
ignore_case: false,
},
warning_value: None,
multi_alert: multi,
});
a
}
/// M-5, PromQL flavour. Without a per-series component in the fingerprint,
/// every series of one alert dedups against every other — so the first
/// series to page suppresses all the rest for the whole window, which is
/// the exact opposite of what per-series alerting is for.
#[test]
fn a_promql_multi_alert_gets_a_distinct_group_key_per_series() {
let alert = promql_alert(true);
let a = row_group_key(
&alert,
&row(&[("pod", json!("web-1")), ("value", json!(5.0))]),
);
let b = row_group_key(
&alert,
&row(&[("pod", json!("web-2")), ("value", json!(9.0))]),
);
assert!(
a.is_some(),
"a per-series alert must carry a group component"
);
assert_ne!(a, b, "two series must not share one fingerprint");
}
/// The value moves every evaluation; the identity must not.
#[test]
fn a_promql_series_keeps_its_key_as_its_value_changes() {
let alert = promql_alert(true);
let a = row_group_key(
&alert,
&row(&[("pod", json!("web-1")), ("value", json!(5.0))]),
);
let b = row_group_key(
&alert,
&row(&[("pod", json!("web-1")), ("value", json!(500.0))]),
);
// `is_some` first: without it this passes trivially when BOTH are None,
// which is exactly the broken state — `None == None`.
assert!(
a.is_some(),
"a per-series alert must carry a group component"
);
assert_eq!(a, b);
}
/// Upgrade safety: a PromQL alert that never opted in must keep the
/// fingerprint it has today, or every live silence window is invalidated.
#[test]
fn a_promql_alert_that_did_not_opt_in_has_no_group_component() {
let alert = promql_alert(false);
assert_eq!(
row_group_key(
&alert,
&row(&[("pod", json!("web-1")), ("value", json!(5.0))])
),
None
);
}
#[test]
fn the_aggregation_family_is_unchanged() {
let alert = agg_alert(true);
let a = row_group_key(
&alert,
&row(&[("host", json!("a")), ("alert_agg_value", json!(1))]),
);
let b = row_group_key(
&alert,
&row(&[("host", json!("b")), ("alert_agg_value", json!(1))]),
);
assert!(a.is_some());
assert_ne!(a, b);
let simple = agg_alert(false);
assert_eq!(row_group_key(&simple, &row(&[("host", json!("a"))])), None);
}
/// An SLO alert has no per-group dispatch (see `multi_alert_enabled`), so
/// it must not grow a group component either.
#[test]
fn an_slo_alert_has_no_group_component() {
let mut alert = Alert::default();
alert.query_condition.query_type = QueryType::Slo;
assert_eq!(row_group_key(&alert, &row(&[("group", json!("g"))])), None);
}
}

View File

@ -0,0 +1,90 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use serde_json::Value;
/// Result of inspecting an inbound webhook payload to figure out which
/// external alerting system produced it (spec §4.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetectedSource {
Grafana,
Alertmanager,
Generic,
Unknown,
}
impl DetectedSource {
pub fn as_str(&self) -> &'static str {
match self {
DetectedSource::Grafana => "grafana",
DetectedSource::Alertmanager => "alertmanager",
DetectedSource::Generic => "generic",
DetectedSource::Unknown => "unknown",
}
}
}
fn object_has_status_and_labels(obj: &serde_json::Map<String, Value>) -> bool {
matches!(obj.get("status"), Some(Value::String(_)))
&& matches!(obj.get("labels"), Some(Value::Object(_)))
}
/// Detect which external alerting system produced `body`, using the
/// `user_agent` header as a secondary signal for the Grafana/Alertmanager
/// ambiguity (both share the same `alerts[]` wire format).
pub fn detect_source(user_agent: Option<&str>, body: &Value) -> DetectedSource {
if let Some(obj) = body.as_object() {
let has_alerts_array = matches!(obj.get("alerts"), Some(Value::Array(_)));
let has_group_or_version = obj.contains_key("groupKey") || obj.contains_key("version");
if has_alerts_array && has_group_or_version {
let ua_is_grafana = user_agent.map(|ua| ua.contains("Grafana")).unwrap_or(false);
let has_grafana_keys =
obj.contains_key("orgId") || obj.contains_key("title") || obj.contains_key("state");
return if ua_is_grafana || has_grafana_keys {
DetectedSource::Grafana
} else {
DetectedSource::Alertmanager
};
}
if matches!(obj.get("source"), Some(Value::String(_))) {
return DetectedSource::Generic;
}
if object_has_status_and_labels(obj) {
return DetectedSource::Generic;
}
return DetectedSource::Unknown;
}
if let Some(arr) = body.as_array() {
if !arr.is_empty()
&& arr.iter().all(|item| {
item.as_object()
.map(|o| {
matches!(o.get("source"), Some(Value::String(_)))
|| object_has_status_and_labels(o)
})
.unwrap_or(false)
})
{
return DetectedSource::Generic;
}
return DetectedSource::Unknown;
}
DetectedSource::Unknown
}

View File

@ -0,0 +1,119 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashMap;
use config::meta::alerts::incidents::{
ExternalAlertEvent, ExternalAlertStatus, map_external_severity,
};
use serde_json::Value;
use super::CLOCK_SKEW_TOLERANCE_MICROS;
fn clamp_ts(ts: i64, now: i64) -> i64 {
let lo = now - CLOCK_SKEW_TOLERANCE_MICROS;
let hi = now + CLOCK_SKEW_TOLERANCE_MICROS;
ts.clamp(lo, hi)
}
fn dedup_key_hash(labels: &HashMap<String, String>) -> String {
let mut pairs: Vec<String> = labels.iter().map(|(k, v)| format!("{k}={v}")).collect();
pairs.sort();
let joined = pairs.join("\n");
format!(
"{:016x}",
config::utils::hash::sum64_bytes(joined.as_bytes())
)
}
fn normalize_one(item: &Value, now: i64) -> Result<ExternalAlertEvent, String> {
let obj = item
.as_object()
.ok_or_else(|| "generic alert item must be an object".to_string())?;
let status_str = obj
.get("status")
.and_then(|s| s.as_str())
.ok_or_else(|| "generic alert item missing required 'status' field".to_string())?;
let status = match status_str {
"firing" => ExternalAlertStatus::Firing,
"resolved" => ExternalAlertStatus::Resolved,
other => return Err(format!("unrecognized status '{other}'")),
};
let raw_labels = obj
.get("labels")
.and_then(|l| l.as_object())
.ok_or_else(|| "generic alert item missing required 'labels' field".to_string())?;
let mut labels: HashMap<String, String> = HashMap::new();
for (k, v) in raw_labels.iter() {
let s = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
labels.insert(k.clone(), s);
}
let severity_raw = obj
.get("severity")
.and_then(|s| s.as_str())
.unwrap_or("warning");
let severity = map_external_severity(severity_raw);
let title = obj
.get("title")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
.or_else(|| labels.values().next().cloned())
.unwrap_or_else(|| "external alert".to_string());
let dedup_key = obj
.get("dedup_key")
.and_then(|d| d.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| dedup_key_hash(&labels));
let event_ts = obj
.get("event_ts")
.and_then(|t| t.as_i64())
.map(|t| clamp_ts(t, now))
.unwrap_or(now);
let source_url = obj
.get("source_url")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
Ok(ExternalAlertEvent {
status,
dedup_key,
title,
severity,
labels,
event_ts,
source_url,
raw: item.clone(),
})
}
/// Normalize a generic JSON payload — a single alert object or an array of
/// alert objects, each requiring `status` and `labels`.
pub fn normalize_generic(body: &Value, now: i64) -> Result<Vec<ExternalAlertEvent>, String> {
if let Some(arr) = body.as_array() {
return arr.iter().map(|item| normalize_one(item, now)).collect();
}
Ok(vec![normalize_one(body, now)?])
}

View File

@ -0,0 +1,151 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashMap;
use config::meta::alerts::incidents::{
ExternalAlertEvent, ExternalAlertStatus, map_external_severity,
};
use serde_json::Value;
use super::CLOCK_SKEW_TOLERANCE_MICROS;
/// Labels never surfaced on the normalized event (spec §4.2 deny-list).
const LABEL_DENY_LIST: &[&str] = &["job"];
fn labels_hash(labels: &serde_json::Map<String, Value>) -> String {
let mut pairs: Vec<String> = labels
.iter()
.map(|(k, v)| format!("{k}={}", value_to_label_string(v)))
.collect();
pairs.sort();
let joined = pairs.join("\n");
format!(
"{:016x}",
config::utils::hash::sum64_bytes(joined.as_bytes())
)
}
fn value_to_label_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn clamp_ts(ts: i64, now: i64) -> i64 {
let lo = now - CLOCK_SKEW_TOLERANCE_MICROS;
let hi = now + CLOCK_SKEW_TOLERANCE_MICROS;
ts.clamp(lo, hi)
}
fn parse_rfc3339_micros(s: &str) -> Option<i64> {
if s.is_empty() || s == "0001-01-01T00:00:00Z" {
return None;
}
chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.timestamp_micros())
}
/// Normalize a Grafana or Alertmanager webhook payload — both share the same
/// wire format (`alerts[]` array of firing/resolved entries).
pub fn normalize_am_format(body: &Value, now: i64) -> Result<Vec<ExternalAlertEvent>, String> {
let alerts = body
.get("alerts")
.and_then(|a| a.as_array())
.ok_or_else(|| "missing alerts array".to_string())?;
let mut events = Vec::with_capacity(alerts.len());
for alert in alerts {
let status_str = alert
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("firing");
let status = if status_str == "resolved" {
ExternalAlertStatus::Resolved
} else {
ExternalAlertStatus::Firing
};
let raw_labels = alert
.get("labels")
.and_then(|l| l.as_object())
.cloned()
.unwrap_or_default();
let dedup_key = alert
.get("fingerprint")
.and_then(|f| f.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| labels_hash(&raw_labels));
let severity_raw = raw_labels
.get("severity")
.and_then(|s| s.as_str())
.unwrap_or("warning");
let severity = map_external_severity(severity_raw);
let mut labels: HashMap<String, String> = HashMap::new();
for (k, v) in raw_labels.iter() {
if LABEL_DENY_LIST.contains(&k.as_str()) {
continue;
}
labels.insert(k.clone(), value_to_label_string(v));
}
let ts_field = if status == ExternalAlertStatus::Resolved {
"endsAt"
} else {
"startsAt"
};
let event_ts = alert
.get(ts_field)
.and_then(|v| v.as_str())
.and_then(parse_rfc3339_micros)
.unwrap_or(now);
let event_ts = clamp_ts(event_ts, now);
let title = labels
.get("alertname")
.cloned()
.or_else(|| {
alert
.get("annotations")
.and_then(|a| a.get("summary"))
.and_then(|s| s.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "external alert".to_string());
let source_url = alert
.get("generatorURL")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
events.push(ExternalAlertEvent {
status,
dedup_key,
title,
severity,
labels,
event_ts,
source_url,
raw: alert.clone(),
});
}
Ok(events)
}

View File

@ -0,0 +1,290 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod detect;
pub mod generic;
pub mod grafana;
use config::meta::alerts::incidents::ExternalAlertEvent;
pub use detect::{DetectedSource, detect_source};
/// Tolerance applied when clamping a source-reported event timestamp against
/// our own receipt clock — guards against wildly skewed sender clocks
/// (spec §4.2).
pub const CLOCK_SKEW_TOLERANCE_MICROS: i64 = 3_600_000_000; // 1h
/// Dispatch to the per-format normalizer for `detected`.
pub fn normalize(
detected: DetectedSource,
body: &serde_json::Value,
now_micros: i64,
) -> Result<Vec<ExternalAlertEvent>, String> {
match detected {
DetectedSource::Grafana | DetectedSource::Alertmanager => {
grafana::normalize_am_format(body, now_micros)
}
DetectedSource::Generic => generic::normalize_generic(body, now_micros),
DetectedSource::Unknown => Err("unrecognized payload format".to_string()),
}
}
/// Derives a display label for the sender of a batch of normalized events,
/// from the first event's `source` label (spec §1: single request = single
/// label). Returns `None` when absent, empty, or whitespace-only — callers
/// fall back to the already-detected system name (spec §2).
pub fn derive_sender_label(events: &[ExternalAlertEvent]) -> Option<String> {
events
.first()
.and_then(|e| e.labels.get("source"))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Resolves the display name shown in the status panel: the sender-provided
/// label when present, otherwise the detected system name (grafana /
/// alertmanager / generic) — exactly today's behavior.
pub fn resolve_display_name(detected_source: &str, sender_label: Option<&str>) -> String {
sender_label
.filter(|s| !s.is_empty())
.unwrap_or(detected_source)
.to_string()
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn grafana_payload() -> serde_json::Value {
serde_json::json!({
"receiver": "o2", "status": "firing", "orgId": 1,
"title": "[FIRING:1]", "state": "alerting", "groupKey": "{}:{}",
"version": "1", "externalURL": "https://grafana.example",
"alerts": [{
"status": "firing",
"labels": {"alertname": "HighCPU", "namespace": "prod", "deployment": "checkout", "severity": "critical", "job": "kube-state-metrics"},
"annotations": {"summary": "CPU high"},
"startsAt": "2026-07-30T10:02:00Z", "endsAt": "0001-01-01T00:00:00Z",
"generatorURL": "https://grafana.example/alerting/rule1",
"fingerprint": "abcdef0123456789"
}]
})
}
fn alertmanager_payload() -> serde_json::Value {
serde_json::json!({
"version": "4", "groupKey": "{}:{alertname=\"DiskLatency\"}",
"status": "firing", "receiver": "o2",
"groupLabels": {}, "commonLabels": {}, "commonAnnotations": {},
"externalURL": "http://am:9093", "truncatedAlerts": 0,
"alerts": [{
"status": "resolved",
"labels": {"alertname": "DiskLatency", "namespace": "prod", "severity": "warning"},
"annotations": {},
"startsAt": "2026-07-30T10:00:00Z", "endsAt": "2026-07-30T10:30:00Z",
"generatorURL": "http://prom/graph",
"fingerprint": "ffff000011112222"
}]
})
}
const NOW: i64 = 1_785_405_600_000_000; // fixed "now" for clamp determinism
#[test]
fn test_detect_grafana_vs_alertmanager_vs_generic() {
assert_eq!(
detect_source(Some("Grafana/12.0"), &grafana_payload()),
DetectedSource::Grafana
);
assert_eq!(
detect_source(None, &grafana_payload()),
DetectedSource::Grafana
); // orgId/title/state keys
assert_eq!(
detect_source(Some("Alertmanager/0.27.0"), &alertmanager_payload()),
DetectedSource::Alertmanager
);
let generic =
serde_json::json!({"status": "firing", "labels": {"service": "x"}, "title": "t"});
assert_eq!(detect_source(None, &generic), DetectedSource::Generic);
assert_eq!(
detect_source(None, &serde_json::json!({"hello": 1})),
DetectedSource::Unknown
);
}
#[test]
fn test_normalize_grafana_extracts_event_and_denies_job_label() {
let evs = normalize(DetectedSource::Grafana, &grafana_payload(), NOW).unwrap();
assert_eq!(evs.len(), 1);
let ev = &evs[0];
assert_eq!(ev.dedup_key, "abcdef0123456789");
assert_eq!(
ev.status,
config::meta::alerts::incidents::ExternalAlertStatus::Firing
);
assert_eq!(
ev.severity,
config::meta::alerts::incidents::IncidentSeverity::P1
); // critical
assert_eq!(ev.title, "HighCPU");
assert_eq!(ev.labels.get("namespace").unwrap(), "prod");
assert!(
!ev.labels.contains_key("job"),
"job label must be deny-listed"
);
assert_eq!(
ev.source_url.as_deref(),
Some("https://grafana.example/alerting/rule1")
);
}
#[test]
fn test_normalize_alertmanager_resolved_uses_endsat() {
let evs = normalize(DetectedSource::Alertmanager, &alertmanager_payload(), NOW).unwrap();
let ev = &evs[0];
assert_eq!(
ev.status,
config::meta::alerts::incidents::ExternalAlertStatus::Resolved
);
// 2026-07-30T10:30:00Z in micros
let expected = chrono::DateTime::parse_from_rfc3339("2026-07-30T10:30:00Z")
.unwrap()
.timestamp_micros();
assert_eq!(ev.event_ts, expected);
}
#[test]
fn test_event_ts_clamped_to_now_when_source_clock_is_wild() {
let mut p = grafana_payload();
p["alerts"][0]["startsAt"] = serde_json::json!("1999-01-01T00:00:00Z");
let evs = normalize(DetectedSource::Grafana, &p, NOW).unwrap();
assert_eq!(evs[0].event_ts, NOW - CLOCK_SKEW_TOLERANCE_MICROS);
}
#[test]
fn test_normalize_generic_object_and_array() {
let one = serde_json::json!({"status": "firing", "severity": "warning", "title": "db slow", "dedup_key": "k1", "labels": {"service": "db"}});
let evs = normalize(DetectedSource::Generic, &one, NOW).unwrap();
assert_eq!(evs.len(), 1);
assert_eq!(
evs[0].severity,
config::meta::alerts::incidents::IncidentSeverity::P3
);
assert_eq!(evs[0].event_ts, NOW); // no ts supplied → receipt time
let arr = serde_json::json!([one.clone(), {"status": "resolved", "title": "x", "labels": {"service": "db"}}]);
let evs = normalize(DetectedSource::Generic, &arr, NOW).unwrap();
assert_eq!(evs.len(), 2);
// no dedup_key on second → hash of sorted labels, deterministic
let evs2 = normalize(DetectedSource::Generic, &arr, NOW).unwrap();
assert_eq!(evs[1].dedup_key, evs2[1].dedup_key);
assert!(!evs[1].dedup_key.is_empty());
}
#[test]
fn test_unknown_format_errors() {
assert!(normalize(DetectedSource::Unknown, &serde_json::json!({"x": 1}), NOW).is_err());
}
fn event_with_label(source_label: Option<&str>) -> ExternalAlertEvent {
let mut labels = HashMap::new();
if let Some(s) = source_label {
labels.insert("source".to_string(), s.to_string());
}
labels.insert("alertname".to_string(), "test".to_string());
ExternalAlertEvent {
status: config::meta::alerts::incidents::ExternalAlertStatus::Firing,
dedup_key: "k".to_string(),
title: "t".to_string(),
severity: config::meta::alerts::incidents::IncidentSeverity::P3,
labels,
event_ts: 0,
source_url: None,
raw: serde_json::json!({}),
}
}
#[test]
fn test_derive_sender_label_present() {
let events = vec![event_with_label(Some("solarwinds"))];
assert_eq!(derive_sender_label(&events), Some("solarwinds".to_string()));
}
#[test]
fn test_derive_sender_label_absent() {
let events = vec![event_with_label(None)];
assert_eq!(derive_sender_label(&events), None);
}
#[test]
fn test_derive_sender_label_empty_string_treated_as_absent() {
let events = vec![event_with_label(Some(""))];
assert_eq!(derive_sender_label(&events), None);
}
#[test]
fn test_derive_sender_label_whitespace_only_treated_as_absent() {
let events = vec![event_with_label(Some(" "))];
assert_eq!(derive_sender_label(&events), None);
}
#[test]
fn test_derive_sender_label_trims_whitespace() {
let events = vec![event_with_label(Some(" solarwinds "))];
assert_eq!(derive_sender_label(&events), Some("solarwinds".to_string()));
}
#[test]
fn test_derive_sender_label_empty_events_list() {
let events: Vec<ExternalAlertEvent> = vec![];
assert_eq!(derive_sender_label(&events), None);
}
#[test]
fn test_derive_sender_label_uses_first_event_only() {
let first = event_with_label(Some("first-sender"));
let mut second_labels = HashMap::new();
second_labels.insert("source".to_string(), "second-sender".to_string());
let second = ExternalAlertEvent {
labels: second_labels,
..event_with_label(None)
};
let events = vec![first, second];
assert_eq!(
derive_sender_label(&events),
Some("first-sender".to_string())
);
}
#[test]
fn test_resolve_display_name_with_label() {
assert_eq!(
resolve_display_name("generic", Some("solarwinds")),
"solarwinds"
);
}
#[test]
fn test_resolve_display_name_without_label() {
assert_eq!(resolve_display_name("generic", None), "generic");
}
#[test]
fn test_resolve_display_name_empty_label_falls_back() {
assert_eq!(resolve_display_name("grafana", Some("")), "grafana");
}
}

View File

@ -33,11 +33,19 @@ static PENDING_BATCHES: Lazy<Arc<DashMap<String, PendingBatch>>> =
#[derive(Clone, Debug)]
pub struct PendingBatch {
pub fingerprint: String,
/// Group identity for a per-group batch (§5.5). `None` for an ordinary
/// alert-level batch. Every entry shares the batch's fingerprint, and the
/// group is part of that fingerprint, so one batch is always one group.
pub group_labels: Option<std::collections::BTreeMap<String, String>>,
pub org_id: String,
pub alerts: Vec<BatchedAlert>,
pub timer_started_at: i64,
pub group_wait_seconds: i64,
pub max_group_size: usize,
/// Evaluated level shared by every entry in this batch. Well-defined
/// because the fingerprint carries the level as an implicit component for
/// multi-level alerts — a Warning batch and a Critical batch are distinct.
pub level: Option<config::meta::alerts::level::AlertLevel>,
}
/// An alert waiting in a batch
@ -57,10 +65,13 @@ impl PendingBatch {
rows: Vec<json::Map<String, json::Value>>,
group_wait_seconds: i64,
max_group_size: usize,
level: Option<config::meta::alerts::level::AlertLevel>,
group_labels: Option<std::collections::BTreeMap<String, String>>,
) -> Self {
let now = Utc::now().timestamp_micros();
Self {
fingerprint,
group_labels,
org_id,
alerts: vec![BatchedAlert {
alert,
@ -70,6 +81,7 @@ impl PendingBatch {
timer_started_at: now,
group_wait_seconds,
max_group_size,
level,
}
}
@ -110,6 +122,11 @@ pub fn add_to_batch(
rows: Vec<json::Map<String, json::Value>>,
group_wait_seconds: i64,
max_group_size: usize,
level: Option<config::meta::alerts::level::AlertLevel>,
// Group identity when this is a per-group batch (§5.5). Every entry
// sharing a fingerprint shares a group, because the group is part of the
// fingerprint — so this is set once, when the batch is created.
group_labels: Option<std::collections::BTreeMap<String, String>>,
) -> bool {
let mut batch_ready = false;
let mut is_new_batch = false;
@ -161,6 +178,8 @@ pub fn add_to_batch(
rows,
group_wait_seconds,
max_group_size,
level,
group_labels,
)
});
@ -378,6 +397,17 @@ pub async fn send_grouped_notification(
rows_end_time,
start_time,
evaluation_timestamp,
// Well-defined for the whole batch: the fingerprint carries the
// level as an implicit component for multi-level alerts, so every
// entry classified the same. `{alert_level}` renders it.
batch.level,
// A batch aggregates several evaluations; no single exact count
// describes it — `{alert_count}` falls back to the row total.
None,
// Group identity for a batched per-group send (M-4). Every entry
// in a batch shares one fingerprint, and the group component is
// part of that fingerprint, so the whole batch is one group.
batch.group_labels.as_ref(),
)
.await
{
@ -450,6 +480,8 @@ mod tests {
vec![],
30,
10,
None,
None,
);
assert_eq!(batch.alerts.len(), 1);
assert!(!batch.is_full());
@ -464,6 +496,8 @@ mod tests {
vec![],
30,
2,
None,
None,
);
assert!(!batch.is_full());
let added = batch.add_alert(make_alert(), vec![]);
@ -480,6 +514,8 @@ mod tests {
vec![],
30,
1,
None,
None,
);
assert!(batch.is_full());
let added = batch.add_alert(make_alert(), vec![]);
@ -496,6 +532,8 @@ mod tests {
vec![],
3600, // 1 hour wait
10,
None,
None,
);
assert!(!batch.is_expired());
}
@ -512,6 +550,8 @@ mod tests {
vec![],
3600,
10,
None,
None,
);
assert!(!ready);
assert!(PENDING_BATCHES.contains_key(&fp));
@ -531,6 +571,8 @@ mod tests {
vec![],
3600,
2,
None,
None,
);
assert!(!ready1); // new batch, 1 alert, not full
@ -541,6 +583,8 @@ mod tests {
vec![],
3600,
2,
None,
None,
);
assert!(ready2); // 2nd alert fills batch, ready=true
@ -559,6 +603,8 @@ mod tests {
vec![],
3600,
10,
None,
None,
);
let batch = get_ready_batch(&fp);
@ -575,8 +621,26 @@ mod tests {
PENDING_BATCHES.remove(&fp1);
PENDING_BATCHES.remove(&fp2);
add_to_batch(fp1.clone(), org.to_string(), make_alert(), vec![], 3600, 10);
add_to_batch(fp2.clone(), org.to_string(), make_alert(), vec![], 3600, 10);
add_to_batch(
fp1.clone(),
org.to_string(),
make_alert(),
vec![],
3600,
10,
None,
None,
);
add_to_batch(
fp2.clone(),
org.to_string(),
make_alert(),
vec![],
3600,
10,
None,
None,
);
let count = get_pending_batch_count(org);
assert!(count >= 2);

View File

@ -23,7 +23,7 @@ use config::{
meta::alerts::{
alert::Alert,
incidents::{
AlertEdge, AlertNode, CorrelationReason, EdgeType, Incident, IncidentAlert,
AlertEdge, AlertKind, AlertNode, CorrelationReason, EdgeType, Incident, IncidentAlert,
IncidentCorrelationOutcome, IncidentEvent, IncidentTopology, IncidentWithAlerts,
},
},
@ -43,6 +43,23 @@ struct FilteredSemanticResult {
key_type: config::meta::alerts::incidents::KeyType,
}
/// Identity being correlated into an incident.
///
/// Decouples correlation/incident-creation from the concrete `Alert` type so that
/// non-alert identities (e.g. external alert sources) can be correlated using the
/// same machinery. For the internal alert path this is built from an `Alert`.
#[derive(Debug, Clone)]
pub struct CorrelationSubject {
/// internal: `alert.get_unique_key()`; external: `external_alerts.id`
pub id: String,
pub name: String,
pub org_id: String,
pub kind: config::meta::alerts::incidents::AlertKind,
pub base_destinations: Vec<String>,
/// Pre-mapped severity for external events; `None` → enterprise default (internal path)
pub severity: Option<config::meta::alerts::incidents::IncidentSeverity>,
}
/// Combined correlation result from both Service Discovery and semantic extraction
struct ParallelCorrelationResult {
service_discovery: Option<ServiceDiscoveryResult>,
@ -64,10 +81,11 @@ async fn extract_filtered_semantic_dimensions(
// Load ServiceIdentityConfig with auto-configuration applied
let identity_config = crate::db::system_settings::get_service_identity_config(org_id).await;
// Validate config has at least one set
if identity_config.sets.is_empty() {
// No identity sets configured AND service is opted out — nothing to extract.
// Otherwise fall through: "service" alone is a viable dimension even with zero sets.
if identity_config.sets.is_empty() && identity_config.service_optional {
log::debug!(
"[incidents] ServiceIdentityConfig for org {} has no identity sets, semantic extraction skipped",
"[incidents] ServiceIdentityConfig for org {} has no identity sets and service is optional, semantic extraction skipped",
org_id
);
return None;
@ -82,6 +100,12 @@ async fn extract_filtered_semantic_dimensions(
for set in &identity_config.sets {
all_distinguish_by.extend(set.distinguish_by.iter().cloned());
}
// "service" is always a correlation dimension unless the org has explicitly
// opted out via service_optional (same toggle Service Discovery uses to match
// streams without requiring the service attribute).
if !identity_config.service_optional {
all_distinguish_by.push("service".to_string());
}
// Remove duplicates and sort for deterministic processing
all_distinguish_by.sort();
all_distinguish_by.dedup();
@ -299,13 +323,47 @@ async fn send_incident_notifications(
event: &str,
triggered_at: i64,
dest_names: &[String],
) {
// Preserve the alert/stream sub-object emitted for the internal alert path.
let alert_block = config::utils::json::json!({
"name": alert.name,
"stream": {
"name": alert.stream_name,
"type": alert.stream_type.to_string(),
}
});
send_incident_notifications_inner(
&alert.org_id,
&alert.name,
incident_id,
event,
triggered_at,
dest_names,
Some(alert_block),
)
.await;
}
/// Build an incident-specific notification payload and send to all given destinations.
///
/// Identity-agnostic core of [`send_incident_notifications`]. `alert_block`, when
/// present, is emitted verbatim as the payload's `incident.alert` sub-object (used
/// by the internal alert path to carry alert/stream metadata).
#[cfg(feature = "enterprise")]
#[allow(clippy::too_many_arguments)]
async fn send_incident_notifications_inner(
org_id: &str,
subject_name: &str,
incident_id: &str,
event: &str,
triggered_at: i64,
dest_names: &[String],
alert_block: Option<Value>,
) {
if dest_names.is_empty() {
return;
}
let org_id = alert.org_id.as_str();
// Load incident to get severity, title and service_name.
let (severity, title, service_name) =
match infra::table::alert_incidents::get(org_id, incident_id).await {
@ -332,6 +390,11 @@ async fn send_incident_notifications(
.map(|dt: chrono::DateTime<chrono::Utc>| dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
.unwrap_or_default();
// Internal path supplies a full alert/stream block; other subjects fall back to
// a name-only block derived from `subject_name`.
let alert_block =
alert_block.unwrap_or_else(|| config::utils::json::json!({ "name": subject_name }));
let payload = config::utils::json::json!({
"incident": {
"id": incident_id,
@ -339,13 +402,7 @@ async fn send_incident_notifications(
"event": event,
"service": service_name,
"severity": severity,
"alert": {
"name": alert.name,
"stream": {
"name": alert.stream_name,
"type": alert.stream_type.to_string(),
}
},
"alert": alert_block,
"time": time_str,
"url": incident_url,
}
@ -482,6 +539,10 @@ pub async fn correlate_alert_to_incident(
result_row: &Map<String, Value>,
notify_rows: &[Map<String, Value>],
triggered_at: i64,
// T-8: the evaluated level drives new-incident severity (Critical → P2,
// Warning → P3). None (manual triggers, single-level alerts) keeps the
// enterprise default.
eval_level: Option<config::meta::alerts::level::AlertLevel>,
) -> Result<Option<IncidentCorrelationOutcome>, anyhow::Error> {
// Extract labels from result row as HashMap
let mut labels: HashMap<String, String> = result_row
@ -570,15 +631,26 @@ pub async fn correlate_alert_to_incident(
sem_result.group_values
);
}
// Build the correlation subject for the internal alert path.
let subject = CorrelationSubject {
id: alert.get_unique_key(),
name: alert.name.clone(),
org_id: alert.org_id.to_string(),
kind: AlertKind::Internal,
base_destinations: alert.destinations.clone(),
severity: None,
};
// Find or create incident
let outcome = find_or_create_incident(
&alert.org_id,
&group_values,
key_type,
alert,
&subject,
triggered_at,
&correlation_reason,
&service_name,
eval_level,
)
.await?;
@ -632,9 +704,11 @@ pub async fn correlate_alert_to_incident(
if !notify_rows.is_empty() {
match &outcome {
IncidentCorrelationOutcome::NewIncidentCreated { incident_id, .. }
| IncidentCorrelationOutcome::NewAlertTypeJoined { incident_id, .. } => {
| IncidentCorrelationOutcome::NewAlertTypeJoined { incident_id, .. }
| IncidentCorrelationOutcome::SeverityEscalated { incident_id, .. } => {
let event = match &outcome {
IncidentCorrelationOutcome::NewIncidentCreated { .. } => "new_incident_created",
IncidentCorrelationOutcome::SeverityEscalated { .. } => "severity_escalated",
_ => "new_alert_correlated",
};
let merged_destinations =
@ -660,6 +734,175 @@ pub async fn correlate_alert_to_incident(
Ok(Some(outcome))
}
/// External-event twin of [`correlate_alert_to_incident`].
///
/// Feeds an externally-ingested alert (from the External Alert Sources feature)
/// through the same incident-correlation machinery as internal alerts. Labels are
/// taken verbatim from the external record — there is no `query_condition` to
/// enrich from, so the condition-dimension enrichment step is skipped.
///
/// `base_destinations` are the integration's configured destinations (for the org
/// default, the default integration's config). Severity is parsed from the
/// external record (falling back to `P3`) rather than resolved by the enterprise
/// default used on the internal path.
///
/// Notification behaviour mirrors the internal path:
/// - `NewIncidentCreated` / `NewAlertTypeJoined` → notify merged destinations.
/// - `ExistingAlertRepeated` → notification suppressed (same subject already in incident).
///
/// The `#[cfg(feature = "cloud")]` AI-credit deduction from the internal path is
/// intentionally NOT replicated here: it is structurally coupled to `&Alert` and
/// P1 external events do not consume incident credits.
pub async fn correlate_external_event(
org_id: &str,
external: &infra::table::external_alerts::ExternalAlertRecord,
base_destinations: Vec<String>,
) -> Result<Option<IncidentCorrelationOutcome>, anyhow::Error> {
use config::meta::alerts::incidents::{IncidentSeverity, KeyType};
// Labels come straight from the external record (no query-condition enrichment).
let labels: HashMap<String, String> =
serde_json::from_value(external.labels.clone()).unwrap_or_default();
log::debug!(
"[incidents] External event '{}' labels: {:?}",
external.title,
labels
);
// Same parallel correlation the internal path uses.
let parallel_result = correlate_parallel(org_id, &labels).await;
let group_values = parallel_result.final_group_values;
let mut key_type = parallel_result.final_key_type;
let correlation_reason = parallel_result.correlation_reason;
// Extract service name using both results.
let service_name = extract_service_name_parallel(&labels, &parallel_result.service_discovery);
// If group_values is empty, isolate by subject id to prevent incorrect grouping.
if group_values.is_empty() {
key_type = KeyType::AlertId;
log::warn!(
"[incidents] External event '{}' has no group_values - isolated by alert_id",
external.title
);
}
log::info!(
"[incidents] External event '{}' correlation result: reason={}, key_type={:?}, dimensions={:?}, service_discovery_success={}, semantic_extraction_success={}",
external.title,
correlation_reason,
key_type,
group_values,
parallel_result.service_discovery.is_some(),
parallel_result.semantic_extraction.is_some()
);
// Build the correlation subject for the external path.
let subject = CorrelationSubject {
id: external.id.clone(),
name: external.title.clone(),
org_id: org_id.to_string(),
kind: AlertKind::External,
base_destinations,
severity: Some(external.severity.parse().unwrap_or(IncidentSeverity::P3)),
};
let triggered_at = external.last_seen_at;
// Find or create incident.
let outcome = find_or_create_incident(
org_id,
&group_values,
key_type,
&subject,
triggered_at,
&correlation_reason,
&service_name,
None, // external events carry no evaluated alert level
)
.await?;
// Send incident notification unless the outcome is a repeated alert (suppressed
// by design). External events always carry a single occurrence, so there is no
// empty-`notify_rows` manual-trigger case to guard against here.
match &outcome {
IncidentCorrelationOutcome::NewIncidentCreated { incident_id, .. }
| IncidentCorrelationOutcome::NewAlertTypeJoined { incident_id, .. }
| IncidentCorrelationOutcome::SeverityEscalated { incident_id, .. } => {
let event = match &outcome {
IncidentCorrelationOutcome::NewIncidentCreated { .. } => "new_incident_created",
IncidentCorrelationOutcome::SeverityEscalated { .. } => "severity_escalated",
_ => "new_alert_correlated",
};
let merged_destinations =
collect_incident_destinations(org_id, incident_id, &subject.base_destinations)
.await;
send_incident_notifications_inner(
org_id,
&subject.name,
incident_id,
event,
triggered_at,
&merged_destinations,
None,
)
.await;
}
IncidentCorrelationOutcome::ExistingAlertRepeated { incident_id, .. } => {
log::debug!(
"[incidents] Suppressing notification for repeated external event in incident {incident_id}"
);
}
}
Ok(Some(outcome))
}
/// Auto-resolve the open incident containing `external.id`, but only once every
/// other `External`-kind alert already linked to that incident is also resolved
/// in `external_alerts` — a single source clearing shouldn't close an incident
/// that other still-firing sources are correlated into.
pub async fn try_auto_resolve_incident_for_external_alert(
org_id: &str,
external_alert_id: &str,
) -> Result<(), anyhow::Error> {
let Some(incident) = infra::table::alert_incidents::find_open_incident_containing_alert(
org_id,
external_alert_id,
)
.await?
else {
return Ok(());
};
let links = infra::table::alert_incidents::get_incident_alerts(&incident.id).await?;
let external_alert_ids: Vec<String> = links
.into_iter()
.filter(|l| l.alert_kind == "external")
.map(|l| l.alert_id)
.collect();
if external_alert_ids.is_empty() {
return Ok(());
}
let records = infra::table::external_alerts::get_by_ids(org_id, &external_alert_ids).await?;
let all_resolved = records.iter().all(|r| r.state == "resolved");
if all_resolved {
update_status(org_id, &incident.id, "resolved", "system@openobserve.ai").await?;
log::info!(
"[incidents] Auto-resolved incident {} — all {} contributing external alert(s) resolved",
incident.id,
external_alert_ids.len()
);
}
Ok(())
}
/// Query Service Discovery for group_values using the correlation API
///
/// Uses ServiceStorage::correlate() for proper dimension matching
@ -780,19 +1023,31 @@ async fn create_new_incident(
org_id: &str,
group_values: &HashMap<String, String>,
key_type: config::meta::alerts::incidents::KeyType,
alert: &Alert,
subject: &CorrelationSubject,
triggered_at: i64,
correlation_reason: &str,
service_name: &str,
eval_level: Option<config::meta::alerts::level::AlertLevel>,
) -> Result<IncidentCorrelationOutcome, anyhow::Error> {
let severity = o2_enterprise::enterprise::alerts::incidents::determine_severity(None);
// Pre-mapped severity from the subject (external events) wins; otherwise
// T-8's evaluated level maps to incident severity — Critical opens a P2,
// Warning a P3. Without either (manual trigger, single-level alert) the
// enterprise default stands.
let severity = match (subject.severity, eval_level) {
(Some(s), _) => s.to_string(),
(None, Some(config::meta::alerts::level::AlertLevel::Critical)) => "P2".to_string(),
(None, Some(config::meta::alerts::level::AlertLevel::Warning)) => "P3".to_string(),
(None, _) => {
o2_enterprise::enterprise::alerts::incidents::determine_severity(None).to_string()
}
};
let title =
o2_enterprise::enterprise::alerts::incidents::generate_title(&alert.name, group_values);
o2_enterprise::enterprise::alerts::incidents::generate_title(&subject.name, group_values);
let incident = infra::table::alert_incidents::create(
org_id,
severity,
&severity,
serde_json::to_value(group_values)?,
&key_type.to_string(),
triggered_at,
@ -825,8 +1080,9 @@ async fn create_new_incident(
// Add the first alert to the incident
infra::table::alert_incidents::add_alert_to_incident(
&incident.id,
&alert.get_unique_key(),
&alert.name,
&subject.id,
&subject.name,
subject.kind.as_str(),
triggered_at,
correlation_reason,
)
@ -836,8 +1092,8 @@ async fn create_new_incident(
if let Err(e) = infra::table::incident_events::record_alert(
org_id,
&incident.id,
&alert.get_unique_key(),
&alert.name,
&subject.id,
&subject.name,
triggered_at,
)
.await
@ -851,7 +1107,7 @@ async fn create_new_incident(
log::info!(
"[incidents] Created new incident {} for alert '{}' (key_type: {:?}, severity: {})",
incident.id,
alert.name,
subject.name,
key_type,
severity
);
@ -880,7 +1136,7 @@ async fn create_new_incident(
&& let Err(e) = o2_enterprise::enterprise::super_cluster::queue::incidents_create(
org_id,
&key_type.to_string(),
severity,
&severity,
serde_json::to_value(group_values)?,
triggered_at,
Some(title),
@ -894,8 +1150,8 @@ async fn create_new_incident(
org_id,
&incident.id,
service_name,
&alert.get_unique_key(),
&alert.name,
&subject.id,
&subject.name,
triggered_at,
);
@ -967,16 +1223,17 @@ async fn find_or_create_incident(
org_id: &str,
group_values: &HashMap<String, String>,
key_type: config::meta::alerts::incidents::KeyType,
alert: &Alert,
subject: &CorrelationSubject,
triggered_at: i64,
correlation_reason: &str,
service_name: &str,
eval_level: Option<config::meta::alerts::level::AlertLevel>,
) -> Result<IncidentCorrelationOutcome, anyhow::Error> {
use config::meta::alerts::incidents::{DimensionRelationship, KeyType};
// STEP 1: AlertId exact match - check for existing incident with same alert_id
if key_type == KeyType::AlertId {
let alert_id = alert.get_unique_key();
let alert_id = subject.id.clone();
// Use the dedicated DB query: junction table lookup + incident fetch
if let Some(incident) =
@ -986,7 +1243,8 @@ async fn find_or_create_incident(
let _ = infra::table::alert_incidents::add_alert_to_incident(
&incident.id,
&alert_id,
&alert.name,
&subject.name,
subject.kind.as_str(),
triggered_at,
correlation_reason,
)
@ -996,7 +1254,7 @@ async fn find_or_create_incident(
org_id,
&incident.id,
&alert_id,
&alert.name,
&subject.name,
triggered_at,
)
.await
@ -1010,7 +1268,7 @@ async fn find_or_create_incident(
if let Err(e) = crate::incidents::send_incident_event_trigger(
org_id,
&incident.id,
IncidentEvent::alert(&alert_id, &alert.name, triggered_at),
IncidentEvent::alert(&alert_id, &subject.name, triggered_at),
)
.await
{
@ -1025,10 +1283,66 @@ async fn find_or_create_incident(
&incident.id,
service_name,
&alert_id,
&alert.name,
&subject.name,
triggered_at,
);
// T-8/§7.1: a repeat at HIGHER severity is an ESCALATION, not a
// repeat. A Warning-created P3 incident must upgrade to P2 and
// notify when the alert re-fires at Critical — the scheduler's
// silence layer explicitly let this delivery through, and
// suppressing it here would lose the only page for the
// escalation.
use config::meta::alerts::incidents::{IncidentEvent, IncidentSeverity};
let level_severity = eval_level.and_then(|l| match l {
config::meta::alerts::level::AlertLevel::Critical => Some(IncidentSeverity::P2),
config::meta::alerts::level::AlertLevel::Warning => Some(IncidentSeverity::P3),
_ => None,
});
// P1 is most urgent; higher urgency = escalation.
let urgency = |s: IncidentSeverity| match s {
IncidentSeverity::P1 => 4u8,
IncidentSeverity::P2 => 3,
IncidentSeverity::P3 => 2,
IncidentSeverity::P4 => 1,
};
if let Some(new_severity) = level_severity
&& let Ok(current_severity) = incident.severity.parse::<IncidentSeverity>()
&& urgency(new_severity) > urgency(current_severity)
{
infra::table::alert_incidents::update_severity(
org_id,
&incident.id,
&new_severity.to_string(),
)
.await?;
if let Err(e) = infra::table::incident_events::append(
org_id,
&incident.id,
IncidentEvent::severity_upgrade(
current_severity,
new_severity,
format!("alert '{}' escalated to {}", subject.name, new_severity),
),
)
.await
{
log::error!(
"[Incidents] Failed to record severity-upgrade event for incident {}: {e}",
incident.id
);
}
log::info!(
"[Incidents] Incident {} escalated {current_severity} -> {new_severity} by alert '{}'",
incident.id,
subject.name
);
return Ok(IncidentCorrelationOutcome::SeverityEscalated {
incident_id: incident.id,
service_name: service_name.to_string(),
});
}
return Ok(IncidentCorrelationOutcome::ExistingAlertRepeated {
incident_id: incident.id,
service_name: service_name.to_string(),
@ -1069,8 +1383,9 @@ async fn find_or_create_incident(
let is_new_alert_type = infra::table::alert_incidents::add_alert_to_incident(
&existing.id,
&alert.get_unique_key(),
&alert.name,
&subject.id,
&subject.name,
subject.kind.as_str(),
triggered_at,
correlation_reason,
)
@ -1079,8 +1394,8 @@ async fn find_or_create_incident(
if let Err(e) = infra::table::incident_events::record_alert(
org_id,
&existing.id,
&alert.get_unique_key(),
&alert.name,
&subject.id,
&subject.name,
triggered_at,
)
.await
@ -1095,7 +1410,7 @@ async fn find_or_create_incident(
&& let Err(e) = crate::incidents::send_incident_event_trigger(
org_id,
&existing.id,
IncidentEvent::alert(alert.get_unique_key(), &alert.name, triggered_at),
IncidentEvent::alert(&subject.id, &subject.name, triggered_at),
)
.await
{
@ -1158,8 +1473,8 @@ async fn find_or_create_incident(
o2_enterprise::enterprise::super_cluster::queue::incidents_add_alert(
org_id,
&existing.id,
&alert.get_unique_key(),
&alert.name,
&subject.id,
&subject.name,
triggered_at,
correlation_reason,
)
@ -1172,8 +1487,8 @@ async fn find_or_create_incident(
org_id,
&existing.id,
service_name,
&alert.get_unique_key(),
&alert.name,
&subject.id,
&subject.name,
triggered_at,
);
@ -1246,10 +1561,11 @@ async fn find_or_create_incident(
org_id,
group_values,
key_type,
alert,
subject,
triggered_at,
correlation_reason,
service_name,
eval_level,
)
.await
}
@ -1286,12 +1602,13 @@ pub async fn get_incident_with_alerts(
}
// Convert incident_alerts to triggers
let triggers: Vec<IncidentAlert> = incident_alerts
let mut triggers: Vec<IncidentAlert> = incident_alerts
.iter()
.map(|a| IncidentAlert {
incident_id: a.incident_id.clone(),
alert_id: a.alert_id.clone(),
alert_name: a.alert_name.clone(),
alert_kind: AlertKind::from_stored(&a.alert_kind),
alert_fired_at: a.alert_fired_at,
correlation_reason: a
.correlation_reason
@ -1299,9 +1616,48 @@ pub async fn get_incident_with_alerts(
.and_then(|r| CorrelationReason::try_from(r.as_str()).ok())
.unwrap_or(CorrelationReason::AlertId),
created_at: a.created_at,
source_url: None,
labels: None,
detected_source: None,
})
.collect();
// Enrich external-kind triggers with data from the external_alerts table.
// Internal alerts are left untouched (their details are hydrated below via
// the live-fetch path).
let external_ids: Vec<String> = triggers
.iter()
.filter(|t| t.alert_kind == AlertKind::External)
.map(|t| t.alert_id.clone())
.collect();
if !external_ids.is_empty() {
match infra::table::external_alerts::get_by_ids(&incident_org_id, &external_ids).await {
Ok(records) => {
let records_by_id: std::collections::HashMap<_, _> =
records.into_iter().map(|r| (r.id.clone(), r)).collect();
for trigger in triggers
.iter_mut()
.filter(|t| t.alert_kind == AlertKind::External)
{
if let Some(record) = records_by_id.get(&trigger.alert_id) {
trigger.source_url = record.source_url.clone();
trigger.labels = serde_json::from_value(record.labels.clone()).ok();
trigger.detected_source = Some(record.detected_source.clone());
}
}
}
Err(e) => {
log::warn!(
"[incidents] Failed to fetch external alert details for incident {}: {}",
incident_id,
e
);
}
}
}
// Get unique alert names from triggers
let unique_alert_names: std::collections::HashSet<String> = incident_alerts
.iter()

View File

@ -18,11 +18,11 @@ use arrow_schema::{DataType, Schema};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use config::{
TIMESTAMP_COL_NAME, ider,
TIMESTAMP_COL_NAME, get_config, ider,
meta::{
alerts::{
AggFunction, AlertConditionParams, Condition, ConditionList, Operator, QueryCondition,
QueryType, TriggerCondition, TriggerEvalResults,
QueryType, TriggerCondition, TriggerEvalResults, grouping::GroupObservation,
},
cluster::RoleGroup,
search::{SearchEventContext, SearchEventType, SqlQuery},
@ -46,6 +46,7 @@ pub mod backfill;
pub mod deduplication;
pub mod derived_streams;
pub mod destinations;
pub mod external_alerts;
#[cfg(feature = "enterprise")]
pub mod grouping;
#[cfg(feature = "enterprise")]
@ -147,6 +148,16 @@ impl QueryConditionExt for QueryCondition {
v.to_string()
}
}
QueryType::Slo => {
// An SLO alert runs no query. It reads the running aggregate
// the ingest pass already computed, which is why five alerts
// on one SLO cost five cheap status reads and ZERO extra
// raw-data scans (§6b.9). The caller branches before reaching
// here (`alert.rs`); this arm exists so the dispatch stays
// exhaustive and a mis-routed SLO alert degrades to "nothing
// matched" rather than running an empty SQL string.
return Ok(eval_results);
}
QueryType::PromQL => {
let Some(v) = self.promql.as_ref() else {
return Ok(eval_results);
@ -165,16 +176,39 @@ impl QueryConditionExt for QueryCondition {
};
let end = end_time;
let condition = self.promql_condition.as_ref().unwrap();
let req = promql_service::MetricsQueryRequest {
query: format!(
// Multi-level PromQL (alerts_2.md §4.4, same strategy as the
// SQL HAVING): query at the LESS severe value so the warning
// band comes back too, then classify each series below.
// Single-level alerts widen to critical, i.e. the expression is
// byte-identical to before.
let promql_critical = to_float(&condition.value);
let promql_filter = config::meta::alerts::aggregation_level::widened_threshold(
condition.operator,
promql_critical,
self.promql_warning_value,
);
// Observation completeness (M-11), the PromQL mirror of the
// SQL multi path dropping its HAVING: with the threshold in
// the expression, a recovered series just stops being
// returned and could only recover K evaluations late via the
// reaper, with a NULL value. Per-series alerts therefore run
// the raw expression and classify in Rust; single alerts keep
// the filtered query unchanged.
let query = if self.promql_multi_alert {
format!("({v})")
} else {
format!(
"({}) {} {}",
v,
match &condition.operator {
&Operator::EqualTo => "==".to_string(),
_ => condition.operator.to_string(),
},
to_float(&condition.value)
),
promql_filter
)
};
let req = promql_service::MetricsQueryRequest {
query,
start,
end,
step: std::cmp::max(
@ -205,15 +239,18 @@ impl QueryConditionExt for QueryCondition {
.await
{
Ok(v) => v,
Err(_) => {
return Ok(eval_results);
// A failed search is an ERROR, not an empty result. Returning
// Ok here would record outcome=Normal/level=Ok and refresh
// `level_at`, silently clearing a prior Critical (§7.6 —
// errors must leave the level axis untouched).
Err(e) => {
return Err(anyhow::anyhow!("PromQL search error for alert query: {e}"));
}
};
let config::meta::promql::value::Value::Matrix(value) = resp else {
log::warn!(
"Alert evaluate: trace_id: {trace_id}, PromQL query {v} returned unexpected response: {resp:?}"
);
return Ok(eval_results);
return Err(anyhow::anyhow!(
"PromQL query returned unexpected (non-matrix) response: {resp:?}"
));
};
let values: Vec<_> =
value
@ -231,16 +268,70 @@ impl QueryConditionExt for QueryCondition {
})
.collect();
let threshold = trigger_condition.threshold as usize;
eval_results.data = match trigger_condition.operator {
Operator::EqualTo => (values.len() == threshold).then_some(values),
Operator::NotEqualTo => (values.len() != threshold).then_some(values),
Operator::GreaterThan => (values.len() > threshold).then_some(values),
Operator::GreaterThanEquals => (values.len() >= threshold).then_some(values),
Operator::LessThan => (values.len() < threshold).then_some(values),
Operator::LessThanEquals => (values.len() <= threshold).then_some(values),
_ => None,
};
// Two axes, exactly like aggregation: each SERIES is classified
// against the promql condition value, then the SERIES COUNT is
// gated by `trigger_condition`. Counting series alone would
// ignore severity and fire spuriously on the widened set.
let series_values: Vec<f64> = values
.iter()
.filter_map(|v| v.get("value").and_then(|x| x.as_f64()))
.collect();
let level = config::meta::alerts::aggregation_level::evaluate_level_over_items(
&series_values,
condition.operator,
promql_critical,
self.promql_warning_value,
trigger_condition,
);
// Worst series' value, so history reports one coherent
// observation rather than a bare series count. Direction is
// operator-aware: for `<`/`<=` the worst offender is the MIN.
//
// KNOWN LIMITATION (§7.5) — single alerts only: their filter
// is widened just to the warning level, so a healthy run
// returns no series and records actual_value=None — history
// shows "— → Ok". Per-series alerts run unfiltered (above)
// and record the real healthy reading.
eval_results.actual_value = config::meta::alerts::level::worst_observed_value(
&series_values,
condition.operator,
);
// T-9: label the worst SERIES by its PromQL labels, so history
// shows which series the value came from.
eval_results.group_label = eval_results.actual_value.and_then(|w| {
values
.iter()
.find(|v| v.get("value").and_then(|x| x.as_f64()) == Some(w))
.map(|v| {
v.iter()
.filter(|(k, _)| k != &"_timestamp" && k != &"value")
.map(|(k, val)| match val.as_str() {
Some(s) => format!("{k}={s}"),
None => format!("{k}={val}"),
})
.collect::<Vec<_>>()
.join(",")
})
.filter(|label| !label.is_empty())
});
// ── Per-series fan-out (M-1/M-2/M-3, gated by M-9) ──────────
// Additive, exactly like the aggregation path above: the
// worst-series collapse computed already is what the single
// per-evaluation trigger record needs (D8); this only ADDS the
// per-group view.
if self.promql_multi_alert {
eval_results.group_classification =
Some(config::meta::alerts::grouping::classify_promql_series(
&values,
condition.operator,
promql_critical,
self.promql_warning_value,
get_config().limit.alert_max_groups,
));
}
eval_results.level = level;
eval_results.data = level.map(|_| values);
log::info!(
"Alert evaluate: trace_id: {trace_id}, PromQL query {v} returned response after filtering: {eval_results:?}"
);
@ -283,10 +374,76 @@ impl QueryConditionExt for QueryCondition {
} else {
Some(end_time - time_diff)
};
// Hybrid count evaluation (alerts_2.md §4.4c). Guards, in order:
// - threshold bypass (search_event_type) — no threshold, no hybrid;
// - aggregation — already exact, needs per-group rows;
// - VRL — transforms rows post-query, a SQL count could disagree;
// - multi-window — separate SQL list, out of scope v1.
let hybrid = self.search_event_type.is_none()
&& self.aggregation.is_none()
&& self.vrl_function.is_none()
&& self
.multi_time_range
.as_ref()
.is_none_or(|mtr| mtr.is_empty())
&& matches!(
config::meta::alerts::level::evaluation_strategy(
trigger_condition,
get_config().limit.alert_hybrid_count_threshold,
),
config::meta::alerts::level::EvaluationStrategy::CountPlusSample { .. }
);
// Exact count from the COUNT(*) pre-query; also carries the decision.
let mut hybrid_exact_count: Option<f64> = None;
if hybrid {
let count_resp = run_alert_count_query(
&trace_id,
org_id,
stream_type,
&sql,
(start_time.unwrap_or(end_time - time_diff), end_time),
search_type,
search_event_context.clone(),
)
.await?;
let exact = count_resp.0;
eval_results.query_took = Some(count_resp.1);
eval_results.actual_value = Some(exact);
let level = config::meta::alerts::level::evaluate_level(exact, trigger_condition);
eval_results.level = level;
if level.is_none() {
// Not firing: the count alone decides. No payload query at all
// — a healthy hybrid evaluation is CHEAPER than the old
// 100-row floor fetch.
return Ok(eval_results);
}
hybrid_exact_count = Some(exact);
}
// Per-group evaluation reads a page sized to the M-6 cap, not to the
// threshold: for a multi-alert the count gate is always "any group"
// (M-10), so `required_search_size` would ask for a handful of rows and
// the fan-out would see a fraction of the groups.
let multi_group_cap = self
.aggregation
.as_ref()
.filter(|a| a.multi_alert && a.group_by.as_ref().is_some_and(|g| !g.is_empty()))
.map(|_| get_config().limit.alert_max_groups);
let size = if self.search_event_type.is_some() {
-1
} else if let Some(cap) = multi_group_cap {
// ONE row past the cap, so a full page is itself the overflow
// signal (M-6) and the persisted counts can be marked lower bounds
// honestly (§5.3). `cap == 0` means unlimited.
if cap == 0 { -1 } else { cap as i64 + 1 }
} else if hybrid {
// Decision already made from the exact count; this fetch is only
// the notification payload sample.
config::meta::alerts::level::PAYLOAD_SAMPLE_ROWS
} else {
std::cmp::max(100, trigger_condition.threshold)
config::meta::alerts::level::required_search_size(trigger_condition)
};
let req_start = std::time::Instant::now();
@ -521,17 +678,170 @@ impl QueryConditionExt for QueryCondition {
Some(search_event_type) => search_event_type == SearchEventType::Alerts,
};
eval_results.data = if apply_threshold {
let threshold = trigger_condition.threshold as usize;
match trigger_condition.operator {
Operator::EqualTo => (records.len() == threshold).then_some(records),
Operator::NotEqualTo => (records.len() != threshold).then_some(records),
Operator::GreaterThan => (records.len() > threshold).then_some(records),
Operator::GreaterThanEquals => (records.len() >= threshold).then_some(records),
Operator::LessThan => (records.len() < threshold).then_some(records),
Operator::LessThanEquals => (records.len() <= threshold).then_some(records),
_ => None,
match self.aggregation.as_ref() {
// ── Aggregation alerts ──────────────────────────────────────
// The threshold is `having.value` / `warning_value` applied to
// each row's aggregate, NOT a row count. The SQL HAVING was
// widened to the less severe threshold (alerts_2.md §4.4), so
// the returned set deliberately includes the warning band and
// MUST be re-classified here — counting rows would both ignore
// severity and fire spuriously on the widened set.
Some(agg) => {
// Classify each group's aggregate, then re-apply the
// GROUP-COUNT threshold. Both axes must hold — dropping the
// count silently rewrites "for at least 3 groups" as "for
// any group".
let classified: Vec<_> = records
.iter()
.filter_map(|r| r.get("alert_agg_value").and_then(|v| v.as_f64()))
.collect();
let level =
config::meta::alerts::aggregation_level::evaluate_aggregation_alert(
&classified,
agg,
trigger_condition,
)
.unwrap_or(None);
// Report the worst group's value, so history's
// "fired at X against Y" is one coherent observation.
// Direction is operator-aware: for `<`/`<=` the worst
// offender is the MIN, not the max.
//
// KNOWN LIMITATION (§7.5): the HAVING filter is widened
// only to the warning level, so a healthy run returns no
// rows and records actual_value=None — history shows
// "— → Ok". Dropping the filter would cost a full
// per-group fetch on every healthy evaluation;
// deliberately deferred to the SLO work.
let offenders: Vec<f64> = classified
.iter()
.filter(|v| {
config::meta::alerts::aggregation_level::evaluate_aggregation_level(
**v, agg,
)
.ok()
.flatten()
.is_some()
})
.cloned()
.collect();
let worst = config::meta::alerts::level::worst_observed_value(
&offenders,
agg.having.operator,
);
// T-9: identify WHICH group produced the worst value, so
// history reads "avg(cpu)=97.2 for host=b" and not just a
// number. Label = the group_by columns of that row.
let group_label = worst.and_then(|w| {
let group_by = agg.group_by.as_deref().unwrap_or(&[]);
if group_by.is_empty() {
return None;
}
records
.iter()
.find(|r| r.get("alert_agg_value").and_then(|v| v.as_f64()) == Some(w))
.map(|r| {
group_by
.iter()
.filter_map(|col| {
r.get(col).map(|v| match v.as_str() {
Some(s) => format!("{col}={s}"),
None => format!("{col}={v}"),
})
})
.collect::<Vec<_>>()
.join(",")
})
.filter(|label| !label.is_empty())
});
eval_results.level = level;
eval_results.actual_value = worst;
eval_results.group_label = group_label;
// ── Per-group fan-out (M-1/M-2/M-3, gated by M-9) ───────
// Purely additive: everything above still runs, because the
// worst-group collapse is what the single per-evaluation
// trigger record needs (D8) and what every non-multi alert
// is evaluated by. This only *adds* the per-group view.
if let Some(cap) = multi_group_cap
&& let Some(group_by) = agg.group_by.as_ref()
{
// Labels come from the SHARED extractor, not a local
// copy: dispatch keys each group's notification
// payload by `group_key(row_group_labels(row))`, so if
// the two renderings ever diverged, every dispatch
// item would fail to find its row and the feature
// would break silently.
let observations: Vec<GroupObservation> = records
.iter()
.filter_map(|r| {
let value = r.get("alert_agg_value")?.as_f64()?;
let labels =
config::meta::alerts::dispatch::row_group_labels(r, group_by);
Some(GroupObservation::new(labels, value))
})
.collect();
let classification = config::meta::alerts::grouping::classify_groups_by(
observations,
|v| {
config::meta::alerts::aggregation_level::evaluate_aggregation_level(
v, agg,
)
.ok()
.flatten()
},
cap,
);
// Both facts come free from the classification: the
// page filled if we got everything we asked for, and it
// reached healthy groups if not every observed group
// was firing. Together they decide whether the counts
// are exact and whether absence proves disappearance.
let observed = classification.groups.len() + classification.dropped.len();
let page = config::meta::alerts::grouping::FetchPage {
filled: size > 0 && records.len() as i64 >= size,
reached_healthy: classification.firing_observed < observed,
};
eval_results.group_classification = Some(classification.with_page(page));
}
level.map(|_| records)
}
// ── Count-based alerts ──────────────────────────────────────
None => {
// Hybrid mode already decided from COUNT(*) — and it only
// reaches here when firing, so `data` is Some
// unconditionally. Re-deriving from the 100-row payload
// sample would silently overwrite the exact count with a
// clamped one.
if let Some(exact) = hybrid_exact_count {
eval_results.actual_value = Some(exact);
// level already set from the exact count
Some(records)
} else {
let actual = records.len() as f64;
let level =
config::meta::alerts::level::evaluate_level(actual, trigger_condition);
eval_results.actual_value = Some(actual);
// The fetch was capped at `size`; a full page means the
// true count may be higher — record it as a lower
// bound so history can render "≥ N" (§7.5).
eval_results.value_is_lower_bound =
size > 0 && records.len() as i64 >= size;
eval_results.level = level;
level.map(|_| records)
}
}
}
} else {
// Threshold bypassed (non-alert search event types) — no level.
Some(records)
};
@ -539,6 +849,72 @@ impl QueryConditionExt for QueryCondition {
}
}
/// Run the §4.4c COUNT(*) decision query for a hybrid count-based alert.
///
/// Returns `(exact_count, query_took_ms)`. The user's SQL runs verbatim inside
/// the wrapper, over the same time window the payload query would use, so the
/// two cannot disagree about which rows exist.
#[allow(clippy::too_many_arguments)]
async fn run_alert_count_query(
trace_id: &str,
org_id: &str,
stream_type: StreamType,
sql: &str,
(start_time, end_time): (i64, i64),
search_type: Option<SearchEventType>,
search_event_context: Option<SearchEventContext>,
) -> Result<(f64, i64), anyhow::Error> {
let req = config::meta::search::Request {
query: config::meta::search::Query {
sql: config::meta::alerts::level::count_query_sql(sql),
from: 0,
// COUNT(*) over a subquery yields exactly one row.
size: 1,
start_time,
end_time,
quick_mode: false,
query_type: "".to_string(),
track_total_hits: false,
action_id: None,
uses_zo_fn: false,
query_fn: None, // guard upstream: hybrid excludes VRL alerts
skip_wal: false,
sampling_config: None,
sampling_ratio: None,
streaming_output: false,
streaming_id: None,
histogram_interval: 0,
timezone: None,
},
encoding: config::meta::search::RequestEncoding::Empty,
regions: vec![],
clusters: vec![],
timeout: 0,
search_type,
search_event_context,
use_cache: false,
clear_cache: false,
local_mode: None,
agent_options: None,
};
let resp = SearchService::grpc_search::grpc_search(
trace_id,
org_id,
stream_type,
None,
&req,
Some(RoleGroup::Background),
)
.await?;
let count = resp
.hits
.first()
.and_then(|h| h.get("zo_alert_count"))
.and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64)))
.ok_or_else(|| anyhow::anyhow!("alert count query returned no zo_alert_count column"))?;
Ok((count, resp.took as i64))
}
#[async_trait]
pub trait ConditionListExt: Sync + Send + 'static {
async fn len(&self) -> u32;
@ -1077,7 +1453,22 @@ pub async fn build_sql(
));
}
};
build_expr(&agg.having, "alert_agg_value", data_type)?
// Multi-level aggregations (alerts_2.md §4.4, option B): widen the
// HAVING clause to the LESS severe threshold so every group that could
// be warning-or-worse comes back, then classify each group in Rust via
// the shared helper. Filtering on the critical threshold would drop the
// entire warning band inside the database, where nothing downstream
// could recover it.
//
// Single-level aggregations widen to the critical value, i.e. the
// clause is byte-identical to before.
let filter_value = config::meta::alerts::aggregation_level::having_filter_value(agg)
.map_err(|e| anyhow::anyhow!("Invalid aggregation threshold: {e}"))?;
let widened = Condition {
value: serde_json::json!(filter_value),
..agg.having.clone()
};
build_expr(&widened, "alert_agg_value", data_type)?
};
let func_expr = match agg.function {
@ -1112,11 +1503,30 @@ pub async fn build_sql(
if let Some(group) = agg.group_by.as_ref()
&& !group.is_empty()
{
sql = format!(
"SELECT {}, {func_expr} AS alert_agg_value, MIN({TIMESTAMP_COL_NAME}) as zo_sql_min_time, MAX({TIMESTAMP_COL_NAME}) AS zo_sql_max_time FROM \"{stream_name}\"{where_sql} GROUP BY {} HAVING {having_expr}",
group.join(", "),
group.join(", "),
);
let cols = group.join(", ");
if agg.multi_alert {
// Multi-alerts (M-9) drop the HAVING filter. A group that falls
// back under the threshold must still be RETURNED: otherwise its
// recovery is indistinguishable from it vanishing, and it would
// only resolve via M-7's timeout — K evaluations late, and with a
// NULL value where the real reading should be.
//
// The page stays bounded, so it is ordered worst-first (§5.3).
// That ordering is what keeps the rollup level exact and lets the
// M-6 cap admit the true top of the distribution rather than an
// arbitrary slice. The group columns are the deterministic
// tiebreak within a severity band.
let severity_order =
config::meta::alerts::aggregation_level::severity_order_sql(agg, "alert_agg_value")
.map_err(|e| anyhow::anyhow!("Invalid aggregation threshold: {e}"))?;
sql = format!(
"SELECT {cols}, {func_expr} AS alert_agg_value, MIN({TIMESTAMP_COL_NAME}) as zo_sql_min_time, MAX({TIMESTAMP_COL_NAME}) AS zo_sql_max_time FROM \"{stream_name}\"{where_sql} GROUP BY {cols} ORDER BY {severity_order}, {cols}"
);
} else {
sql = format!(
"SELECT {cols}, {func_expr} AS alert_agg_value, MIN({TIMESTAMP_COL_NAME}) as zo_sql_min_time, MAX({TIMESTAMP_COL_NAME}) AS zo_sql_max_time FROM \"{stream_name}\"{where_sql} GROUP BY {cols} HAVING {having_expr}"
);
}
}
if sql.is_empty() {
sql = format!(

File diff suppressed because it is too large Load Diff

View File

@ -609,6 +609,18 @@ fn resolve_module_configs(
concurrency: pick_concurrency(cfg.limit.scheduler_anomaly_concurrency),
poll_interval_secs: pick_interval(cfg.limit.scheduler_anomaly_interval),
},
ModuleSchedulerConfig {
module: TriggerModule::Slo,
concurrency: pick_concurrency(cfg.limit.scheduler_slo_concurrency),
poll_interval_secs: pick_interval(cfg.limit.scheduler_slo_interval),
},
ModuleSchedulerConfig {
// Its own lane on purpose: a bulk historical scan sharing a
// budget with incremental SLI passes would starve them (§6b.9).
module: TriggerModule::SloBackfill,
concurrency: std::cmp::max(1, cfg.limit.scheduler_slo_backfill_concurrency),
poll_interval_secs: pick_interval(cfg.limit.scheduler_slo_backfill_interval),
},
ModuleSchedulerConfig {
module: TriggerModule::QueryRecommendations,
concurrency: pick_concurrency(cfg.limit.scheduler_query_reco_concurrency),
@ -749,10 +761,10 @@ mod tests {
// never be pulled once per-module pullers are enabled.
let cfg = config::Config::default();
let configs = resolve_module_configs(&cfg, &make_config());
assert_eq!(configs.len(), 6);
assert_eq!(configs.len(), 8);
let modules: std::collections::HashSet<_> =
configs.iter().map(|c| c.module.clone()).collect();
assert_eq!(modules.len(), 6, "duplicate module in resolved configs");
assert_eq!(modules.len(), 8, "duplicate module in resolved configs");
for m in [
TriggerModule::Alert,
TriggerModule::Report,
@ -760,6 +772,8 @@ mod tests {
TriggerModule::Backfill,
TriggerModule::AnomalyDetection,
TriggerModule::QueryRecommendations,
TriggerModule::Slo,
TriggerModule::SloBackfill,
] {
assert!(modules.contains(&m), "missing module {m:?}");
}
@ -779,6 +793,7 @@ mod tests {
TriggerModule::DerivedStream,
TriggerModule::AnomalyDetection,
TriggerModule::QueryRecommendations,
TriggerModule::Slo,
] {
let c = find_module(&configs, m.clone());
assert_eq!(c.concurrency, 3, "{m:?} should inherit base concurrency");
@ -794,6 +809,13 @@ mod tests {
backfill.poll_interval_secs, 10,
"backfill inherits the alert pull frequency"
);
// SLO backfill floors to 1 for the same reason the alert backfill
// does: a bulk historical scan must never crowd out the incremental
// passes that alerts read from.
let slo_backfill = find_module(&configs, TriggerModule::SloBackfill);
assert_eq!(slo_backfill.concurrency, 1, "slo backfill floors to 1");
assert_eq!(slo_backfill.poll_interval_secs, 10);
}
#[test]
@ -805,6 +827,8 @@ mod tests {
cfg.limit.scheduler_anomaly_concurrency = 4;
cfg.limit.scheduler_query_reco_concurrency = 2;
cfg.limit.scheduler_backfill_concurrency = 3;
cfg.limit.scheduler_slo_concurrency = 5;
cfg.limit.scheduler_slo_backfill_concurrency = 2;
let configs = resolve_module_configs(&cfg, &base);
// Alert has no dedicated var: it reuses the base (ZO_ALERT_SCHEDULE_CONCURRENCY).
@ -826,6 +850,11 @@ mod tests {
find_module(&configs, TriggerModule::Backfill).concurrency,
3
);
assert_eq!(find_module(&configs, TriggerModule::Slo).concurrency, 5);
assert_eq!(
find_module(&configs, TriggerModule::SloBackfill).concurrency,
2
);
}
#[test]
@ -838,6 +867,8 @@ mod tests {
cfg.limit.scheduler_derived_stream_interval = 120;
cfg.limit.scheduler_backfill_interval = 60;
cfg.limit.scheduler_anomaly_interval = 30;
cfg.limit.scheduler_slo_interval = 45;
cfg.limit.scheduler_slo_backfill_interval = 300;
// query_reco left at 0 → inherits the alert pull frequency (10)
let configs = resolve_module_configs(&cfg, &base);
@ -862,6 +893,14 @@ mod tests {
find_module(&configs, TriggerModule::AnomalyDetection).poll_interval_secs,
30
);
assert_eq!(
find_module(&configs, TriggerModule::Slo).poll_interval_secs,
45
);
assert_eq!(
find_module(&configs, TriggerModule::SloBackfill).poll_interval_secs,
300
);
assert_eq!(
find_module(&configs, TriggerModule::QueryRecommendations).poll_interval_secs,
10,

View File

@ -60,6 +60,32 @@ pub struct CreateAnomalyConfigRequest {
pub enabled: Option<bool>,
pub folder_id: Option<String>,
pub owner: Option<String>,
/// Triage priority P1..P5 (Feature 2, PT-1). Anomaly configs appear in the
/// same alert list as scheduled/realtime alerts, so they carry the same
/// metadata. Absent = unset.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<u8>, example = 3)]
pub priority: Option<config::meta::alerts::priority::AlertPriority>,
/// Selection tags (PT-6), normalized and validated on save exactly as for
/// alerts — one `normalize_tags` serves both.
#[serde(default)]
pub tags: Vec<String>,
}
/// Deserializer that keeps "absent" and "explicit null" apart for a
/// `Option<Option<T>>` field.
///
/// Serde's default collapses both to `None`: a plain `Option<Option<T>>`
/// deserializes `null` to the OUTER `None`, so "clear this value" becomes
/// indistinguishable from "field not supplied". That silently made priority
/// unclearable through the direct anomaly endpoint; this restores the
/// distinction (`null` -> `Some(None)`).
fn double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
serde::Deserialize::deserialize(deserializer).map(Some)
}
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
@ -82,6 +108,25 @@ pub struct UpdateAnomalyConfigRequest {
pub enabled: Option<bool>,
pub folder_id: Option<String>,
pub owner: Option<String>,
/// Double-option so "absent" and "explicit null" stay distinguishable
/// (same shape as `TimedAnnotationUpdate::end_time`):
/// * `None` — field not supplied, leave the stored value
/// * `Some(None)` — clear the priority
/// * `Some(Some(p))` — set it
///
/// A plain `Option` cannot express "clear", which made priority the only
/// field on an anomaly config that could be set but never unset — and
/// inconsistent with alerts, where clearing works.
#[serde(
default,
deserialize_with = "double_option",
skip_serializing_if = "Option::is_none"
)]
#[schema(value_type = Option<u8>, example = 3)]
pub priority: Option<Option<config::meta::alerts::priority::AlertPriority>>,
/// `None` leaves stored tags untouched; `Some(vec![])` clears them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
/// Resolve a folder name (e.g. "default") to the PK stored in `folders.id`.
@ -316,6 +361,12 @@ pub async fn create_config(
// Validate request
validate_config_request(&req)?;
// Feature 2 (PT-7): same normalization the alerts path uses, so a tag
// means the same thing on both. Kept typed, not stringified, so the API
// layer can downcast it to a 400.
let normalized_tags =
config::meta::alerts::tags::normalize_tags(&req.tags).map_err(anyhow::Error::new)?;
let db = ORM_CLIENT
.get()
.ok_or_else(|| anyhow::anyhow!("Database not initialized"))?;
@ -386,6 +437,16 @@ pub async fn create_config(
),
folder_id: folder_pk,
owner: req.owner.clone(),
// Feature 2. Tags are NORMALIZED here, not merely validated, so the
// stored form matches what the alerts table stores and one filter
// compares like with like. NULL rather than 0/[] when unset, so an
// existing config's row is unchanged.
priority: req.priority.map(|p| p.to_i32()),
tags: if normalized_tags.is_empty() {
None
} else {
Some(serde_json::json!(normalized_tags))
},
status: 0i32, // 0 = waiting
retries: 0,
last_updated: now_us,
@ -610,6 +671,24 @@ pub async fn update_config(
if let Some(owner) = req.owner {
active_model.owner = Set(Some(owner));
}
// Feature 2. `None` means "not supplied, leave as-is"; an explicit value
// replaces it. Tags go through the same normalization as the alerts path,
// so an edit cannot smuggle in a form the filter will never match.
// `Some(None)` clears, `Some(Some(_))` sets, `None` leaves alone — so a
// partial update (e.g. enable/disable) cannot wipe the priority.
if let Some(priority) = req.priority {
active_model.priority = Set(priority.map(|p| p.to_i32()));
}
if let Some(tags) = req.tags {
// Typed for the same reason as on create: the API downcasts to 400.
let normalized =
config::meta::alerts::tags::normalize_tags(&tags).map_err(anyhow::Error::new)?;
active_model.tags = Set(if normalized.is_empty() {
None // an explicit empty list clears the tags
} else {
Some(serde_json::json!(normalized))
});
}
active_model.updated_at = Set(Utc::now().timestamp_micros());
@ -895,6 +974,11 @@ pub async fn clone_config(
alert_destinations: src.alert_destinations.clone(),
folder_id: resolved_folder_id,
owner: src.owner.clone(),
// Feature 2: a clone inherits the original's triage metadata —
// copying an alert that is P1/tagged and silently dropping both
// would hand back something that looks configured but is not.
priority: src.priority,
tags: src.tags.clone(),
status: 0i32,
retries: 0,
last_updated: now_us,
@ -1853,6 +1937,84 @@ pub async fn send_anomaly_alert(
#[cfg(test)]
mod tests {
// ── Feature 2: priority & tags on anomaly configs ───────────────────────
/// Tags round-trip through the create request, and an absent `tags` key
/// yields an empty list rather than failing — every pre-Feature-2 client
/// omits it.
#[test]
fn test_create_request_tags_default_to_empty() {
let without: CreateAnomalyConfigRequest = serde_json::from_str(
r#"{"name":"a","stream_name":"s","stream_type":"logs","query_mode":"filters",
"detection_function":"count","histogram_interval":"5m",
"schedule_interval":"15m","detection_window_seconds":3600}"#,
)
.unwrap();
assert!(without.tags.is_empty());
assert_eq!(without.priority, None);
}
/// The create path must store the NORMALIZED form, so a tag means the same
/// thing on an anomaly config as on an alert and one filter matches both.
#[test]
fn test_anomaly_tags_normalize_exactly_like_alert_tags() {
let raw = vec![
" PROD ".to_string(),
"Service:Checkout".to_string(),
"prod".to_string(),
"".to_string(),
];
let normalized = config::meta::alerts::tags::normalize_tags(&raw).unwrap();
assert_eq!(normalized, vec!["prod", "service:checkout"]);
}
/// Invalid tags are rejected on anomaly configs too, naming the offender —
/// the same contract the alerts path has.
#[test]
fn test_anomaly_invalid_tag_is_rejected_and_named() {
let err = config::meta::alerts::tags::normalize_tags(&["1bad".to_string()]).unwrap_err();
assert!(err.to_string().contains("1bad"), "got: {err}");
}
/// Priority ids must be the SAME as the alerts table's, since one column
/// mapping and one enum serve both. If these ever diverge, a P2 anomaly
/// and a P2 alert would store different integers.
#[test]
fn test_anomaly_priority_ids_match_the_alert_scale() {
use config::meta::alerts::priority::AlertPriority;
for (p, id) in [
(AlertPriority::P1, 1),
(AlertPriority::P2, 2),
(AlertPriority::P3, 3),
(AlertPriority::P4, 4),
(AlertPriority::P5, 5),
] {
assert_eq!(p.to_i32(), id);
}
}
/// PROBE: does serde distinguish "absent" from "explicit null" for the
/// double option? Written first because the answer decides whether the
/// clear-via-null path works, or whether only the Rust-constructed
/// `Some(None)` from the v2 handler does.
#[test]
fn test_update_request_priority_absent_vs_null() {
let absent: UpdateAnomalyConfigRequest = serde_json::from_str("{}").unwrap();
let null: UpdateAnomalyConfigRequest =
serde_json::from_str(r#"{"priority": null}"#).unwrap();
let set: UpdateAnomalyConfigRequest = serde_json::from_str(r#"{"priority": 3}"#).unwrap();
assert_eq!(absent.priority, None, "absent must leave the value alone");
assert_eq!(
null.priority,
Some(None),
"explicit null must mean CLEAR, distinct from absent"
);
assert_eq!(
set.priority,
Some(Some(config::meta::alerts::priority::AlertPriority::P3))
);
}
use super::*;
// ── combine_detection_fn ────────────────────────────────────────────────
@ -1938,6 +2100,8 @@ mod tests {
enabled: None,
folder_id: None,
owner: None,
priority: None,
tags: vec![],
}
}

View File

@ -1061,6 +1061,11 @@ mod tests {
// If we reach here, the function completed successfully
}
// Gated to match what it asserts. Without this the test runs under
// `--features enterprise` too, where `get_role` returns the mapped role
// rather than collapsing to Admin — so it failed the entire enterprise
// test build on a claim it never made about enterprise.
#[cfg(not(feature = "enterprise"))]
#[test]
fn test_get_role_non_enterprise() {
let user_role = UserOrgRole {

View File

@ -69,6 +69,15 @@ impl From<AlertError> for Response {
| AlertError::AlertDestinationMissing
| AlertError::TemplateNotConfigured { .. }
| AlertError::RealtimeMissingCustomQuery
// Both are user input errors -> 400, same as the other
// trigger-condition validations.
| AlertError::InvalidWarningThreshold(_)
| AlertError::InvalidAggregationThreshold(_)
| AlertError::InvalidMultiAlert(_)
| AlertError::WarningThresholdOnRealtimeAlert
| AlertError::WarningOnCoverageGate { .. }
| AlertError::PromqlWarningWithoutCondition
| AlertError::InvalidTag(_)
| AlertError::SqlMissingQuery
| AlertError::SqlContainsSelectStar
| AlertError::PromqlMissingQuery

View File

@ -29,7 +29,7 @@ use config::{
ider::SnowflakeIdGenerator,
meta::{
alerts::alert::Alert,
self_reporting::usage::{RequestStats, TriggerData, TriggerDataStatus, TriggerDataType},
self_reporting::usage::{RequestStats, RunOutcome, TriggerData, TriggerDataType},
stream::{PartitionTimeLevel, StreamParams, StreamPartition, StreamType},
},
utils::{flatten, json::*, schema::format_partition_key},
@ -150,7 +150,7 @@ pub async fn evaluate_trigger(triggers: TriggerAlertData) {
next_run_at: now,
is_realtime: true,
is_silenced: false,
status: TriggerDataStatus::Completed,
status: RunOutcome::Firing,
start_time: now,
end_time: 0,
retries: 0,
@ -173,12 +173,12 @@ pub async fn evaluate_trigger(triggers: TriggerAlertData) {
alert.name
);
match alert
.send_notification(&trace_id, val, now, None, now)
.send_notification(&trace_id, val, now, None, now, None, None, None)
.await
{
Err(e) => {
log::error!("Failed to send notification: {e}");
trigger_data_stream.status = TriggerDataStatus::Failed;
trigger_data_stream.status = RunOutcome::NotifyFailed;
trigger_data_stream.error =
Some(format!("error sending notification for alert: {e}"));
}

View File

@ -58,6 +58,7 @@ pub mod self_reporting;
pub mod service;
pub mod session;
pub mod short_url;
pub mod slo;
pub mod stream;
pub mod stream_utils;
pub mod synthetics;

View File

@ -361,6 +361,8 @@ pub async fn manual_evaluate(
o2_enterprise::enterprise::llm_evaluations::eval_jobs::tasks::EvaluationQueryWindow {
start_us: body.start_time,
end_us: body.end_time,
// manual evaluations use the caller's window verbatim
ingest_cutoff_us: None,
},
reason: body
.reason

View File

@ -20,7 +20,7 @@ use std::{
use axum::body::Bytes;
#[cfg(feature = "cloud")]
use config::meta::self_reporting::usage::is_reserved_self_reporting_stream;
use config::meta::self_reporting::usage::is_reserved_internal_stream;
use config::{
BLOCKED_STREAMS, TIMESTAMP_COL_NAME, get_config,
meta::stream::StreamType,
@ -150,7 +150,7 @@ pub async fn ingest(
// non-bulk `IngestionRequest::Usage` channel), so this is safe.
// Cloud-only: OSS / self-hosted may legitimately use these names.
#[cfg(feature = "cloud")]
if is_reserved_self_reporting_stream(&stream_name) {
if is_reserved_internal_stream(&stream_name) {
let err_msg =
format!("stream '{stream_name}' is reserved and cannot be ingested into");
log::warn!("[LOGS:BULK] {err_msg}");

Some files were not shown because too many files have changed in this diff Show More