Skip to content

feat(incidents): ingest alerts from external systems over a webhook - #13501

Open
prabhatsharma wants to merge 9 commits into
mainfrom
feat/incident-webhook-ingest
Open

feat(incidents): ingest alerts from external systems over a webhook#13501
prabhatsharma wants to merge 9 commits into
mainfrom
feat/incident-webhook-ingest

Conversation

@prabhatsharma

Copy link
Copy Markdown
Contributor

Implements the external alert ingest webhook. Pairs with openobserve/o2-enterprise#2287 (OpenFGA route permission — required, the endpoint is unauthorized without it).

Design and rationale: openobserve/o2-enterprise#2286.

Why

Incident correlation could only ever see alerts OpenObserve evaluated itself. An org running Alertmanager or Datadog — i.e. most of them — got a partial picture of an incident, which is worse than no picture because it looks complete.

What

POST /api/v2/{org_id}/alerts/incidents/ingest

{
  "source": "alertmanager",        // required
  "alert_name": "HighErrorRate",   // required
  "dedup_key": "a1b2c3",           // idempotency across retries
  "severity": "critical",          // mapped onto IncidentSeverity
  "status": "firing",              // firing | resolved
  "timestamp": 1753612800000000,   // epoch micros, defaults to now
  "labels": { "service": "checkout", "k8s_namespace_name": "prod" },
  "annotations": { "summary": "" },
  "external_url": "https://…"
}

The payload becomes a synthetic in-memory Alert — never persisted — and goes through the existing correlate_alert_to_incident path. External and native alerts land in the same incident when their identity labels agree, and separate incidents when they don't. Only labels drives correlation, and the semantic field groups already normalize vendor-specific names (host / instance / k8s.node.name), so no per-vendor adapters are needed.

The junction table never had an FK to alerts, so external alerts join incidents without a row there. That's what makes this cheap.

The three sharp edges, each pinned by a test

These are the ones that fail silently rather than loudly:

  1. Alert::get_unique_key() returns "" when id is None. Correlation tells "this alert type is already here" from "a new alert type joined" purely by alert id. A None id would give every external alert the same identity and suppress notifications that should fire; a fresh id per delivery would notify on every repeat. The id is derived from (org, source, alert_name) — stable across deliveries, distinct per rule.
  2. get_incident_with_alerts parses alert_id as a Ksuid and skips anything that fails. The derived id is KSUID-shaped, with a fixed timestamp prefix since it encodes identity, not creation time.
  3. destinations is empty. Routing belongs to the incident's native alerts — an external sender doesn't get to choose who gets paged. Deliberate, not an oversight.

Also in scope

  • Resolve. status: resolved closes the alert's contribution; the incident resolves once nothing in it is still firing. Without this, external alerts sit open until O2_INCIDENTS_AUTO_RESOLVE_AFTER_MINUTES (3 days by default).
  • Idempotency. dedup_key reuses the existing alert_dedup_state table and its cleanup job rather than adding a parallel store. 30-minute window — long enough for retries, short enough that a genuine re-fire hours later isn't swallowed.
  • Rendering. get_incident_with_alerts now builds external alerts from the junction row instead of looking them up and logging a miss.
  • Migration. source, external_url, annotations, resolved_at on alert_incident_alerts. source NULL means native, so every existing row is untouched.

UI — Data Sources › Custom › Alerts

New tab alongside Logs / Metrics / Traces, with firing, resolve, and Alertmanager-forwarding examples.

Two deliberate departures worth flagging:

  • It doesn't use DataSourceSetupCard. That card's detect step counts rows on a stream to confirm the integration works. These alerts never land in a stream, so there's nothing to count — the card's core affordance doesn't apply. It follows the simpler IngestionContent + CopyContent sibling pattern (metrics/VMagentConfig.vue) instead.
  • The Alertmanager example shows a payload transform, not webhook_configs. Alertmanager posts its own fixed shape, which this endpoint rejects. A bare webhook_configs block would look right and 400 at runtime. A test asserts the example does not contain webhook_configs.

Verification

Check Result
cargo test -p config --lib meta::alerts::incidents 99 passed
cargo test -p openobserve-core --features enterprise --lib alerts::incidents::tests 20 passed
cargo clippy (core, infra, config, api-management, super_cluster_queue) -- -D warnings clean
npm run type-check clean
npm run lint:design:strict clean
npx vitest run src/components/ingestion/alerts 5 passed
Custom.spec.ts + useIngestionRoutes.spec.ts 114 passed
cargo fmt --all applied

The workspace build couldn't be run end to end locally — vectorscan-rs-sys fails to build in this environment (native dep, unrelated to this change). Every crate this PR touches was checked individually with --features enterprise, and openobserve-api-http was checked separately for the router and OpenAPI registration.

Not in scope

  • Native vendor adapters. A single normalized contract first; Alertmanager-shaped and Datadog-shaped receivers can layer on top once this proves out. Documented in fix: reverse order issue for h bar #2286 under alternatives.
  • RCA over external-only incidents. Untested — an incident containing only external alerts may give the RCA agent less to work with. Worth a follow-up.

@Shrinath-O2 Shrinath-O2 added this to the v0.92.0 milestone Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Failed to generate code suggestions for PR

@prabhatsharma prabhatsharma added the e2e This label decides if the playwright runs trigger on a PR or not. DO NOT DELETE or edit this label. label Jul 28, 2026
@Shrinath-O2

Copy link
Copy Markdown
Contributor

⏭️ Playwright E2E Crosscheck — handled by the sister ENT PR

This OSS PR has a matching enterprise PR on branch feat/incident-webhook-ingest: openobserve/o2-enterprise#2287. The enterprise-only e2e specs run there, so this gate defers to that PR's checks and does not build here.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

AI Code Review (DeepSeek-V4-Pro)

Decision: minor_issues

Both warnings from the prior review remain unfixed: injecting _o2_external_source into correlation labels breaks the stated cross-source grouping design, and dedup_key is still the only string field with no length validation. Three suggestions carry forward.

Findings: 🔴 0 blocker · 🟡 2 warnings · 🔵 3 suggestions

Details Show findings (5)

🟡 Warnings

  • src/core/src/alerts/incidents.rs:1472 [Logic Error] _o2_external_source is inserted into correlation labels — since this value differs per source ("alertmanager" vs "datadog"), two external alerts with identical identity labels from different systems will produce distinct group_values and land in separate incidents, contradicting the PR description that "External and native alerts land in the same incident when their identity labels agree" (→ remove the 3-line result_row.insert("_o2_external_source", …) block at L1472-1475; source is already threaded through ExternalAlertMeta.source for rendering).
  • src/config/src/meta/alerts/incidents.rs:1022 [Input Validation] dedup_key has no length bound in validate() — every other string field (source ≤ 128, alert_name ≤ 512, external_url ≤ 2048, label key-values ≤ 1024) is checked, but dedup_key backed by VARCHAR(512) is not. On PostgreSQL/MySQL an oversized key triggers a constraint-violation 500 instead of a 400 (→ add if key.len() > 512 after the alert_name check at L1022).

🔵 Suggestions

  • src/core/src/alerts/incidents.rs:1448 [Race Condition] external_alert_already_seen runs as a standalone query outside any transaction — two concurrent requests with the same dedup_key can both pass the early check and both insert junction rows (PK includes distinct alert_fired_at), inflating alert_count. Notification suppression is preserved by the transaction's duplicate check, so practical impact is cosmetic (→ move the dedup check inside add_alert_to_incident's transaction after the incident row lock).
  • src/infra/src/table/alert_incidents.rs:81 [Documentation] The /// doc block at L81-92 describes both add_alert_to_incident (L81-86) and ExternalAlertMeta (L87-92), but no blank line separates them, so cargo doc attaches the entire block to ExternalAlertMeta at L94 — the struct gets function-behavior docs while add_alert_to_incident at L115 is undocumented (→ insert a blank // line before L87).
  • src/config/src/meta/alerts/incidents.rs:998 [Documentation] #[schema(pattern = "^https?://")] declares the URL must start with http:// or https://, but validate() at L1049 calls url.trim() first, so the server accepts leading whitespace (e.g. " https://example.com"). A generated OpenAPI client would reject payloads the server accepts (→ either update the pattern to ^\\s*https?:// or drop the .trim() in validate for consistency).

  • Risk tier: full
  • Reviewers: Security Reviewer, Code Quality Reviewer, Documentation Reviewer

Closes openobserve/o2-enterprise#2286.

Incident correlation could only ever see alerts OpenObserve evaluated itself,
so an org running Alertmanager or Datadog got a partial picture of an incident
— which is worse than none, because it looks complete.

Adds `POST /api/v2/{org_id}/alerts/incidents/ingest`. The payload is turned
into a synthetic in-memory Alert (never persisted) and handed to the existing
`correlate_alert_to_incident` path, so external and native alerts land in the
same incident whenever their identity labels agree, and in separate incidents
when they do not. Only `labels` drives correlation; the semantic field groups
already normalize vendor-specific names, so no per-vendor adapters are needed.

Three things the correlation engine silently depends on, each pinned by a test:

- The synthetic alert's id is derived from (org, source, alert_name), never
  generated fresh. Correlation distinguishes "this alert type is already here"
  from "a new alert type joined" purely by alert id — a fresh id per delivery
  would notify on every repeat, and `Alert::get_unique_key()` returning "" for
  a `None` id would collapse every external alert into one identity and
  suppress notifications that should fire.
- The id is KSUID-shaped, because `get_incident_with_alerts` parses alert_id as
  a Ksuid and silently skips anything that fails.
- `destinations` is empty. Routing belongs to the incident's native alerts; an
  external sender does not get to choose who is paged.

Also:
- `status: resolved` closes the alert's contribution and resolves the incident
  once nothing in it is still firing. Without it, external alerts would sit
  open until O2_INCIDENTS_AUTO_RESOLVE_AFTER_MINUTES (3 days by default).
- `dedup_key` makes redelivery idempotent, reusing the existing
  alert_dedup_state table rather than adding a parallel store.
- `get_incident_with_alerts` renders external alerts from the junction row
  instead of looking them up and logging a miss. Migration adds `source`,
  `external_url`, `annotations` and `resolved_at` to alert_incident_alerts;
  `source` NULL means native, so every existing row is unaffected.

UI: new Alerts tab under Data Sources > Custom, alongside Logs/Metrics/Traces,
with firing, resolve and Alertmanager-forwarding examples. It does not use the
rich DataSourceSetupCard — that card's detect step counts rows on a stream, and
these alerts never land in one. The Alertmanager example shows the payload
transform rather than a bare webhook_configs block, which would look correct
and 400 at runtime.

Requires the companion o2-enterprise change for the OpenFGA route permission.
The placeholder URL did not match where the page actually lands
(user-guide/analytics/incidents/incident-webhook).
db_schema_version_check requires the constant to advance whenever a
migration file is added; 53 -> 54.
…path

Two defects found reviewing this branch against its own documentation.

**Severity was accepted but never applied.** `ExternalAlertPayload.severity`
was parsed, length-validated, mapped by `map_external_severity`, and
documented with a full vocabulary table — and then dropped on the floor.
`create_new_incident` called `determine_severity(None)` unconditionally, so
every externally-ingested alert opened its incident at the default severity
no matter what the sender reported. A caller sending `"severity": "critical"`
got a P3.

`map_external_severity` had no non-test callers at all, which is what gave it
away. It now normalizes at the ingest boundary and rides to incident creation
on `ExternalAlertMeta`, which was already threaded down that path. An
unrecognized value still maps to `None` so the default stands rather than
being replaced by a guess.

**Resolve bypassed the crate-level `update_status`.** `resolve_external_alert`
called `infra::table::alert_incidents::update_status` directly, which only
writes the row. The crate-level function is what also emits the `Resolved`
timeline event and publishes the status change to the super cluster — so an
incident resolved by an upstream system showed no resolution on its timeline
and stayed open forever on every peer cluster.

Both are covered by tests that fail against the previous code.
@prabhatsharma
prabhatsharma force-pushed the feat/incident-webhook-ingest branch from dd073bd to d2e5982 Compare July 28, 2026 18:11
@prabhatsharma

Copy link
Copy Markdown
Contributor Author

Self-review pass + rebase onto main

Rebased onto origin/main (resolved two conflicts: migration ordering — mine m20260727 now sorts before main's m20260728 — and DB_SCHEMA_VERSION, which main moved to 55, so this branch is now 56).

Reviewing the branch against its own documentation turned up two real defects, both now fixed with regression tests:

1. severity was accepted but never applied

ExternalAlertPayload.severity was parsed, length-validated, mapped by map_external_severity, and documented with a full vocabulary table — then dropped. create_new_incident called determine_severity(None) unconditionally, so every externally-ingested alert opened its incident at the default severity. A caller sending "severity": "critical" got a P3.

The tell: map_external_severity had no non-test callers. It now normalizes at the ingest boundary and reaches incident creation via ExternalAlertMeta, which was already threaded down that path.

2. Resolve bypassed the crate-level update_status

resolve_external_alert called infra::table::alert_incidents::update_status directly, which only writes the row. The crate-level update_status is what also emits the Resolved timeline event and publishes to the super cluster — so an incident resolved upstream showed no resolution on its timeline and stayed open forever on every peer cluster.

Also caught by the rebuild

super_cluster_queue constructs ExternalAlertMeta and needed the new field. Set to None there deliberately: severity belongs to the incident, and the peer already receives it on the Create message.

Checked and found NOT to be bugs

  • _o2_external_source leaking into correlation dimensions. It's injected into the result row, but extract_filtered_semantic_dimensions only promotes fields matching a configured semantic group, so it can't become a group_value — the documented "no labels → own incident" behaviour holds.
  • Dedup state being reaped early. The cleanup job retains 24h, well beyond the 30-minute dedup window.

Verification after rebase

cargo test -p openobserve-core --features enterprise 22 passed · cargo test -p config 99 passed · o2-enterprise route_permissions 94 passed (incl. the router-coverage test read against this branch) · vitest 119 passed · type-check and lint:design:strict clean · cargo clippy --no-deps -D warnings clean on every crate this PR touches.

Note on clippy: running -D warnings across path dependencies surfaces pre-existing lints in src/db/src/workflows.rs, src/core/src/incidents.rs, src/core/src/workflows/mod.rs and src/super_cluster_queue/src/search_job/mod.rs — all from #13472 and other main commits, none in this diff.

Second review pass over the ingest payload validation.

**external_url accepted any scheme.** The field is stored and handed back to
clients as a link to render — the payload reference literally calls it a deep
link. Only its length was checked, so `javascript:alert(1)` or a `data:` URL
would round-trip intact and become stored XSS the moment the incident detail
view renders it as an anchor. Now an allowlist: `http://` or `https://` only.

Nothing renders it yet, which is exactly why this is worth fixing now — the
trap would otherwise be sprung by whoever wires up that view.

**timestamp was unbounded.** It is documented as microseconds, but seconds and
milliseconds are what most senders have to hand. A seconds value silently
placed the incident in 1970, where the auto-resolve sweep treats it as long
stale and closes it on the next tick: the caller sees 200 and the alert
disappears. A far-future value pinned last_alert_at ahead of now so the
incident could never age out at all.

Now rejected outside 2000..2100 with an error naming the likely unit mistake.
Bounds are constants rather than clock-relative so validation stays pure.

Docs updated to match, including one imprecision the review surfaced: an
incident that also holds natively-evaluated alerts never auto-closes on
resolve, because native alerts have no upstream resolved signal. That is the
correct behaviour — closing on the external half would hide a native alert
still firing — but the page claimed otherwise.
@prabhatsharma

Copy link
Copy Markdown
Contributor Author

Second review pass — two more findings

1. external_url accepted any scheme (security)

Only length was validated. The field is stored and returned to clients as a link — the payload reference calls it a deep link — so javascript:alert(1) or a data: URL round-tripped intact and became stored XSS the moment an incident detail view renders it as an anchor.

Now an allowlist: http:// or https:// only, case-insensitive, after trim. Nothing renders it yet, which is precisely why this was worth fixing now rather than later — the trap would have been sprung by whoever wires up that view.

2. timestamp was unbounded (silent data loss)

Documented as microseconds, but seconds and milliseconds are what most senders have to hand.

  • Seconds value → incident lands in 1970 → the auto-resolve sweep treats it as long stale and closes it on the next tick. Caller sees 200; the alert silently disappears.
  • Far-future valuelast_alert_at pinned ahead of now → the incident can never age out.

Now rejected outside 2000–2100 with an error that names the likely unit mistake. Bounds are constants, not clock-relative, so validation stays pure and testable.

Docs imprecision also fixed

The page claimed "the incident resolves once every alert in it has resolved." For an incident that also holds natively-evaluated alerts that never happens — native alerts have no upstream resolved signal, so all_resolved is never true. That behaviour is correct (closing on the external half would hide a native alert still firing), but the docs oversold it. Now stated explicitly in openobserve-docs#491.

Checked and cleared this pass

  • FGA model grants POST on incidents — already used by /rca and /events/comment, so the new route authorizes correctly rather than being denied for everyone.
  • Route orderingingest is registered before {incident_id}, and axum prioritises static segments regardless.
  • Re-firing after resolvefind_open_incident_by_alert_id filters status != resolved, so a re-fire opens a fresh incident instead of resurrecting a closed one.
  • Resolve/fire race — a firing arriving between the resolve's read and its write gets swept into the same resolve. Bounded and acceptable.

Verification

cargo test -p config 109 passed (10 new) · cargo test -p openobserve-core --features enterprise 22 passed · cargo clippy --no-deps -D warnings clean on config; the single remaining hit in openobserve-core is the pre-existing IncidentEvent::alert borrow at incidents.rs:1116, confirmed absent from this diff.

Third review pass. `dedup_key` was advertised in the API, documented with its
own section, and had never worked once.

Idempotency state was being written to `alert_dedup_state`, whose `alert_id`
column carries a foreign key to `alerts` (`fk_alert_dedup_alert`, added in
m20251024_000001). An externally-ingested alert has no row in `alerts` by
design — that is the whole premise of this feature — so every write violated
the constraint. The write was wrapped in a warn-only handler, so the request
still returned 200 while:

  - the insert failed on every single ingest, logging a warning each time;
  - the paired read therefore always missed, making the dedup check dead code;
  - retries were recorded as distinct firings, inflating `alert_count`.

Idempotency now lives on the `alert_incident_alerts` junction row, which has
no foreign key to `alerts`. The key is written by the same insert that records
the alert, so there is no window where a firing is stored but its dedup key is
not. Adds a `dedup_key` column and an `(alert_id, dedup_key)` index to the
migration this branch already ships.

Behaviour change worth noting: a payload with no `dedup_key` is no longer
deduplicated at all. The previous fallback keyed on `(source, alert_name)`,
which would have collapsed two genuine firings of the same rule minutes apart
into one. Without a key nothing distinguishes a retry from a real re-fire, and
losing a real alert is worse than recording a duplicate. Docs updated.

Also in this pass:

  - `find_open_incidents_containing_alert` loaded full junction models for
    every firing the alert had ever had, across all orgs, just to collect
    incident ids. Now selects the id column only, distinct.
  - Removed `dedup_identity`, which after the rewrite had only test callers
    and described a fallback the system no longer has — the same dead-code
    shape that hid the severity bug two passes ago. Replaced with
    `effective_dedup_key` and tests that exercise the real behaviour.
@prabhatsharma

Copy link
Copy Markdown
Contributor Author

Third review pass — dedup_key had never worked

The headline finding: dedup_key was advertised in the API, given its own docs section, and was a complete no-op.

Idempotency state was written to alert_dedup_state, whose alert_id column carries a foreign key to alerts (fk_alert_dedup_alert, from m20251024_000001). An externally-ingested alert has no row in alerts by design — that is the entire premise of this feature — so every write violated the constraint.

Because that write sat behind a warn-only handler, the request still returned 200 while:

  • the insert failed on every single ingest, logging a warning each time;
  • the paired read therefore always missed, making the dedup check dead code;
  • retries were recorded as distinct firings, inflating alert_count.

This is precisely the class of bug I said last pass needed a database to catch. It didn't — the FK was readable in the migration.

Fix

Idempotency now lives on the alert_incident_alerts junction row, which has no FK to alerts. The key is written by the same insert that records the alert, so there is no window where a firing is stored but its key is not. Adds a dedup_key column and an (alert_id, dedup_key) index to the migration this branch already ships, and replicates the key on the super-cluster AddAlert message (o2-enterprise#2287).

Behaviour change worth a reviewer's eye

A payload with no dedup_key is no longer deduplicated at all. The previous fallback keyed on (source, alert_name), which would have collapsed two genuine firings of the same rule minutes apart into one. Without a key nothing distinguishes a retry from a real re-fire, and losing a real alert is worse than recording a duplicate. Docs corrected — they had documented the old fallback.

Also this pass

  • Unbounded query. find_open_incidents_containing_alert loaded full junction models for every firing the alert had ever had, across all orgs, just to collect incident ids. Now selects the id column only, distinct.
  • Dead code removed. dedup_identity had only test callers after the rewrite and described a fallback the system no longer has — the same shape that hid the severity bug two passes ago. Replaced with effective_dedup_key plus tests that exercise real behaviour rather than a function nothing calls.

Verification

config 111 passed · openobserve-core --features enterprise 22 passed · cargo check clean on core, infra, super_cluster_queue, o2_enterprise · clippy --no-deps -D warnings clean on config, infra, api-management; core's only hit remains the pre-existing IncidentEvent::alert borrow at incidents.rs:1116, confirmed absent from this diff.

Running tally

Five defects over three passes: severity discarded, resolve not replicating, unvalidated URL scheme, unbounded timestamp, and now dedup never working. Four of the five were behaviours my own PR text asserted were handled.

The schemathesis fuzz job flagged this endpoint under "schema constraints
don't match API validation" — it generated payloads that satisfied the
declared OpenAPI schema and got 400s back, because the schema said nothing
about the rules `validate()` actually applies.

Declares them: min/max length on `source` and `alert_name`, max length on
`dedup_key`, the `^https?://` pattern on `external_url`, and the microsecond
bounds on `timestamp`. Generated clients now see the real contract, and the
timestamp bound in particular documents itself at the schema level rather than
only failing at runtime.

No behaviour change — `validate()` was already enforcing all of this.
Adds 20 tests in tests/api-testing against a live server, plus a
`client.incidents` wrapper. These exercise what unit tests structurally
cannot: the migration having applied, correlation actually grouping alerts,
idempotency resolving against the database, and resolve closing an incident.

Enables O2_INCIDENTS_ENABLED in the api-testing workflow — without it the
endpoint 403s and none of this runs. The tests skip cleanly via /config's
`incidents_enabled` when pointed at a non-enterprise build.

Running them immediately found a correlation defect that every prior review
pass missed, because it is invisible in the diff.

`DimensionRelationship::check` returns `NewIsSuperset` when the EXISTING
incident has no dimensions — "compatible with anything". So the first alert
that fails to correlate opens a dimensionless incident, and every unrelated
alert afterwards joins it. On a default install, where no semantic field
groups are configured, that is not a corner case: it is the common path, and
it silently collapses every external alert into one incident. The documented
promise that an alert with no matching labels gets an incident of its own was
false.

`find_or_create_incident` now skips dimensionless incidents when evaluating
join candidates. Empty means "identity unknown", not "matches everything".

Note for reviewers: this touches correlation shared with natively-evaluated
alerts, not just the ingest path. The previous behaviour looks unintentional —
nothing else treats an empty dimension set as a wildcard — but it predates
this branch and deserves a second opinion.

Two unit tests pin the relationship semantics, including the empty-existing
case, so the trap is documented where the next reader will meet it.
Review pass over the tests themselves. The previous set passed, but several
assertions were weaker than they looked and the most important behaviour —
the correlation fix in the preceding commit — had no test of its own.

Added:

- **Dimensionless-magnet regression.** The defect the suite found was only
  caught incidentally, by an unrelated test's expectation shifting. Now
  explicit: ingest an alert whose labels yield no dimension (on a default
  install `service` maps to no semantic group, `k8s_namespace_name` does), then
  ingest an unrelated labelled alert and assert they do NOT merge. Verified it
  actually catches the bug by reverting the guard, rebuilding, and confirming
  this test fails — a regression test that passes against the broken code
  would have been worthless.
- **Severity mapping** parameterised across critical/P1/error/warning/info,
  plus the unrecognised-value fallback. Previously only critical -> P1.
- **external_url and annotations round-trip.** These columns exist so an
  external alert can render without a row in `alerts`; nothing checked they
  came back.
- **Refire after resolve** opens a new incident rather than reopening the
  closed one. Previously reasoned about, never executed.
- **Microsecond timestamp is honoured** — the rejection cases were covered,
  the accepted case was not.
- **`_o2_external_source` does not leak into group_values.** Had it, every
  alert from one source would correlate together regardless of identity.
- **alert_count advances** on repeat firings.

Strengthened: the external_url acceptance test asserted only that an incident
came back; it now checks the URL round-trips, and covers uppercase scheme.

Documented gap, deliberately not faked: external + native alert correlating
into one incident is the feature's headline claim and is still uncovered. It
needs a native alert whose evaluated row carries matching dimensions — a
stream, ingested data, an alert definition and a scheduler tick. The
manual-trigger shortcut does not work, since its synthetic row carries none of
the identity labels correlation groups on.
@Shrinath-O2 Shrinath-O2 modified the milestones: v0.92.0, v0.93.0 Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

e2e This label decides if the playwright runs trigger on a PR or not. DO NOT DELETE or edit this label.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants