diff --git a/.agents/engineering-guidelines.md b/.agents/engineering-guidelines.md
new file mode 100644
index 0000000000..0fd84f8dd3
--- /dev/null
+++ b/.agents/engineering-guidelines.md
@@ -0,0 +1,231 @@
+# How to build things here
+
+These rules apply in every chat and every repo. They are not a style preference. They exist because
+an agent can write thousands of lines in an hour, some of them invented, and a human then has to
+review all of it. Every rule below either cuts invention or makes the code easier to review.
+
+**Precedence.** A repo's own instruction file (`AGENTS.md` / `CLAUDE.md`) wins on everything
+mechanical: formatting, item order, comment rules, lint thresholds, schema rules, PR conventions.
+This file governs judgment: what to build, how much of it, and how to prove it works. If the two
+conflict, follow the repo file and say so in your report.
+
+The definitions and tests apply to every language in the repo. The examples in §2 and §3 are written
+for the Rust backend; a frontend list belongs next to them when the frontend owners write one.
+
+## 0. Pick the tier first
+
+Every task is one of three sizes. Say which one you are in before touching code; it decides how
+much of the rest applies.
+
+| Tier | What it is | Spec gate | Tests | Review |
+|---|---|---|---|---|
+| **Patch** | One cause, one place. No new public API, table, flag or dependency. Typically under ~50 changed lines. | None. One sentence: cause → fix. | One failing test that reproduces the cause, then green. | Self-review against the three tests in §2, then the gates. |
+| **Change** | New behaviour inside the existing architecture: a new branch of logic, a new field, a new error path. | Numbered rules and open questions (§5.2), a few lines each. | One per rule. | Full loop (§5). |
+| **Feature** | A new component, endpoint, table, background job or config flag. | Design doc first, per the repo's PR rules; the numbered rules derive from it. | One per rule, plus a failure-path test for every `[strong]` / `[weak]` function. | Full loop, security depth where §5 says so. |
+
+If a Patch outgrows its definition while you work, stop, say so, and re-enter as a Change. Do not
+keep going.
+
+## 1. Never invent facts
+
+- **A claim about existing code needs a `path:line` you opened in this session.** A function,
+ table, config flag, env var or endpoint you have not opened does not exist yet. Search for it
+ before you use it. This applies to specs, reviews and reports, not to every sentence in chat.
+- **A claim about an outside service** (Twilio, Slack, a cloud API) needs a link to the vendor's
+ docs. "Twilio probably returns X" is not allowed.
+- **"I don't know" is a correct answer.** Write it down as an open question. Do not fill the gap
+ with something that sounds right.
+- **If the spec does not decide something, stop and ask.** An invented decision is how a feature
+ turns into an endless fix loop. Anything new you discover while building becomes a new numbered
+ rule, and the human decides whether it is in scope.
+
+## 2. The three things we refuse
+
+Each one has a definition, a test you can apply mechanically, and examples. The cold reviewer (§5)
+applies the same three tests, so there is one vocabulary for writer and reviewer.
+
+### 2.1 Hacks
+
+**Definition.** Code that makes a symptom disappear while the underlying statement about the system
+stays false.
+
+**Test.** Delete the new code: is the thing it "fixed" still wrong, for the same reason? Then it was
+a hack. Second test: does the fix only work because of a value, name, order or timing that is not
+part of any contract?
+
+**Examples, all banned:**
+
+- A branch keyed on one input, test name, org id or environment so that one case passes.
+- Turning an error into success where the caller needed to know: `.ok()`, `.unwrap_or_default()`,
+ `let _ = …`, `if let Ok(x)` with no `else`, a handler that logs at debug and continues.
+- A sleep, retry or re-ordering that makes a race rarer instead of removing it.
+- Widening a type (`String`, `serde_json::Value`, `i64` for everything) so the compiler stops
+ complaining, instead of modelling the value.
+- `#[allow(clippy::…)]`, `#[ignore]`, a weakened assertion, or a raised lint threshold to get green.
+- A flag that defaults the new behaviour off so the bug stops reproducing.
+- A "temporary" anything with no issue number and no removal condition.
+
+**Not a hack:** a fallback the spec names and that is visible when it fires (a `warn!` with context,
+a metric, an error field in the response). A default is a decision; a silent default is a hack.
+
+**When the real cause is out of scope:** stop and report it. Do not patch the symptom to finish the
+task.
+
+### 2.2 Over-engineering
+
+**Definition.** Structure that exists for a future the spec does not contain.
+
+**Test.** Name the second concrete user that exists today, or that the spec names. If you cannot,
+inline it.
+
+**Examples, all banned until the second user exists:**
+
+- A trait with one impl; a generic with one instantiation; a type parameter "for testing".
+- A config option, env var or feature flag nobody asked for ("in case someone wants to tune it").
+- A newtype or wrapper that enforces no invariant.
+- Layers that only forward: `handler → service → manager → repository` with logic in one of them.
+- A builder for a struct with three fields; a registry or plugin point with one entry.
+- An error enum with variants nothing matches on; one variant per call site when every caller
+ just logs.
+- A new module or file for one function.
+
+**Counts as a second user:** another call site in the same diff, another provider the spec names,
+or an existing trait that every implementation in the repo already follows. **Does not count:** "the
+UI will need it", "we may add Postgres", or a test that is the only other caller.
+
+### 2.3 Over-complication
+
+**Definition.** Control flow or state a reviewer cannot predict from the signature and the first
+screen of the body. Over-engineering is too much structure around the code; over-complication is
+too much happening inside one piece of it.
+
+**Test.** Cover everything below the first screen of the function. Can you say what it returns for
+each kind of input? If not, split it.
+
+**Examples, each needs a split or a name:**
+
+- A `bool` parameter that switches behaviour (`send(msg, true, false)`): two functions, or an enum.
+- More than ~5 parameters: a named struct.
+- Nesting deeper than three: an early return or a helper.
+- `Option>`, `Result >>`, `(bool, bool, Option)` as a return
+ type: a named enum.
+- An iterator chain whose closures contain branches: a loop.
+- A function that is only correct if another one ran first: return the value and pass it in, or
+ make the ordering a type.
+- `Arc>` to share what could be passed by value; `async` or `spawn` for work that is not
+ I/O; a macro where a function works.
+- A `&str` or `i32` that is really an enum (`status == "running"`).
+- A mutable local that is reassigned across more than one screen.
+
+**Default when unsure:** the version with fewer names and fewer branches wins. If two versions both
+meet the rules, ship the shorter diff.
+
+## 3. Build in logical components with predictable behaviour
+
+Split each Change or Feature into **logical components**. A logical component is one unit with one
+job, such as "decide who to page", "render the SMS text" or "send through the provider".
+
+- **One line, no exceptions.** You must be able to explain each component in one plain sentence
+ that has no "except when". If you cannot, it is two components.
+- **Same input, same output.** Keep the decision logic pure: no I/O, and the clock (`now: i64`),
+ randomness and config are passed in as arguments. Side effects (database, network, queue) live in
+ a thin outer layer that only carries out what the pure core decided. In practice: the core takes
+ the config *values* it needs; the outer layer is the one that reads the global config.
+- **Deep is fine, wide is not.** A function may run many steps one after another. It should not
+ fan out into many branches. Prefer an exhaustive `match` on a project enum (no catch-all `_`
+ hiding a future variant; `_` is only for `#[non_exhaustive]` foreign enums), early returns, and
+ lookup tables over chains of `if/else`. If a decision has more than about four outcomes, give the
+ outcome a name: make it an enum.
+- **Write the guarantee in the type wherever the type can say it.** Adapted from Audacity's
+ safety guarantees (https://audacity.gitbook.io/dev/getting-started/coding-standards#safety_guarantees_defined):
+ - *pure*: the signature says it. Takes values or `&`, returns a value, not `async`, touches no
+ global. No tag needed.
+ - *no-fail*: returns `T`, not `Result`. Any `unwrap`, `expect`, index or unchecked arithmetic
+ inside needs a one-line comment naming the invariant that makes failure impossible.
+ - `[strong]`, tagged at the end of the doc comment: either it fully succeeds, or it fails and
+ nothing changed. For example, one database transaction.
+ - `[weak: ]`, tagged: a failure can leave partial effects, but
+ everything stays consistent. Name what is left behind and what makes a retry safe. For example:
+ `[weak: the ledger row may exist without a send; a retry is a no-op]`.
+
+ Only the outer I/O layer carries `[strong]` / `[weak]`. A tag is a promise: each one needs a test
+ of its failure path.
+
+## 4. TDD, without the bloat
+
+- **Test first, and see it fail.** A test written after the code, or one you never saw fail,
+ proves nothing. Run the narrowest target that contains it red, then make it green.
+ Exempt: a pure refactor (behaviour unchanged, the existing tests are the net and you do not edit
+ them) and a pure deletion.
+- **One test per numbered rule or per real branch.** Use table-driven tests (inputs → expected
+ outputs) for pure components. Do not test the framework, getters or private helpers on their
+ own. Do not repeat an assertion that another test already makes.
+- **Pass values, do not mock.** If a function needs a mock to be tested, its I/O is in the wrong
+ layer (§3). Mocks are for the thin outer layer only, and only when no real local dependency is
+ available.
+- **Never change a test's expectation to get green** unless the PR states why the old expectation
+ was wrong.
+- **Done = every numbered rule has a passing test and the repo's gates pass.** "It looks
+ complete" is not done.
+- **Size each diff by rule, not by subsystem.** One rule (or a few small ones), its test and its
+ code, small enough for a person to review in one sitting.
+
+## 5. The working loop
+
+In repos that have the `o2-loop` skill (openobserve, o2-enterprise), the skill *is* steps 3–6
+below: it owns the round cap and the findings ledger. Elsewhere, run the steps by hand.
+
+1. **The main chat is the monitor.** It holds the spec, the rule list and the progress. It
+ delegates the work and does not do bulk reading or writing itself.
+2. **Spec gate** (Change and Feature only). Before any code, write the numbered rules, the
+ components with their one-line jobs and guarantee tags, and the open questions. Ask the human
+ about every open question.
+3. **Tests.** A subagent writes the tests for the next rule-sized slice. Before it reports, it
+ checks them once against this list: does each test map to a rule, did it fail first, is any
+ test a duplicate, is anything invented.
+4. **Cold review.** A fresh reviewer (one that did not write the code) gets only the spec path
+ and the diff, not the conversation, and applies the three tests in §2. Each finding must give
+ `path:line` and a concrete failure: "input X gives wrong output Y". A finding with no concrete
+ failure is dropped.
+5. **Confirm each finding before acting on it.** Read the cited lines, or run a test that shows
+ the failure. Reviewers make things up too. Fix only the findings you confirmed, and record the
+ ones you rejected along with the reason.
+6. **Repeat step 4** until a round produces no confirmed finding. If the round cap is reached and
+ real problems remain, the spec is the problem: go back to the human.
+7. **Run the repo's gates (fmt, clippy, tests) before every review round** so reviewers do not
+ spend tokens on what a tool would catch.
+
+**Review depth follows risk.** Pure logic with good table tests needs one cold round. Full depth
+for anything that touches: unauthenticated endpoints, authorization, signature verification,
+secrets, money, data deletion, migrations, anything that pages a human.
+
+**Stop and ask the human when:**
+
+- the spec does not decide something (§1);
+- the only green path you can find is a hack (§2.1);
+- the fix needs to touch files or layers outside the stated scope;
+- an abstraction needs a second user and the spec has none (§2.2);
+- a test can only pass by changing its expectation;
+- the gates fail for a reason that predates your change.
+
+**Keeping the token cost down.** Give a subagent file paths and a narrow question, not pasted
+contents, and ask for conclusions with citations, capped in length. Use read-only search agents
+for searching. Continue an existing agent instead of starting one that has to re-read everything.
+Do not re-read files you already read, and do not re-derive facts you already settled.
+
+## 6. UI
+
+- Follow the repo's UI rules. In openobserve that is the `ui-architect` skill: O2 components,
+ rem not px, design tokens. Mock the flow on the real product UI before building it.
+- **Zero-config for 90% of users.** Defaults that work without any setup, controls whose meaning
+ is obvious from the screen, and no step that needs the docs. Advanced options stay out of the way
+ until someone needs them.
+
+## 7. How to write for the human
+
+- Write for a reader who has not followed the conversation. Define each term the first time you
+ use it, use plain words, and make the conclusion the first thing they read.
+- When agents reach a decision, present it as: what we will build, how it behaves for the user,
+ what we decided not to do, and what still needs a human answer.
+- A PR description states the tier (§0), lists the numbered rules with the test that covers each,
+ and lists the rejected review findings with their reasons.
diff --git a/.claude/agents/o2-coder.md b/.claude/agents/o2-coder.md
index 2fe33b1f4b..ceafcc734d 100644
--- a/.claude/agents/o2-coder.md
+++ b/.claude/agents/o2-coder.md
@@ -10,7 +10,7 @@ When the brief names a checkout path, every file you read or edit and every comm
## Ground rules
-- Follow the repo's `CLAUDE.md` exactly: item ordering, one-line comments, tests module last, clippy thresholds.
+- Follow the repo's `AGENTS.md` exactly: item ordering, one-line comments, tests module last, clippy thresholds.
- Never run `git commit`, `git add`, `git reset`, `git stash`, or `git push`. The orchestrator's script freezes your working tree as a WIP commit; you only edit files.
- Never spawn agents or split your work; if the spec needs more than one coder, that is the orchestrator's decision, and you say so in a `question:` line.
- Never write to the ledger except the files named below, and never edit `verdict.json`.
@@ -27,7 +27,7 @@ When the brief names a checkout path, every file you read or edit and every comm
cargo fmt --all
cargo clippy --workspace --all-targets -- -W clippy::too_many_lines -W clippy::cognitive_complexity -W clippy::excessive_nesting -D warnings
```
- If the change touches `#[cfg(feature = "enterprise")]` code, follow `CLAUDE.md`'s enterprise verification rule or say in the evidence that it was not checked.
+ If the change touches `#[cfg(feature = "enterprise")]` code, follow `AGENTS.md`'s enterprise verification rule or say in the evidence that it was not checked.
4. Write `/round-1/evidence.md`: the exact commands, pass or fail, test names, tails of any failure output, and anything skipped.
5. Reply to the orchestrator with: files changed, gates run, anything you deviated from in the spec and why.
diff --git a/.claude/skills/o2-loop/prompts/backend-codex.md b/.claude/skills/o2-loop/prompts/backend-codex.md
index 10adb4e17d..615d24c4a6 100644
--- a/.claude/skills/o2-loop/prompts/backend-codex.md
+++ b/.claude/skills/o2-loop/prompts/backend-codex.md
@@ -1,2 +1,2 @@
## Notes for the Codex reviewer process
-You run in Codex's read-only sandbox inside a disposable checkout of the commit under review. There is no `AGENTS.md` in this repository; `CLAUDE.md` at the checkout root is the rules file, so do not search for anything else. The ledger paths given below are absolute and readable; `rg` is not installed, use `grep`, `cat`, and `git`. Do not run cargo; the evidence section already carries the build and test results.
+You run in Codex's read-only sandbox inside a disposable checkout of the commit under review. There is no `CLAUDE.md` in this repository; `AGENTS.md` at the checkout root is the rules file, so do not search for anything else. The ledger paths given below are absolute and readable; `rg` is not installed, use `grep`, `cat`, and `git`. Do not run cargo; the evidence section already carries the build and test results.
diff --git a/.claude/skills/o2-loop/prompts/review.md b/.claude/skills/o2-loop/prompts/review.md
index 1950fba27f..926ce47143 100644
--- a/.claude/skills/o2-loop/prompts/review.md
+++ b/.claude/skills/o2-loop/prompts/review.md
@@ -1,8 +1,9 @@
You are the second reviewer in a two-AI loop: the coder (a Claude agent) wrote this change, you (an independent reviewer process) review it, the coder fixes, you verify. Your job is to find real defects that a careful senior Rust engineer would block a PR on. Be precise, not exhaustive.
## Before you look at the diff
-1. Read `CLAUDE.md` at the repo root. Its rules on item ordering, comments, and clippy thresholds are hard requirements for this repo.
-2. Read the evidence file listed below. It contains the build, clippy, and test results the coder already ran. Do not re-run cargo; the sandbox is read-only.
+1. Read `AGENTS.md` at the repo root. Its rules on item ordering, comments, and clippy thresholds are hard requirements for this repo.
+2. Read §2 of `.agents/engineering-guidelines.md`: the definitions and tests for a hack, over-engineering and over-complication. Apply those tests, not your own taste, when you report one.
+3. Read the evidence file listed below. It contains the build, clippy, and test results the coder already ran. Do not re-run cargo; the sandbox is read-only.
## What to review
Only the change set described in the "Change set" section. Read surrounding code as needed to judge it, but do not report pre-existing problems outside the diff unless the diff makes them worse.
@@ -14,7 +15,8 @@ Look for, in priority order:
- Error handling: swallowed errors, `unwrap` on fallible paths, wrong status codes, silent truncation of results.
- API and compatibility contracts: wire-format changes, enterprise `cfg` gates, behaviour differences between OSS and enterprise builds.
- Performance regressions on hot paths: extra allocations or clones in loops, O(n²), needless full scans, metrics with high-cardinality labels.
-- Repo conventions from `CLAUDE.md`: multi-line or narrating comments, functions placed above constants or types, tests module not last, `pub` and private functions interleaved.
+- Hacks, over-engineering and over-complication as `.agents/engineering-guidelines.md` §2 defines them: a symptom patch, an abstraction with one user, a function whose result cannot be predicted from its first screen.
+- Repo conventions from `AGENTS.md`: multi-line or narrating comments, functions placed above constants or types, tests module not last, `pub` and private functions interleaved.
- Missing tests for new branches, or tests that cannot fail.
Do not report: formatting, naming taste, anything `cargo fmt` or `cargo clippy` already enforces, or speculative issues you cannot point to a concrete line for.
diff --git a/.claude/skills/o2-loop/prompts/verify.md b/.claude/skills/o2-loop/prompts/verify.md
index d5016fc6bd..4c808a69be 100644
--- a/.claude/skills/o2-loop/prompts/verify.md
+++ b/.claude/skills/o2-loop/prompts/verify.md
@@ -1,7 +1,8 @@
You are the second reviewer in a two-AI loop: the coder (a Claude agent) wrote this change, an independent reviewer process (possibly you) reviewed it in a previous round, the coder responded, and now you verify. You have no memory of the previous round; everything you need is in the files listed below.
## Before you look at the code
-1. Read `CLAUDE.md` at the repo root. Its rules are hard requirements for this repo.
+1. Read `AGENTS.md` at the repo root. Its rules are hard requirements for this repo.
+ Apply §2 of `.agents/engineering-guidelines.md` (hack, over-engineering, over-complication) by its tests, not by taste.
2. Read the evidence file for this round (build, clippy, and test results the coder ran after fixing). Do not re-run cargo; the sandbox is read-only.
3. Read every earlier round's findings and the coder's per-finding responses, all listed below in order. A finding's severity, location, and defect description live in the round that first reported it; later rounds only carry its id and status.
@@ -14,7 +15,7 @@ A prior finding is any id from any earlier round whose latest status is not `res
Do not repeat a prior finding in `findings`; its status lives in `prior_findings`.
## Step 2: review what changed since the previous round
-The delta between the previous round's commit and this round's commit is listed below. Review only that delta for new defects, using the same priorities as a first-round review: correctness, concurrency, security, error handling, API contracts, performance, `CLAUDE.md` conventions, missing tests. New findings get fresh ids continuing after the highest id used in any earlier round.
+The delta between the previous round's commit and this round's commit is listed below. Review only that delta for new defects, using the same priorities as a first-round review: correctness, concurrency, security, error handling, API contracts, performance, `AGENTS.md` conventions, missing tests. New findings get fresh ids continuing after the highest id used in any earlier round.
## Output rules
- `verdict` is `approve` only if no prior finding is `still_open` with an original severity of critical, high, or medium, and no new finding is critical, high, or medium.
diff --git a/.gitignore b/.gitignore
index 9c69de02e0..25a38a8e65 100644
--- a/.gitignore
+++ b/.gitignore
@@ -68,6 +68,7 @@ tests/ui-testing/playwright-tests/utils/auth/user.json
# ---------- other agents ----------
/.agents/*
+!/.agents/engineering-guidelines.md
alert_mock
# Local verification artifacts (browser screenshots, per-crate scratch DBs)
diff --git a/AGENTS.md b/AGENTS.md
index 5c2f7db9cb..919077ff86 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,5 +1,12 @@
# OpenObserve — project rules
+## Engineering guidelines
+
+- [.agents/engineering-guidelines.md](.agents/engineering-guidelines.md) governs judgment: task
+ tiers, what counts as a hack / over-engineering / over-complication, TDD and the review loop.
+ Read it before any change bigger than a one-cause patch; reviewers apply its §2 tests. On
+ anything mechanical (ordering, comments, lint thresholds, schema, PR rules) this file wins.
+
## Build & workflow
- Build with `cargo build` (debug). Never use `--release` unless explicitly asked.
diff --git a/Cargo.toml b/Cargo.toml
index f45cea1a6a..015d785250 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -51,8 +51,14 @@ cloud = [
]
mimalloc = ["dep:mimalloc"]
jemalloc = ["dep:tikv-jemallocator"]
+cpu-profiling = [
+ "openobserve-api-http/cpu-profiling",
+ "openobserve-jobs/cpu-profiling",
+ "openobserve-core/cpu-profiling",
+]
profiling = [
"jemalloc",
+ "cpu-profiling",
"openobserve-api-http/profiling",
"openobserve-jobs/profiling",
"openobserve-core/profiling",
diff --git a/deploy/build/Dockerfile.profiling.amd64 b/deploy/build/Dockerfile.profiling.amd64
index efc262062d..b7c91e0fa6 100644
--- a/deploy/build/Dockerfile.profiling.amd64
+++ b/deploy/build/Dockerfile.profiling.amd64
@@ -23,7 +23,7 @@ COPY . /openobserve
COPY --from=webbuilder /web/dist web/dist
RUN mkdir -p /openobserve/target/release/
-RUN --mount=type=cache,target=/root/.cache/sccache cargo build --profile release-profiling --features mimalloc,profiling --target x86_64-unknown-linux-gnu \
+RUN --mount=type=cache,target=/root/.cache/sccache cargo build --profile release-profiling --features mimalloc,cpu-profiling --target x86_64-unknown-linux-gnu \
&& sccache --show-stats
RUN mv /openobserve/target/x86_64-unknown-linux-gnu/release-profiling/openobserve /openobserve/target/release/openobserve
diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json
index e872946d4e..ec18762b32 100644
--- a/scripts/translations/.translation_state.json
+++ b/scripts/translations/.translation_state.json
@@ -2361,8 +2361,8 @@
"actions": {
"attachDestination": "e4ee81538188171d",
"attachDestinationDesc": "79999248115223c2",
- "enableDeduplication": "23b7113a61773230",
- "enableDeduplicationDesc": "51b6f906f1e9112d",
+ "enableDeduplication": "58597d12e244abbb",
+ "enableDeduplicationDesc": "312e4a7af5d06ccf",
"fixAlertQuery": "d5402a926641e540",
"fixAlertQueryDesc": "dfb99a99051ee69b",
"lengthenEvaluationWindow": "a77df21ce70c24c1",
@@ -3337,6 +3337,8 @@
"refreshDestinations": "30bb2318f36e608d",
"sectionTitle": "74a883a037bc227f",
"sendsToStatus": "df6e0cc2cad0b7b6",
+ "suppressRepeats": "fb0d6c1b34ed1c65",
+ "suppressRepeatsTooltip": "cfaddd8a61012886",
"targetsDestinationsGroup": "72eb63f032e47e34",
"targetsWorkflowsGroup": "e2073645cb0dbbdd",
"timezoneWarning": "6baa6a168e65198c"
@@ -3654,27 +3656,6 @@
"confirmDeleteDestinations": "9acb690d79a0b4e5",
"confirmDeleteTemplates": "e7e356f0b59c474c",
"continue": "31fbef162594de01",
- "correlation": {
- "cancelButton": "19766ed6ccb2f4a3",
- "defaultWindow": "75ae7c854a023384",
- "defaultWindowDescription": "10dca533b41a0e87",
- "defaultWindowPlaceholder": "be8da1a40343815a",
- "defaultWindowTooltip": "10dca533b41a0e87",
- "description": "3e56d5465d4efdbe",
- "enableCrossAlert": "57396cfdc96e6c94",
- "enableCrossAlertTooltip": "9c3aaf4cc07f9aee",
- "enableOrgLevel": "56334c505798bed6",
- "enableOrgLevelTooltip": "7227130ee5c954f6",
- "fingerprintGroups": "4784ece2c2a26f88",
- "fingerprintGroupsHint": "eb3787c49ca121d4",
- "fingerprintGroupsRequired": "a9ffea79feeeeef4",
- "fingerprintGroupsTooltip": "ad5c93ed4ffc68fe",
- "saveButton": "1509f561f2416598",
- "semanticFieldNote": "746705420935a034",
- "settingsSaveError": "4924972f958737c6",
- "settingsSaved": "51b7016346687749",
- "title": "eb8f2d364eed82fa"
- },
"createdAt": "3d44337010bfbdf7",
"createdBy": "dfd60b9aa5ca29f4",
"criticalIf": "d51f1d573fc13e14",
@@ -3682,17 +3663,6 @@
"customDestination": "595795c3f2d32edd",
"customDestinationDescription": "5966438ae41e979b",
"customSql": "874d485cbc30fc67",
- "dedup": "401f0a26aa07419c",
- "deduplication": {
- "fingerprintFields": "971a78dedba8e98c",
- "fingerprintFieldsHelp": "3cae384fc3fc416f",
- "fingerprintFieldsHint": "f4b085fa4f07283b",
- "fingerprintFieldsTooltip": "e481b9fe6b452cdd",
- "timeWindow": "671058294d73de1e",
- "timeWindowHint": "01d49f5a8b894cd6",
- "timeWindowTooltip": "04b798d6e4136819"
- },
- "deduplicationConfiguredTooltip": "ac43a07ea8e2bd56",
"delete": "e2d0a54968ead24e",
"deleteAlertMessage": "b5bd997057b8e7db",
"deleteAlertTitle": "73d4e7aa312ba1ac",
@@ -5030,8 +5000,6 @@
"actions": {
"actionsFor": "c9cb1c707b38a9b8",
"closeActions": "821790b7ba7639e1",
- "configureDedup": "f352a1749543adbd",
- "configureDedupTooltip": "160ba9c7a3b6ae2f",
"editAlert": "55d309d6da250466",
"editAlertTooltip": "b2960b39285cbde9",
"viewAlertHistory": "0e26841319ab8a3b",
@@ -5134,9 +5102,6 @@
"noData": "af75117dfbb0edb7",
"noTemplatesMsg": "e9e871d4bebbbe00",
"noTriggersLoaded": "980bb294c56dd9e3",
- "notificationSent": "02e87bf90c4c4431",
- "occurrence": "1b12a9d74483571f",
- "occurrencesDeduplicated": "46d74043371765f4",
"oncallTeam": "b250e6b0b87af388",
"oncallTeamAtFireTime": "80a154924b2e1c49",
"oncallTeamFromRules": "6ca74cdb253f8fc9",
@@ -5148,14 +5113,10 @@
"outputMessages": "ace70a9ce0d04504",
"outputMessagesLabel": "ace70a9ce0d04504",
"owner": "4b1b8aa3608a26da",
- "passedLabel": "11abdb02ce12dc7e",
"pause": "858e4ba7a29fd38b",
- "pendingBatches": "5a61ad2dc761ebb3",
- "pendingBatchesTooltip": "1851046da4cddf4a",
"period": "b885b560b9174430",
"placeholders": {
"addTag": "221f0b0cd0dd4842",
- "autoUsesCheckInterval": "580e20ef34637b15",
"savedFunctions": "e26ec955b2ba4407",
"selectColumn": "91db086469f6b553",
"typeSomething": "680ec866a90e116c",
@@ -5514,13 +5475,11 @@
"alertSettings": "507c5a3eed5763d3",
"alertSetup": "cce2cf121808b3fc",
"compareWithPast": "2b534069633d462f",
- "conditions": "6303136617bd2f68",
- "deduplication": "b1136c8463de1038"
+ "conditions": "6303136617bd2f68"
},
"steps": {
"advanced": "9f088dbebd6c3c70",
- "compareWithPast": "f72a7d32906c557c",
- "deduplication": "68c30f88becadc79"
+ "compareWithPast": "f72a7d32906c557c"
},
"streamConfig": "50009d375dd72fc0",
"streamName": "4a25bb3cfd6fd66c",
@@ -5588,8 +5547,8 @@
"summaryRecent": "5b047e7e0aea16e4",
"summaryTotal": "c9b3c38247f744e1",
"suppressed": "feb3abdafeb72a33",
- "suppressedByDeduplication": "222f34152e79262d",
- "suppressionRate24h": "84e7bccc111a1c19",
+ "suppressedAsRepeat": "e37596eb0cf6120b",
+ "suppression": "858c847b742b96bd",
"tags": "1331275bc537b4c2",
"tagsTooltip": "a56e372b90037999",
"template": "84de89c26b0ff73f",
@@ -5611,15 +5570,12 @@
"timestamp": "115a2cc92c1097ac",
"timezoneRequiredLabel": "109c6d257c45352e",
"title": "7e8cd2056da73a7f",
- "total": "11239872d178729a",
- "totalAlerts": "43ac1c2b6422ca65",
"trainingWindow": "9480a67efe24c0ee",
"triggerAlert": "8b9c643731c94119",
"triggerDetection": "ad2fdbfe066f7a7e",
"type": "baaddf70fb5d432b",
"unknownHttpStatus": "b764cdc0eab71374",
"useCurlyBracesNote": "40531e1b24e2da8b",
- "usingDeduplication": "b9b769b6e61b4efa",
"validation": {
"aggregationColumnRequired": "c2a4688b43f9b7f7",
"aggregationFunctionRequired": "4941ce4b9d80bcd5",
@@ -6225,7 +6181,7 @@
"AddLibraryAlert": "babfb4ab4d1ddb7a",
"AttachDestination": "3c4146ec72d1b65a",
"CreateDetector": "f92e9e088ba7d13a",
- "EnableDeduplication": "1f9ce350c9773ecf",
+ "EnableDeduplication": "eba2293ef985f5c3",
"EvictLeakedConnections": "4d8d53793e2ad3b6",
"FixAlertQuery": "39f58fd1741f97bd",
"LengthenEvaluationWindow": "651154e9e09287fd",
@@ -6243,7 +6199,7 @@
},
"connected": "22965568d22a14ee",
"defaultsNote": "e87dd6ae47bb0736",
- "gateHint": "fd019c81f3b1f9e7",
+ "gateHint": "186791c4a234cc95",
"groupCalibration": "2cc6a26ef5e2484f",
"groupCoverage": "879ed1aa0d5a6ce9",
"groupCoverageHint": "6c192c1211614195",
@@ -6385,11 +6341,11 @@
"why": "7d27ee7ad327d185"
},
"L-302": {
- "done": "c6a795b938e2583f",
- "from": "154366e9201d07e3",
+ "done": "6d93312b7188de0d",
+ "from": "62bf37b09b005aee",
"on": "294f3ee187954fa2",
- "to": "6b86b273ff34fce1",
- "why": "739fb6ef2a71c28e"
+ "to": "6979e49983feda16",
+ "why": "c5fea437c2f5558f"
},
"L-303": {
"done": "752f6289efad802d",
@@ -6606,7 +6562,7 @@
"foundWatch": "9251b3f34a2e81d8",
"fridaySummary": "62905f1ed1b16b86",
"gatewayPaged": "58a7e729458969c0",
- "latencyGrouped": "9a1ebc4193819ad7",
+ "latencyGrouped": "a81dff48ae005919",
"loginPaged": "d8e4ed65191a0363",
"newWatch": "f21fc04ec0805552",
"poolApproved": "8ff3647e9840cc9c",
@@ -8261,6 +8217,7 @@
"critical": "427dd2969bd140be",
"degraded": "a8494c12f2243903",
"down": "b86d11af79188e58",
+ "error": "54a0e8c17ebb21a1",
"failed": "031a8f0f659df890",
"healthy": "7f1e323b6272c620",
"offline": "a1794783aab72d20",
@@ -8944,6 +8901,9 @@
"bits": "6b4ef7a4f34f2685",
"bitsPerSecond": "f3b0dd77d1d3149a",
"bottom": "479ad21ac9096236",
+ "boxplotWhisker": "ace8ebbdaba90aaa",
+ "boxplotWhiskerIqr": "3217336eec297a44",
+ "boxplotWhiskerMinMax": "a9636b513c88e43b",
"browserDeniedFileDownload": "c29bc3d4a1ceed2b",
"bucketUnit": "9c255386ebf5b06b",
"bucketUnitCustom": "a7ea7b936ab593f2",
@@ -8966,12 +8926,14 @@
"chartSelection": {
"alertList": "e4e74afbcd38eab5",
"barGauge": "9e949a5820332fca",
+ "boxplot": "e4d59eb4ea943661",
"customChart": "5502642e42fe9de9",
"distribution": "72a23e549d099bbd",
"flameGraph": "d776d0b395c67943",
"funnel": "fc063af055a3a9f3",
"gauge": "f6d8f4c86cebb44b",
"html": "07239dbd2a1a1dd7",
+ "logs": "ea2100dc89ae9fe2",
"markdown": "0e52f6b9d02515c5",
"nodeGraph": "4d9a159f99ec9f79",
"sankey": "362c638c72a060cd",
@@ -9544,6 +9506,7 @@
"hours": "21e8492938abc179",
"hstackedLabel": "0bf475d412d632f3",
"htmlPanelEmbedBlocked": "34424226c6a45711",
+ "htmlPanelFieldTooltip": "94c0048448e73412",
"hundredPlaceholder": "ad57366865126e55",
"id": "12156e921bdb6085",
"identifier": "9b10587f84a2c374",
@@ -9675,6 +9638,16 @@
"lineThicknessRange": "ab9f0dadabe42cc2",
"linear": "e6950b45d25c2027",
"localeFormat": "b8017eeb489109d9",
+ "logsLevelField": "67352c54cca99a32",
+ "logsMessageField": "8a0e4ee365cd7b37",
+ "logsOrder": "6be090825c997103",
+ "logsOrderNewest": "ffb6f5764bddb68c",
+ "logsOrderOldest": "6e2ebdab3c02a3e6",
+ "logsOrderTooltip": "c60ccd9e4b72b11c",
+ "logsPrettifyJson": "c96fbb7e82c0c6e3",
+ "logsShowTime": "c53847ca2389f46e",
+ "logsTimeField": "4e1ee7767412cb49",
+ "logsWrap": "6727132ac799a256",
"longitudeLabel": "6d80458fd17d33fc",
"mapCluster": "158f734a97e87cd5",
"mapClusterRadius": "5b37352fbfb9f34c",
@@ -10100,6 +10073,7 @@
"dashboardDeletedAction": "9af86340789ed912",
"dashboardMissing": "5a625c76e6145acc",
"defaultNotAvailable": "f100490863c82d99",
+ "disabledPaused": "4a436bdcf4fbf5d4",
"editAccessRequired": "085cd7d51f0b9062",
"editForbidden": "3a3a55d6afcabfbe",
"editNote": "604f60afa00cb59c",
@@ -10122,6 +10096,7 @@
"newLink": "39890d5b20848a6b",
"openDashboard": "803f2313cdf44275",
"openPublicPage": "193b919d4957430f",
+ "orgDisabledBanner": "b3c3f4ddc639449b",
"orgDisabledHint": "76b67d3fe9918c0b",
"pageSubtitle": "69e8f1d6eb64b08b",
"pageTitle": "b4bee918655d4e11",
@@ -10670,6 +10645,7 @@
"utils": {
"addAtLeastOneFieldFor": "e6a070d000e6a750",
"addAtLeastOneFieldOnEither": "c26044cb61956d35",
+ "addAtMostOneFieldFor": "15c467b3dfde01d6",
"addExactlyOneFieldAreaStacked": "9307a7ecdb9a1807",
"addExactlyOneFieldOnAreaStacked": "5e54b757bd6c1193",
"addExactlyOneFieldStacked": "9728d7a031952d75",
@@ -10695,6 +10671,13 @@
"argMustBeValidNumber": "333ba2eddd983a49",
"argNumberNoValue": "1264a103288e24ce",
"argStringNoValue": "cd79d486c995bca9",
+ "boxplotLowerWhisker": "3415f15325daa2cb",
+ "boxplotMax": "a1a5936d3b0f8a69",
+ "boxplotMedian": "daab7c435134742a",
+ "boxplotMin": "dea79332147ffe1f",
+ "boxplotOutlier": "c1f25bd5c2732041",
+ "boxplotSamples": "bc228b269ce0d87f",
+ "boxplotUpperWhisker": "8d54fdd65dbb7ecf",
"configValueNotNegative": "1a5497bb6e9a2fff",
"configValueNotNumeric": "c92a74f449a4d907",
"configValueOutOfRange": "1a8f45a1d20d6cd4",
@@ -12304,6 +12287,12 @@
"description": "b928f13366a82214",
"title": "5cc7ab6259dc720f"
},
+ "noBlockedUsers": {
+ "action": "38398e7642a68580",
+ "actionDesc": "b83179b51b4348ea",
+ "description": "f37628054e521e56",
+ "title": "7ee90761f864fedc"
+ },
"noCipherKeys": {
"action": "eda5496b306f05bc",
"actionDesc": "1001dea858967963",
@@ -12354,6 +12343,16 @@
"description": "58946c6145c03a73",
"title": "29b0352f2dc58297"
},
+ "noDomainMappings": {
+ "action": "160cc87a108cd97f",
+ "actionDesc": "2d52feca2091f687",
+ "description": "8d2a1d3961a85569",
+ "title": "5e555e9dfc2f7305"
+ },
+ "noDomainRequests": {
+ "description": "eec9ff2e2ca9e75f",
+ "title": "c242dd5f6af4b624"
+ },
"noDowntimes": {
"action": "928a7246a60c12cf",
"actionDesc": "b39e9d84e595a81e",
@@ -12422,6 +12421,14 @@
"description": "f8d04da459e9beac",
"title": "dbeed0f9193cc3e6"
},
+ "noLinkedDomains": {
+ "description": "fa12abdd3599d33a",
+ "dns": "4bed48849e63af07",
+ "dnsDesc": "c377b7285abb1d9d",
+ "request": "66151f5b1821c8d6",
+ "requestDesc": "6752b021a2d01da5",
+ "title": "f806dd9c9c9fccc4"
+ },
"noLlmProviders": {
"action": "8cd1856b03dd6844",
"actionDesc": "b67cc2375086b515",
@@ -12695,10 +12702,13 @@
"anomalyConfigs": "3eec8e1c618c4e60",
"apiLimits": "277e1f454b2456f5",
"backfillJobs": "9e55cd82af3ec79c",
+ "blockedUsers": "baa1092cc5cce495",
"cipherKeys": "a4ec922695af251c",
"dashboards": "21b2e2095fd38f7d",
"datasetItems": "5f3c4f8580d392e4",
"discoveryItems": "1ee23025c9c0c5a9",
+ "domainMappings": "2820946d61f000b8",
+ "domainRequests": "2e76551da294e5e7",
"enrichmentTables": "2523e745479b9694",
"evalJobs": "6559acc79b8655cb",
"evalTemplates": "3d717d23df0a3510",
@@ -12708,6 +12718,7 @@
"incidents": "a6d1765ab64d82a7",
"ingestionTokens": "94150066b0bceb34",
"invitations": "33cd5e40bb5fc8a9",
+ "linkedDomains": "3478feb608d25877",
"llmProviders": "437dbe28117905aa",
"logs": "98f38f12db221a8c",
"modelPricing": "960087629fb9d135",
@@ -13754,13 +13765,34 @@
"all": "a52ace420f2175d0",
"allModulesTitle": "7bd30494297e0f94",
"bulkSelectColumn": "917098c49df7353d",
+ "changeCount": "36b588a4e74d7752",
+ "changeOtherGroup": "f97e9da0e3b879f0",
"childResource": "916cd7c979a0b3e2",
"clearFilter": "910bda6aed78b23e",
"collapseModuleGroup": "35044c06f245c5fe",
+ "companionAdd": "9fd728c66c9a256b",
+ "companionAddAll": "05401f46f7cada88",
+ "companionAddItem": "19daf8f79286b612",
+ "companionNeeds": "6818176c00700328",
+ "companionNeedsThis": "63a7087c9444e6e6",
+ "companionOpenStreams": "ef836aa4f8f14513",
+ "companionOpenStreamsTooltip": "b4fc0385e294d732",
+ "companionReasonAlertDestinations": "7919e3c99e13467e",
+ "companionReasonAlertStreams": "c9bd36f5d3a37867",
+ "companionReasonDashboardStreams": "1aa7a8e2230242b5",
+ "companionReasonDestinationTemplates": "0b434e9829465f1f",
+ "companionStillNeedsThis": "1aabb4e04dc120a6",
"dbmPresetNoMatch": "db8e3bdafce56e16",
"dbmPresetSeeded": "5248e9a5b6aace4f",
+ "effectCan": "653af35fcda08995",
+ "effectCanLoadDestinations": "bde2e69f02f75fde",
+ "effectCanLoadTemplates": "dec9cf07c30bd11a",
+ "effectCannot": "f6e679263cfda4e6",
+ "effectCannotLoadDestinations": "53a21e80ef59aaad",
+ "effectCannotLoadTemplates": "c79800a8598665dd",
+ "effectStillAllowed": "5f13d4761582023c",
"expandModuleGroup": "ed520c2c4b64e090",
- "filterModules": "ceee269a8c7fd7c6",
+ "filterModulesTasks": "ceee269a8c7fd7c6",
"forAllInstances": "6407662fdde3ff55",
"grantedByWiderScope": "0dce4fb6dbbe656b",
"grantedHereAndByWiderScope": "842472e92e47c946",
@@ -13795,28 +13827,77 @@
"moduleScopeAll": "a52ace420f2175d0",
"moduleScopeGranted": "57fd7a0cf33f2666",
"modulesTitle": "76c86c4c32432be3",
+ "neededBy": "ac5a7811408bde24",
"noGrantedModule": "fce6c29bb74e45f0",
"noMatchingResources": "0ff4417804c27000",
- "noModuleMatch": "ee7dcb6a716543e0",
+ "noModuleMatchHint": "76eaaeed65096525",
+ "noModuleMatchQuery": "7d2ec4c807d9baba",
"noUpdatesDetected": "5b015412d0730e6f",
+ "notAddedYetTitle": "eb7ab14d0c4a7368",
+ "noteDestinationsFiltered": "ab2187bc5bf81cca",
+ "noteTemplatesFiltered": "406e4b9e6af308cc",
"openFolder": "68b3f4bff6de90ae",
"permissionCountSingular": "051f06305d595a6b",
"permissions": "abccc78cc93c0793",
+ "presetAddButton": "914af4653212a900",
+ "presetAddsTooltip": "13dac67f1e227459",
+ "presetAlertManagerDescription": "2844c891cb1db276",
+ "presetAlertManagerTitle": "e86bbad1e2cc63ab",
+ "presetAlertViewerDescription": "73f45a2fd669376e",
+ "presetAlertViewerTitle": "649642cf0302439e",
+ "presetApplied": "430fbac3cb84d5e1",
+ "presetApplyNote": "bd39009c9fc74003",
+ "presetBack": "5d84c790bd898dd1",
+ "presetDashboardEditorDescription": "9136fcdd6f7915f2",
+ "presetDashboardEditorTitle": "906da4e8764c7d56",
+ "presetDashboardViewerDescription": "d317eb6722ac155d",
+ "presetDashboardViewerTitle": "4c576a48726aee1b",
"presetDbmDescription": "6fa3fc84efd23374",
"presetDbmTitle": "e2795b484db7c9dc",
+ "presetInRole": "9f31bb38d9b6e0d1",
+ "presetInRoleTooltip": "0dcd109291b0802a",
"presetK8sDescription": "1ab5869177620a3c",
"presetK8sTitle": "1348fce2efb5d899",
+ "presetLoadingStreams": "497dfcec1787bb0e",
+ "presetMissingItem": "18fcad9e28c393d3",
+ "presetModuleCount": "3e5b84b40963657c",
+ "presetNoMatchingStreams": "9e739c44f5232efc",
+ "presetNothingMissing": "f3a4df9f5f9524c6",
+ "presetNothingToAdd": "aa4db700a5dec3e1",
+ "presetPermissionCount": "574057eeed7f5ff4",
+ "presetPipelineEditorDescription": "de6e3add2840c75e",
+ "presetPipelineEditorTitle": "af77ceae37d95856",
"presetReadonlyDescription": "65d35f1e5efff445",
"presetReadonlyTitle": "fd523668f3222a15",
+ "presetReadsEveryStream": "779eeb4275b6a7f3",
+ "presetRestoreButton": "37496b30cafdbd3f",
+ "presetRestored": "98af67b8d9a42604",
+ "presetRetry": "942087cc2d41e013",
"presetRumEditorDescription": "45af3f7b0636c328",
"presetRumEditorTitle": "f44097ce697f860c",
"presetRumViewerDescription": "397aac1005ba2e4a",
"presetRumViewerTitle": "098cb30398d04ae9",
+ "presetStillMissing": "7b74472a3465f1d2",
+ "presetStreamsFailed": "770dea01f8e12907",
+ "presetSummaryAdds": "28ba936dbff7014a",
+ "presetSummaryHasAll": "bbe8cad8cacfb4f3",
+ "presetSummaryRestores": "22d18edbad0b14ae",
+ "presetSummarySkipped": "9952d82e7c251536",
+ "presetSummaryViewOnly": "3f60f63609742357",
+ "presetSummaryWrite": "f9265078106ee504",
+ "presetsClose": "f592612715ae2d33",
+ "presetsSectionBuiltin": "86d50abefe4a4533",
+ "presetsSectionJob": "8cd9b4e7688723aa",
+ "presetsShow": "55ed0bb59f5ff96d",
+ "presetsSubtitle": "261cf9df639b302a",
+ "presetsTitle": "954f93fe1c66ae44",
"quickReference": "8dc01f004f155067",
"reviewChanges": "d9764d6d64cedf78",
+ "reviewMissingTooltip": "84f3995080a989b0",
"roleNotFound": "eb230bc586caaebf",
"rumPresetNoMatch": "95873dff644884f3",
"rumPresetSeeded": "f9b03957a5cad1c5",
+ "saveSuccessCounts": "a424d67969865677",
"scopeAllOf": "c600e6cbe1bf6ad6",
"scopeCoveredByParent": "f8e7e0b7e085d5a1",
"scopeDowntimeHint": "2d5e550e6c592398",
@@ -13833,9 +13914,10 @@
"show": "0df6f1cad36c49da",
"specificInstanceOr": "2b209ead151a81a7",
"stillGrantedByWiderScope": "b18dc7bb028b3b76",
+ "stillNeededBy": "ece32491c7520328",
"summaryChangeAdded": "6b02e0d363a4af1c",
"summaryChangeRemoved": "4118fb4fed0ecec9",
- "summaryEmptyDescription": "bfe990d7ed130301",
+ "summaryEmptyDescription": "d4777882f82af0b1",
"summaryEmptyTitle": "afef6f392c9add8d",
"summaryGrantCount": "7ea21433a2c5ca22",
"summaryGrantsLabel": "c30209075bd95b47",
@@ -13849,13 +13931,41 @@
"summaryReachSome": "de344ba2ec6092cb",
"summaryUndoRemove": "a8283ade31856f71",
"table": "16d1c9050a0b32e8",
+ "targetAlertsEveryFolder": "1b30c61b34c2aae2",
+ "targetAlertsInFolder": "d1132b49171b0895",
+ "targetAllOf": "4f6fb834c57dacf6",
+ "targetDashboardsEveryFolder": "de65b7ac822f6f8e",
+ "targetDashboardsInFolder": "4ad30bcf340c009a",
+ "targetEveryDestination": "8d06c600c367bbb4",
+ "targetEveryStream": "41831f70a3de8a91",
+ "targetEveryTemplate": "33d89317e5d6565f",
+ "targetItemIn": "0a405945881e1c6a",
+ "targetOneAlert": "b07dd9e4c2b99b1d",
+ "targetOneDashboard": "60e538b12883ecbb",
+ "targetOneDestination": "728f70dab0c79cf2",
+ "targetOneReport": "7373c57a2afb80cd",
+ "targetOneStream": "bda1eea240ccdce6",
+ "targetOneTemplate": "bec08c395e9bcbc4",
+ "targetReportsEveryFolder": "5735ad2a2035e6e4",
+ "targetReportsInFolder": "36b71f4bfab131ee",
+ "targetTypeStreams": "697c8545d79c6c2a",
"undoAllChanges": "0ee88bcfd6a2821d",
"unsavedDot": {
"title": "10ba25333a83c618"
},
"updateError": "f63709346743dd32",
"updateSuccess": "71bc5feee968e4c1",
- "users": "6b0cc904d081a8c1"
+ "users": "6b0cc904d081a8c1",
+ "verbCreate": "fa8847b0c3318327",
+ "verbDelete": "6197595503f01ee2",
+ "verbEdit": "262121c5372be8af",
+ "verbList": "a330395cc0a53ad1",
+ "verbOpen": "2348f99874421257",
+ "verbSeeListOf": "6a3a48332191d4f9",
+ "verbView": "2bcb43cbc8f6b7ef",
+ "yourChanges": "ca00e53e8f4182b3",
+ "yourChangesCount": "6da14898b0687060",
+ "yourChangesNone": "98ca0d518997f8a4"
},
"externalUserWarningMessage": "0140f11bb6c72a86",
"externalUserWarningTitle": "88bc98107237635a",
@@ -14058,9 +14168,14 @@
"memberUpdatedSuccessfully": "b5f31eeadc42a082"
},
"user": {
+ "blockedButNotRemoved": "cc3e20dfda028109",
+ "blockedUsersNotRemoved": "da4d989021bf0d6e",
+ "blockedUsersPartial": "3396915b26488411",
+ "blockedUsersSuccess": "25d80ed957b338fd",
"delete": "e2d0a54968ead24e",
"deletedUsersPartial": "63dd9d50783eb53e",
"deletedUsersSuccess": "ff64d76cb2cf3e31",
+ "errorBlockingUser": "8c9761a0644254f4",
"errorDeletingUser": "1fdccd5e6ca1b3ac",
"errorDeletingUsers": "4486d04a04ee6f34",
"errorRevokingInvitation": "3a16c6ec8755e277",
@@ -14075,6 +14190,7 @@
"pleaseWaitLoadingUsers": "fb6392d9b349425a",
"unknownError": "27c2ccd962c2b8dc",
"userAddedSuccess": "35cfd2ebb7ee4177",
+ "userBlockedSuccess": "51037bfae45b0ca4",
"userDeletedSuccess": "b2d6db91aa0c838b",
"userUpdatedSuccess": "c54ffa5dc3449c37"
},
@@ -15429,6 +15545,7 @@
"dynamodbPipelineDesc": "f636a7855f10eac7",
"dynamodbPipelineTitle": "1e6cafceaead0e9e",
"dynamodbTagline": "64b74c679cbff83d",
+ "ebpfNetworkFlowsGuideLink": "f9c328e8b9c96f47",
"ebpfTracingGuideLink": "927dc7edecfa2962",
"ec2IamNote": "6110e1fc314f8c86",
"enableSessionReplayDesc": "40519dfa9b2e2269",
@@ -15546,6 +15663,9 @@
"k8sInstallerNote": "5f425a9e70ac3551",
"k8sInternalEndpointNote": "f126f11d13c13953",
"k8sLibrariesVariant": "0c527194c5b7ec36",
+ "k8sNetworkFlowsDesc": "ff40fa4596abcb07",
+ "k8sNetworkFlowsNote": "f317e2bba99a032f",
+ "k8sNetworkFlowsTitle": "78e2b5cf58d2ef0d",
"k8sTroubleAnnotationA": "aaedaf1ae3dca70c",
"k8sTroubleAnnotationQ": "f1d7d6ac036d9fd4",
"k8sTroubleEndpointA": "9a233ffcb5cb08e9",
@@ -15557,6 +15677,9 @@
"k8sTroubleWebhookA": "91f81f5fbca81dea",
"k8sTroubleWebhookQ": "a911ec912d7a5a4d",
"linuxAgentFixBody": "998a0d0a4fb72d7d",
+ "linuxNetworkFlowsDesc": "ce7edf9369ed6098",
+ "linuxNetworkFlowsNote": "89060e6b3b2e637c",
+ "linuxNetworkFlowsTitle": "6d919988371b8fb8",
"linuxTracesNote": "0301ee038a3d5143",
"linuxTracesVerifyNote": "0278ba0252e48cc9",
"logsMetricsTracesVariant": "0036533de19fb57f",
@@ -15579,6 +15702,9 @@
"mysqlAdminNote": "077314ff6018fa5f",
"mysqlHostLabel": "9897c29e4ea26191",
"mysqlTagline": "71e733e42a22a969",
+ "networkFlowsHelp": "5935d43be1041111",
+ "networkFlowsLabel": "d814b8c3c8c539d7",
+ "networkFlowsRequired": "ba7a0d62fddf54fb",
"noLogsFoundYet": "b33c2d121bfd5962",
"noMetricsFoundYet": "ccedc0cc2959d1d3",
"noSpansFoundYet": "c6e7c48e1b4b65e2",
@@ -19945,6 +20071,7 @@
"textbox": "449d1c52f1b8acf6"
},
"weight": "81d27ef6d5033c3e",
+ "wellCategory": "292c06f0045a45d0",
"wellGroup": "34ca0e76608842ff",
"wellSize": "1af851907331c0ed",
"wellSubgroup": "96fe12a20ed919a6",
@@ -20773,6 +20900,72 @@
"unitPerSecond": "71f1adcb645b5392",
"updateLimit": "0fc43ae1b62fbe3e"
},
+ "recentSearches": {
+ "aboutKeptDays": "31640629384cbf7e",
+ "aboutKeptForever": "2e08559121a01fa6",
+ "allPages": "903542125c003f8f",
+ "allStreams": "7ba01d16e615fc96",
+ "clear": "0d0ff3d78c31ee1a",
+ "clearBodyAll": "3979c4c7803210eb",
+ "clearBodyPage": "51a8842d6e7464e8",
+ "confirmClear": "496121494e4a967a",
+ "copyFunction": "6fb093137db6056d",
+ "copyLink": "dbf362d4f210c780",
+ "copyQuery": "8e5a75e93167b5a0",
+ "datePlaceholder": "038e472923ed356d",
+ "deletedQuery": "6d7f86f5dc466780",
+ "emptyTitle": "3dca22657bdc4672",
+ "failed": "031a8f0f659df890",
+ "failedCount": "cd8f5051cba3686a",
+ "filterByDateRun": "e78d12f2d00398bd",
+ "functionCopied": "2f4b48e8061978cc",
+ "functionLabel": "350c73d410d6a3bc",
+ "includeStarred": "3e622ea859a8790a",
+ "keyHints": "ed540677df5e80bc",
+ "lineCount": "e7b8886bd3dbee6c",
+ "linkCopied": "d12860c21e78baa5",
+ "loadError": "bee6fadd3aefbbe7",
+ "loadMore": "ac8991ef01019cf5",
+ "loadOnly": "d25f6d7876fb7647",
+ "loadToFix": "529d4a9b26d98d6b",
+ "noMatch": "074aee6c434f78e0",
+ "page": {
+ "dbm": "fa7fe67124e94375",
+ "logs": "ea2100dc89ae9fe2",
+ "metrics": "a58da793c7250f1b",
+ "profiles": "535e52e4a2616ebe",
+ "traces": "998741a58ed2b411"
+ },
+ "pageTitle": {
+ "dbm": "228c84b5a56e6bce",
+ "logs": "21a9d8d924e102cd",
+ "metrics": "30219f1c45b8c972",
+ "profiles": "228c84b5a56e6bce",
+ "traces": "e1dc9727e9f50873"
+ },
+ "queryCopied": "2b5f7fe902cb48bc",
+ "ranCount": "e505811bceef8aa8",
+ "recentRow": "7cb59bfc93961fa0",
+ "relativeWindow": "e35e9688dee4f7ee",
+ "rerunNow": "22de87ddeda82846",
+ "rerunOriginal": "781be17093338815",
+ "reset": "645982c52b7c11ae",
+ "resetFilters": "10afa98480f2d06c",
+ "retry": "942087cc2d41e013",
+ "searchPlaceholder": "7d854a995bf7dc35",
+ "star": "e357d396871d1012",
+ "starredOnly": "fbee73c784a49660",
+ "statusAll": "ac78229d6b822ce5",
+ "statusError": "031a8f0f659df890",
+ "statusOk": "6d9a6f97a5fdaf3b",
+ "title": "228c84b5a56e6bce",
+ "today": "2b065c7c9ce466e5",
+ "undo": "a8283ade31856f71",
+ "unstar": "318e826a75161b9f",
+ "viewAll": "30a64216eaeac5d3",
+ "warning": "d0f76e2e3abbb739",
+ "yesterday": "566181254b293aa6"
+ },
"refreshButton": {
"dataFresh": "7c21db7765d42a41",
"dataGettingStale": "4854378d5d270821",
@@ -20782,6 +20975,8 @@
"lastRefreshed": "81969c7ddc3eb624",
"minutesAgo": "33f4d52fdfa39099",
"notYetRefreshed": "cde59065c4a2f1a1",
+ "refresh": "0e91610117029a62",
+ "refreshWithAge": "964aef05f56113a1",
"secondsAgo": "8a421d3a725aacf9"
},
"regex_patterns": {
@@ -21096,6 +21291,7 @@
"loginCallback": "abc3ccb8b27e3974",
"modelPricingEditor": "bcb792875fefde91",
"notFound": "2d857a3660e0240b",
+ "orgDomainMapping": "91537c30fffb01dc",
"passwordPolicy": "11831de5b6d62b7c",
"pipelineBackfillJobs": "54a8767a761a58e0",
"regexPatterns": "83a58bf362610444",
@@ -21983,6 +22179,7 @@
"events": "8d14f6e72de8f18a",
"eventsCount": "70894bd5fc64132e",
"eventsUnit": "862417b9e7c3720b",
+ "exitFullScreen": "897e0cf78b808edd",
"failedToDeleteSourceMaps": "c0d7f7d0ff020512",
"failedToFetchTraces": "bdde58cab9c24864",
"failedToTranslateStackTrace": "f9d33e143a021bff",
@@ -22004,6 +22201,7 @@
"frustrationRageTap": "cf9680dedd1e387e",
"frustrationSignalType": "ad5bb48dadd8e856",
"frustrationSignalsDetected": "7ec6a4626da9715c",
+ "fullScreen": "674fe2acd0d5f47b",
"handled": "1e7536d56c45d9cd",
"health": "55898449eb74fb2e",
"inTimeRange": "e07beeb7056f9845",
@@ -22037,6 +22235,163 @@
"orClickToBrowse": "1c412f2d53be72cd",
"osImageAlt": "343d012d58b2e98f",
"overview": "d4b1ea5708dd5329",
+ "overviewPage": {
+ "asideLabel": "7f42198dd61617ad",
+ "bandAria": "5ba32c31f9967b1e",
+ "bandAtP75": "2a617709dcd228a3",
+ "bandGood": "c939327ca16dcf97",
+ "bandGoodPct": "103634e5ca02f3b9",
+ "bandNeeds": "738a3278e945747c",
+ "bandNeedsPct": "bb57abeb7367b245",
+ "bandPoor": "94dfd46d9dae63e6",
+ "bandPoorPct": "975fd9ed2d52b52e",
+ "chartInterval": "bb124e5867fdbc08",
+ "chartIntervalOther": "bb124e5867fdbc08",
+ "chartPerDay": "489b7ab47238e88a",
+ "chartPerFiveMinutes": "021a55c7a6d71a4c",
+ "chartPerHour": "6c72a5669544996a",
+ "chartPerSixHours": "b5df04a98ef1b95b",
+ "chartPerThirtyMinutes": "f489c4e09a100b11",
+ "chartTableCaption": "6aa2268c5bb0e779",
+ "chartTableHide": "a0879dad7c3f8a3d",
+ "chartTableToggle": "96fa2b6893c3aa4d",
+ "chartTime": "33b93476cf597a33",
+ "chartTitle": "45405319ec4a4cba",
+ "clearFilters": "7179ea0035fca783",
+ "deadClicks": "00d8ca7f888311a1",
+ "deltaChange": "9f0e28d14900e0b2",
+ "deltaChangePts": "e13f0a1ddee092fe",
+ "deltaFaster": "6a325085434f1240",
+ "deltaFlat": "3a4846c0d2b92e66",
+ "deltaHigher": "a8850998e2087257",
+ "deltaLower": "6379bccb1ae0895d",
+ "deltaNewMsg": "ec3bed16707958c0",
+ "deltaNoPrev": "7d233f38225a052d",
+ "deltaNone": "5d69f9dac5b02100",
+ "deltaSlower": "946227b5e1582391",
+ "diffBody": "30d7cce86b377ec7",
+ "diffLink": "2486d2e78f281c29",
+ "diffNone": "15c50d905a7115dc",
+ "diffSegment": "e14ff835a13bf68f",
+ "diffTitle": "ab83f728b5d37538",
+ "filterAll": "a52ace420f2175d0",
+ "filterEnv": "9e471951a1b4106e",
+ "filterService": "d677190e0a9990e7",
+ "filterVersion": "dd167905de0defca",
+ "forbiddenBody": "70052a9d798c22aa",
+ "forbiddenTitle": "1995b5ea7f6ce981",
+ "frustrationHint": "55abdb4890279913",
+ "frustrationReplayHint": "1aa9a318829058cf",
+ "frustrationReplayLink": "20a5e44483605bea",
+ "frustrationTitle": "56e60702769e4a73",
+ "kpiEmptyBody": "906be48ece2078ea",
+ "kpiEmptyTitle": "22be08ecd16a04a6",
+ "kpiErrorFree": "7d060fc5d0c5e396",
+ "kpiErrors": "5ea9d9389ee079aa",
+ "kpiRegion": "fd18cfaf42d52eae",
+ "kpiSessions": "6fa3cbf451b2a1d5",
+ "kpiUnhandled": "5b135a6d535f6e27",
+ "legendErrors": "cb702378f31507ef",
+ "legendSessions": "6fa3cbf451b2a1d5",
+ "markNeeds": "be5d23a3a45353b5",
+ "markPoor": "4e3795d05d9d3ed2",
+ "mixedBrowserNote": "004e723c7f422b4d",
+ "mixedMobileNote": "a9faf098a9c11cc8",
+ "pageErrorBody": "18f57a4129ed3f49",
+ "pageErrorTitle": "a4a790365c6aaad9",
+ "pageForbiddenTitle": "18fb9490a361a81b",
+ "pagesAbout": "78549e1bd0c69ec8",
+ "pagesAllLink": "903542125c003f8f",
+ "pagesAllLinkAria": "ebc1f3d358b3b82f",
+ "pagesColCls": "c47c6c6948ec5634",
+ "pagesColErrors": "cb702378f31507ef",
+ "pagesColImpact": "d1f23f0d13613e27",
+ "pagesColInp": "515b1a2be24746c8",
+ "pagesColLcp": "32106b7f555aa7de",
+ "pagesColPage": "0a30a815d67d7dd2",
+ "pagesColViews": "69c404591e99d86f",
+ "pagesEmptyBody": "bb22ebea13bc952d",
+ "pagesEmptyTitle": "bedda18bba81977d",
+ "pagesHint": "6c666de4002fc017",
+ "pagesImpactAria": "26b2708a79bc02ab",
+ "pagesLegend": "e9750ad656e68f14",
+ "pagesOpenErrors": "d17006b266c9da39",
+ "pagesOpenSessions": "2bda223ed7de6e96",
+ "pagesShowAll": "1177039dd3c91649",
+ "pagesShowFewer": "f8d72db5eef844c0",
+ "pagesTableLabel": "4ade2839c953451c",
+ "pagesTitle": "84cae825462bef43",
+ "panelFrustration": "a599d4cf692cd001",
+ "panelKpis": "5c512dd66195eed3",
+ "panelPages": "bfa062de040f55a1",
+ "panelRelease": "e0e8c0c197f8220c",
+ "panelSegments": "08fddbc83d3de346",
+ "panelVitals": "41d00ea2b1306bee",
+ "percentileLabel": "bb13af3d0ba72ee2",
+ "rageClicks": "c75d81aa8628a403",
+ "regionLabel": "d96040fed3bf811d",
+ "releaseBefore": "227c3d8e40305d8c",
+ "releaseEmptyBody": "a1971f4e7dcf7e6b",
+ "releaseEmptyTitle": "5962ee6e0edc2f96",
+ "releaseErrorFree": "d2a16e135b1bc49e",
+ "releaseHint": "a62a0366c41737f4",
+ "releaseImproved": "e88853216dc0a71e",
+ "releaseInp": "515b1a2be24746c8",
+ "releaseLcp": "32106b7f555aa7de",
+ "releaseLink": "2ceeb359b1e6871a",
+ "releaseMarker": "60368fcfa4e99557",
+ "releaseMetric": "2d275a74912cca2e",
+ "releaseNewErrors": "1b43c82ebc7ff5e2",
+ "releaseNote": "904272b628b800b2",
+ "releaseSessions": "6fa3cbf451b2a1d5",
+ "releaseTitle": "c84fabaf892a66b0",
+ "releaseWorse": "e760bdbe0be65db9",
+ "retry": "942087cc2d41e013",
+ "schemaErrorBody": "ddb5a959ca3f0192",
+ "schemaErrorTitle": "1bee2618fa07ae28",
+ "segBrowser": "d31de1a5c5c8ba2a",
+ "segConnection": "639a40e82b9a96f0",
+ "segCountry": "701d021d08c54579",
+ "segDevice": "6ba0bdeccb426d7b",
+ "segEmptyBody": "4ece6c1e99aa06d5",
+ "segEmptyTitle": "f20e5e6eeb43ab16",
+ "segInp": "95e0af44dc3b5f10",
+ "segNone": "72b7d7e92c4845f1",
+ "segOs": "343d012d58b2e98f",
+ "segShare": "f5c9b642aacbb5d9",
+ "segmentLabel": "29ee089ccecadf9d",
+ "sparkAria": "0908e97f1487e4c7",
+ "stateEmptyAction": "ee7646b3f423fb67",
+ "stateEmptyBody": "0e869c5ea6c07f51",
+ "stateEmptyFiltered": "ea6f94beb910cab9",
+ "stateEmptyQuiet": "c17f96e5fe52e329",
+ "stateEmptyTitle": "6f319360f9e15afc",
+ "stateErrorBody": "9fe5eb8b129794f1",
+ "stateErrorTitle": "0aa75faea4d1d91c",
+ "stateLoading": "eacd56e95b5f3c76",
+ "stateLowBody": "41b00a8813ab4166",
+ "stateLowTitle": "1f9b63a6d01336ee",
+ "stateRefreshing": "b79aa54865301ba9",
+ "tileOpenErrors": "3f6db05d68ba6874",
+ "tileOpenSessions": "61c01a80f711f64b",
+ "verdictAllPass": "02eabfdc35dffe20",
+ "verdictAllSome": "259b1a4fcf57bf51",
+ "verdictAllThree": "f5530a6cda768528",
+ "verdictCompare": "eeb12384c2ff9fa8",
+ "verdictFail": "bb95413b26462bc7",
+ "verdictNeedWork": "3a4c30caa999f317",
+ "verdictNeutral": "41d00ea2b1306bee",
+ "verdictNoData": "a621207696b9e79f",
+ "verdictNoDataLead": "c9de9a6ba7dd1ddf",
+ "verdictOver": "6a95f5e212faa9c1",
+ "verdictP75Note": "fbf2ee1626eb57ac",
+ "verdictPass": "f638962f3cc6807c",
+ "verdictSessions": "8cce09a9cd4caa12",
+ "vitalCls": "2592bbc7acc7fc0d",
+ "vitalInp": "33a2dbe6373afa87",
+ "vitalLcp": "a1ce627d3525a22c",
+ "vitalLinkFull": "cd27d4c527ac12cb"
+ },
"performance": "442aded87a55aa8c",
"performanceEmptyDescription": "89af28b2afec4422",
"performanceEmptyTitle": "b75c54a6d0ca045f",
@@ -22484,6 +22839,7 @@
"queryPlan": "f44ad102b5dd5df1",
"queryRangeHours": "1eb8a48ad2a585d1",
"queryRangeRestrictionMsg": "ea63dde7a2a85e18",
+ "quickModeHiddenFields": "8be086af5b4a1a1a",
"quickModeLabel": "6cd472b871517ce9",
"quickPickMoreStreams": "4e1a29d3b10349d0",
"quickPickStreamsLabel": "aa2db1e403a5ced8",
@@ -22587,6 +22943,7 @@
"traceIdLabel": "13181f1360728438",
"transformType": "658b33acde44acb7",
"turnOffLiveMode": "c3dfdea2b88d959c",
+ "turnOffQuickMode": "a8830cbebeaf7cea",
"turnOnLiveMode": "bcefde79656f0abc",
"unknownError": "27c2ccd962c2b8dc",
"unknownErrorOccurred": "43c4deec555b4c7e",
@@ -22594,6 +22951,7 @@
"updateSavedView": "f0e9e5ac3af3b640",
"updateSavedViewConfirm": "165c5f6398bd57f9",
"updateSavedViewWithCurrent": "9ec1d2751c4654c4",
+ "usageCostHistory": "b97c1a6d83f75800",
"userDefinedSchemaLabel": "ad895e214ecd7872",
"viewCreatedSuccessfully": "6a620ddf5d4013a9",
"viewDeletedSuccessfully": "6cc6f04153415865",
@@ -22608,11 +22966,11 @@
"webUrlNotConfigured": "da2e1df8a17952bc"
},
"search_history": {
- "delayMessage": "33ba3ca60b3d278e",
+ "delayMessage": "b61c3cadbb0996d7",
"executed_at": "5f3ff136b10e28d5",
"get_history": "0cccb92604f6a427",
"sql_query": "9a4f1370f2b72c82",
- "title": "66d7e4a1c738f0b1"
+ "title": "b97c1a6d83f75800"
},
"search_scheduler_job": {
"actions": "ff8059dc6752afdd",
@@ -22819,7 +23177,6 @@
"addFieldTooltip": "4607cc8ee893f1fa",
"addGroup": "94901258ed029f18",
"addGroupTooltip": "a01adc7d7cacdae4",
- "alertCorrelationTab": "b9b26f1bed14a1cb",
"autoSuggestedBanner": "2854b4d3462748aa",
"configureServicesHint": "0e2d04c7b30c6a3b",
"customizeFieldMappings": "9aa6f419e1527cc3",
@@ -22939,28 +23296,6 @@
"domainManagementDesc": "88398e3ac40ce4d7",
"domainNeedsBlockedEmails": "53170f72948b23a8",
"domainNeedsEmails": "c68d07b51aa28f5a",
- "domainOrgMappings": {
- "addMapping": "160cc87a108cd97f",
- "baseRole": "33551d66efcd283e",
- "baseRoleHelp": "50482be96fa489a0",
- "confirmRemove": "09bf5961ed02c7ae",
- "createMissingRoles": "469ee08dedc79c24",
- "deleteTitle": "06380e0419eca2ef",
- "domain": "f865f1ee55b26840",
- "domainHelp": "c927b335c093577b",
- "duplicateDomain": "197ff12f947386be",
- "editMapping": "d5ef1883268e1264",
- "empty": "df0e529517206088",
- "organization": "d764d42592fc9687",
- "organizationHelp": "783574eea65c82ad",
- "organizationPlaceholder": "d7f6cb5385e83aca",
- "organizationRequired": "7d12b1ae42d8cc36",
- "roleClaimName": "ed8728fb5ccdde85",
- "roleClaimNameHelp": "3247090d0155c3cb",
- "roleClaimNamePlaceholder": "2fc659f8dd586eba",
- "subtitle": "031a53b7414811e0",
- "title": "ac852cfa9baf6fa9"
- },
"domainPlaceholder": "cc921aaa05754257",
"domainRemoved": "2984d48362fadf0b",
"domainRequired": "025f3d105c5c8f7c",
@@ -23107,6 +23442,7 @@
"modelPricingList": {
"subtitle": "511e0ecb589643d4"
},
+ "noAllowedEmailsDenyAll": "2fe011dd2aebac32",
"noDomainMessage": "6e3b65f95acec5dd",
"noRecentErrors": "6e11fa6ce48a8831",
"noTextAvailable": "aed109960ed9410e",
@@ -23122,6 +23458,145 @@
},
"notAllowedAction": "516e566b5d2c3fb6",
"orgDesc": "4a1927826d0fa512",
+ "orgDomainMapping": {
+ "accessTab": "f8173cd6b94a1c2d",
+ "addMapping": "160cc87a108cd97f",
+ "alreadyLinked": "6ae3405ed1780f90",
+ "answeredBy": "1cbdadbedf5c01a5",
+ "approve": "6007acbe30b2cd98",
+ "approveBody": "b00d39614ef4322d",
+ "approveTitle": "366542c6d1f68b00",
+ "approvedLinkInfo": "b1cfd424b6a7445e",
+ "baseRole": "33551d66efcd283e",
+ "baseRoleHelp": "85a90ff83b26634c",
+ "block": "211d0bb8cf4f5b52",
+ "blockUsers": "38398e7642a68580",
+ "blockedCount": "194ba0dff38e03e3",
+ "blockedEmails": "34325ba4b11e09a4",
+ "blocklistDeleted": "e2fad69615931a95",
+ "blocklistDomainHelp": "5f3dbd2ee5132f73",
+ "blocklistEmailsHelp": "686ebe2ffc2bddc9",
+ "blocklistSaved": "eb876f029f0688fa",
+ "blocklistSection": "61d2a6f5d1a11c99",
+ "copyTxtName": "def0de935f302292",
+ "copyTxtValue": "569f923bc810a4cb",
+ "createMissingRoles": "469ee08dedc79c24",
+ "createsRoles": "12c02b1ee83f01dc",
+ "decisionFailed": "b4fc33bc42ac27d2",
+ "deleteMappingBody": "6a3206c520f864ab",
+ "deleteTitle": "45afe4152eb71013",
+ "deleted": "6abd8b1966fb2d45",
+ "details": "45989de49fb7f66d",
+ "detailsDialogTitle": "63acc3402811b23a",
+ "domain": "f865f1ee55b26840",
+ "domainHelp": "1be318ee4cb4e85e",
+ "domainHelpRequest": "edd3090574cd3cf4",
+ "domainNotVerified": "15133907259330da",
+ "domainRequired": "025f3d105c5c8f7c",
+ "duplicateMapping": "61dcad620b1e2340",
+ "editBlockedUsers": "ee186f1494ed6d9a",
+ "editMapping": "d5ef1883268e1264",
+ "enforceConfirmAction": "5a1f096a0d8d7674",
+ "enforceConfirmBody": "5acef29e5051a39d",
+ "enforceConfirmTitle": "2dccc6f1294e2267",
+ "enforceDesc": "4d5ef499aabc3eff",
+ "enforceSaved": "7e6799ca91bfa75f",
+ "enforceTitle": "49e1311912a28788",
+ "failureReason": {
+ "dnsFailed": "191653c1e32269f8",
+ "recordMissing": "44606b9b5761726f",
+ "valueMismatch": "8be355f637c38207"
+ },
+ "filterAll": "a52ace420f2175d0",
+ "filterPending": "331551b0de4157c9",
+ "goToLinked": "032b897355406970",
+ "goToLinkedDesc": "708f38e4fa11734e",
+ "keepRecordHint": "d48e2893e3f293ff",
+ "lastCheckFailed": "fb0953690ba3562b",
+ "lastChecked": "0c0c351a971353b5",
+ "linkDomain": "c296ce16414c87a7",
+ "linkFailed": "4f298906f73e8190",
+ "linkMethod": "bf44e5102a62aa62",
+ "linked": "61375bb06624de6a",
+ "linkedTab": "7b80e95841c5b918",
+ "linkedVia": "fe93da5acf84729f",
+ "loadFailed": "ff3241d73e3ed1e1",
+ "mappingDomainHelp": "9be86be6ff2a9ceb",
+ "mappingsTab": "f64ec16b0d88b0da",
+ "method": {
+ "approved": "e0aa737c9d31c98a",
+ "dns": "ebda700dd30bb4e5",
+ "dnsDesc": "90d12954ebf11f00",
+ "dnsTitle": "e831fa84f01eda8c",
+ "request": "9f1faa720fa12bc5",
+ "requestDesc": "eeed9a515bfa9c53",
+ "requestTitle": "58c5afe53ea21557"
+ },
+ "noDomainForBlocklist": "a28c7f62b38ab2f3",
+ "noVerifiedDomains": "4b2fbc7b41b4dbbb",
+ "notVerifiedYet": "86d22cfc659ee79a",
+ "organization": "d764d42592fc9687",
+ "organizationHelp": "103c714455e69979",
+ "organizationRequired": "7d12b1ae42d8cc36",
+ "pageSubtitle": "50fdfbf47a2b0743",
+ "pendingRequestInfo": "780463319de82427",
+ "reason": "f81ab834de5f8491",
+ "reasonHelp": "f846e8387152bb77",
+ "reasonRequired": "79f223934e006694",
+ "recordDialogTitle": "8aa73754d5a7ee28",
+ "recordInstructions": "4ca5d0b0f5b2c777",
+ "reject": "ab604a360777735f",
+ "rejectBody": "930940c39aa22759",
+ "rejectTitle": "20536d41e50d6fa7",
+ "rejectedRequestInfo": "80371f6d79594b87",
+ "removeBody": "4b6ebb95913dfb3e",
+ "removeDomain": "5ce435076ca12c56",
+ "removeTitle": "716dfac9ef62efb1",
+ "removeWithMappingsBody": "a81e80b80275a075",
+ "removed": "186cc53091ad1fd8",
+ "requestApproved": "fe79a279deff856a",
+ "requestFailed": "c6cdee5b0a95e612",
+ "requestRejected": "0fb6a196029117df",
+ "requestSent": "5b7f10f4000b2af2",
+ "requestedAt": "2d9e28289facab94",
+ "requestedBy": "9199a054972b1489",
+ "requestsTab": "635cd0e916595276",
+ "roleClaimName": "ed8728fb5ccdde85",
+ "roleClaimNameHelp": "cc0d33be97a5ae25",
+ "roleClaimNamePlaceholder": "2fc659f8dd586eba",
+ "ruleBlockedRequired": "cb376d7e54530b2a",
+ "saveFailed": "4924972f958737c6",
+ "saved": "e0bc9ee67876b410",
+ "searchDomains": "df3b2794a7b1fe92",
+ "searchMappings": "0076316b776d02c6",
+ "searchRequests": "4132cecef4287500",
+ "sendRequest": "3a69f897298e727e",
+ "state": {
+ "approved": "87b42e40c2a290e0",
+ "failed": "031a8f0f659df890",
+ "pending": "331551b0de4157c9",
+ "rejected": "aea4a04a80426ed8",
+ "requested": "ae25c9b1d366d159",
+ "verified": "4f7838402f37674e"
+ },
+ "status": "920e413c7d411b61",
+ "tabDesc": "50fdfbf47a2b0743",
+ "tabLabel": "91537c30fffb01dc",
+ "txtNameCopied": "b353561fcad08b44",
+ "txtNameLabel": "e3f134323855ff3a",
+ "txtValueCopied": "c8dcad93b2b0d76f",
+ "txtValueLabel": "f7b42a22b9cedbfa",
+ "unblockAll": "1aec444ddd5eb456",
+ "unblockAllBody": "79b40d4f58e76828",
+ "unblockAllTitle": "3c8499510ce80365",
+ "unlinkFailed": "beae9c59b2f9f019",
+ "verified": "08e0df6712bb45ef",
+ "verifiedAt": "4f7838402f37674e",
+ "verifyDialogTitle": "91e82537dcacc7c5",
+ "verifyFailed": "f0f2383f8af8d23d",
+ "verifyHint": "32281a270a11f2f9",
+ "verifyNow": "db383efba6aef9d9"
+ },
"orgLabel": "c12331aa1a61f587",
"orgStorageEditor": {
"accessKeyLabel": "176f1c109e28fa68",
@@ -23433,7 +23908,7 @@
"logsRowOpen": "4e62957ded0d0dd0",
"logsRunQuery": "0b9252e840f32989",
"logsSaveView": "af29df0f9ca1446c",
- "logsSearchHistory": "145dd6f1992eb214",
+ "logsSearchHistory": "8962685dd0fd843e",
"logsToggleHistogram": "bebe67d66ebc5b81",
"logsToggleSidebar": "9cf94e5f212e1985",
"metricsAddToDashboard": "81c5e9c7bb53cc85",
@@ -23573,7 +24048,7 @@
"runningQueries": "a026d17f06f54cec",
"scoreConfigs": "c3c78db2263139e4",
"scorers": "966ddbb027958248",
- "searchHistory": "66d7e4a1c738f0b1",
+ "searchHistory": "b97c1a6d83f75800",
"searchSchedulers": "047df1ad4dee93bc",
"sessions": "6fa3cbf451b2a1d5",
"sourceMaps": "74e923a5b4614cf6",
@@ -25148,6 +25623,7 @@
"durationFast": "cfd210c0d957d454",
"durationMid": "6a8fa448ca025881",
"durationSlow": "03e0b960ac4d0d17",
+ "error": "54a0e8c17ebb21a1",
"failed": "031a8f0f659df890",
"passed": "436fe71bb9561f05",
"warning": "e981ddae45d8f4ca"
@@ -25692,11 +26168,13 @@
"back": "7e411e6d1fb73865",
"checkName": "dcd1d5223f73b3a9",
"checksTitle": "10014025dca36813",
+ "loadFailed": "0715f69c63f61005",
"noAgents": "53f30015179ba65a",
"noChecks": "3709b844e6f621e3",
"notFound": "8df056a568a44b88",
"pool": "f13024272c385f73",
"restartConfig": "39cd3ae99887b0c9",
+ "retry": "942087cc2d41e013",
"title": "57d84785ba9c4e19",
"version": "dd167905de0defca"
},
@@ -25919,6 +26397,10 @@
"attempts": "06e70139fcf5e5c7",
"detailAction": "64cff1319d2fd2cb",
"detailSelector": "f02a172a35354ca2",
+ "detailSkipReason": "52bbd7b4a76b81ed",
+ "detailStatus": "920e413c7d411b61",
+ "detailStepId": "22e1beecfe5cf990",
+ "detailTargetUrl": "5b341c960fb7166e",
"detailUrl": "e7a241debad56609",
"downloadTrace": "0d79fab080c1efe2",
"errorFullscreenTitle": "024c4be2f89daed1",
@@ -25963,8 +26445,11 @@
"settleStaleNote": "0aee2b4a2b2be6a0",
"showFullError": "4450d34301ab8d70",
"showLess": "94ea9b1d33a02975",
+ "skippedCount": "28541aa9135d134e",
"startLoadLabel": "db96939121284345",
"stepOf": "9bd8548d86fd91f5",
+ "stepStatusSkipped": "12698ce1ea5cd4ab",
+ "stepStatusUnknown": "b764cdc0eab71374",
"stepsCount": "6e336a2b2b738463",
"title": "95ccb018c85dce04",
"viewFullError": "e0fcf523d4155c70",
@@ -26069,7 +26554,7 @@
"retriesOnFailure": "a2e021cdc7d91dbd",
"retriesOnFailureSuffix": "ad5f0c777a2b470f",
"retryDelay": "bbbdc7fae12b28e4",
- "retryDelaySuffix": "48e77c7c4e24e2f4",
+ "retryDelaySuffix": "a2f9474ace454f9a",
"schedule": "f4830a1dae298044",
"scheduleLater": "058e3d9d22f8df88",
"scheduleLaterTooltip": "946c56014f45896f",
@@ -26457,7 +26942,6 @@
"aiAnalysisIsAlreadyRunningFor": "cff089f517a5afd5",
"alertClonedSuccessfully": "93729e0e45c2f03b",
"alertNotFound": "1cb1c54427acf236",
- "alertNotFoundInAlerts": "8032bd1c0b1f7207",
"alertPausedSuccessfully": "4bbb140b6866b255",
"alertResumedSuccessfully": "83e2bd8646284c49",
"alertSavedSuccessfully": "797986766aad6827",
@@ -26507,7 +26991,6 @@
"noAlertsToPause": "e38354226ff0921c",
"noAlertsToResume": "f8190fba75eb0c63",
"noDestinationsSelectedForDeletion": "6f3d55af7be19703",
- "openingDedupConfig": "8c2139ac0be96855",
"pausingAlerts": "22bcfb597fcc0141",
"pleaseSelectStreamName": "b7888dff292770a1",
"pleaseSelectStreamType": "8dc36f136ae1dc78",
@@ -27604,13 +28087,17 @@
"actions": "ff8059dc6752afdd",
"add": "6384ffa7712cb2d5",
"authType": "8eb3ea9bbde63159",
+ "block": "211d0bb8cf4f5b52",
"cancel": "19766ed6ccb2f4a3",
"changePassword": "a949f46230748d3f",
+ "confirmBlockHead": "e761ae1549da54aa",
+ "confirmBlockMsg": "90a9f36f481f86ee",
"confirmDeleteHead": "4e920cc9cc7cd6e1",
"confirmDeleteMsg": "3afa72b6b4978bf9",
"currentPasswordRequired": "5000b74e76ec8b67",
"customRole": "6e8453849bf2d267",
"delete": "e2d0a54968ead24e",
+ "deleteUsersBlockNote": "783ff61096dbb22c",
"deleteUsersMsg": "ce952715001e5703",
"deleteUsersTitle": "329abf36a8bd020d",
"description": "526e0087cc3f254d",
diff --git a/src/api/common/src/auth/jwt.rs b/src/api/common/src/auth/jwt.rs
index 3b1386026d..1693e11c1b 100644
--- a/src/api/common/src/auth/jwt.rs
+++ b/src/api/common/src/auth/jwt.rs
@@ -13,11 +13,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-#[cfg(feature = "cloud")]
-use openobserve_core::{
- organization::list_org_users_by_user,
- self_reporting::cloud_events::{CloudEvent, EventType, enqueue_cloud_event},
-};
#[cfg(all(feature = "enterprise", not(feature = "cloud")))]
use {
crate::common::meta::user::RoleOrg,
@@ -36,6 +31,25 @@ use {
std::collections::HashMap,
std::sync::LazyLock as Lazy,
};
+#[cfg(feature = "cloud")]
+use {
+ config::get_config,
+ config::{DEFAULT_ORG, meta::user::UserRole, utils::rand::generate_random_string},
+ db::org_status,
+ db::{org_users, organization::get_org_setting},
+ o2_enterprise::enterprise::cloud::org_invites,
+ o2_enterprise::enterprise::{cloud::billing_group, domain_management::meta::AccessDecision},
+ o2_openfga::authorizer::authz::get_add_user_to_org_tuples,
+ o2_openfga::authorizer::roles::{
+ check_and_get_crole_tuple_for_new_user, get_roles_for_org_user,
+ get_user_crole_removal_tuples,
+ },
+ openobserve_core::org_domain_ownership::get_cached_org_for_domain,
+ openobserve_core::organization::list_org_users_by_user,
+ openobserve_core::self_reporting::cloud_events::{CloudEvent, EventType, enqueue_cloud_event},
+ openobserve_core::users,
+ std::{collections::HashSet, str::FromStr as _},
+};
#[cfg(all(feature = "enterprise", not(feature = "cloud")))]
use {
config::meta::user::{UserOrg, UserRole},
@@ -45,19 +59,6 @@ use {
},
std::str::FromStr,
};
-#[cfg(feature = "cloud")]
-use {
- config::{DEFAULT_ORG, META_ORG_ID, meta::user::UserRole, utils::rand::generate_random_string},
- db::org_status,
- db::{org_users, organization::get_org_setting},
- o2_enterprise::enterprise::cloud::org_invites,
- o2_openfga::authorizer::authz::get_add_user_to_org_tuples,
- o2_openfga::authorizer::roles::{
- check_and_get_crole_tuple_for_new_user, get_roles_for_org_user,
- get_user_crole_removal_tuples,
- },
- std::{collections::HashSet, str::FromStr as _},
-};
#[cfg(feature = "cloud")]
use crate::common::meta::{
@@ -124,9 +125,9 @@ pub async fn process_token(
let mut custom_roles: Vec = vec![];
let mut tuples_to_add = HashMap::new();
- // Try custom claim parsing first
+ // Try custom claim parsing first, always from _meta
if let Some((parsed_orgs, parsed_roles)) =
- process_custom_claim_parsing(&dec_token.claims).await
+ process_custom_claim_parsing(&dec_token.claims, "_meta").await
{
log::info!(
"Using custom claim parsing results: {} orgs, {} custom roles",
@@ -879,141 +880,262 @@ pub async fn process_domain_org_mapping(
claims: HashMap,
is_new_user: bool,
) -> Result {
- let meta_settings = get_org_setting(META_ORG_ID).await?;
- let mappings = meta_settings.domain_org_mappings;
// split email to get the domain
- if let Some((_, domain)) = user_email.to_lowercase().split_once("@") {
- if let Some(mapped) = mappings
- .into_iter()
- .find(|m| m.domain.to_lowercase() == domain)
- {
- match org_status::read_active(&mapped.org_id).await {
- Ok(true) => {}
- Ok(false) => {
- log::warn!(
- "domain org mapping for user {user_email} points at inactive org {}, skipping",
- mapped.org_id
- );
- return Ok(false);
+ let Some((_, domain)) = user_email.split_once("@") else {
+ log::warn!(
+ "user email {user_email} could not be split correctly at @, skipping org domain mapping"
+ );
+ return Ok(false);
+ };
+ let domain = domain.trim().to_lowercase();
+
+ let Some(org_id) = get_cached_org_for_domain(&domain).await else {
+ log::info!("no cached domain org mapping found for user {user_email}, continuing normally");
+ return Ok(false);
+ };
+
+ let org_settings = get_org_setting(&org_id).await?;
+ let mappings = org_settings.domain_org_mappings;
+
+ let mut allowed_orgs: HashSet<_> = billing_group::list_billing_group_members_of(&org_id)
+ .await?
+ .into_iter()
+ .map(|v| v.member_org_id)
+ .collect();
+ allowed_orgs.insert(org_id.clone());
+
+ let decision = org_settings.domain_management_config.evaluate(user_email);
+ if matches!(decision, AccessDecision::Deny) {
+ if is_new_user {
+ // for new user who is blocked via domain, we return false, so
+ // the normal flow can take place
+ return Ok(false);
+ }
+ let orgs = org_users::list_orgs_by_user(user_email).await?;
+ let mut orgs_to_remove = Vec::new();
+ for org in orgs {
+ if allowed_orgs.contains(&org.org_id) {
+ orgs_to_remove.push(org.org_id);
+ }
+ }
+
+ for org in orgs_to_remove {
+ match users::remove_user_from_org(
+ &org,
+ &user_email,
+ &get_config().auth.root_user_email,
+ users::RemovalTarget::User,
+ )
+ .await
+ {
+ Ok(v) => {
+ if !v.status().is_success() {
+ log::error!(
+ "error user {user_email} was blocked from org {org_id}, but failed to remove from org {org} with status : {}",
+ v.status().as_str()
+ );
+ }
}
Err(e) => {
- log::warn!(
- "domain org mapping for user {user_email}: cannot read the status of org {}, skipping: {e}",
- mapped.org_id
+ log::error!(
+ "error user {user_email} was blocked from org {org_id}, but failed to remove from org {org} : {e}"
);
- return Ok(false);
}
}
+ }
- log::info!("found domain org mapping for user {user_email}, processing");
- let base_role = UserRole::from_str(&mapped.base_role).map_err(|e| {
- anyhow::anyhow!(
- "error parsing domain org role {} to user role : {e:?}",
- mapped.base_role
- )
- })?;
+ log::info!("user {user_email} blocked by domain management config of org {org_id}");
+ return Ok(true);
+ }
- let need_to_add_user = org_users::get_optional(&mapped.org_id, user_email)
- .await?
- .is_none();
+ let mapped_orgs: Vec<_> = mappings
+ .into_iter()
+ .filter(|m| m.domain.to_lowercase() == domain && allowed_orgs.contains(&m.org_id))
+ .collect();
- if is_new_user || need_to_add_user {
- let mut new_tuples = Vec::new();
- get_add_user_to_org_tuples(
- &mapped.org_id,
- user_email,
- &mapped.base_role,
- &mut new_tuples,
+ if mapped_orgs.is_empty() {
+ log::info!(
+ "domain {domain} is owned by {org_id} but no mapping found, processing user {user_email} normally"
+ );
+ return Ok(false);
+ }
+
+ let mut added = 0;
+
+ for mapped in mapped_orgs {
+ match org_status::read_active(&mapped.org_id).await {
+ Ok(true) => {}
+ Ok(false) => {
+ log::warn!(
+ "domain org mapping for user {user_email} points at inactive org {}, skipping",
+ mapped.org_id
);
- update_tuples(new_tuples, vec![]).await?;
- org_users::add(
- &mapped.org_id,
- user_email,
- base_role,
- &generate_random_string(16),
- Some(format!("rum{}", generate_random_string(16))),
- )
- .await?;
+ continue;
}
- if let Some(claim_name) = mapped.role_claim_name
- && let Some(v) = claims.get(&claim_name)
- && let Some(arr) = v.as_array()
- {
- let mut roles = HashSet::new();
- for claim in arr {
- if let Some(role) = claim.as_str() {
- roles.insert(format_role_name_only(role));
- }
- }
- let existing_roles = get_roles_for_org_user(&mapped.org_id, user_email).await;
+ Err(e) => {
+ log::warn!(
+ "domain org mapping for user {user_email}: cannot read the status of org {}, skipping: {e}",
+ mapped.org_id
+ );
+ continue;
+ }
+ }
- let mut add_roles = Vec::new();
- let mut add_tuples = Vec::new();
- let mut remove_tuples = Vec::new();
+ if org_status::is_blocked(&mapped.org_id) {
+ log::info!(
+ "user {user_email} supposed to be mapped to org {} but org status is blocked, so skipping",
+ mapped.org_id
+ );
+ continue;
+ }
+ log::info!(
+ "found domain org mapping {} for user {user_email}, processing",
+ mapped.org_id
+ );
+ let base_role = UserRole::from_str(&mapped.base_role).map_err(|e| {
+ anyhow::anyhow!(
+ "error parsing domain org role {} to user role : {e:?}",
+ mapped.base_role
+ )
+ })?;
- for role in &existing_roles {
- if !roles.contains(role) {
- let name = format_role_name(&mapped.org_id, role);
- get_user_crole_removal_tuples(user_email, &name, &mut remove_tuples);
- }
- }
- for role in &roles {
- if !existing_roles.contains(role) {
- add_roles.push(format_role_name(&mapped.org_id, role))
- }
- }
- if !add_roles.is_empty() {
- check_and_get_crole_tuple_for_new_user(
- user_email,
- &mapped.org_id,
- add_roles,
- mapped.create_missing_roles,
- &mut add_tuples,
- )
- .await;
- }
+ let need_to_add_user = org_users::get_optional(&mapped.org_id, user_email)
+ .await?
+ .is_none();
- if (!add_tuples.is_empty() || !remove_tuples.is_empty())
- && let Err(e) = update_tuples(add_tuples, remove_tuples).await
- {
+ if is_new_user || need_to_add_user {
+ let mut new_tuples = Vec::new();
+ get_add_user_to_org_tuples(
+ &mapped.org_id,
+ user_email,
+ &mapped.base_role,
+ &mut new_tuples,
+ );
+ update_tuples(new_tuples, vec![]).await?;
+ org_users::add(
+ &mapped.org_id,
+ user_email,
+ base_role,
+ &generate_random_string(16),
+ Some(format!("rum{}", generate_random_string(16))),
+ )
+ .await?;
+ }
+ if let Some(claim_name) = mapped.role_claim_name
+ && let Some(v) = claims.get(&claim_name)
+ && let Some(arr) = v.as_array()
+ {
+ let mut roles = HashSet::new();
+ for claim in arr {
+ if let Some(role) = claim.as_str() {
+ roles.insert(format_role_name_only(role));
+ }
+ }
+ let existing_roles = get_roles_for_org_user(&mapped.org_id, user_email).await;
+
+ let mut add_roles = Vec::new();
+ let mut add_tuples = Vec::new();
+ let mut remove_tuples = Vec::new();
+
+ for role in &existing_roles {
+ if !roles.contains(role) {
+ let name = format_role_name(&mapped.org_id, role);
+ get_user_crole_removal_tuples(user_email, &name, &mut remove_tuples);
+ }
+ }
+ for role in &roles {
+ if !existing_roles.contains(role) {
+ add_roles.push(format_role_name(&mapped.org_id, role))
+ }
+ }
+ if !add_roles.is_empty() {
+ check_and_get_crole_tuple_for_new_user(
+ user_email,
+ &mapped.org_id,
+ add_roles,
+ mapped.create_missing_roles,
+ &mut add_tuples,
+ )
+ .await;
+ }
+
+ if !add_tuples.is_empty() || !remove_tuples.is_empty() {
+ if let Err(e) = update_tuples(add_tuples, remove_tuples).await {
log::error!(
"error updating claim based role tuples for user {user_email} org {} : {e}",
mapped.org_id
);
}
}
-
+ }
+ log::info!(
+ "user {user_email} mapped to org {} successfully via domain org mapping",
+ mapped.org_id
+ );
+ if need_to_add_user {
log::info!(
- "user {user_email} mapped to org {} successfully via domain org mapping",
+ "deleting any invites for {user_email} in org {} as added via org mapping",
mapped.org_id
);
- if need_to_add_user {
- log::info!(
- "deleting any invites for {user_email} in org {} as added via org mapping",
+ if let Err(e) =
+ org_invites::delete_invites_for_user(&mapped.org_id, &user_email.to_lowercase())
+ .await
+ {
+ log::error!(
+ "error in deleting invites for user {user_email} for org {} after joining via domain mapping : {e}",
mapped.org_id
);
- if let Err(e) =
- org_invites::delete_invites_for_user(&mapped.org_id, &user_email.to_lowercase())
- .await
- {
- log::error!(
- "error in deleting invites for user {user_email} for org {} after joining via domain mapping : {e}",
- mapped.org_id
- );
- }
}
- log::info!("domain org mapping for user {user_email} successfully processed");
- Ok(true)
- } else {
- log::info!("no domain org mapping found for user {user_email}, continuing normally");
- Ok(false)
}
- } else {
- log::warn!(
- "user email {user_email} could not be split correctly at @, skipping org domain mapping"
- );
- Ok(false)
+ added += 1;
}
+
+ let mut removed = 0;
+ // if added = 0, it means no viable orgs were there for mapping,
+ // so removing the user would just keep recreating new orgs at each login
+ if org_settings.enforce_allowed_orgs_only && !is_new_user && added > 0 {
+ let orgs = org_users::list_orgs_by_user(user_email).await?;
+ let mut orgs_to_remove = Vec::new();
+ for org in orgs {
+ if !allowed_orgs.contains(&org.org_id) {
+ orgs_to_remove.push(org.org_id);
+ }
+ }
+
+ removed = orgs_to_remove.len();
+ for org in orgs_to_remove {
+ match users::remove_user_from_org(
+ &org,
+ &user_email,
+ &get_config().auth.root_user_email,
+ users::RemovalTarget::User,
+ )
+ .await
+ {
+ Ok(v) => {
+ if !v.status().is_success() {
+ log::error!(
+ "error user {user_email} was domain mapped from org {org_id}, but failed to remove from extra org {org} with status : {}",
+ v.status().as_str()
+ );
+ }
+ }
+ Err(e) => log::error!(
+ "error user {user_email} was domain mapped from org {org_id}, but failed to remove from extra org {org} : {e}"
+ ),
+ }
+ }
+ }
+
+ if added == 0 && removed == 0 {
+ log::info!(
+ "no mapped orgs were viable, so skipped processing domain mapping for user {user_email} from org {org_id}"
+ );
+ return Ok(false);
+ }
+
+ log::info!("domain org mapping for user {user_email} successfully processed");
+ Ok(true)
}
#[cfg(feature = "cloud")]
@@ -1040,8 +1162,8 @@ pub async fn check_and_add_to_org(
vec![]
}
};
- let pending_invites = pending_invites
- .into_iter()
+ let mut has_pending_invites = pending_invites
+ .iter()
.any(|invite| invite.status == OrgInviteStatus::Pending && invite.expires_at > now);
if db_user.is_none() {
is_new_user = true;
@@ -1069,7 +1191,7 @@ pub async fn check_and_add_to_org(
}
Err(e) => {
log::error!("Error adding user to the database: {}", e);
- return Ok((is_new_user, pending_invites));
+ return Ok((is_new_user, has_pending_invites));
}
}
}
@@ -1078,8 +1200,48 @@ pub async fn check_and_add_to_org(
match process_domain_org_mapping(user_email, claims, is_new_user).await {
Ok(processed) => {
if processed {
- // domain based adding is done, so do not treat as a new user
- return Ok((false, pending_invites));
+ let user_record = db::user::get_user_by_email(user_email).await;
+ if user_record.is_none() {
+ is_new_user = true;
+ has_pending_invites = false;
+ match create_new_user(DBUser {
+ email: user_email.to_owned(),
+ first_name: first_name.to_owned(),
+ last_name: last_name.to_owned(),
+ password: "".to_owned(),
+ salt: "".to_owned(),
+ organizations: vec![], // No org
+ is_external: true,
+ password_ext: Some("".to_owned()),
+ })
+ .await
+ {
+ Ok(_) => {
+ let mut tuples = vec![];
+ get_new_user_creation_tuple(user_email, &mut tuples);
+ tuples_to_add.insert(user_email.to_string(), tuples);
+ log::info!("User added to the database");
+ }
+ Err(e) => {
+ log::error!(
+ "Error adding user to the database after domain mapping: {}",
+ e
+ );
+ return Ok((is_new_user, has_pending_invites));
+ }
+ }
+ } else {
+ // domain based adding is done, so do not treat as a new user
+ let orgs = org_users::list_orgs_by_user(user_email).await?;
+ if orgs.is_empty() {
+ is_new_user = true;
+ has_pending_invites = pending_invites.iter().any(|invite| {
+ invite.status == OrgInviteStatus::Pending && invite.expires_at > now
+ });
+ } else {
+ return Ok((false, has_pending_invites));
+ }
+ }
}
}
Err(e) => log::error!(
@@ -1108,8 +1270,8 @@ pub async fn check_and_add_to_org(
existing_orgs[0].role.to_string(),
),
// if there are some pending invites, we do not want to create any orgs for the user
- _ if pending_invites => {
- return Ok((is_new_user, pending_invites));
+ _ if has_pending_invites => {
+ return Ok((is_new_user, has_pending_invites));
}
_ => {
// Create a default org for the user
@@ -1209,7 +1371,7 @@ pub async fn check_and_add_to_org(
.await;
}
- Ok((is_new_user, pending_invites))
+ Ok((is_new_user, has_pending_invites))
}
/// Process custom claim parsing and populate source_orgs and custom_roles
@@ -1219,6 +1381,7 @@ pub async fn check_and_add_to_org(
#[cfg(all(feature = "enterprise", not(feature = "cloud")))]
async fn process_custom_claim_parsing(
claims: &HashMap,
+ org_id: &str,
) -> Option<(Vec, Vec)> {
use o2_openfga::config::get_config as get_openfga_config;
@@ -1234,8 +1397,9 @@ async fn process_custom_claim_parsing(
// Prepare callback functions for claim parser
let iam_settings_fn = || {
- Box::pin(async {
- match db::organization::get_org_setting("_meta").await {
+ let org_id = org_id.to_owned();
+ Box::pin(async move {
+ match db::organization::get_org_setting(&org_id).await {
Ok(settings) => settings.claim_parser_function,
Err(_) => "claim_parser".to_string(),
}
@@ -1257,11 +1421,12 @@ async fn process_custom_claim_parsing(
let function_loader_fn = |function_name: &str| {
let function_name = function_name.to_string();
+ let org_id = org_id.to_owned();
Box::pin(async move {
- match db::functions::get("_meta", &function_name).await {
+ match db::functions::get(&org_id, &function_name).await {
Ok(func) => Ok(func), // Return full Transform object with trans_type
Err(e) => Err(anyhow::anyhow!(
- "Failed to load function '{}' from _meta org: {}",
+ "Failed to load function '{}' from {org_id} org: {}",
function_name,
e
)),
@@ -1279,7 +1444,7 @@ async fn process_custom_claim_parsing(
// Use String as compiled program type for Send compatibility
let vrl_compile_fn = |function_content: &str| -> Result {
// Just validate that it compiles, but return the source for execution
- transform::compile_vrl_function(function_content, "_meta")
+ transform::compile_vrl_function(function_content, org_id)
.map_err(|e| anyhow::anyhow!("VRL compilation failed: {}", e))?;
Ok(function_content.to_string())
};
@@ -1289,7 +1454,7 @@ async fn process_custom_claim_parsing(
use vrl::compiler::{TargetValue, runtime::Runtime};
// Compile the VRL function
- let vrl_config = transform::compile_vrl_function(function_content, "_meta")
+ let vrl_config = transform::compile_vrl_function(function_content, org_id)
.map_err(|e| anyhow::anyhow!("VRL compilation failed: {}", e))?;
let mut runtime = Runtime::default();
@@ -1319,12 +1484,12 @@ async fn process_custom_claim_parsing(
let js_execute_fn = |function_content: &str, claims: Value| -> Result {
// Compile the JavaScript function
- let js_config = transform::js::compile_js_function(function_content, "_meta")
+ let js_config = transform::js::compile_js_function(function_content, org_id)
.map_err(|e| anyhow::anyhow!("JavaScript compilation failed: {}", e))?;
// Execute JavaScript with claims as input
let (result, error) =
- transform::js::apply_js_fn(&js_config, claims, "_meta", &[String::new()]);
+ transform::js::apply_js_fn(&js_config, claims, org_id, &[String::new()]);
if let Some(err) = error {
return Err(anyhow::anyhow!("JavaScript execution failed: {}", err));
diff --git a/src/api/http/Cargo.toml b/src/api/http/Cargo.toml
index 4291696ccb..4e4f761924 100644
--- a/src/api/http/Cargo.toml
+++ b/src/api/http/Cargo.toml
@@ -42,7 +42,8 @@ vectorscan = [
"openobserve-core/vectorscan",
"stream/vectorscan",
]
-profiling = ["openobserve-api-management/profiling"]
+cpu-profiling = ["openobserve-api-management/cpu-profiling"]
+profiling = ["cpu-profiling", "openobserve-api-management/profiling"]
[dependencies]
common.workspace = true
diff --git a/src/api/http/src/handler/http/router/mod.rs b/src/api/http/src/handler/http/router/mod.rs
index b6e203df29..41bfdafb56 100644
--- a/src/api/http/src/handler/http/router/mod.rs
+++ b/src/api/http/src/handler/http/router/mod.rs
@@ -26,7 +26,9 @@ use axum::{
use config::get_config;
use openobserve_api_common::{X_O2_ASSISTANT_SESSION_ID, X_O2_ASSISTANT_TURN_ID};
use openobserve_api_ingest::request::{clusters, logs, metrics, profiles, rum};
-#[cfg(feature = "profiling")]
+#[cfg(feature = "cloud")]
+use openobserve_api_management::request::org_domains;
+#[cfg(feature = "cpu-profiling")]
use openobserve_api_management::request::profiling;
use openobserve_api_management::request::{
alerts, announcements, authz, dashboards, db_monitoring, downtimes, folders, ingest_rejections,
@@ -952,12 +954,14 @@ pub fn basic_routes() -> Router {
router = router.nest("/node", node_routes);
// Debug/profiling routes with auth
- #[cfg(feature = "profiling")]
+ #[cfg(feature = "cpu-profiling")]
{
- let debug_routes = Router::new()
+ let debug_routes = Router::new().route("/profile/cpu", get(profiling::cpu_profile));
+ #[cfg(feature = "profiling")]
+ let debug_routes = debug_routes
.route("/profile/memory", get(profiling::memory_profile))
- .route("/profile/stats", get(profiling::jemalloc_stats))
- .route("/profile/cpu", get(profiling::cpu_profile))
+ .route("/profile/stats", get(profiling::jemalloc_stats));
+ let debug_routes = debug_routes
.layer(middleware::from_fn(password_policy_middleware))
.layer(middleware::from_fn(auth_middleware));
@@ -1376,7 +1380,7 @@ pub fn service_routes() -> Router {
.route("/{org_id}/savedviews", get(search::saved_view::get_views).post(search::saved_view::create_view))
.route("/{org_id}/savedviews/{view_id}", get(search::saved_view::get_view).put(search::saved_view::update_view).delete(search::saved_view::delete_view))
- .route("/{org_id}/query_history", get(query_history::list).post(query_history::record).layer(DefaultBodyLimit::max(query_history::MAX_BODY_BYTES)))
+ .route("/{org_id}/query_history", get(query_history::list).post(query_history::record).delete(query_history::clear).layer(DefaultBodyLimit::max(query_history::MAX_BODY_BYTES)))
.route("/{org_id}/query_history/{id}", patch(query_history::star).delete(query_history::delete).layer(DefaultBodyLimit::max(query_history::MAX_BODY_BYTES)))
// Functions
@@ -2171,6 +2175,18 @@ pub fn service_routes() -> Router {
get(billing_group::check_membership).delete(billing_group::leave_group),
)
.route("/{org_id}/billing_group/members", get(billing_group::check_members))
+ .route(
+ "/{org_id}/settings/domains",
+ get(org_domains::get_linked_domain).post(org_domains::link_domain)
+ )
+ .route("/{org_id}/settings/domains/requests",post(org_domains::raise_request).get(org_domains::list_requests))
+ .route("/{org_id}/settings/domain/request/{id}/approve",post(org_domains::approve_request))
+ .route("/{org_id}/settings/domain/request/{id}/reject",post(org_domains::reject_request))
+ .route("/{org_id}/settings/domains/{domain}",delete(org_domains::delete_linked_domain))
+ .route(
+ "/{org_id}/settings/domains/verify/{domain}",
+ post(org_domains::verify_domain),
+ )
.route(
"/{org_id}/billing_group/members/{member_org_id}",
delete(billing_group::remove_member),
@@ -4356,6 +4372,29 @@ mod tests {
);
}
+ #[test]
+ fn query_history_list_publishes_both_response_shapes() {
+ let spec = serde_json::to_value(super::openapi::ApiDoc::openapi()).unwrap();
+ let ok = &spec["paths"]["/api/{org_id}/query_history"]["get"]["responses"]["200"];
+ assert_eq!(
+ ok["content"]["application/json"]["schema"]["$ref"],
+ "#/components/schemas/QueryHistoryListResponse"
+ );
+ let shapes = spec["components"]["schemas"]["QueryHistoryListResponse"]["oneOf"]
+ .as_array()
+ .expect("the list response is a oneOf");
+ let shapes = serde_json::to_string(shapes).unwrap();
+ assert!(
+ shapes.contains("#/components/schemas/QueryHistoryEntry"),
+ "{shapes}"
+ );
+ assert!(shapes.contains("\"type\":\"array\""), "{shapes}");
+ assert!(
+ shapes.contains("#/components/schemas/QueryHistoryCountResponse"),
+ "{shapes}"
+ );
+ }
+
#[cfg(feature = "enterprise")]
#[test]
fn experiment_lifecycle_actions_are_published_in_the_openapi_surface() {
diff --git a/src/api/http/src/handler/http/router/openapi.rs b/src/api/http/src/handler/http/router/openapi.rs
index 25cdcfed5e..4795c10455 100644
--- a/src/api/http/src/handler/http/router/openapi.rs
+++ b/src/api/http/src/handler/http/router/openapi.rs
@@ -120,6 +120,7 @@ use crate::{
openobserve_api_search::search::saved_view::update_view,
openobserve_api_management::request::query_history::record,
openobserve_api_management::request::query_history::list,
+ openobserve_api_management::request::query_history::clear,
openobserve_api_management::request::query_history::star,
openobserve_api_management::request::query_history::delete,
openobserve_api_management::request::folders::delete_folder,
@@ -698,6 +699,9 @@ use crate::{
openobserve_api_management::request::query_history::QueryHistoryRequest,
openobserve_api_management::request::query_history::QueryHistoryStarRequest,
openobserve_api_management::request::query_history::QueryHistoryEntry,
+ openobserve_api_management::request::query_history::QueryHistoryCountResponse,
+ openobserve_api_management::request::query_history::QueryHistoryListResponse,
+ openobserve_api_management::request::query_history::QueryHistoryClearResponse,
meta::user::UpdateUser,
meta::user::UserRoleRequest,
meta::user::PostUserRequest,
@@ -1618,6 +1622,7 @@ mod tests {
for (path, method) in [
("/api/{org_id}/query_history", "post"),
("/api/{org_id}/query_history", "get"),
+ ("/api/{org_id}/query_history", "delete"),
("/api/{org_id}/query_history/{id}", "patch"),
("/api/{org_id}/query_history/{id}", "delete"),
] {
diff --git a/src/api/management/Cargo.toml b/src/api/management/Cargo.toml
index 59729546bf..6f2c87107a 100644
--- a/src/api/management/Cargo.toml
+++ b/src/api/management/Cargo.toml
@@ -42,7 +42,8 @@ vectorscan = [
"search_service/vectorscan",
"stream/vectorscan",
]
-profiling = ["openobserve-core/profiling"]
+cpu-profiling = ["openobserve-core/cpu-profiling"]
+profiling = ["cpu-profiling", "openobserve-core/profiling"]
[dependencies]
anyhow.workspace = true
diff --git a/src/api/management/src/models/alerts/mod.rs b/src/api/management/src/models/alerts/mod.rs
index 3dc0c2a989..0895929e88 100644
--- a/src/api/management/src/models/alerts/mod.rs
+++ b/src/api/management/src/models/alerts/mod.rs
@@ -241,6 +241,10 @@ pub struct Alert {
#[serde(default)]
#[schema(example = 0)]
pub keep_firing_for: i64,
+
+ /// Notify once per state change instead of on every firing evaluation. Enterprise only.
+ #[serde(default)]
+ pub suppress_repeats: bool,
}
/// Configuration for when and how an alert should be triggered.
@@ -638,6 +642,7 @@ impl From<(meta_alerts::alert::Alert, Option)> for Alert {
pending_period_sec: alert.pending_period_sec,
notify_on_recovery: alert.notify_on_recovery,
keep_firing_for: alert.keep_firing_for,
+ suppress_repeats: alert.suppress_repeats,
}
}
}
@@ -847,6 +852,7 @@ impl From for meta_alerts::alert::Alert {
alert.notify_on_recovery = value.notify_on_recovery;
alert.recovery_destinations = value.recovery_destinations;
alert.keep_firing_for = value.keep_firing_for.max(0);
+ alert.suppress_repeats = value.suppress_repeats;
alert.normalize_promql_rule_mode();
alert
diff --git a/src/api/management/src/models/dashboards.rs b/src/api/management/src/models/dashboards.rs
index 5eb6e08c87..402ce77e78 100644
--- a/src/api/management/src/models/dashboards.rs
+++ b/src/api/management/src/models/dashboards.rs
@@ -558,6 +558,14 @@ mod tests {
"alert_list_folder": "",
"flame_graph_stack_field": "folded",
"flame_graph_value_field": "value",
+ "boxplot_whisker": "minmax",
+ "logs_message_field": "log",
+ "logs_level_field": "severity",
+ "logs_time_field": "_timestamp",
+ "logs_wrap": false,
+ "logs_prettify_json": true,
+ "logs_show_time": false,
+ "logs_order": "newest",
"override_config": [{
"field": {"matchBy": "series", "value": "errors"},
"config": [
diff --git a/src/api/management/src/request/alerts/history.rs b/src/api/management/src/request/alerts/history.rs
index 284e7e8802..e2c2b5de29 100644
--- a/src/api/management/src/request/alerts/history.rs
+++ b/src/api/management/src/request/alerts/history.rs
@@ -42,12 +42,13 @@ use crate::common::{
};
/// Columns a stream gains only once a row carried them, so [history_columns] adds each one present.
-const OPTIONAL_HISTORY_COLUMNS: [&str; 5] = [
+const OPTIONAL_HISTORY_COLUMNS: [&str; 6] = [
"downtime_id",
"acked_by",
"ack_action",
"ack_source",
"taken_over_from",
+ "repeat_suppressed",
];
/// Every column the data query reads but [OPTIONAL_HISTORY_COLUMNS].
@@ -144,6 +145,9 @@ pub struct AlertHistoryEntry {
pub ack_source: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub taken_over_from: Option,
+ /// `suppress_repeats` withheld this run because it repeated the last delivered state.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub repeat_suppressed: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -437,6 +441,7 @@ pub async fn get_alert_history(
ack_action: None,
ack_source: None,
taken_over_from: None,
+ repeat_suppressed: None,
}
})
.collect();
@@ -845,6 +850,7 @@ pub async fn get_alert_history(
ack_action: hit_text(&hit, "ack_action"),
ack_source: hit_text(&hit, "ack_source"),
taken_over_from: hit_text(&hit, "taken_over_from"),
+ repeat_suppressed: hit.get("repeat_suppressed").and_then(|v| v.as_bool()),
});
}
@@ -1256,6 +1262,7 @@ mod tests {
ack_action: None,
ack_source: None,
taken_over_from: None,
+ repeat_suppressed: None,
};
assert_eq!(entry.alert_name, "test_alert");
@@ -1315,6 +1322,7 @@ mod tests {
ack_action: None,
ack_source: None,
taken_over_from: None,
+ repeat_suppressed: None,
};
let response = AlertHistoryResponse {
@@ -1363,6 +1371,7 @@ mod tests {
ack_action: None,
ack_source: None,
taken_over_from: None,
+ repeat_suppressed: None,
};
assert_eq!(entry.status, "error");
@@ -1593,6 +1602,7 @@ mod tests {
ack_action: None,
ack_source: None,
taken_over_from: None,
+ repeat_suppressed: None,
};
let json = serde_json::to_string(&entry).unwrap();
@@ -1637,6 +1647,7 @@ mod tests {
ack_action: None,
ack_source: None,
taken_over_from: None,
+ repeat_suppressed: None,
};
let response = AlertHistoryResponse {
@@ -1688,6 +1699,7 @@ mod tests {
ack_action: None,
ack_source: None,
taken_over_from: None,
+ repeat_suppressed: None,
};
let json = serde_json::to_value(&entry).unwrap();
let obj = json.as_object().unwrap();
@@ -1729,6 +1741,7 @@ mod tests {
ack_action: None,
ack_source: None,
taken_over_from: None,
+ repeat_suppressed: None,
};
let json = serde_json::to_value(&entry).unwrap();
let obj = json.as_object().unwrap();
@@ -1826,4 +1839,17 @@ mod tests {
assert_eq!(hit_text(&hit, "acked_by").as_deref(), Some("dana@acme.com"));
assert_eq!(hit_text(&hit, "ack_action"), None);
}
+
+ #[test]
+ fn the_repeat_suppressed_column_is_read_once_the_stream_has_it() {
+ use arrow_schema::{DataType, Field};
+
+ let mut fields: Vec = HISTORY_COLUMNS
+ .split(", ")
+ .map(|name| Field::new(name, DataType::Utf8, true))
+ .collect();
+ assert!(!history_columns(&Schema::new(fields.clone())).contains("repeat_suppressed"));
+ fields.push(Field::new("repeat_suppressed", DataType::Boolean, true));
+ assert!(history_columns(&Schema::new(fields)).ends_with(", repeat_suppressed"));
+ }
}
diff --git a/src/api/management/src/request/cloud/org_usage.rs b/src/api/management/src/request/cloud/org_usage.rs
index 6640712cc4..49806c8a33 100644
--- a/src/api/management/src/request/cloud/org_usage.rs
+++ b/src/api/management/src/request/cloud/org_usage.rs
@@ -18,15 +18,10 @@ use axum::{
response::Response,
};
use hashbrown::HashMap;
-use o2_enterprise::enterprise::cloud::{
- billing_group,
- billings::{self, MeteringProvider},
-};
+use o2_enterprise::enterprise::cloud::billings::MeteringProvider;
-use super::IntoHttpResponse;
use crate::{
- common::meta::http::HttpResponse as MetaHttpResponse,
- models::billings::GetOrgUsageResponseBody, service::org_usage,
+ common::meta::http::HttpResponse as MetaHttpResponse, models::billings::GetOrgUsageResponseBody,
};
/// GetUsageData
diff --git a/src/api/management/src/request/fleet/mod.rs b/src/api/management/src/request/fleet/mod.rs
index 39d9697e27..bfee817f75 100644
--- a/src/api/management/src/request/fleet/mod.rs
+++ b/src/api/management/src/request/fleet/mod.rs
@@ -1609,7 +1609,11 @@ pub async fn list_audit(
if !allowed_on(FLEET_MANAGEMENT, &org_id, &user_email.user_id, "GET").await {
return MetaHttpResponse::forbidden("Forbidden");
}
- match openobserve_core::fleet::list_audit(&org_id, query).await {
+ let permitted = match permitted_fleets(&org_id, &user_email.user_id).await {
+ Ok(permitted) => permitted,
+ Err(refused) => return refused,
+ };
+ match openobserve_core::fleet::list_audit(&org_id, query, permitted.as_ref()).await {
Ok(mut list) => {
list.total = list.items.len();
MetaHttpResponse::json(list)
@@ -1808,7 +1812,7 @@ async fn desired_request(
}
}
-/// D19c: the fleets whose items an org-wide list or aggregate shows; `None` is unfiltered.
+/// D19c: the fleets an org-wide list or aggregate shows, whatever the list setting; `None` is all.
#[cfg(feature = "enterprise")]
async fn permitted_fleets(
org_id: &str,
@@ -1821,7 +1825,7 @@ async fn permitted_fleets(
if let Some(objects) = tests::listed_objects(org_id, user_id) {
return Ok(permitted_from(org_id, key, objects));
}
- match db::authz::list_objects_for_user(org_id, user_id, "GET", key).await {
+ match db::authz::list_permitted_objects(org_id, user_id, "GET", key).await {
Ok(objects) => Ok(permitted_from(org_id, key, objects)),
Err(e) => Err(MetaHttpResponse::forbidden(e)),
}
@@ -2272,7 +2276,7 @@ mod tests {
assert_eq!(
permitted_fleets(ORG, custom).await.ok(),
Some(None),
- "OpenFGA off, or the list flag off: unfiltered"
+ "OpenFGA off: unfiltered"
);
for (listed, want) in [
(None, None),
diff --git a/src/api/management/src/request/mod.rs b/src/api/management/src/request/mod.rs
index f50bb6e105..cd5e8d45be 100644
--- a/src/api/management/src/request/mod.rs
+++ b/src/api/management/src/request/mod.rs
@@ -58,7 +58,7 @@ pub mod organization;
pub mod password_policy;
#[cfg(feature = "enterprise")]
pub mod playground;
-#[cfg(feature = "profiling")]
+#[cfg(feature = "cpu-profiling")]
pub mod profiling;
#[cfg(feature = "enterprise")]
pub mod prompts;
@@ -90,4 +90,7 @@ pub mod users;
#[cfg(feature = "enterprise")]
pub mod workflows;
+#[cfg(feature = "cloud")]
+pub mod org_domains;
+
use openobserve_api_common::request::{BulkDeleteRequest, BulkDeleteResponse};
diff --git a/src/api/management/src/request/org_domains/mod.rs b/src/api/management/src/request/org_domains/mod.rs
new file mode 100644
index 0000000000..737b5098c4
--- /dev/null
+++ b/src/api/management/src/request/org_domains/mod.rs
@@ -0,0 +1,341 @@
+// 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 .
+
+use axum::{Json, extract::Path, response::Response};
+use common::meta::http::HttpResponse as MetaHttpResponse;
+use config::{META_ORG_ID, meta::status_pages::CreateDomainRequest, utils::str::is_valid_domain};
+use infra::table::org_domain_requests::Status;
+use openobserve_api_common::extractors::Headers;
+use openobserve_core::{org_domain_ownership, org_domain_requests};
+use serde::Deserialize;
+
+use crate::service::auth::UserEmail;
+
+#[derive(Deserialize)]
+pub struct RequestDecision {
+ reason: String,
+}
+pub async fn get_linked_domain(Path(org_id): Path) -> Response {
+ match org_domain_ownership::get_domains_for_org(&org_id).await {
+ Ok(v) => MetaHttpResponse::json(v),
+ Err(e) => {
+ log::error!("error listing domains for org {org_id} : {e}");
+ MetaHttpResponse::internal_error(format!("error listing domains : {e}"))
+ }
+ }
+}
+
+pub async fn link_domain(
+ Path(org_id): Path,
+ Headers(user): Headers,
+ Json(body): Json,
+) -> Response {
+ let domain = body.domain.trim().to_lowercase();
+
+ if !is_valid_domain(&domain) {
+ return MetaHttpResponse::bad_request(format!(
+ "domain {domain} is not a valid domain to link"
+ ));
+ }
+
+ match org_domain_ownership::get_verified_org_for_domain(&domain).await {
+ Ok(Some(v)) => {
+ log::info!(
+ "org {org_id} tried to link domain {domain} but it is already verified by org {v}"
+ );
+ let org_info = if v == org_id {
+ "to this org"
+ } else {
+ "by some other org"
+ };
+ return MetaHttpResponse::bad_request(format!(
+ "this domain is already linked {org_info}"
+ ));
+ }
+ Err(e) => {
+ return MetaHttpResponse::internal_error(format!(
+ "error getting org domain linking : {e}"
+ ));
+ }
+ _ => {}
+ }
+
+ let existing = match org_domain_requests::get_req_by_org_domain(&org_id, &domain).await {
+ Ok(v) => v,
+ Err(e) => {
+ return MetaHttpResponse::internal_error(format!(
+ "error getting org domain requests : {e}"
+ ));
+ }
+ };
+ if existing.is_some() {
+ return MetaHttpResponse::bad_request(
+ "there is already a request open for this domain by this org",
+ );
+ }
+
+ let attempt = match org_domain_ownership::get_domain_org_record(&org_id, &domain).await {
+ Ok(v) => v,
+ Err(e) => {
+ return MetaHttpResponse::internal_error(format!(
+ "error getting org domain requests : {e}"
+ ));
+ }
+ };
+
+ if attempt.is_some() {
+ return MetaHttpResponse::bad_request(format!(
+ "there is already a linking created for this domain by this org"
+ ));
+ }
+
+ match org_domain_ownership::save_org_domain_mapping(&org_id, &domain).await {
+ Ok(_) => {
+ log::info!(
+ "successfully created org domain ownership entry for {org_id} domain {domain} by user {}",
+ user.user_id
+ );
+ MetaHttpResponse::ok("successfully created mapping request")
+ }
+ Err(e) => {
+ log::error!("error saving domain {domain} link to org {org_id} : {e}",);
+ MetaHttpResponse::internal_error(format!("error saving org domain linking : {e}"))
+ }
+ }
+}
+
+pub async fn raise_request(
+ Path(org_id): Path,
+ Headers(user): Headers,
+ Json(body): Json,
+) -> Response {
+ let domain = body.domain.trim().to_lowercase();
+
+ if !is_valid_domain(&domain) {
+ return MetaHttpResponse::bad_request(format!("domain {domain} is not a valid domain",));
+ }
+
+ match org_domain_ownership::get_verified_org_for_domain(&domain).await {
+ Ok(Some(v)) => {
+ log::info!(
+ "org {org_id} tried to link domain {domain} but it is already verified by org {v}"
+ );
+ return MetaHttpResponse::bad_request("this domain is already linked by some org");
+ }
+ Err(e) => {
+ return MetaHttpResponse::internal_error(format!(
+ "error getting org domain linking : {e}"
+ ));
+ }
+ _ => {}
+ }
+
+ match org_domain_requests::get_req_by_org_domain(&org_id, &domain).await {
+ Err(e) => {
+ log::error!("error getting org domain request for {org_id} domain {domain} : {e}");
+ return MetaHttpResponse::internal_error(e);
+ }
+ Ok(Some(v)) => {
+ return MetaHttpResponse::bad_request(format!(
+ "org {org_id} already has a request for domain {domain} with status {:?}",
+ Status::from(v.status)
+ ));
+ }
+ _ => {}
+ }
+
+ match org_domain_requests::save_org_domain_request(&org_id, &domain, &user.user_id).await {
+ Ok(_) => {
+ log::info!(
+ "successfully created org domain ownership request for {org_id} domain {domain} by user {}",
+ user.user_id
+ );
+ MetaHttpResponse::ok("successfully created ownership request")
+ }
+ Err(e) => {
+ log::error!("error saving domain {domain} link request to org {org_id} : {e}",);
+ MetaHttpResponse::internal_error(format!(
+ "error saving org domain ownership request : {e}"
+ ))
+ }
+ }
+}
+
+pub async fn list_requests(Path(org_id): Path) -> Response {
+ let requests = if org_id == META_ORG_ID {
+ org_domain_requests::list_all_req().await
+ } else {
+ org_domain_requests::list_by_org(&org_id).await
+ };
+
+ match requests {
+ Ok(v) => MetaHttpResponse::json(v),
+ Err(e) => {
+ log::error!("error listing org domain mapping requests for org {org_id} : {e}");
+ MetaHttpResponse::internal_error(e)
+ }
+ }
+}
+
+pub async fn approve_request(
+ Path((org_id, id)): Path<(String, String)>,
+ Headers(user): Headers,
+ Json(body): Json,
+) -> Response {
+ if org_id != META_ORG_ID {
+ return MetaHttpResponse::forbidden("operation not allowed");
+ }
+ let reason = body.reason.trim().to_string();
+
+ let req = match org_domain_requests::get_req_by_id(&id).await {
+ Ok(Some(v)) => v,
+ Ok(None) => {
+ return MetaHttpResponse::not_found("request with given id not found");
+ }
+ Err(e) => {
+ log::error!("error getting org domain request by id {id} for approval : {e}");
+ return MetaHttpResponse::internal_error(e);
+ }
+ };
+
+ if req.status != Status::Pending as i32 {
+ return MetaHttpResponse::bad_request("This request is not pending, cannot be approved");
+ }
+
+ match org_domain_ownership::get_verified_org_for_domain(&req.domain).await {
+ Ok(Some(v)) => {
+ return MetaHttpResponse::bad_request(format!(
+ "this domain is already linked to org {v}, request cannot be processed"
+ ));
+ }
+ Err(e) => {
+ return MetaHttpResponse::internal_error(format!(
+ "error getting org domain linking : {e}"
+ ));
+ }
+ _ => {}
+ }
+
+ if let Err(e) = org_domain_ownership::link_from_meta(&req.org_id, &req.domain).await {
+ log::error!("error linking the domain to org after request approval : {e}");
+
+ return MetaHttpResponse::internal_error(format!(
+ "error linking domain from req {} : {e}",
+ req.id
+ ));
+ }
+
+ match org_domain_requests::update_status(&id, &user.user_id, Status::Approved, reason).await {
+ Ok(_) => MetaHttpResponse::ok("request approved successfully"),
+ Err(e) => {
+ log::error!("error approving domain ownership request {id} : {e}",);
+ MetaHttpResponse::internal_error(e)
+ }
+ }
+}
+
+pub async fn reject_request(
+ Path((org_id, id)): Path<(String, String)>,
+ Headers(user): Headers,
+ Json(body): Json,
+) -> Response {
+ if org_id != META_ORG_ID {
+ return MetaHttpResponse::forbidden("operation not allowed");
+ }
+ let reason = body.reason.trim().to_string();
+
+ let req = match org_domain_requests::get_req_by_id(&id).await {
+ Ok(Some(v)) => v,
+ Ok(None) => {
+ return MetaHttpResponse::not_found("request with given id not found");
+ }
+ Err(e) => {
+ log::error!("error getting org domain request by id {id} for approval : {e}");
+ return MetaHttpResponse::internal_error(e);
+ }
+ };
+
+ if req.status != Status::Pending as i32 {
+ return MetaHttpResponse::bad_request("This request is not pending, cannot be rejected");
+ }
+
+ if let Err(e) =
+ org_domain_requests::update_status(&id, &user.user_id, Status::Rejected, reason).await
+ {
+ log::error!("error rejecting domain ownership request {id} : {e}",);
+ return MetaHttpResponse::internal_error(format!(
+ "error approving org domain ownership request : {e}"
+ ));
+ }
+ log::info!(
+ "org domain request {id} for org {} successfully rejected by user {}",
+ req.org_id,
+ user.user_id
+ );
+ MetaHttpResponse::ok("request rejected successfully")
+}
+
+pub async fn delete_linked_domain(
+ Path((org_id, did)): Path<(String, String)>,
+ Headers(user): Headers,
+) -> Response {
+ let domain = did.trim().to_lowercase();
+ let record = match org_domain_ownership::get_domain_org_record(&org_id, &domain).await {
+ Ok(v) => v,
+ Err(e) => {
+ return MetaHttpResponse::internal_error(format!(
+ "error getting org domain linking : {e}"
+ ));
+ }
+ };
+
+ let Some(model) = record else {
+ return MetaHttpResponse::bad_request("org domain link not found");
+ };
+
+ match org_domain_ownership::delete_linked_domain(model).await {
+ Ok(_) => {
+ log::info!(
+ "removed domain link of {domain} from org {org_id} by user {}",
+ user.user_id
+ );
+ MetaHttpResponse::ok("successfully deleted mapping")
+ }
+ Err(e) => {
+ log::error!(
+ "error removing domain link of {domain} from org {org_id} by user {} : {e}",
+ user.user_id
+ );
+ MetaHttpResponse::internal_error(format!("error deleting org domain linking : {e}"))
+ }
+ }
+}
+
+pub async fn verify_domain(
+ Path((org_id, did)): Path<(String, String)>,
+ Headers(user): Headers,
+) -> Response {
+ let domain = did.trim().to_lowercase();
+ match org_domain_ownership::verify_domain_now(&org_id, &domain).await {
+ Ok(record) => MetaHttpResponse::json(record),
+ Err(e) => {
+ log::error!(
+ "error verifying domain {domain} for org {org_id} by user {}: {e}",
+ user.user_id
+ );
+ MetaHttpResponse::bad_request(e)
+ }
+ }
+}
diff --git a/src/api/management/src/request/organization/billing_group.rs b/src/api/management/src/request/organization/billing_group.rs
index c4644a4968..a1f6d532a5 100644
--- a/src/api/management/src/request/organization/billing_group.rs
+++ b/src/api/management/src/request/organization/billing_group.rs
@@ -316,6 +316,23 @@ pub async fn remove_member(
if let Err(e) = billing_group::remove_billing_member(&org_id, &member_org_id).await {
return HttpResponse::bad_request(format!("error in removing member: {e}"));
}
+
+ let org_id_copy = member_org_id.clone();
+
+ if let Err(e) = db::organization::update_org_setting(&org_id, move |settings| {
+ let before = settings.domain_org_mappings.len();
+ settings
+ .domain_org_mappings
+ .retain(|m| m.org_id != member_org_id);
+ settings.domain_org_mappings.len() != before
+ })
+ .await
+ {
+ log::error!(
+ "error updating domain mapping for {org_id_copy} after removing the membership from {org_id} : {e}"
+ );
+ };
+
HttpResponse::ok("successfully removed")
}
@@ -366,6 +383,23 @@ pub async fn leave_group(
if let Err(e) = billing_group::remove_billing_member(&membership.payer_org_id, &org_id).await {
return HttpResponse::bad_request(format!("error in leaving billing group: {e}"));
}
+
+ let org_id_copy = org_id.clone();
+
+ if let Err(e) =
+ db::organization::update_org_setting(&membership.payer_org_id, move |settings| {
+ let before = settings.domain_org_mappings.len();
+ settings.domain_org_mappings.retain(|m| m.org_id != org_id);
+ settings.domain_org_mappings.len() != before
+ })
+ .await
+ {
+ log::error!(
+ "error updating domain mapping for {} after removing the membership of {org_id_copy} : {e}",
+ &membership.payer_org_id
+ );
+ };
+
HttpResponse::ok("successfully left")
}
diff --git a/src/api/management/src/request/organization/settings.rs b/src/api/management/src/request/organization/settings.rs
index 9bdedb504f..65bda8e3f9 100644
--- a/src/api/management/src/request/organization/settings.rs
+++ b/src/api/management/src/request/organization/settings.rs
@@ -19,6 +19,8 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
+#[cfg(feature = "cloud")]
+use config::utils::str::is_valid_domain;
use db::organization::{get_org_setting, set_org_setting};
use infra::errors::{DbError, Error};
#[cfg(feature = "enterprise")]
@@ -28,7 +30,6 @@ use {
};
#[cfg(feature = "cloud")]
use {
- config::META_ORG_ID,
o2_enterprise::enterprise::cloud::{
billing_group::list_billing_group_members_of, billings::MeteringProvider, customer_billings,
},
@@ -78,13 +79,19 @@ pub async fn create(
#[cfg(feature = "cloud")]
let settings = match check_domain_org_mappings(&org_id, settings).await {
Ok(settings) => settings,
- Err(refusal) => return refusal,
+ Err(refusal) => return MetaHttpResponse::bad_request(refusal),
};
#[cfg(feature = "cloud")]
if let Err(response) = check_budget_config(&org_id, &settings).await {
return response;
}
+ #[cfg(feature = "cloud")]
+ let settings = match check_domain_management_config(settings).await {
+ Ok(settings) => settings,
+ Err(msg) => return MetaHttpResponse::bad_request(msg),
+ };
+
match db::organization::update_org_setting(&org_id, |data| {
apply_settings_payload(data, settings)
})
@@ -262,64 +269,130 @@ fn validate_settings_payload(settings: &OrganizationSettingPayload) -> Result<()
async fn check_domain_org_mappings(
org_id: &str,
mut settings: OrganizationSettingPayload,
-) -> Result {
+) -> Result {
+ use hashbrown::HashSet;
let Some(mut mappings) = settings.domain_org_mappings.take() else {
return Ok(settings);
};
- if org_id != META_ORG_ID {
- return Ok(settings);
- }
+ let child_orgs =
+ o2_enterprise::enterprise::cloud::billing_group::list_billing_group_members_of(&org_id)
+ .await
+ .unwrap_or_default();
+
+ let mut allowed_orgs: HashSet<_> = child_orgs.into_iter().map(|v| v.member_org_id).collect();
+ allowed_orgs.insert(org_id.to_string());
+
+ let mut seen_domains = HashSet::new();
for mapping in &mut mappings {
+ use openobserve_core::org_domain_ownership;
+
+ let domain = mapping.domain.trim().to_lowercase();
+
+ if !is_valid_domain(&domain) {
+ return Err(format!("domain {domain} is not a valid domain",));
+ }
+
+ if seen_domains.contains(&(domain.clone(), mapping.org_id.clone())) {
+ return Err(format!(
+ "domain {domain} is linked to {} multiple times",
+ mapping.org_id
+ ));
+ }
+ seen_domains.insert((domain.clone(), mapping.org_id.clone()));
+
+ if !allowed_orgs.contains(&mapping.org_id) {
+ return Err(format!(
+ "Not allowed to setup a mapping for org {}",
+ mapping.org_id
+ ));
+ }
+
+ if openobserve_core::organization::get_org(&mapping.org_id)
+ .await
+ .is_none()
+ {
+ return Err(format!("No org with org id {} found", mapping.org_id));
+ }
+
// A deleted org keeps its row as a tombstone, so the row alone does not make it a target.
match db::org_status::read_active(&mapping.org_id).await {
Ok(true) => {}
Ok(false) => return Err(inactive_mapping_target(&mapping.org_id).await),
Err(e) => {
- return Err(MetaHttpResponse::internal_error(format!(
+ return Err(format!(
"cannot read the status of org {}: {e}",
mapping.org_id
- )));
+ ));
}
}
- if mapping.domain.is_empty() || mapping.domain.contains(' ') {
- return Err(MetaHttpResponse::bad_request(format!(
+ if domain.is_empty() || domain.contains(' ') {
+ return Err(format!(
"domain cannot have space or be empty, bad domain '{}'",
mapping.domain
- )));
+ ));
}
if !matches!(
mapping.base_role.as_str(),
"admin" | "editor" | "viewer" | "allowed_user"
) {
- return Err(MetaHttpResponse::bad_request(format!(
+ return Err(format!(
"base role {} for org {} is not a valid base role - only admin, editor, viewer, allowed_user are supported",
mapping.base_role, mapping.org_id
- )));
+ ));
}
if let Some(claim) = mapping.role_claim_name.as_mut() {
if claim.trim().is_empty() {
- return Err(MetaHttpResponse::bad_request(
- "role claim name must not be empty",
- ));
+ return Err("role claim name must not be empty".into());
}
*claim = claim.trim().to_string();
}
- mapping.domain = mapping.domain.to_lowercase();
+ match org_domain_ownership::get_domain_org_record(&org_id, &domain).await {
+ Ok(None) => {
+ return Err("This domain is not linked yet".to_string());
+ }
+ Err(e) => {
+ return Err(format!("error getting domain mappings : {e}"));
+ }
+ Ok(Some(v)) => {
+ use infra::table::org_domain_ownership::OwnershipState;
+
+ if v.verification_state != OwnershipState::Verified as i32 {
+ return Err("This domain is not verified yet".to_string());
+ }
+ }
+ }
+
+ mapping.domain = domain;
}
settings.domain_org_mappings = Some(mappings);
Ok(settings)
}
+#[cfg(feature = "cloud")]
+async fn check_domain_management_config(
+ mut settings: OrganizationSettingPayload,
+) -> Result {
+ if let Some(mut config) = settings.domain_management_config {
+ config.blocked_emails = config
+ .blocked_emails
+ .into_iter()
+ .map(|v| v.trim().to_lowercase())
+ .collect();
+
+ settings.domain_management_config = Some(config);
+ }
+ Ok(settings)
+}
/// The refusal for a mapping whose target org is not active: missing, blocked, or unreadable.
#[cfg(feature = "cloud")]
-async fn inactive_mapping_target(org_id: &str) -> Response {
+async fn inactive_mapping_target(org_id: &str) -> String {
match infra::table::organizations::find(org_id).await {
- Ok(None) => MetaHttpResponse::bad_request(format!("No org with org id {org_id} found")),
- Ok(Some(_)) => MetaHttpResponse::bad_request(format!("org {org_id} is not active")),
- Err(e) => MetaHttpResponse::internal_error(format!("cannot read org {org_id}: {e}")),
+ Ok(None) => format!("No org with org id {org_id} found"),
+ Ok(Some(_)) => format!("org {org_id} is not active"),
+ Err(e) => format!("cannot read org {org_id}: {e}"),
}
}
@@ -449,6 +522,16 @@ fn apply_settings_payload(
field_found = true;
data.budget_config = configs;
}
+ #[cfg(feature = "cloud")]
+ if let Some(enforce) = settings.enforce_allowed_orgs_only {
+ field_found = true;
+ data.enforce_allowed_orgs_only = enforce;
+ }
+ #[cfg(feature = "cloud")]
+ if let Some(config) = settings.domain_management_config {
+ field_found = true;
+ data.domain_management_config = config;
+ }
// this will always be taken from orgs table, never from settings
data.free_trial_expiry = None;
field_found
diff --git a/src/api/management/src/request/profiling.rs b/src/api/management/src/request/profiling.rs
index c1d791cb6e..14ec5c8c0c 100644
--- a/src/api/management/src/request/profiling.rs
+++ b/src/api/management/src/request/profiling.rs
@@ -15,11 +15,14 @@
//! HTTP handlers for profiling endpoints
-use axum::{Json, extract::Query};
+#[cfg(feature = "profiling")]
+use axum::Json;
+use axum::extract::Query;
use config::get_config;
+use openobserve_core::self_profiles::dump_cpu_profile;
+#[cfg(feature = "profiling")]
use openobserve_core::self_profiles::{
- dump_cpu_profile, dump_memory_flamegraph, dump_memory_pprof,
- jemalloc_stats as read_jemalloc_stats,
+ dump_memory_flamegraph, dump_memory_pprof, jemalloc_stats as read_jemalloc_stats,
};
use serde::Deserialize;
@@ -29,6 +32,7 @@ use serde::Deserialize;
/// The profile can be analyzed with jeprof or converted to flamegraph.
///
/// Returns: Binary profile data
+#[cfg(feature = "profiling")]
pub async fn memory_profile() -> Result {
let pprof_data = dump_memory_pprof().await?;
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
@@ -58,6 +62,7 @@ pub async fn memory_profile() -> Result {
/// - allocated: Current allocated memory (bytes)
/// - resident: Current resident memory (bytes)
/// - metadata: Metadata overhead (bytes)
+#[cfg(feature = "profiling")]
pub async fn jemalloc_stats() -> Result, String> {
Ok(Json(read_jemalloc_stats()?))
}
diff --git a/src/api/management/src/request/public_dashboards/public.rs b/src/api/management/src/request/public_dashboards/public.rs
index 71b0354871..3f5f4243a3 100644
--- a/src/api/management/src/request/public_dashboards/public.rs
+++ b/src/api/management/src/request/public_dashboards/public.rs
@@ -19,7 +19,7 @@ use std::{sync::LazyLock, time::Duration};
use axum::{
Json,
- extract::{Extension, Path, Query},
+ extract::{Extension, Path, Query, rejection::QueryRejection},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
@@ -182,7 +182,7 @@ pub async fn config(ip: Option>, Path(slug): Path) ->
pub async fn data(
ip: Option>,
Path(slug): Path,
- Query(params): Query,
+ query: Result, QueryRejection>,
headers: HeaderMap,
) -> Response {
let if_none_match = headers
@@ -191,6 +191,9 @@ pub async fn data(
if rate_limited(ip.map(|e| e.0)) || shed_early(&slug, if_none_match) {
return with_headers(too_many_requests(READ_WINDOW));
}
+ let Ok(Query(params)) = query else {
+ return with_headers(invalid_range());
+ };
with_headers(serve_data(&slug, params.range.as_deref(), if_none_match).await)
}
@@ -430,6 +433,11 @@ fn expired() -> Response {
(StatusCode::GONE, "expired").into_response()
}
+// The extractor's own text names serde internals, e.g. a repeated `range`.
+fn invalid_range() -> Response {
+ (StatusCode::BAD_REQUEST, "invalid range").into_response()
+}
+
// A failed read is temporary, so the viewer retries instead of treating the link as gone.
fn server_error() -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong.").into_response()
@@ -833,6 +841,7 @@ mod tests {
StatusCode::OK.into_response(),
StatusCode::NOT_FOUND.into_response(),
expired(),
+ invalid_range(),
unavailable(),
too_many_requests(READ_WINDOW),
server_error(),
@@ -846,6 +855,22 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn a_query_the_extractor_refuses_is_a_plain_invalid_range() {
+ let uri: axum::http::Uri = "/data?range=r3600&range=r60".parse().unwrap();
+ let resp = data(
+ None,
+ Path("Qz8Zt2mR7aB3cD4eF5gH6Q".into()),
+ Query::try_from_uri(&uri),
+ HeaderMap::new(),
+ )
+ .await;
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
+ assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ assert_eq!(&body[..], b"invalid range");
+ }
+
#[test]
fn gate12_a_missing_settings_row_means_enabled_and_a_disabled_row_stops_serving() {
assert_eq!(org_gate(None), (true, 0));
diff --git a/src/api/management/src/request/query_history.rs b/src/api/management/src/request/query_history.rs
index 81be8fcbaf..b48d88d9d0 100644
--- a/src/api/management/src/request/query_history.rs
+++ b/src/api/management/src/request/query_history.rs
@@ -21,7 +21,10 @@ use axum::{
};
use infra::table::{
entity::query_history::Model,
- query_history::{self, MAX_STARRED_PER_USER, SetStarred},
+ query_history::{
+ self, DEFAULT_SOURCE, ListFilter, MAX_STARRED_PER_USER, NewRun, SOURCES, STATUS_ERROR,
+ STATUS_OK, SetStarred,
+ },
};
use openobserve_api_common::extractors::Headers;
use openobserve_core::auth::UserEmail;
@@ -34,14 +37,27 @@ use crate::common::meta::http::HttpResponse as MetaHttpResponse;
pub const MAX_BODY_BYTES: usize = 128 * 1024;
const MAX_QUERY_BYTES: usize = 16 * 1024;
const MAX_CONTEXT_BYTES: usize = 64 * 1024;
+const MAX_STREAMS: usize = 50;
const DEFAULT_LIMIT: u64 = 50;
const MAX_LIMIT: u64 = 200;
#[derive(Deserialize, Serialize, ToSchema)]
pub struct QueryHistoryRequest {
+ /// `logs`, `traces`, `metrics`, `dbm` or `profiles`; defaults to `metrics`.
+ #[serde(default)]
+ pub source: Option,
/// The query text, at most 16 KB and not blank.
pub query: String,
- /// Time range, step, chart type and the panel data that reopens the query, at most 64 KB.
+ /// Runs with the same source and key merge into one entry; defaults to the query text.
+ #[serde(default)]
+ pub query_key: Option,
+ /// Streams the query ran on, at most 50; each name non-empty and without a comma.
+ #[serde(default)]
+ pub streams: Vec,
+ /// `ok` or `error`; defaults to `ok`.
+ #[serde(default)]
+ pub status: Option,
+ /// What the page needs to restore the run, at most 64 KB; stored as given.
pub context: serde_json::Value,
}
@@ -55,24 +71,43 @@ pub struct QueryHistoryStarRequest {
pub struct QueryHistoryEntry {
/// Entry id.
pub id: String,
+ /// Page the query ran on.
+ pub source: String,
/// The query text.
pub query: String,
- /// Time range, step, chart type and the panel data that reopens the query.
+ /// Hex sha256 of the merge key; null on entries recorded before keys existed.
+ pub query_key: Option,
+ /// Streams the query ran on.
+ pub streams: Vec,
+ /// What the page needs to restore the run.
pub context: serde_json::Value,
+ /// `ok` or `error`, from the latest run; `ok` on entries recorded by v1.1.x.
+ pub status: String,
/// Starred entries are kept until deleted.
pub starred: bool,
- /// When the query was last run, in microseconds since the epoch.
+ /// How many runs merged into this entry; 1 on entries recorded by v1.1.x.
+ pub run_count: i64,
+ /// First run, in microseconds since the epoch; the last run on entries recorded by v1.1.x.
pub created_at: i64,
+ /// Latest run, in microseconds since the epoch.
+ pub updated_at: i64,
}
impl From for QueryHistoryEntry {
fn from(m: Model) -> Self {
Self {
+ streams: query_history::decode_streams(&m.streams),
id: m.id,
+ source: m.source,
query: m.query,
+ query_key: m.query_key,
context: m.context,
+ status: m.status,
starred: m.starred,
+ run_count: m.run_count,
created_at: m.created_at,
+ // A v1.1.x node in a rolling upgrade writes updated_at = 0; only the response is fixed.
+ updated_at: m.updated_at.max(m.created_at),
}
}
}
@@ -80,18 +115,55 @@ impl From for QueryHistoryEntry {
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct QueryHistoryListParams {
+ /// Comma list of sources (logs, traces, metrics, dbm, profiles); omitted means metrics.
+ pub source: Option,
+ /// `ok` or `error`.
+ pub status: Option,
+ /// Exact stream name the entry ran on.
+ pub stream: Option,
+ /// Case-insensitive substring of the query text or the stream names.
+ pub q: Option,
/// Only starred (`true`) or only unstarred (`false`) entries.
pub starred: Option,
- /// Substring match on the query text.
- pub q: Option,
+ /// Entries last run at or after this time, in microseconds.
+ pub ran_from: Option,
+ /// Entries last run at or before this time, in microseconds.
+ pub ran_to: Option,
/// Defaults to 50, at most 200.
pub limit: Option,
- /// Entries to skip, for paging. Defaults to 0.
+ /// Entries to skip, for paging. Defaults to 0, at most 9223372036854775807.
pub offset: Option,
+ /// Answer `{"count": n}`, the number of entries the filters match, instead of the entries.
+ pub count_only: Option,
}
-fn list_limit(limit: Option) -> u64 {
- limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
+#[derive(Deserialize, IntoParams)]
+#[into_params(parameter_in = Query)]
+pub struct QueryHistoryClearParams {
+ /// One source or a comma-separated list; all sources when omitted.
+ pub source: Option,
+ /// Also delete starred entries. Defaults to false.
+ pub include_starred: Option,
+}
+
+#[derive(Deserialize, Serialize, ToSchema)]
+pub struct QueryHistoryCountResponse {
+ /// Entries matching the filters, ignoring limit and offset.
+ pub count: u64,
+}
+
+/// The entries, or with `count_only=true` how many entries match.
+#[derive(Serialize, ToSchema)]
+#[serde(untagged)]
+pub enum QueryHistoryListResponse {
+ Entries(Vec),
+ Count(QueryHistoryCountResponse),
+}
+
+#[derive(Deserialize, Serialize, ToSchema)]
+pub struct QueryHistoryClearResponse {
+ /// Number of entries deleted.
+ pub deleted: u64,
}
/// RecordQueryHistory
@@ -103,7 +175,7 @@ fn list_limit(limit: Option) -> u64 {
tag = "Query History",
operation_id = "RecordQueryHistory",
summary = "Record a query history entry",
- description = "Records a query the caller ran. Running the same query as the caller's latest entry refreshes that entry instead of adding one. Stores nothing and answers 204 when ZO_QUERY_HISTORY_ENABLED is false.",
+ description = "Records a query the caller ran. A run with the same source and query_key as an existing entry of the caller merges into it: the run count goes up and the latest run time, context and status replace the old ones. Stores nothing and answers 204 when ZO_QUERY_HISTORY_ENABLED is false.",
security(
("Authorization"= [])
),
@@ -114,7 +186,7 @@ fn list_limit(limit: Option) -> u64 {
responses(
(status = 200, description = "Success", content_type = "application/json", body = QueryHistoryEntry),
(status = 204, description = "Query history is disabled; nothing was stored"),
- (status = 400, description = "Query empty, or query or context too large", content_type = "application/json", body = ()),
+ (status = 400, description = "Unknown source or status, query empty, query or context too large, too many streams, or an empty stream name or one with a comma (the message lists them)", content_type = "application/json", body = ()),
(status = 413, description = "Request body larger than 128 KB"),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
@@ -146,7 +218,7 @@ pub async fn record(
tag = "Query History",
operation_id = "ListQueryHistory",
summary = "List query history",
- description = "Lists the caller's query history, newest first. Empty when ZO_QUERY_HISTORY_ENABLED is false.",
+ description = "Lists the caller's query history, most recently run first. With count_only=true it answers {\"count\": n} (QueryHistoryCountResponse), the number of entries the filters match. Empty, or a count of 0, when ZO_QUERY_HISTORY_ENABLED is false.",
security(
("Authorization"= [])
),
@@ -155,7 +227,8 @@ pub async fn record(
QueryHistoryListParams,
),
responses(
- (status = 200, description = "Success", content_type = "application/json", body = Vec),
+ (status = 200, description = "The entries, or with count_only=true the count", content_type = "application/json", body = QueryHistoryListResponse),
+ (status = 400, description = "Unknown source or status, or an offset above 9223372036854775807", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -177,6 +250,49 @@ pub async fn list(
.await
}
+/// ClearQueryHistory
+
+#[utoipa::path(
+ delete,
+ path = "/{org_id}/query_history",
+ context_path = "/api",
+ tag = "Query History",
+ operation_id = "ClearQueryHistory",
+ summary = "Clear the caller's query history",
+ description = "Deletes the caller's query history entries for the given sources, or all sources. Starred entries are kept unless include_starred is true.",
+ security(
+ ("Authorization"= [])
+ ),
+ params(
+ ("org_id" = String, Path, description = "Organization name"),
+ QueryHistoryClearParams,
+ ),
+ responses(
+ (status = 200, description = "Success", content_type = "application/json", body = QueryHistoryClearResponse),
+ (status = 400, description = "Unknown source", content_type = "application/json", body = ()),
+ (status = 500, description = "Failure", content_type = "application/json", body = ()),
+ ),
+ extensions(
+ ("x-o2-ratelimit" = json!({"module": "Query History", "operation": "delete"})),
+ ("x-o2-mcp" = json!({"enabled": false}))
+ )
+)]
+pub async fn clear(
+ Path(org_id): Path,
+ Headers(user_email): Headers,
+ Query(params): Query,
+) -> Response {
+ let sources = match parse_sources(params.source.as_deref()) {
+ Ok(sources) => sources,
+ Err(e) => return MetaHttpResponse::bad_request(e),
+ };
+ let include_starred = params.include_starred.unwrap_or(false);
+ match query_history::clear(&org_id, &user_email.user_id, &sources, include_starred).await {
+ Ok(deleted) => MetaHttpResponse::json(QueryHistoryClearResponse { deleted }),
+ Err(e) => MetaHttpResponse::internal_error(e),
+ }
+}
+
/// StarQueryHistory
#[utoipa::path(
@@ -186,7 +302,7 @@ pub async fn list(
tag = "Query History",
operation_id = "StarQueryHistory",
summary = "Star or unstar a query history entry",
- description = "Starred entries are kept until deleted. A user can star at most 200 entries per organization.",
+ description = "Starred entries are kept until deleted. A user can star at most 200 entries per organization, shared across every source (logs, traces, metrics, dbm, profiles).",
security(
("Authorization"= [])
),
@@ -213,10 +329,10 @@ pub async fn star(
Json(req): Json,
) -> Response {
match query_history::set_starred(&org_id, &user_email.user_id, &id, req.starred).await {
- Ok(SetStarred::Updated(entry)) => MetaHttpResponse::json(QueryHistoryEntry::from(entry)),
+ Ok(SetStarred::Updated(entry)) => MetaHttpResponse::json(QueryHistoryEntry::from(*entry)),
Ok(SetStarred::NotFound) => MetaHttpResponse::not_found("query history entry not found"),
Ok(SetStarred::StarredCapReached) => MetaHttpResponse::bad_request(format!(
- "at most {MAX_STARRED_PER_USER} query history entries can be starred; unstar one first"
+ "at most {MAX_STARRED_PER_USER} query history entries can be starred across all sources; unstar one first"
)),
Err(e) => MetaHttpResponse::internal_error(e),
}
@@ -260,6 +376,81 @@ pub async fn delete(
}
}
+fn list_limit(limit: Option) -> u64 {
+ limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
+}
+
+/// The databases bind OFFSET as a signed 64-bit integer.
+fn list_offset(offset: Option) -> Result {
+ let offset = offset.unwrap_or(0);
+ if offset > i64::MAX as u64 {
+ return Err(format!("offset exceeds {}", i64::MAX));
+ }
+ Ok(offset)
+}
+
+/// Splits a comma list of sources; empty means all, an unknown name is an error.
+fn parse_sources(sources: Option<&str>) -> Result, String> {
+ let mut parsed = Vec::new();
+ for source in sources.unwrap_or("").split(',').map(str::trim) {
+ if source.is_empty() {
+ continue;
+ }
+ if !SOURCES.contains(&source) {
+ return Err(format!("unknown source: {source}"));
+ }
+ parsed.push(source.to_string());
+ }
+ Ok(parsed)
+}
+
+fn parse_status(status: Option<&str>) -> Result, String> {
+ match status.map(str::trim).filter(|s| !s.is_empty()) {
+ None => Ok(None),
+ Some(s) if s == STATUS_OK || s == STATUS_ERROR => Ok(Some(s.to_string())),
+ Some(s) => Err(format!("unknown status: {s}")),
+ }
+}
+
+/// The source and status a record stores, or why the request is refused.
+fn validate_record(req: &QueryHistoryRequest) -> Result<(String, String), String> {
+ let source = req
+ .source
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .unwrap_or(DEFAULT_SOURCE);
+ if !SOURCES.contains(&source) {
+ return Err(format!("unknown source: {source}"));
+ }
+ let status = parse_status(req.status.as_deref())?.unwrap_or_else(|| STATUS_OK.to_string());
+ if req.query.trim().is_empty() {
+ return Err("query is empty".to_string());
+ }
+ if req.query.len() > MAX_QUERY_BYTES {
+ return Err("query exceeds 16 KB".to_string());
+ }
+ if req.context.to_string().len() > MAX_CONTEXT_BYTES {
+ return Err("context exceeds 64 KB".to_string());
+ }
+ if req.streams.len() > MAX_STREAMS {
+ return Err(format!("at most {MAX_STREAMS} streams"));
+ }
+ let invalid: Vec = req
+ .streams
+ .iter()
+ .filter(|s| s.is_empty() || s.contains(','))
+ .map(|s| format!("{s:?}"))
+ .collect();
+ if !invalid.is_empty() {
+ return Err(format!(
+ "stream names must be non-empty and contain no comma: {}",
+ invalid.join(", ")
+ ));
+ }
+ Ok((source.to_string(), status))
+}
+
async fn record_entry(
enabled: bool,
org_id: &str,
@@ -269,24 +460,22 @@ async fn record_entry(
if !enabled {
return StatusCode::NO_CONTENT.into_response();
}
- if req.query.trim().is_empty() {
- return MetaHttpResponse::bad_request("query is empty");
- }
- if req.query.len() > MAX_QUERY_BYTES {
- return MetaHttpResponse::bad_request("query exceeds 16 KB");
- }
- if req.context.to_string().len() > MAX_CONTEXT_BYTES {
- return MetaHttpResponse::bad_request("context exceeds 64 KB");
- }
- match query_history::record(
+ let (source, status) = match validate_record(&req) {
+ Ok(valid) => valid,
+ Err(e) => return MetaHttpResponse::bad_request(e),
+ };
+ let run = NewRun {
org_id,
user_email,
- &req.query,
- req.context,
- config::utils::time::now_micros(),
- )
- .await
- {
+ source: &source,
+ query: &req.query,
+ query_key: req.query_key.as_deref(),
+ streams: &req.streams,
+ status: &status,
+ context: req.context,
+ now_us: config::utils::time::now_micros(),
+ };
+ match query_history::record(run).await {
Ok(entry) => MetaHttpResponse::json(QueryHistoryEntry::from(entry)),
Err(e) => MetaHttpResponse::internal_error(e),
}
@@ -298,45 +487,81 @@ async fn list_entries(
user_email: &str,
params: QueryHistoryListParams,
) -> Response {
+ let count_only = params.count_only.unwrap_or(false);
if !enabled {
- return MetaHttpResponse::json(Vec::::new());
+ return MetaHttpResponse::json(if count_only {
+ QueryHistoryListResponse::Count(QueryHistoryCountResponse { count: 0 })
+ } else {
+ QueryHistoryListResponse::Entries(Vec::new())
+ });
+ }
+ let (filter, offset) =
+ match list_filter(¶ms).and_then(|f| Ok((f, list_offset(params.offset)?))) {
+ Ok(valid) => valid,
+ Err(e) => return MetaHttpResponse::bad_request(e),
+ };
+ if count_only {
+ return match query_history::count(org_id, user_email, &filter).await {
+ Ok(count) => {
+ MetaHttpResponse::json(QueryHistoryListResponse::Count(QueryHistoryCountResponse {
+ count,
+ }))
+ }
+ Err(e) => MetaHttpResponse::internal_error(e),
+ };
}
match query_history::list(
org_id,
user_email,
- params.starred,
- params.q.as_deref(),
+ &filter,
list_limit(params.limit),
- params.offset.unwrap_or(0),
+ offset,
)
.await
{
- Ok(entries) => MetaHttpResponse::json(
- entries
- .into_iter()
- .map(QueryHistoryEntry::from)
- .collect::>(),
- ),
+ Ok(entries) => MetaHttpResponse::json(QueryHistoryListResponse::Entries(
+ entries.into_iter().map(QueryHistoryEntry::from).collect(),
+ )),
Err(e) => MetaHttpResponse::internal_error(e),
}
}
+fn list_filter(params: &QueryHistoryListParams) -> Result {
+ // A v1.1.x client sends no source and can only load Metrics entries.
+ let sources = parse_sources(params.source.as_deref())?;
+ let sources = if sources.is_empty() {
+ vec![DEFAULT_SOURCE.to_string()]
+ } else {
+ sources
+ };
+ Ok(ListFilter {
+ sources,
+ status: parse_status(params.status.as_deref())?,
+ stream: params.stream.clone(),
+ q: params.q.clone(),
+ starred: params.starred,
+ ran_from: params.ran_from,
+ ran_to: params.ran_to,
+ })
+}
+
#[cfg(test)]
mod tests {
- use infra::table::entity::query_history::Entity;
- use sea_orm::{ConnectionTrait, Schema};
use serde_json::{Value, json};
use super::*;
+ const ALL: &str = "source=logs,traces,metrics,dbm,profiles";
+
async fn setup() {
- infra::db::create_table().await.unwrap();
- let conn = infra::db::get_orm_client_rw().await;
- let backend = conn.get_database_backend();
- let mut stmt = Schema::new(backend).create_table_from_entity(Entity);
- conn.execute(backend.build(stmt.if_not_exists()))
- .await
- .unwrap();
+ static TABLE: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
+ TABLE
+ .get_or_init(|| async {
+ infra::db::create_table().await.unwrap();
+ let conn = infra::db::get_orm_client_rw().await;
+ query_history::create_table_for_tests(conn).await.unwrap();
+ })
+ .await;
}
fn user() -> String {
@@ -349,6 +574,17 @@ mod tests {
})
}
+ fn request(query: &str, context: Value) -> QueryHistoryRequest {
+ QueryHistoryRequest {
+ source: None,
+ query: query.to_string(),
+ query_key: None,
+ streams: Vec::new(),
+ status: None,
+ context,
+ }
+ }
+
async fn parts(resp: Response) -> (StatusCode, Value) {
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
@@ -360,23 +596,26 @@ mod tests {
)
}
- async fn post(org: &str, user: &str, query: &str, context: Value) -> (StatusCode, Value) {
- let req = QueryHistoryRequest {
- query: query.to_string(),
- context,
- };
+ async fn post_req(org: &str, user: &str, req: QueryHistoryRequest) -> (StatusCode, Value) {
parts(record(Path(org.to_string()), headers(user), Json(req)).await).await
}
+ async fn post(org: &str, user: &str, query: &str, context: Value) -> (StatusCode, Value) {
+ post_req(org, user, request(query, context)).await
+ }
+
async fn get(org: &str, user: &str, params: &str) -> Vec {
- let params: QueryHistoryListParams = parse_params(params);
- let (status, body) =
- parts(list(Path(org.to_string()), headers(user), Query(params)).await).await;
- assert_eq!(status, StatusCode::OK);
+ let (status, body) = get_raw(org, user, params).await;
+ assert_eq!(status, StatusCode::OK, "{body}");
body.as_array().unwrap().clone()
}
- fn parse_params(params: &str) -> QueryHistoryListParams {
+ async fn get_raw(org: &str, user: &str, params: &str) -> (StatusCode, Value) {
+ let params: QueryHistoryListParams = parse_params(params);
+ parts(list(Path(org.to_string()), headers(user), Query(params)).await).await
+ }
+
+ fn parse_params(params: &str) -> T {
let uri: axum::http::Uri = format!("/?{params}").parse().unwrap();
Query::try_from_uri(&uri).unwrap().0
}
@@ -392,6 +631,17 @@ mod tests {
parts(delete(path, headers(user)).await).await.0
}
+ async fn clear_all(org: &str, user: &str, params: &str) -> (StatusCode, Value) {
+ let params: QueryHistoryClearParams = parse_params(params);
+ parts(clear(Path(org.to_string()), headers(user), Query(params)).await).await
+ }
+
+ fn queries(rows: Vec) -> Vec {
+ rows.iter()
+ .map(|e| e["query"].as_str().unwrap().to_string())
+ .collect()
+ }
+
#[tokio::test]
async fn test_another_users_entry_is_not_found() {
setup().await;
@@ -438,73 +688,317 @@ mod tests {
}
#[tokio::test]
- async fn test_rerun_updates_the_latest_entry_context() {
+ async fn test_an_old_client_without_source_or_key_merges_by_query_into_metrics() {
setup().await;
let a = user();
post("default", &a, "up", json!({"chart": "line"})).await;
- post("default", &a, "up", json!({"chart": "bar"})).await;
+ let (_, entry) = post("default", &a, "up", json!({"chart": "bar"})).await;
+ assert_eq!(entry["source"], "metrics");
+ assert_eq!(entry["status"], "ok");
+ assert_eq!(entry["run_count"], 2);
+ assert_eq!(entry["streams"], json!([]));
+ assert_eq!(entry["query_key"].as_str().unwrap().len(), 64);
let listed = get("default", &a, "").await;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0]["context"]["chart"], "bar");
+ assert!(listed[0]["updated_at"].as_i64() >= listed[0]["created_at"].as_i64());
}
#[tokio::test]
- async fn test_oversized_query_or_context_is_rejected() {
+ async fn test_a_v1_1_row_without_updated_at_answers_its_created_at() {
+ use sea_orm::ConnectionTrait;
setup().await;
let a = user();
- let long_query = "x".repeat(MAX_QUERY_BYTES + 1);
+ let id = config::ider::uuid();
+ infra::db::get_orm_client_rw()
+ .await
+ .execute_unprepared(&format!(
+ "INSERT INTO query_history (id, org_id, user_email, query, context, created_at, \
+ updated_at, source) VALUES ('{id}', 'default', '{a}', 'up', '{{}}', 500, 0, \
+ 'metrics')"
+ ))
+ .await
+ .unwrap();
+
+ let listed = get("default", &a, "").await;
assert_eq!(
- post("default", &a, &long_query, json!({})).await.0,
- StatusCode::BAD_REQUEST
+ (
+ listed[0]["created_at"].as_i64(),
+ listed[0]["updated_at"].as_i64()
+ ),
+ (Some(500), Some(500))
);
- let big = json!({"d": "x".repeat(MAX_CONTEXT_BYTES)});
- assert_eq!(
- post("default", &a, "up", big).await.0,
- StatusCode::BAD_REQUEST
- );
- let max_query = "x".repeat(MAX_QUERY_BYTES);
- assert_eq!(
- post("default", &a, &max_query, json!({})).await.0,
- StatusCode::OK
- );
- assert_eq!(get("default", &a, "").await.len(), 1);
+ let (status, starred) = patch("default", &a, &id, true).await;
+ assert_eq!(status, StatusCode::OK);
+ assert_eq!(starred["updated_at"], 500);
+ assert_eq!(starred["created_at"], 500);
}
#[tokio::test]
- async fn test_blank_query_is_rejected() {
+ async fn test_repeats_of_one_search_stay_one_entry() {
setup().await;
let a = user();
- for query in ["", " ", "\n\t "] {
+ for _ in 0..3 {
+ let req = QueryHistoryRequest {
+ source: Some("logs".to_string()),
+ query_key: Some("k8s|sql|up|".to_string()),
+ streams: vec!["k8s".to_string()],
+ ..request("up", json!({}))
+ };
+ assert_eq!(post_req("default", &a, req).await.0, StatusCode::OK);
+ }
+ let rows = get("default", &a, "source=logs").await;
+ assert_eq!(rows.len(), 1);
+ assert_eq!(rows[0]["run_count"], 3);
+ assert_eq!(rows[0]["streams"], json!(["k8s"]));
+ }
+
+ #[tokio::test]
+ async fn test_query_key_is_a_hash_per_user_and_falls_back_to_the_query() {
+ setup().await;
+ let (a, b) = (user(), user());
+ let keyed = |key: Option<&str>| QueryHistoryRequest {
+ source: Some("logs".to_string()),
+ query_key: key.map(str::to_string),
+ ..request("up", json!({}))
+ };
+ let (_, first) = post_req("default", &a, keyed(Some("k8s|sql|up|"))).await;
+ let key = first["query_key"].as_str().unwrap();
+ assert_eq!(key.len(), 64);
+ assert!(key.chars().all(|c| c.is_ascii_hexdigit()), "{key}");
+ assert_ne!(key, "k8s|sql|up|");
+
+ let (_, other_user) = post_req("default", &b, keyed(Some("k8s|sql|up|"))).await;
+ assert_eq!(other_user["query_key"], first["query_key"]);
+ assert_ne!(other_user["id"], first["id"], "user_email is in the key");
+ assert_eq!(other_user["run_count"], 1);
+
+ let (_, no_key) = post_req("default", &a, keyed(None)).await;
+ let (_, query_as_key) = post_req("default", &a, keyed(Some("up"))).await;
+ assert_eq!(query_as_key["id"], no_key["id"], "no key hashes the query");
+ assert_eq!(query_as_key["run_count"], 2);
+ assert_eq!(get("default", &a, "source=logs").await.len(), 2);
+ }
+
+ #[test]
+ fn test_invalid_stream_names_are_listed_in_the_error() {
+ let req = QueryHistoryRequest {
+ streams: vec!["ok".to_string(), "a,b".to_string(), String::new()],
+ ..request("up", json!({}))
+ };
+ assert_eq!(
+ validate_record(&req).unwrap_err(),
+ r#"stream names must be non-empty and contain no comma: "a,b", """#
+ );
+ }
+
+ #[tokio::test]
+ async fn test_record_validation() {
+ setup().await;
+ let a = user();
+ let refused = [
+ QueryHistoryRequest {
+ source: Some("dashboards".to_string()),
+ ..request("up", json!({}))
+ },
+ QueryHistoryRequest {
+ status: Some("timeout".to_string()),
+ ..request("up", json!({}))
+ },
+ QueryHistoryRequest {
+ streams: (0..=MAX_STREAMS).map(|i| format!("s{i}")).collect(),
+ ..request("up", json!({}))
+ },
+ QueryHistoryRequest {
+ streams: vec!["a,b".to_string()],
+ ..request("up", json!({}))
+ },
+ request(&"x".repeat(MAX_QUERY_BYTES + 1), json!({})),
+ request("up", json!({"d": "x".repeat(MAX_CONTEXT_BYTES)})),
+ request("", json!({})),
+ request(" \n\t ", json!({})),
+ ];
+ for req in refused {
assert_eq!(
- post("default", &a, query, json!({})).await.0,
- StatusCode::BAD_REQUEST,
- "{query:?}"
+ post_req("default", &a, req).await.0,
+ StatusCode::BAD_REQUEST
);
}
assert!(get("default", &a, "").await.is_empty());
+
+ let accepted = QueryHistoryRequest {
+ source: Some("traces".to_string()),
+ status: Some("error".to_string()),
+ streams: (0..MAX_STREAMS).map(|i| format!("s{i}")).collect(),
+ ..request(&"x".repeat(MAX_QUERY_BYTES), json!({}))
+ };
+ let (status, entry) = post_req("default", &a, accepted).await;
+ assert_eq!(status, StatusCode::OK, "{entry}");
+ assert_eq!(entry["status"], "error");
+ assert_eq!(entry["source"], "traces");
}
#[tokio::test]
async fn test_list_applies_filters() {
setup().await;
let a = user();
- for query in ["up", "rate(http_total[5m])", "sum(http_total)"] {
- post("default", &a, query, json!({})).await;
+ for (source, query, stream, status) in [
+ ("metrics", "up", None, "ok"),
+ ("logs", "level = 'error'", Some("k8s_logs"), "error"),
+ ("traces", "service = 'cart'", Some("default"), "ok"),
+ ("metrics", "sum(http_total)", None, "ok"),
+ ] {
+ let req = QueryHistoryRequest {
+ source: Some(source.to_string()),
+ streams: stream.map(|s| vec![s.to_string()]).unwrap_or_default(),
+ status: Some(status.to_string()),
+ ..request(query, json!({}))
+ };
+ post_req("default", &a, req).await;
}
- let queries = |rows: Vec| {
- rows.iter()
- .map(|e| e["query"].as_str().unwrap().to_string())
- .collect::>()
- };
assert_eq!(
- queries(get("default", &a, "q=http_total&limit=1").await),
+ queries(get("default", &a, "source=logs,traces").await),
+ vec!["service = 'cart'", "level = 'error'"]
+ );
+ assert_eq!(
+ queries(get("default", &a, &format!("{ALL}&status=error")).await),
+ vec!["level = 'error'"]
+ );
+ assert_eq!(
+ queries(get("default", &a, &format!("{ALL}&stream=k8s_logs")).await),
+ vec!["level = 'error'"]
+ );
+ assert_eq!(
+ queries(get("default", &a, &format!("{ALL}&q=K8S")).await),
+ vec!["level = 'error'"]
+ );
+ assert_eq!(
+ queries(get("default", &a, "").await),
+ vec!["sum(http_total)", "up"],
+ "a client that sends no source is a v1.1.x Metrics page"
+ );
+ assert_eq!(
+ queries(get("default", &a, "source=metrics&limit=1").await),
vec!["sum(http_total)"]
);
assert_eq!(
- queries(get("default", &a, "q=http_total&offset=1").await),
- vec!["rate(http_total[5m])"]
+ queries(get("default", &a, "source=metrics&offset=1").await),
+ vec!["up"]
+ );
+ assert!(
+ get("default", &a, &format!("{ALL}&ran_from=1&ran_to=2"))
+ .await
+ .is_empty()
+ );
+ assert_eq!(
+ get("default", &a, &format!("{ALL}&ran_from=1")).await.len(),
+ 4
+ );
+ assert!(
+ get("default", &a, &format!("{ALL}&starred=true"))
+ .await
+ .is_empty()
+ );
+ assert_eq!(
+ get_raw("default", &a, "source=dashboards").await.0,
+ StatusCode::BAD_REQUEST
+ );
+ assert_eq!(
+ get_raw("default", &a, "status=partial").await.0,
+ StatusCode::BAD_REQUEST
+ );
+ }
+
+ #[tokio::test]
+ async fn test_count_only_answers_the_count_for_the_filters() {
+ setup().await;
+ let a = user();
+ for (source, query) in [
+ ("logs", "a"),
+ ("logs", "b"),
+ ("traces", "c"),
+ ("metrics", "d"),
+ ] {
+ let req = QueryHistoryRequest {
+ source: Some(source.to_string()),
+ ..request(query, json!({}))
+ };
+ post_req("default", &a, req).await;
+ }
+ let starred = get("default", &a, "source=logs").await[0]["id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ patch("default", &a, &starred, true).await;
+ let count = |params: &str| {
+ let a = a.clone();
+ let params = format!("count_only=true&{params}");
+ async move { get_raw("default", &a, ¶ms).await }
+ };
+ assert_eq!(
+ count("source=logs").await,
+ (StatusCode::OK, json!({"count": 2}))
+ );
+ assert_eq!(
+ count("source=logs&starred=false").await.1,
+ json!({"count": 1})
+ );
+ assert_eq!(
+ count(&format!("{ALL}&starred=true")).await.1,
+ json!({"count": 1})
+ );
+ assert_eq!(count(ALL).await.1, json!({"count": 4}));
+ assert_eq!(count("source=logs&limit=1").await.1, json!({"count": 2}));
+ assert_eq!(count("source=nope").await.0, StatusCode::BAD_REQUEST);
+
+ let disabled = list_entries(
+ false,
+ "default",
+ &a,
+ parse_params("count_only=true&source=logs"),
+ )
+ .await;
+ assert_eq!(parts(disabled).await, (StatusCode::OK, json!({"count": 0})));
+ }
+
+ #[tokio::test]
+ async fn test_clear_my_history() {
+ setup().await;
+ let (a, b) = (user(), user());
+ for (source, query) in [
+ ("logs", "a"),
+ ("logs", "b"),
+ ("traces", "c"),
+ ("metrics", "d"),
+ ] {
+ let req = QueryHistoryRequest {
+ source: Some(source.to_string()),
+ ..request(query, json!({}))
+ };
+ post_req("default", &a, req).await;
+ }
+ post("default", &b, "up", json!({})).await;
+ let starred = get("default", &a, "source=logs").await[0]["id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ patch("default", &a, &starred, true).await;
+
+ let (status, body) = clear_all("default", &a, "source=logs").await;
+ assert_eq!(status, StatusCode::OK);
+ assert_eq!(body, json!({"deleted": 1}));
+ assert_eq!(clear_all("default", &a, "").await.1, json!({"deleted": 2}));
+ assert_eq!(queries(get("default", &a, ALL).await), vec!["b"]);
+ assert_eq!(
+ clear_all("default", &a, "include_starred=true").await.1,
+ json!({"deleted": 1})
+ );
+ assert!(get("default", &a, ALL).await.is_empty());
+ assert_eq!(get("default", &b, ALL).await.len(), 1);
+ assert_eq!(
+ clear_all("default", &a, "source=nope").await.0,
+ StatusCode::BAD_REQUEST
);
- assert!(get("default", &a, "starred=true").await.is_empty());
}
#[tokio::test]
@@ -512,10 +1006,7 @@ mod tests {
setup().await;
let a = user();
post("default", &a, "up", json!({})).await;
- let req = QueryHistoryRequest {
- query: "rate(x[5m])".to_string(),
- context: json!({}),
- };
+ let req = request("rate(x[5m])", json!({}));
let (status, body) = parts(record_entry(false, "default", &a, req).await).await;
assert_eq!(status, StatusCode::NO_CONTENT);
assert_eq!(body, Value::Null, "204 carries no body");
@@ -535,4 +1026,37 @@ mod tests {
assert_eq!(list_limit(Some(500)), 200);
assert_eq!(list_limit(Some(0)), 1);
}
+
+ #[tokio::test]
+ async fn test_an_offset_past_i64_max_is_a_bad_request() {
+ setup().await;
+ let a = user();
+ post("default", &a, "up", json!({})).await;
+ let max = i64::MAX as u64;
+ let (status, _) = get_raw("default", &a, &format!("offset={}", max + 1)).await;
+ assert_eq!(status, StatusCode::BAD_REQUEST);
+ let (status, _) = get_raw(
+ "default",
+ &a,
+ &format!("offset={}&count_only=true", u64::MAX),
+ )
+ .await;
+ assert_eq!(status, StatusCode::BAD_REQUEST);
+ assert!(
+ get("default", &a, &format!("offset={max}"))
+ .await
+ .is_empty()
+ );
+ assert_eq!(list_offset(None), Ok(0));
+ }
+
+ #[test]
+ fn test_sources_parse_as_a_comma_list_of_known_pages() {
+ assert_eq!(parse_sources(None), Ok(vec![]));
+ assert_eq!(
+ parse_sources(Some("logs, traces,")),
+ Ok(vec!["logs".to_string(), "traces".to_string()])
+ );
+ assert!(parse_sources(Some("logs,alerts")).is_err());
+ }
}
diff --git a/src/api/management/src/request/status/mod.rs b/src/api/management/src/request/status/mod.rs
index c96ae67ab5..5431120d60 100644
--- a/src/api/management/src/request/status/mod.rs
+++ b/src/api/management/src/request/status/mod.rs
@@ -249,6 +249,10 @@ struct ConfigResponse<'a> {
synthetics_recorder_extension_url: String,
/// The only DBM flag (`ZO_DB_MONITORING_ENABLED`): one OSS switch for every signal.
database_monitoring_enabled: bool,
+ /// Off hides recent searches in the UI; the server then records nothing.
+ query_history_enabled: bool,
+ /// Days unstarred recent searches are kept; 0 or less keeps them until the per-page cap.
+ query_history_retention_days: i64,
enable_cross_linking: bool,
show_fts_field_values: bool,
search_inspector_enabled: bool,
@@ -668,6 +672,8 @@ pub async fn zo_config(
synthetics_browser_max_steps,
synthetics_recorder_extension_url: synthetics_recorder_extension_url.to_string(),
database_monitoring_enabled: cfg.db_monitoring.enabled,
+ query_history_enabled: cfg.limit.query_history_enabled,
+ query_history_retention_days: cfg.limit.query_history_retention_days,
enable_cross_linking: cfg.common.enable_cross_linking,
show_fts_field_values: cfg.common.show_fts_field_values,
search_inspector_enabled,
@@ -1286,11 +1292,11 @@ pub async fn redirect(
Ok(v) => v.map(|(new_user, pending_invites)| {
format!("&new_user_login={new_user}&pending_invites={pending_invites}")
}),
- Err(_) => {
+ Err(e) => {
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, "application/json")
- .body(Body::from("\"Email Domain not allowed\""))
+ .body(Body::from(e.to_string()))
.unwrap();
}
};
@@ -2563,6 +2569,20 @@ mod tests {
assert_eq!(json["public_dashboards_enabled"], true);
}
+ #[tokio::test]
+ async fn test_zo_config_serves_query_history_settings() {
+ let cfg = get_config();
+ let json = zo_config_json().await;
+ assert_eq!(
+ json["query_history_enabled"],
+ cfg.limit.query_history_enabled
+ );
+ assert_eq!(
+ json["query_history_retention_days"],
+ cfg.limit.query_history_retention_days
+ );
+ }
+
#[tokio::test]
async fn test_zo_config_reports_slack_per_org() {
let reason = if cfg!(feature = "enterprise") {
diff --git a/src/common/src/meta/organization.rs b/src/common/src/meta/organization.rs
index 6413cbcfde..04c11f4b97 100644
--- a/src/common/src/meta/organization.rs
+++ b/src/common/src/meta/organization.rs
@@ -14,6 +14,8 @@
// along with this program. If not, see .
use config::{meta::user::UserRole, stats::MemorySize};
+#[cfg(feature = "cloud")]
+use o2_enterprise::enterprise::domain_management::meta::DomainManagementConfig;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -539,8 +541,16 @@ pub struct OrganizationSettingPayload {
pub claim_parser_function: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub cross_links: Option>,
+ #[cfg(feature = "cloud")]
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_org_mappings: Option>,
+ #[cfg(feature = "cloud")]
+ #[serde(default)]
+ pub domain_management_config: Option,
+ #[cfg(feature = "cloud")]
+ #[serde(default)]
+ pub enforce_allowed_orgs_only: Option,
+ #[cfg(feature = "cloud")]
#[serde(skip_serializing_if = "Option::is_none")]
pub budget_config: Option>,
}
@@ -592,6 +602,12 @@ pub struct OrganizationSetting {
pub domain_org_mappings: Vec,
#[cfg(feature = "cloud")]
#[serde(default)]
+ pub enforce_allowed_orgs_only: bool,
+ #[cfg(feature = "cloud")]
+ #[serde(default)]
+ pub domain_management_config: DomainManagementConfig,
+ #[cfg(feature = "cloud")]
+ #[serde(default)]
pub budget_config: Vec,
}
@@ -631,6 +647,10 @@ impl Default for OrganizationSetting {
#[cfg(feature = "cloud")]
domain_org_mappings: Vec::new(),
#[cfg(feature = "cloud")]
+ domain_management_config: Default::default(),
+ #[cfg(feature = "cloud")]
+ enforce_allowed_orgs_only: false,
+ #[cfg(feature = "cloud")]
budget_config: Vec::new(),
}
}
@@ -1474,7 +1494,13 @@ mod tests {
#[cfg(feature = "enterprise")]
claim_parser_function: None,
cross_links: None,
+ #[cfg(feature = "cloud")]
domain_org_mappings: None,
+ #[cfg(feature = "cloud")]
+ domain_management_config: None,
+ #[cfg(feature = "cloud")]
+ enforce_allowed_orgs_only: None,
+ #[cfg(feature = "cloud")]
budget_config: None,
};
let json = serde_json::to_value(&payload).unwrap();
@@ -1558,6 +1584,10 @@ mod tests {
#[cfg(feature = "cloud")]
domain_org_mappings: vec![],
#[cfg(feature = "cloud")]
+ domain_management_config: Default::default(),
+ #[cfg(feature = "cloud")]
+ enforce_allowed_orgs_only: false,
+ #[cfg(feature = "cloud")]
budget_config: vec![],
};
let json = serde_json::to_value(&setting).unwrap();
@@ -1592,6 +1622,10 @@ mod tests {
#[cfg(feature = "cloud")]
domain_org_mappings: vec![],
#[cfg(feature = "cloud")]
+ domain_management_config: Default::default(),
+ #[cfg(feature = "cloud")]
+ enforce_allowed_orgs_only: false,
+ #[cfg(feature = "cloud")]
budget_config: vec![],
};
let json = serde_json::to_value(&setting).unwrap();
diff --git a/src/config/src/config.rs b/src/config/src/config.rs
index 5a8a766c06..7c3f9ad8df 100644
--- a/src/config/src/config.rs
+++ b/src/config/src/config.rs
@@ -126,7 +126,10 @@ pub type RwBTreeMap = tokio::sync::RwLock>;
// 126: add held_action to slack_link_requests.
// 127: add group_label_set to alert_states.
// 128: widen alert_dedup_state.fingerprint to varchar(256).
-pub const DB_SCHEMA_VERSION: u64 = 128;
+// 129: add source, query_key, streams, run_count, updated_at and status to query_history.
+// 130: add suppress_repeats to alerts.
+// 131: org domain mapping migrations
+pub const DB_SCHEMA_VERSION: u64 = 131;
pub const DB_SCHEMA_KEY: &str = "/db_schema_version/";
// global version variables
@@ -526,6 +529,11 @@ pub const SYNTHETICS_RELOAD_CLASSES: &[(&str, SyntheticsReloadClass)] = &[
"ZO_SYNTHETICS_ORPHAN_DETECTION_ENABLED",
SyntheticsReloadClass::Hot,
),
+ ("ZO_SYNTHETICS_LEASE_BATCH_SIZE", SyntheticsReloadClass::Hot),
+ (
+ "ZO_SYNTHETICS_MAX_INFLIGHT_PER_TICK",
+ SyntheticsReloadClass::Hot,
+ ),
// Were `HotViaLimits` while these lived in `o2_enterprise` and had to be
// pushed across a seam into this crate. They are declared here now, so the
// seam and its class are both gone and they are plainly `Hot`.
@@ -580,6 +588,8 @@ pub(crate) fn synthetics_restart_required_changes(
devices: _,
scheduler_jitter_enabled: _,
orphan_detection_enabled: _,
+ lease_batch_size: _,
+ max_inflight_per_tick: _,
} = new;
let mut changed = Vec::new();
@@ -1126,14 +1136,14 @@ impl PublicAiChat {
}
}
-/// Background self CPU/memory profile ingest into `_meta.self_profiles`.
-/// Gated at runtime by `enabled`; sampling code is compile-gated on `profiling`.
+/// Background self CPU/memory profile ingest into the `default` profiles stream.
+/// Gated at runtime by `enabled`; CPU needs `cpu-profiling`, memory needs `profiling`.
#[derive(Debug, Serialize, EnvConfig, Default)]
pub struct SelfProfiles {
#[env_config(
name = "ZO_SELF_PROFILES_ENABLED",
default = false,
- help = "Enable background self CPU/memory profile sampling into _meta.self_profiles"
+ help = "Enable background self CPU/memory profile sampling into the default profiles stream"
)]
pub enabled: bool,
#[env_config(
@@ -1493,6 +1503,23 @@ pub struct Synthetics {
help = "Kill switch for orphan detection, which reports enabled checks no scheduler is claiming."
)]
pub orphan_detection_enabled: bool,
+ /// Jobs leased per pool in one dispatcher lease call. Must be > 0; a
+ /// non-positive value falls back to the default. Read per tick.
+ #[env_config(
+ name = "ZO_SYNTHETICS_LEASE_BATCH_SIZE",
+ default = 10,
+ help = "Jobs the dispatcher leases per pool per lease call. Must be greater than 0."
+ )]
+ pub lease_batch_size: i64,
+ /// Upper bound on jobs the dispatcher leases across all pools in one tick.
+ /// While a pool's batch comes back full the dispatcher keeps leasing, up to
+ /// this cap. Must be > 0; a non-positive value falls back to the default.
+ #[env_config(
+ name = "ZO_SYNTHETICS_MAX_INFLIGHT_PER_TICK",
+ default = 100,
+ help = "Cap on jobs the dispatcher leases across all pools in one 2s tick. Must be greater than 0."
+ )]
+ pub max_inflight_per_tick: i64,
}
/// Feature 5 — SLO measurement (`alerts_2.md` §6b).
@@ -2911,7 +2938,7 @@ pub struct Limit {
#[env_config(
name = "ZO_QUERY_HISTORY_RETENTION_DAYS",
default = 14,
- help = "How long unstarred query history entries are kept, in days. Starred entries are kept until deleted. 0 or less keeps every entry forever; it does not stop collection, ZO_QUERY_HISTORY_ENABLED=false does."
+ help = "How long unstarred query history entries are kept, in days. Starred entries are kept until deleted. 0 or less removes the age limit; each user still keeps at most 500 unstarred entries per source (logs, traces, metrics, dbm, profiles), the oldest of that source pruned first. It does not stop collection, ZO_QUERY_HISTORY_ENABLED=false does."
)]
pub query_history_retention_days: i64,
#[env_config(name = "ZO_ALERT_SCHEDULE_TIMEOUT", default = 90)] // seconds
@@ -5270,6 +5297,21 @@ fn check_synthetics_config(cfg: &mut Config) {
cfg.synthetics.max_net_timeout_ms = crate::meta::synthetics::DEFAULT_MAX_NET_TIMEOUT_MS;
}
+ if cfg.synthetics.lease_batch_size <= 0 {
+ log::error!(
+ "ZO_SYNTHETICS_LEASE_BATCH_SIZE ({}) must be greater than 0, using 10",
+ cfg.synthetics.lease_batch_size
+ );
+ cfg.synthetics.lease_batch_size = 10;
+ }
+ if cfg.synthetics.max_inflight_per_tick <= 0 {
+ log::error!(
+ "ZO_SYNTHETICS_MAX_INFLIGHT_PER_TICK ({}) must be greater than 0, using 100",
+ cfg.synthetics.max_inflight_per_tick
+ );
+ cfg.synthetics.max_inflight_per_tick = 100;
+ }
+
// Every synthetics var now has exactly one name, in every build — which
// means an OSS user can set a private-agent var and get silence back. One
// line per var is the difference between "configured and ignored" and
@@ -5450,6 +5492,8 @@ mod tests {
"ZO_SYNTHETICS_DEVICES",
"ZO_SYNTHETICS_SCHEDULER_JITTER_ENABLED",
"ZO_SYNTHETICS_ORPHAN_DETECTION_ENABLED",
+ "ZO_SYNTHETICS_LEASE_BATCH_SIZE",
+ "ZO_SYNTHETICS_MAX_INFLIGHT_PER_TICK",
];
fn synthetics_names_in_class(class: SyntheticsReloadClass) -> Vec<&'static str> {
@@ -5469,8 +5513,8 @@ mod tests {
fn synthetics_reload_classification_is_pinned() {
assert_eq!(
SYNTHETICS_RELOAD_CLASSES.len(),
- 16,
- "Synthetics has 16 keys; every one needs a reload class"
+ 18,
+ "Synthetics has 18 keys; every one needs a reload class"
);
let mut classified: Vec<&str> = SYNTHETICS_RELOAD_CLASSES
@@ -5502,7 +5546,9 @@ mod tests {
"ZO_SYNTHETICS_JOB_LEASE_SECS",
"ZO_SYNTHETICS_LAMBDA_BROWSER",
"ZO_SYNTHETICS_LAMBDA_NET",
+ "ZO_SYNTHETICS_LEASE_BATCH_SIZE",
"ZO_SYNTHETICS_MAX_CHECK_BUDGET_SECS",
+ "ZO_SYNTHETICS_MAX_INFLIGHT_PER_TICK",
"ZO_SYNTHETICS_MAX_NET_TIMEOUT_MS",
"ZO_SYNTHETICS_ORPHAN_DETECTION_ENABLED",
"ZO_SYNTHETICS_RECORDER_EXTENSION_URL",
@@ -5565,6 +5611,8 @@ mod tests {
cfg.devices = "desktop:800:600".to_string();
cfg.scheduler_jitter_enabled = !cfg.scheduler_jitter_enabled;
cfg.orphan_detection_enabled = !cfg.orphan_detection_enabled;
+ cfg.lease_batch_size += 1;
+ cfg.max_inflight_per_tick += 1;
}
#[test]
diff --git a/src/config/src/meta/alerts/alert.rs b/src/config/src/meta/alerts/alert.rs
index 5e247c2ea2..44bef5ab8d 100644
--- a/src/config/src/meta/alerts/alert.rs
+++ b/src/config/src/meta/alerts/alert.rs
@@ -165,6 +165,10 @@ pub struct Alert {
/// today. Named after Prometheus' `keep_firing_for`, with the same meaning.
#[serde(default)]
pub keep_firing_for: i64,
+
+ /// Notify once per state change; enterprise only, other builds ignore it.
+ #[serde(default)]
+ pub suppress_repeats: bool,
}
/// Accept a runbook link, or say why not.
@@ -266,11 +270,17 @@ impl Default for Alert {
notify_on_recovery: false,
recovery_destinations: vec![],
keep_firing_for: 0,
+ suppress_repeats: false,
}
}
}
impl Alert {
+ /// Whether repeat notifications are withheld; other builds than enterprise ignore the switch.
+ pub fn suppresses_repeats(&self) -> bool {
+ cfg!(feature = "enterprise") && self.suppress_repeats
+ }
+
/// Pending rule mode needs per-series state; the default count gate means any series.
pub fn normalize_promql_rule_mode(&mut self) {
if self.query_condition.query_type != super::QueryType::PromQL
@@ -1233,4 +1243,22 @@ mod tests {
let json = serde_json::to_value(&alert).unwrap();
assert!(json.get("runbook_url").is_none());
}
+
+ #[cfg(not(feature = "enterprise"))]
+ #[test]
+ fn repeat_suppression_is_ignored_outside_enterprise() {
+ let alert = Alert {
+ suppress_repeats: true,
+ ..Default::default()
+ };
+ assert!(!alert.suppresses_repeats());
+ }
+
+ #[test]
+ fn an_alert_saved_before_repeat_suppression_reads_with_it_off() {
+ let mut stored = serde_json::to_value(Alert::default()).unwrap();
+ stored.as_object_mut().unwrap().remove("suppress_repeats");
+ let alert: Alert = serde_json::from_value(stored).unwrap();
+ assert!(!alert.suppress_repeats);
+ }
}
diff --git a/src/config/src/meta/alerts/dispatch.rs b/src/config/src/meta/alerts/dispatch.rs
index e4194bb629..e7ad7f4e5d 100644
--- a/src/config/src/meta/alerts/dispatch.rs
+++ b/src/config/src/meta/alerts/dispatch.rs
@@ -112,6 +112,8 @@ pub struct DispatchPlan {
pub inconsistent: Vec,
/// Groups that would deliver but a downtime mutes; also counted in `suppressed`.
pub muted: Vec,
+ /// Groups withheld because they repeat their last delivery; also counted in `suppressed`.
+ pub repeats: usize,
}
/// Decide which groups this evaluation notifies (MN-1, MN-8).
@@ -135,6 +137,7 @@ pub struct DispatchPlan {
/// * `max_sends` is the MN-8 knob: `0` = unlimited; otherwise it counts **qualifying** sends
/// (applied after `delivery_decision`), worst-first.
/// * A deliverable group `muted(key, decision, last_notified_level)` holds goes to `muted`, first.
+#[allow(clippy::too_many_arguments)]
pub fn plan_dispatch(
classification: &GroupClassification,
states: &HashMap,
@@ -142,6 +145,7 @@ pub fn plan_dispatch(
tc: &TriggerCondition,
now: i64,
max_sends: usize,
+ suppress_repeats: bool,
muted: impl Fn(&str, &DeliveryDecision, Option) -> bool,
) -> DispatchPlan {
let mut items = Vec::new();
@@ -150,6 +154,7 @@ pub fn plan_dispatch(
let mut dropped_by_knob = Vec::new();
let mut inconsistent = Vec::new();
let mut muted_keys = Vec::new();
+ let mut repeats = 0usize;
// A key appearing twice in one classification is an invariant violation
// (validation rejects the known source, MN-12). Counting first means both
@@ -218,6 +223,17 @@ pub fn plan_dispatch(
muted_keys.push(key.clone());
continue;
}
+ if suppress_repeats
+ && repeats_last_notification(
+ level,
+ state.episode_id.is_some(),
+ state.last_notified_level,
+ )
+ {
+ suppressed += 1;
+ repeats += 1;
+ continue;
+ }
// The knob counts QUALIFYING sends, so a silenced group never
// spends budget a deliverable one could have used.
if max_sends > 0 && items.len() >= max_sends {
@@ -237,7 +253,8 @@ pub fn plan_dispatch(
DeliveryDecision::SuppressedBySilence
| DeliveryDecision::SuppressedByWarningPolicy
| DeliveryDecision::SuppressedByDowntime(_)
- | DeliveryDecision::SuppressedByAck(_) => suppressed += 1,
+ | DeliveryDecision::SuppressedByAck(_)
+ | DeliveryDecision::SuppressedAsRepeat => suppressed += 1,
DeliveryDecision::SuppressedByPending => pending += 1,
DeliveryDecision::NotFiring => {}
}
@@ -250,9 +267,19 @@ pub fn plan_dispatch(
dropped_by_knob,
inconsistent,
muted: muted_keys,
+ repeats,
}
}
+/// The open recovery episode was already notified at `level`, so this firing tells nothing new.
+pub fn repeats_last_notification(
+ level: AlertLevel,
+ episode_open: bool,
+ last_notified: Option,
+) -> bool {
+ episode_open && last_notified == Some(level)
+}
+
/// The write that records one group's **successful** delivery (MN-6).
///
/// Returns the full replacement row, or `None` when the carried **level-episode
@@ -744,6 +771,7 @@ mod tests {
&tc(None),
1_000 * SEC,
0,
+ false,
|_, _, _| false,
);
@@ -769,9 +797,16 @@ mod tests {
let c = classified(&[("crit", 150.0), ("ok1", 1.0), ("ok2", 2.0)], 500);
let states = states_for(&c, now, None, None);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
assert_eq!(plan.items.len(), 1);
assert_eq!(
@@ -791,9 +826,16 @@ mod tests {
let c = classified(&[("a", 150.0)], 500);
let states = states_for(&c, now, None, None);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
assert!(
plan.items[0].escalation,
@@ -816,6 +858,7 @@ mod tests {
&tc(None),
1_000 * SEC,
0,
+ false,
|_, _, _| false,
);
let b = plan
@@ -841,6 +884,7 @@ mod tests {
&tc(None),
1_000 * SEC,
0,
+ false,
|_, _, _| false,
);
let keys: Vec<_> = plan.items.iter().map(|i| i.group_key.clone()).collect();
@@ -863,9 +907,16 @@ mod tests {
let c = classified(&[("a", 150.0)], 500);
let states = states_for(&c, now, Some(AlertLevel::Critical), Some(now + 10 * MIN));
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
assert!(plan.items.is_empty());
assert_eq!(plan.suppressed, 1);
@@ -879,9 +930,16 @@ mod tests {
let c = classified(&[("a", 150.0)], 500);
let states = states_for(&c, now, Some(AlertLevel::Critical), Some(now - 1));
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
assert_eq!(plan.items.len(), 1);
assert!(
@@ -898,9 +956,16 @@ mod tests {
let c = classified(&[("a", 150.0)], 500);
let states = states_for(&c, now, Some(AlertLevel::Warning), Some(now + 10 * MIN));
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
assert_eq!(plan.items.len(), 1);
assert!(plan.items[0].escalation);
@@ -933,9 +998,16 @@ mod tests {
assert!(row.is_firing(), "NotifyFailed is still a firing state");
let states = HashMap::from([(key.clone(), row)]);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
assert_eq!(
plan.items.len(),
@@ -958,6 +1030,7 @@ mod tests {
&tc(Some(false)),
1_000 * SEC,
0,
+ false,
|_, _, _| false,
);
@@ -999,7 +1072,9 @@ mod tests {
]),
);
- let plan = plan_dispatch(&c, &states, &rows, &tc(None), now, 0, |_, _, _| false);
+ let plan = plan_dispatch(&c, &states, &rows, &tc(None), now, 0, false, |_, _, _| {
+ false
+ });
assert_eq!(plan.items.len(), 2, "only retained groups dispatch");
assert!(
@@ -1030,9 +1105,16 @@ mod tests {
state(ROLLUP_GROUP_KEY, AlertLevel::Critical, now, None, None),
);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
assert_eq!(plan.items.len(), 1, "one group fired, so one page");
assert_eq!(plan.items[0].group_key, key_of("a"));
@@ -1060,9 +1142,16 @@ mod tests {
state(&gone, AlertLevel::Critical, now - 5 * MIN, None, None),
);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
assert!(!plan.items.iter().any(|i| i.group_key == gone));
}
@@ -1084,6 +1173,7 @@ mod tests {
&tc(None),
now,
0,
+ false,
|_, _, _| false,
);
@@ -1111,6 +1201,7 @@ mod tests {
&tc(None),
now,
0,
+ false,
|_, _, _| false,
);
@@ -1139,9 +1230,16 @@ mod tests {
assert_eq!(c.groups.len(), 3, "fixture: `a` is observed twice");
let states = states_for(&c, now, None, None);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
assert!(
!plan.items.iter().any(|i| i.group_key == key_of("a")),
@@ -1170,9 +1268,16 @@ mod tests {
assert_eq!(c.groups.len(), 3, "fixture: `a` observed at two levels");
let states = states_for(&c, now, None, None);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
for item in &plan.items {
assert_ne!(
@@ -1202,9 +1307,16 @@ mod tests {
);
let states = states_for(&c, now, None, None);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 2, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 2,
+ false,
+ |_, _, _| false,
+ );
assert_eq!(plan.items.len(), 2, "`b` and `c` both page");
assert!(plan.dropped_by_knob.is_empty());
@@ -1219,7 +1331,9 @@ mod tests {
let states = states_for(&c, now, None, None);
let rows = rows_for(&c);
- let plan = plan_dispatch(&c, &states, &rows, &tc(None), now, 0, |_, _, _| false);
+ let plan = plan_dispatch(&c, &states, &rows, &tc(None), now, 0, false, |_, _, _| {
+ false
+ });
assert_eq!(
&plan.items[0].row,
@@ -1243,9 +1357,16 @@ mod tests {
row.level_at = Some(now); // freshness advanced by a later same-level run
let states = HashMap::from([(key.clone(), row)]);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |_, _, _| false,
+ );
assert_eq!(
plan.items[0].episode,
@@ -1270,6 +1391,7 @@ mod tests {
&tc(None),
1_000 * SEC,
0,
+ false,
|_, _, _| false,
);
assert_eq!(plan.items.len(), 3);
@@ -1290,6 +1412,7 @@ mod tests {
&tc(None),
1_000 * SEC,
2,
+ false,
|_, _, _| false,
);
@@ -1324,9 +1447,16 @@ mod tests {
let c = build();
let states = states_for(&c, now, None, None);
- let first = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 2, |_, _, _| {
- false
- });
+ let first = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 2,
+ false,
+ |_, _, _| false,
+ );
let second = plan_dispatch(
&build(),
&states,
@@ -1334,6 +1464,7 @@ mod tests {
&tc(None),
now,
2,
+ false,
|_, _, _| false,
);
@@ -1367,9 +1498,16 @@ mod tests {
),
]);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 1, |_, _, _| {
- false
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 1,
+ false,
+ |_, _, _| false,
+ );
assert_eq!(plan.items.len(), 1);
assert_eq!(plan.items[0].group_key, key_b);
@@ -1387,9 +1525,16 @@ mod tests {
let (key_a, key_b) = (key_of("a"), key_of("b"));
let states = states_for(&c, now, None, None);
for _ in 0..2 {
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 1, |k, _, _| {
- k == key_a
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 1,
+ false,
+ |k, _, _| k == key_a,
+ );
assert_eq!(plan.items.len(), 1);
assert_eq!(plan.items[0].group_key, key_b);
assert_eq!(plan.suppressed, 1);
@@ -1408,7 +1553,7 @@ mod tests {
let acked = |k: &str, d: &DeliveryDecision, last: Option| {
(k == key_a || k == key_b) && d.ack_withholds(last)
};
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, acked);
+ let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, false, acked);
assert_eq!(
plan.muted,
vec![key_a],
@@ -1428,9 +1573,16 @@ mod tests {
let c = classified(&[("a", 150.0), ("b", 75.0)], 500);
let (key_a, key_b) = (key_of("a"), key_of("b"));
let states = states_for(&c, now, None, None);
- let plan = plan_dispatch(&c, &states, &rows_for(&c), &tc(None), now, 0, |k, _, _| {
- k == key_a
- });
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ now,
+ 0,
+ false,
+ |k, _, _| k == key_a,
+ );
assert_eq!(plan.items.len(), 1);
assert_eq!(plan.items[0].group_key, key_b);
assert_eq!(plan.muted, vec![key_a]);
@@ -2185,4 +2337,109 @@ mod tests {
assert_eq!(back, episode);
assert_eq!(back.notified_at_enqueue, (None, None));
}
+
+ // ═════════════════════════════════════════════════════════════════════
+ // suppress_repeats — notify once per state change
+ // ═════════════════════════════════════════════════════════════════════
+
+ #[test]
+ fn only_the_notified_level_inside_an_open_episode_is_a_repeat() {
+ use AlertLevel::{Critical, Warning};
+ // Still firing at the delivered level, or back at it inside a keep_firing_for hold.
+ assert!(repeats_last_notification(Critical, true, Some(Critical)));
+ // Escalated or de-escalated since the last delivery.
+ assert!(!repeats_last_notification(Critical, true, Some(Warning)));
+ assert!(!repeats_last_notification(Warning, true, Some(Critical)));
+ // The episode closed on a recovery: a new incident.
+ assert!(!repeats_last_notification(Critical, false, Some(Critical)));
+ // Nothing was ever delivered.
+ assert!(!repeats_last_notification(Critical, false, None));
+ }
+
+ #[test]
+ fn a_repeat_is_withheld_like_a_downtime_or_an_ack() {
+ assert!(DeliveryDecision::SuppressedAsRepeat.withheld());
+ assert!(DeliveryDecision::SuppressedByAck("u".into()).withheld());
+ assert!(!DeliveryDecision::SuppressedBySilence.withheld());
+ assert!(!DeliveryDecision::Deliver.withheld());
+ }
+
+ /// Every group of `c` delivered at Critical, inside an open recovery episode.
+ fn notified_states(c: &GroupClassification) -> HashMap {
+ let mut states = states_for(c, MIN, Some(AlertLevel::Critical), None);
+ for row in states.values_mut() {
+ row.episode_id = Some("ep-1".to_string());
+ }
+ states
+ }
+
+ #[test]
+ fn plan_dispatch_withholds_a_repeat_only_when_the_switch_is_on() {
+ let c = classified(&[("a", 150.0)], 500);
+ let states = notified_states(&c);
+ let plan = |on| {
+ plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ 10 * MIN,
+ 0,
+ on,
+ |_, _, _| false,
+ )
+ };
+
+ assert_eq!(
+ plan(false).items.len(),
+ 1,
+ "silence 0 and the switch off page again"
+ );
+ let on = plan(true);
+ assert!(on.items.is_empty());
+ assert_eq!((on.suppressed, on.repeats), (1, 1));
+ }
+
+ #[test]
+ fn a_repeat_does_not_spend_the_send_budget() {
+ // `a` repeats; `b` fires for the first time and must get the single slot.
+ let c = classified(&[("a", 150.0), ("b", 150.0)], 500);
+ let mut states = states_for(&c, MIN, None, None);
+ let a = states.get_mut(&key_of("a")).unwrap();
+ a.last_notified_level = Some(AlertLevel::Critical);
+ a.episode_id = Some("ep-1".to_string());
+
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ 10 * MIN,
+ 1,
+ true,
+ |_, _, _| false,
+ );
+ assert_eq!(plan.items.len(), 1);
+ assert_eq!(plan.items[0].group_key, key_of("b"));
+ assert!(plan.dropped_by_knob.is_empty());
+ assert_eq!(plan.repeats, 1);
+ }
+
+ #[test]
+ fn a_muted_group_is_reported_muted_not_repeated() {
+ let c = classified(&[("a", 150.0)], 500);
+ let states = notified_states(&c);
+ let plan = plan_dispatch(
+ &c,
+ &states,
+ &rows_for(&c),
+ &tc(None),
+ 10 * MIN,
+ 0,
+ true,
+ |_, _, _| true,
+ );
+ assert_eq!(plan.muted, vec![key_of("a")]);
+ assert_eq!(plan.repeats, 0);
+ }
}
diff --git a/src/config/src/meta/alerts/level.rs b/src/config/src/meta/alerts/level.rs
index a77d64fa97..0b981e99a4 100644
--- a/src/config/src/meta/alerts/level.rs
+++ b/src/config/src/meta/alerts/level.rs
@@ -366,6 +366,8 @@ pub enum DeliveryDecision {
SuppressedByDowntime(String),
/// The current firing is acknowledged by this user; repeats are withheld.
SuppressedByAck(String),
+ /// `suppress_repeats` is on and nothing changed since the last delivered notification.
+ SuppressedAsRepeat,
}
impl DeliveryDecision {
@@ -385,6 +387,14 @@ impl DeliveryDecision {
}
}
+ /// Withheld by a downtime, an ack or as a repeat, rather than by the level decision.
+ pub fn withheld(&self) -> bool {
+ matches!(
+ self,
+ Self::SuppressedByDowntime(_) | Self::SuppressedByAck(_) | Self::SuppressedAsRepeat
+ )
+ }
+
pub fn acked_by(&self) -> Option<&str> {
match self {
Self::SuppressedByAck(by) => Some(by),
diff --git a/src/config/src/meta/dashboards/v8/mod.rs b/src/config/src/meta/dashboards/v8/mod.rs
index 57384779c8..8abb67ea34 100644
--- a/src/config/src/meta/dashboards/v8/mod.rs
+++ b/src/config/src/meta/dashboards/v8/mod.rs
@@ -480,6 +480,10 @@ pub struct FilterCondition {
pub value: Option,
pub logical_operator: String,
pub filter_type: String,
+ /// Set when `value` is an explicit `''` from SQL, so the builder keeps `x <> ''`
+ /// instead of treating the empty value as "not entered yet".
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub empty_literal: Option,
}
#[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize, ToSchema)]
@@ -810,6 +814,22 @@ pub struct PanelConfig {
#[serde(skip_serializing_if = "Option::is_none")]
flame_graph_value_field: Option,
#[serde(skip_serializing_if = "Option::is_none")]
+ boxplot_whisker: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ logs_message_field: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ logs_level_field: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ logs_time_field: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ logs_wrap: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ logs_prettify_json: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ logs_show_time: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ logs_order: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
hostmap_entity_label: Option,
#[serde(skip_serializing_if = "Option::is_none")]
hostmap_group_by: Option>,
@@ -1556,6 +1576,39 @@ mod tests {
assert_eq!(again, d);
}
+ #[test]
+ fn test_filter_condition_empty_literal_roundtrip() {
+ let kept = serde_json::json!({
+ "type": "condition",
+ "values": [],
+ "column": { "field": "service_name", "streamAlias": null },
+ "operator": "<>",
+ "value": "",
+ "logicalOperator": "AND",
+ "filterType": "condition",
+ "emptyLiteral": true
+ });
+ let filter: PanelFilter = serde_json::from_value(kept).unwrap();
+ match &filter {
+ PanelFilter::Condition(c) => assert_eq!(c.empty_literal, Some(true)),
+ PanelFilter::Group(_) => panic!("expected a condition"),
+ }
+ assert_eq!(serde_json::to_value(&filter).unwrap()["emptyLiteral"], true);
+
+ let plain = serde_json::json!({
+ "type": "condition",
+ "values": [],
+ "column": { "field": "service_name", "streamAlias": null },
+ "operator": "=",
+ "value": "",
+ "logicalOperator": "AND",
+ "filterType": "condition"
+ });
+ let filter: PanelFilter = serde_json::from_value(plain).unwrap();
+ let back = serde_json::to_value(&filter).unwrap();
+ assert!(!back.as_object().unwrap().contains_key("emptyLiteral"));
+ }
+
#[test]
fn test_annotation_sources_change_the_hash() {
let plain: super::super::Dashboard = make_dashboard().into();
@@ -3022,7 +3075,15 @@ mod tests {
});
let rest4 = serde_json::json!({
"flame_graph_stack_field": "stack",
- "flame_graph_value_field": "samples"
+ "flame_graph_value_field": "samples",
+ "boxplot_whisker": "iqr",
+ "logs_message_field": "message",
+ "logs_level_field": "level",
+ "logs_time_field": "_timestamp",
+ "logs_wrap": true,
+ "logs_prettify_json": false,
+ "logs_show_time": true,
+ "logs_order": "oldest"
});
for part in [rest, rest2, rest3, rest4] {
fields
diff --git a/src/config/src/meta/fleet.rs b/src/config/src/meta/fleet.rs
index 2b96feff45..1909e1670b 100644
--- a/src/config/src/meta/fleet.rs
+++ b/src/config/src/meta/fleet.rs
@@ -69,7 +69,7 @@ pub struct Collector {
pub version: Option,
pub outdated: bool,
pub status: CollectorStatus,
- /// The connection's peer address, honouring `X-Forwarded-For`.
+ /// The connection's client address, as `ZO_HTTP_REAL_IP_SOURCE` resolves it.
pub last_connected_from: Option,
pub fleet: CollectorFleet,
pub token_id: Option,
diff --git a/src/config/src/meta/public_dashboards.rs b/src/config/src/meta/public_dashboards.rs
index 1ab0d08b46..ee428e750c 100644
--- a/src/config/src/meta/public_dashboards.rs
+++ b/src/config/src/meta/public_dashboards.rs
@@ -413,9 +413,12 @@ pub struct KeyedRange {
pub refresh_secs: i64,
}
-/// A dashboard variable as the public viewer sees it: display label and frozen value only.
+/// A dashboard variable as the public viewer sees it: name, display label and frozen value only.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PublicVariable {
+ /// The name panel titles reference as `$name`; empty in a result stored before it was added.
+ #[serde(default)]
+ pub name: String,
pub label: String,
pub value: serde_json::Value,
/// Set for a tab-scoped variable: the tab this value belongs to.
@@ -472,6 +475,7 @@ impl PublicVariable {
.into_iter()
.map(|(key, tab_id, panel_id)| {
let entry = Self {
+ name: v.name.clone(),
label: label.clone(),
value: values.get(&key).cloned().unwrap_or(serde_json::Value::Null),
tab_id,
@@ -750,6 +754,34 @@ mod tests {
assert_eq!(cfg.refresh_secs, 300);
}
+ #[test]
+ fn public_variables_carry_the_name_panel_titles_reference() {
+ let dash = Dashboard {
+ v8: Some(
+ serde_json::from_value(serde_json::json!({
+ "variables": { "list": [
+ { "type": "custom", "name": "level", "label": "Level" },
+ { "type": "custom", "name": "svc", "scope": "tabs", "tabs": ["t1"] }
+ ] }
+ }))
+ .unwrap(),
+ ),
+ ..Default::default()
+ };
+ let values = BTreeMap::from([("level".to_string(), serde_json::json!("error"))]);
+ let list = PublicVariable::list(&dash, &values);
+ let names: Vec<(&str, &str)> = list
+ .iter()
+ .map(|v| (v.name.as_str(), v.label.as_str()))
+ .collect();
+ assert_eq!(names, [("level", "Level"), ("svc", "svc")]);
+ assert_eq!(serde_json::to_value(&list[0]).unwrap()["name"], "level");
+ let stored: PublicVariable =
+ serde_json::from_value(serde_json::json!({ "label": "Level", "value": "error" }))
+ .unwrap();
+ assert_eq!(stored.name, "");
+ }
+
#[test]
fn identity_names_the_org() {
assert_eq!(
diff --git a/src/config/src/meta/self_reporting/mod.rs b/src/config/src/meta/self_reporting/mod.rs
index 187de9df48..d202fdb155 100644
--- a/src/config/src/meta/self_reporting/mod.rs
+++ b/src/config/src/meta/self_reporting/mod.rs
@@ -405,6 +405,7 @@ mod tests {
ack_source: None,
taken_over_from: None,
delivery_attempted: None,
+ repeat_suppressed: None,
trigger_source: None,
triggered_by: None,
recipient_count: None,
@@ -965,6 +966,7 @@ mod tests {
ack_source: None,
taken_over_from: None,
delivery_attempted: None,
+ repeat_suppressed: None,
trigger_source: None,
triggered_by: None,
recipient_count: None,
@@ -1057,6 +1059,7 @@ mod tests {
ack_source: None,
taken_over_from: None,
delivery_attempted: None,
+ repeat_suppressed: None,
trigger_source: None,
triggered_by: None,
recipient_count: None,
@@ -1133,6 +1136,7 @@ mod tests {
ack_source: None,
taken_over_from: None,
delivery_attempted: None,
+ repeat_suppressed: None,
trigger_source: None,
triggered_by: None,
recipient_count: None,
diff --git a/src/config/src/meta/self_reporting/usage.rs b/src/config/src/meta/self_reporting/usage.rs
index 703e85cc2f..8724dbf3e9 100644
--- a/src/config/src/meta/self_reporting/usage.rs
+++ b/src/config/src/meta/self_reporting/usage.rs
@@ -354,6 +354,9 @@ pub struct TriggerData {
/// Whether a firing tried a destination, workflow or incident notification; None on old rows.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivery_attempted: Option,
+ /// Set when `suppress_repeats` withheld a notification that repeated the last delivered state.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub repeat_suppressed: Option,
/// "scheduled" or "manual"; None on older rows, which read as scheduled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_source: Option,
@@ -413,6 +416,7 @@ impl Default for TriggerData {
ack_source: None,
taken_over_from: None,
delivery_attempted: None,
+ repeat_suppressed: None,
trigger_source: None,
triggered_by: None,
recipient_count: None,
@@ -473,6 +477,7 @@ impl TriggerData {
ack_source: Some(AckSource::Api),
taken_over_from: Some(String::new()),
delivery_attempted: Some(true),
+ repeat_suppressed: None,
trigger_source: Some(String::new()),
triggered_by: Some(String::new()),
recipient_count: Some(0),
@@ -1915,6 +1920,7 @@ mod tests {
ack_source: None,
taken_over_from: None,
delivery_attempted: None,
+ repeat_suppressed: None,
trigger_source: None,
triggered_by: None,
recipient_count: None,
diff --git a/src/config/src/meta/status_pages.rs b/src/config/src/meta/status_pages.rs
index 944f9128c8..d426f41e74 100644
--- a/src/config/src/meta/status_pages.rs
+++ b/src/config/src/meta/status_pages.rs
@@ -46,6 +46,10 @@ pub const CHECK_STATUS_UNKNOWN: i32 = 0;
pub const CHECK_STATUS_UP: i32 = 1;
pub const CHECK_STATUS_WARNING: i32 = 2;
pub const CHECK_STATUS_DOWN: i32 = 3;
+/// The probe could not produce a verdict (dispatch dead-letter, expired lease,
+/// infra fault). Says nothing about the target: it never opens an incident and
+/// never counts toward recovery. Written by the dispatcher and the reaper.
+pub const CHECK_STATUS_ERROR: i32 = 4;
const MICROS_PER_MIN: i64 = 60 * 1_000_000;
const MICROS_PER_DAY: i64 = 86_400 * 1_000_000;
@@ -120,6 +124,10 @@ pub struct EngineCheck {
pub first_fail_at: Option,
/// When the streak started failing, for time-based confirmation.
pub failing_since: Option,
+ /// True only when this tick sees a run result it has not seen before.
+ /// Recovery advances on distinct results, never on rebuild ticks, so the
+ /// same UP observed over ten ticks counts once.
+ pub fresh_result: bool,
}
/// An auto-incident notice currently open (or just resolved, for the merge
@@ -535,12 +543,33 @@ pub fn incident_actions(
snoozed: &HashSet,
th: &Thresholds,
now: i64,
+) -> Vec {
+ incident_actions_with(
+ checks,
+ open_by_check,
+ recently_resolved,
+ snoozed,
+ |_| *th,
+ now,
+ )
+}
+
+/// [`incident_actions`] with per-check thresholds (each check is judged by the
+/// confirmation settings of the page(s) it is mapped to).
+pub fn incident_actions_with(
+ checks: &[EngineCheck],
+ open_by_check: &HashMap,
+ recently_resolved: &HashMap,
+ snoozed: &HashSet,
+ th_for: impl Fn(&str) -> Thresholds,
+ now: i64,
) -> Vec {
let mut actions = Vec::new();
for check in checks {
+ let th = th_for(&check.id);
match open_by_check.get(&check.id) {
- Some(open) => handle_open(check, open, th, now, &mut actions),
- None => handle_closed(check, recently_resolved, snoozed, th, now, &mut actions),
+ Some(open) => handle_open(check, open, &th, now, &mut actions),
+ None => handle_closed(check, recently_resolved, snoozed, &th, now, &mut actions),
}
}
actions
@@ -568,7 +597,16 @@ fn handle_open(
now: i64,
actions: &mut Vec,
) {
- if check.last_check_status == CHECK_STATUS_UP {
+ // UP and WARNING both mean the target answered: they advance recovery, but
+ // only on a result this loop has not counted yet.
+ let healthy = matches!(
+ check.last_check_status,
+ CHECK_STATUS_UP | CHECK_STATUS_WARNING
+ );
+ if healthy {
+ if !check.fresh_result {
+ return;
+ }
let streak = open.recovery_streak + 1;
if streak >= th.confirm_recovery {
actions.push(EngineAction::Resolve {
@@ -581,8 +619,14 @@ fn handle_open(
streak,
});
}
- } else if check.last_check_status == CHECK_STATUS_DOWN && open.recovery_streak > 0 {
- // Failed again mid-recovery: the streak restarts from zero.
+ } else if matches!(
+ check.last_check_status,
+ CHECK_STATUS_DOWN | CHECK_STATUS_ERROR
+ ) && open.recovery_streak > 0
+ {
+ // Failed again mid-recovery: the streak restarts from zero. ERROR does
+ // the same: an unknown verdict cannot prove recovery, so UP, ERROR, UP
+ // is not two consecutive good results. The incident stays open.
actions.push(EngineAction::RecoveryProgress {
notice_id: open.notice_id.clone(),
streak: 0,
@@ -761,9 +805,119 @@ mod tests {
consecutive_failures: streak,
first_fail_at: None,
failing_since: None,
+ fresh_result: true,
}
}
+ fn open_inc(streak: i32) -> HashMap {
+ [(
+ "c1".to_string(),
+ OpenAutoIncident {
+ notice_id: "n1".into(),
+ check_id: "c1".into(),
+ recovery_streak: streak,
+ },
+ )]
+ .into()
+ }
+
+ /// Replays results against an open incident, applying RecoveryProgress
+ /// back into the streak; returns whether it resolved.
+ fn replay(statuses: &[(i32, bool)], th: Thresholds) -> bool {
+ let mut streak = 0;
+ for &(status, fresh) in statuses {
+ let mut c = check("c1", status, 0);
+ c.fresh_result = fresh;
+ let acts = incident_actions(
+ &[c],
+ &open_inc(streak),
+ &HashMap::new(),
+ &HashSet::new(),
+ &th,
+ 0,
+ );
+ for a in acts {
+ match a {
+ EngineAction::Resolve { .. } => return true,
+ EngineAction::RecoveryProgress { streak: s, .. } => streak = s,
+ _ => panic!("unexpected {a:?}"),
+ }
+ }
+ }
+ false
+ }
+
+ fn th(failures: i32, recovery: i32) -> Thresholds {
+ Thresholds {
+ confirm_failures: failures,
+ confirm_recovery: recovery,
+ confirm_after_micros: None,
+ }
+ }
+
+ #[test]
+ fn up_error_up_does_not_resolve_with_recovery_two() {
+ let s = CHECK_STATUS_UP;
+ let e = CHECK_STATUS_ERROR;
+ assert!(!replay(&[(s, true), (e, true), (s, true)], th(3, 2)));
+ assert!(replay(
+ &[(s, true), (e, true), (s, true), (s, true)],
+ th(3, 2)
+ ));
+ }
+
+ #[test]
+ fn error_never_opens_an_incident() {
+ let c = check("c1", CHECK_STATUS_ERROR, 50);
+ let acts = incident_actions(
+ &[c],
+ &HashMap::new(),
+ &HashMap::new(),
+ &HashSet::new(),
+ &th(1, 1),
+ 0,
+ );
+ assert!(acts.is_empty());
+ }
+
+ #[test]
+ fn error_keeps_an_open_incident_open() {
+ let e = CHECK_STATUS_ERROR;
+ assert!(!replay(&[(e, true); 10], th(3, 1)));
+ }
+
+ #[test]
+ fn warning_advances_recovery() {
+ let w = CHECK_STATUS_WARNING;
+ assert!(replay(&[(w, true), (w, true)], th(3, 2)));
+ }
+
+ #[test]
+ fn the_same_result_over_many_ticks_counts_once() {
+ let mut ticks = vec![(CHECK_STATUS_UP, true)];
+ ticks.extend(std::iter::repeat_n((CHECK_STATUS_UP, false), 20));
+ assert!(!replay(&ticks, th(3, 2)));
+ ticks.push((CHECK_STATUS_UP, true));
+ assert!(replay(&ticks, th(3, 2)));
+ }
+
+ #[test]
+ fn seven_failures_four_recoveries() {
+ let t = th(7, 4);
+ let none = HashMap::new();
+ let quiet = HashSet::new();
+ let six = [check("c1", CHECK_STATUS_DOWN, 6)];
+ assert!(incident_actions(&six, &none, &HashMap::new(), &quiet, &t, 0).is_empty());
+ let seven = [check("c1", CHECK_STATUS_DOWN, 7)];
+ assert_eq!(
+ incident_actions(&seven, &none, &HashMap::new(), &quiet, &t, 0).len(),
+ 1
+ );
+ let up = CHECK_STATUS_UP;
+ assert!(!replay(&[(up, true); 3], t));
+ assert!(replay(&[(up, true); 4], t));
+ }
+
#[test]
fn union_counts_overlaps_once() {
// Two checks confirm the same outage: 10:00-10:30 and 10:10-10:40.
diff --git a/src/config/src/meta/user.rs b/src/config/src/meta/user.rs
index 6454a1dc80..5869dfb25e 100644
--- a/src/config/src/meta/user.rs
+++ b/src/config/src/meta/user.rs
@@ -153,7 +153,10 @@ impl FromStr for UserRole {
"user" | "allowed_user" => Ok(UserRole::User),
"service_account" => Ok(UserRole::ServiceAccount),
"sre_agent" => Ok(UserRole::SreAgent),
+ #[cfg(not(feature = "enterprise"))]
_ => Ok(UserRole::Admin),
+ #[cfg(feature = "enterprise")]
+ _ => Ok(UserRole::User),
}
}
}
@@ -413,9 +416,18 @@ mod tests {
#[test]
fn test_invalid_string_defaults_to_admin() {
+ #[cfg(feature = "enterprise")]
+ assert_eq!(UserRole::from_str("invalid").unwrap(), UserRole::User);
+ #[cfg(not(feature = "enterprise"))]
assert_eq!(UserRole::from_str("invalid").unwrap(), UserRole::Admin);
+ #[cfg(feature = "enterprise")]
+ assert_eq!(UserRole::from_str("").unwrap(), UserRole::User);
+ #[cfg(not(feature = "enterprise"))]
assert_eq!(UserRole::from_str("").unwrap(), UserRole::Admin);
+ #[cfg(not(feature = "enterprise"))]
assert_eq!(UserRole::from_str("Admin").unwrap(), UserRole::Admin); // case sensitive
+ #[cfg(feature = "enterprise")]
+ assert_eq!(UserRole::from_str("Admin").unwrap(), UserRole::User); // case sensitive
}
}
diff --git a/src/config/src/utils/str.rs b/src/config/src/utils/str.rs
index 60f5cca90a..46d5142756 100644
--- a/src/config/src/utils/str.rs
+++ b/src/config/src/utils/str.rs
@@ -34,11 +34,19 @@ pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| {
.unwrap()
});
+pub static DOMAIN_REGEX: LazyLock =
+ LazyLock::new(|| Regex::new(r"^([a-zA-Z0-9]+([\-\.][a-zA-Z0-9]+)*\.[a-zA-Z]{2,63})$").unwrap());
+
/// Returns true if `email` is syntactically valid per [`EMAIL_REGEX`].
pub fn is_valid_email(email: &str) -> bool {
EMAIL_REGEX.is_match(email)
}
+/// Returns true if `domain` is syntactically valid per [`DOMAIN_REGEX`].
+pub fn is_valid_domain(domain: &str) -> bool {
+ DOMAIN_REGEX.is_match(domain)
+}
+
/// Characters an OpenFGA object id cannot represent. Detection
/// ([`is_ofga_unsupported`]) and sanitization ([`into_ofga_supported_format`])
/// share this pattern so they always agree on what is unsafe.
diff --git a/src/core/Cargo.toml b/src/core/Cargo.toml
index 2bd451861a..816cd6f0bd 100644
--- a/src/core/Cargo.toml
+++ b/src/core/Cargo.toml
@@ -48,9 +48,10 @@ vectorscan = [
]
# Lets dependants' tests install a fake stream access checker; never enabled outside dev-dependencies.
test-utils = []
+cpu-profiling = ["dep:pprof"]
profiling = [
+ "cpu-profiling",
"dep:jemalloc_pprof",
- "dep:pprof",
"dep:tikv-jemalloc-ctl",
]
diff --git a/src/core/src/alerts/alert.rs b/src/core/src/alerts/alert.rs
index ac197dd8df..796a150157 100644
--- a/src/core/src/alerts/alert.rs
+++ b/src/core/src/alerts/alert.rs
@@ -327,6 +327,8 @@ pub enum AlertError {
CronHasNoFutureOccurrence { cron: String },
#[error("Realtime alerts cannot notify on recovery or keep firing")]
RecoveryOnRealtimeAlert,
+ #[error("Realtime alerts cannot suppress repeat notifications")]
+ RepeatSuppressionOnRealtimeAlert,
#[error("recovery destinations were set without notify_on_recovery, so nothing would use them")]
RecoveryDestinationsWithoutRecovery,
#[error(
@@ -895,6 +897,10 @@ async fn prepare_alert(
if alert.is_real_time && (alert.notify_on_recovery || alert.keep_firing_for != 0) {
return Err(AlertError::RecoveryOnRealtimeAlert);
}
+ // A realtime alert keeps no state row, so it has no last delivered state to compare against.
+ if alert.is_real_time && alert.suppress_repeats {
+ return Err(AlertError::RepeatSuppressionOnRealtimeAlert);
+ }
// Multi-level thresholds (alerts_2.md Feature 1). Rejected at write time so
// an unreachable warning level can never reach the evaluator.
@@ -8853,6 +8859,33 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn prepare_alert_refuses_repeat_suppression_on_a_realtime_alert() {
+ use infra::schema::{STREAM_SCHEMAS_LATEST, SchemaCache};
+
+ let org_id = "test_org_alert_save_repeat_realtime";
+ let key = format!("{org_id}/{}/app", StreamType::Logs);
+ STREAM_SCHEMAS_LATEST.write().await.insert(
+ key.clone(),
+ SchemaCache::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
+ "id",
+ DataType::Utf8,
+ true,
+ )])),
+ );
+ let mut alert = Alert::default();
+ alert.trigger_condition.frequency = 60;
+ alert.is_real_time = true;
+ alert.suppress_repeats = true;
+ let res = prepare_alert(org_id, "app", "realtime", &mut alert, true, false).await;
+ STREAM_SCHEMAS_LATEST.write().await.remove(&key);
+
+ assert!(
+ matches!(res, Err(AlertError::RepeatSuppressionOnRealtimeAlert)),
+ "{res:?}"
+ );
+ }
+
#[tokio::test]
async fn prepare_alert_rejects_a_cron_with_no_future_occurrence() {
let err = prepare_schedule(|a| {
diff --git a/src/core/src/alerts/prom_rule.rs b/src/core/src/alerts/prom_rule.rs
index 166957f33d..5aa028977b 100644
--- a/src/core/src/alerts/prom_rule.rs
+++ b/src/core/src/alerts/prom_rule.rs
@@ -446,6 +446,7 @@ mod tests {
&TriggerCondition::default(),
600,
0,
+ false,
|_, _, _| false,
);
assert!(dispatch.inconsistent.is_empty());
diff --git a/src/core/src/alerts/scheduler/handlers.rs b/src/core/src/alerts/scheduler/handlers.rs
index 43d68b3bff..5b278ad2d7 100644
--- a/src/core/src/alerts/scheduler/handlers.rs
+++ b/src/core/src/alerts/scheduler/handlers.rs
@@ -515,6 +515,8 @@ struct GroupDispatchOutcome {
downtime_id: Option,
/// Who acked the first group an ack kept silent.
acked_by: Option,
+ /// Some group was withheld because it repeated its last delivery.
+ repeated: bool,
}
impl GroupDispatchOutcome {
@@ -629,6 +631,7 @@ async fn dispatch_per_group(
state_failed: true,
downtime_id: None,
acked_by: None,
+ repeated: false,
});
};
@@ -647,6 +650,7 @@ async fn dispatch_per_group(
state_failed: true,
downtime_id: None,
acked_by: None,
+ repeated: false,
});
}
};
@@ -684,6 +688,7 @@ async fn dispatch_per_group(
&alert.trigger_condition,
now_micros(),
cfg.limit.alert_max_group_notifications_per_eval,
+ alert.suppresses_repeats(),
|key, decision, last_notified| {
muted_groups.contains_key(key)
|| (acked_groups.contains_key(key) && decision.ack_withholds(last_notified))
@@ -749,7 +754,7 @@ async fn dispatch_per_group(
continue;
}
// Chosen before the send: a resolve only closes a ticket opened under the key it quotes.
- let episode_key = alert.notify_on_recovery.then(|| {
+ let episode_key = (alert.notify_on_recovery || alert.suppresses_repeats()).then(|| {
states
.get(&item.group_key)
.and_then(|state| state.episode_id.clone())
@@ -771,7 +776,7 @@ async fn dispatch_per_group(
// last so a label value containing `{...}` cannot expand.
Some(&item.labels),
&[],
- episode_key.clone(),
+ episode_key.clone().filter(|_| alert.notify_on_recovery),
)
.await;
@@ -835,9 +840,11 @@ async fn dispatch_per_group(
log::info!(
"[SCHEDULER trace_id {trace_id}] alert {alert_id}: per-group dispatch delivered={delivered} \
- pending={} failed={failed} suppressed={} downtime={downtime_suppressed} candidates={}",
+ pending={} failed={failed} suppressed={} repeats={} downtime={downtime_suppressed} \
+ candidates={}",
plan.pending,
plan.suppressed,
+ plan.repeats,
plan.items.len()
);
Some(GroupDispatchOutcome {
@@ -849,6 +856,7 @@ async fn dispatch_per_group(
state_failed: false,
downtime_id,
acked_by,
+ repeated: plan.repeats > 0,
})
}
@@ -3053,6 +3061,45 @@ pub(crate) async fn correlate_muted_incident(
}
}
+/// A repeat still correlates, or a continuous firing lets its incident go stale and auto-resolve.
+#[cfg(feature = "enterprise")]
+async fn correlate_repeated_incident(
+ trace_id: &str,
+ alert: &config::meta::alerts::alert::Alert,
+ rows: &[config::utils::json::Map],
+ triggered_at: i64,
+ eval_level: Option,
+ actual_value: Option,
+) {
+ let Some(first_row) = rows.first() else {
+ return;
+ };
+ let incidents_enabled = o2_enterprise::enterprise::common::config::get_config()
+ .incidents
+ .enabled;
+ if !correlates_repeated_firing(alert.creates_incident, incidents_enabled) {
+ return;
+ }
+ // Correlation itself stays silent on a repeat and only notifies a new or escalated incident.
+ if let Err(e) = crate::alerts::incidents::correlate_alert_to_incident(
+ alert,
+ first_row,
+ rows,
+ triggered_at,
+ eval_level,
+ actual_value,
+ None,
+ )
+ .await
+ {
+ log::error!(
+ "[SCHEDULER trace_id {trace_id}] repeat incident correlation failed for {}/{}: {e}",
+ alert.org_id,
+ alert.name
+ );
+ }
+}
+
/// Whether a matched single-series run is still inside its pending period.
fn holds_pending(
alert: &config::meta::alerts::alert::Alert,
@@ -3134,6 +3181,12 @@ fn correlates_muted_firing(
creates_incident && incidents_enabled && downtime.incident_mode.is_muted()
}
+/// Whether a firing withheld as a repeat still feeds its incident.
+#[cfg(feature = "enterprise")]
+fn correlates_repeated_firing(creates_incident: bool, incidents_enabled: bool) -> bool {
+ creates_incident && incidents_enabled
+}
+
/// Who acked a single-row alert's current firing, when that ack withholds this run's send.
async fn single_row_ack(
alert: &config::meta::alerts::alert::Alert,
@@ -3191,6 +3244,32 @@ fn records_delivery_state(alert_level_delivery: bool, delivery: &DeliveryDecisio
alert_level_delivery && delivery.resets_silence()
}
+/// Whether a single-row firing at `level` repeats what its open recovery episode already sent.
+fn single_row_repeats(
+ alert: &config::meta::alerts::alert::Alert,
+ level: Option,
+ rollup: Option<&config::meta::alerts::state::AlertState>,
+ recorded_level: Option,
+ alert_level_delivery: bool,
+) -> bool {
+ let (Some(level), Some(rollup)) = (level.filter(|l| l.is_firing()), rollup) else {
+ return false;
+ };
+ // Only the multi-level path records the delivered level; a single-level alert fires at one.
+ let last_notified = if alert_level_delivery {
+ recorded_level.and_then(config::meta::alerts::level::AlertLevel::from_i32)
+ } else {
+ Some(level)
+ };
+ alert.suppresses_repeats()
+ && !alert.query_condition.multi_alert_enabled()
+ && config::meta::alerts::dispatch::repeats_last_notification(
+ level,
+ rollup.episode_id.is_some(),
+ last_notified,
+ )
+}
+
/// A delivered firing ends the Muted chip; a pending run persists before the downtime decision.
#[cfg(feature = "enterprise")]
fn clears_recorded_mute(outcome: &RunOutcome) -> bool {
@@ -3594,7 +3673,7 @@ async fn handle_alert_triggers(
.unwrap();
let last_states = if !alert.query_condition.multi_alert_enabled()
- && alert.pending_period_sec > 0
+ && (alert.pending_period_sec > 0 || alert.suppresses_repeats())
{
load_tracked_group_states(&alert.get_unique_key()).await.inspect_err(|e|{
log::error!("[SCHEDULER trace_id {scheduler_trace_id}] alert {}/{} error in getting alert state: {e}",trigger.org,trigger.module_key);
@@ -3927,10 +4006,27 @@ async fn handle_alert_triggers(
} else {
None
};
+ let delivery = match (downtime.as_ref(), acked_by) {
+ (Some(downtime), _) => DeliveryDecision::SuppressedByDowntime(downtime.id.clone()),
+ (None, Some(by)) => DeliveryDecision::SuppressedByAck(by),
+ (None, None)
+ if level_delivery.should_deliver()
+ && single_row_repeats(
+ &alert,
+ eval_level,
+ last_states.get(config::meta::alerts::state::ROLLUP_GROUP_KEY),
+ trigger_data.last_notified_level,
+ alert_level_delivery,
+ ) =>
+ {
+ DeliveryDecision::SuppressedAsRepeat
+ }
+ (None, None) => level_delivery,
+ };
// D6: a suppressed run starts no silence window, so the first run after the window delivers.
if starts_silence_window(
trigger_results.data.is_some(),
- downtime.is_some() || acked_by.is_some(),
+ delivery.withheld(),
alert.trigger_condition.silence,
evaluates_through_silence,
) {
@@ -3952,14 +4048,7 @@ async fn handle_alert_triggers(
// send notification
// Nowhere to send means no delivery to record: no silence window or episode.
let has_target = has_delivery_target(&alert);
- let delivery = match (downtime.as_ref(), acked_by) {
- (Some(downtime), _) => DeliveryDecision::SuppressedByDowntime(downtime.id.clone()),
- (None, Some(by)) => DeliveryDecision::SuppressedByAck(by),
- (None, None) => level_delivery,
- };
- if !delivery.should_deliver()
- && (alert_level_delivery || downtime.is_some() || delivery.acked_by().is_some())
- {
+ if !delivery.should_deliver() && (alert_level_delivery || delivery.withheld()) {
log::info!(
"[SCHEDULER trace_id {scheduler_trace_id}] delivery suppressed ({delivery:?}) for {}/{}",
new_trigger.org,
@@ -4016,6 +4105,20 @@ async fn handle_alert_triggers(
)
.await;
}
+ #[cfg(feature = "enterprise")]
+ if delivery == DeliveryDecision::SuppressedAsRepeat
+ && let Some(rows) = trigger_results.data.as_deref()
+ {
+ correlate_repeated_incident(
+ &scheduler_trace_id,
+ &alert,
+ rows,
+ triggered_at,
+ eval_level,
+ trigger_results.actual_value,
+ )
+ .await;
+ }
// What this evaluation DELIVERED, not what it decided to: the episode opens on a landed send.
let mut episode_delivered = false;
@@ -4213,6 +4316,9 @@ async fn handle_alert_triggers(
}
dispatch.record_downtime(&mut trigger_data_stream);
+ if dispatch.repeated {
+ trigger_data_stream.repeat_suppressed = Some(true);
+ }
// The rollup row was committed before anything was sent.
if let Some(outcome) = dispatch.rollup_rewrite()
&& let Some(alert_id) = alert.id.as_ref()
@@ -4486,6 +4592,8 @@ async fn handle_alert_triggers(
"alerts",
by.to_string(),
);
+ } else if delivery == DeliveryDecision::SuppressedAsRepeat {
+ trigger_data_stream.repeat_suppressed = Some(true);
}
} else if trigger_results.frozen {
// Frozen is not Normal: nothing was measured (§7.6). `Skipped` is
@@ -7384,6 +7492,7 @@ mod tests {
state_failed: false,
downtime_id: None,
acked_by: None,
+ repeated: false,
}
}
@@ -8944,6 +9053,14 @@ mod tests {
);
}
+ #[cfg(feature = "enterprise")]
+ #[test]
+ fn a_repeat_still_feeds_its_incident_only_when_incidents_apply() {
+ assert!(correlates_repeated_firing(true, true));
+ assert!(!correlates_repeated_firing(false, true));
+ assert!(!correlates_repeated_firing(true, false));
+ }
+
#[cfg(feature = "enterprise")]
#[test]
fn only_an_unsuppressed_matched_run_clears_the_recorded_mute() {
diff --git a/src/core/src/fleet.rs b/src/core/src/fleet.rs
index 65cada0036..ec93d431f1 100644
--- a/src/core/src/fleet.rs
+++ b/src/core/src/fleet.rs
@@ -58,9 +58,19 @@ pub async fn get_pipelines(
}
/// The handler has already checked the caller's org-wide fleet access, so this runs as the server.
-pub async fn list_audit(org_id: &str, query: AuditQuery) -> Result {
- o2_enterprise::enterprise::fleet::audit::list_audit(org_id, query, now_micros(), search_audit)
- .await
+pub async fn list_audit(
+ org_id: &str,
+ query: AuditQuery,
+ fleets: Option<&HashSet>,
+) -> Result {
+ o2_enterprise::enterprise::fleet::audit::list_audit(
+ org_id,
+ query,
+ fleets,
+ now_micros(),
+ search_audit,
+ )
+ .await
}
/// The check `/prometheus/api/v1/query` makes per metric stream; a refused query is `None`.
diff --git a/src/core/src/http.rs b/src/core/src/http.rs
index 0db066b3c1..06cc541502 100644
--- a/src/core/src/http.rs
+++ b/src/core/src/http.rs
@@ -99,6 +99,7 @@ impl From for Response {
| AlertError::TzOffsetOutOfRange
| AlertError::CronHasNoFutureOccurrence { .. }
| AlertError::RecoveryOnRealtimeAlert
+ | AlertError::RepeatSuppressionOnRealtimeAlert
| AlertError::RecoveryDestinationsWithoutRecovery
| AlertError::RecoveryDestinationIsPlatform { .. }
| AlertError::RecoveryNeedsJsonPayload { .. }
diff --git a/src/core/src/lib.rs b/src/core/src/lib.rs
index 39a82c671e..02ada8c213 100644
--- a/src/core/src/lib.rs
+++ b/src/core/src/lib.rs
@@ -67,7 +67,7 @@ pub mod public_dashboards;
pub mod ratelimit;
pub mod rum_pa;
use search_service as search;
-#[cfg(feature = "profiling")]
+#[cfg(feature = "cpu-profiling")]
pub mod self_profiles;
pub mod self_reporting;
pub mod service;
@@ -88,6 +88,10 @@ pub mod users;
#[cfg(feature = "enterprise")]
pub mod workflows;
+#[cfg(feature = "cloud")]
+pub mod org_domain_ownership;
+#[cfg(feature = "cloud")]
+pub mod org_domain_requests;
/// One authorized AI operation, or `()` in builds without the quota module, so
/// the RCA signature keeps one shape instead of being cfg-gated at every caller.
#[cfg(feature = "cloud")]
diff --git a/src/core/src/logs/bulk.rs b/src/core/src/logs/bulk.rs
index f8f282e5df..645a12f0e1 100644
--- a/src/core/src/logs/bulk.rs
+++ b/src/core/src/logs/bulk.rs
@@ -288,6 +288,12 @@ pub async fn ingest(
written_any = true;
}
for status in v.status {
+ // the stream status keeps the items but not the write's errors flag
+ bulk_res.errors |= status
+ .items
+ .iter()
+ .flat_map(|item| item.values())
+ .any(|item| item.error.is_some());
bulk_res.items.extend(status.items);
}
}
@@ -621,8 +627,7 @@ mod tests {
} else {
assert!(rejected.is_empty());
}
- // not set: Fluent Bit retries a whole chunk on it, duplicating the other streams' rows
- assert!(!resp.errors);
+ assert_eq!(resp.errors, bounded);
assert!(
infra::schema::exists("test_org_id", StreamType::Logs, "test_bulk_key_len_ok").await
);
diff --git a/src/core/src/logs/mod.rs b/src/core/src/logs/mod.rs
index ab38955c0d..02c9792e41 100644
--- a/src/core/src/logs/mod.rs
+++ b/src/core/src/logs/mod.rs
@@ -396,6 +396,7 @@ fn reject_stream_rows(
.map(|(_, record)| record.get("_id").and_then(Value::as_str).map(String::from))
.chain(columnar.into_iter().flat_map(|mut c| c.take_bulk_ids()));
for doc_id in doc_ids {
+ bulk_res.errors = true;
bulk::add_record_status(
stream_name.to_string(),
doc_id,
@@ -798,7 +799,7 @@ mod tests {
let IngestionStatus::Bulk(bulk) = bulk else {
unreachable!()
};
- assert!(!bulk.errors);
+ assert!(bulk.errors);
let ids: Vec<&str> = bulk
.items
.iter()
diff --git a/src/core/src/metrics/ingest.rs b/src/core/src/metrics/ingest.rs
index cb44c233f8..f3bf2fd2d5 100644
--- a/src/core/src/metrics/ingest.rs
+++ b/src/core/src/metrics/ingest.rs
@@ -105,10 +105,10 @@ impl StreamKeyChecks {
}
/// Removes the records bound for streams that cannot be stored, as (stream, reason, records).
- pub(super) fn drop_unstorable(
+ pub(super) fn drop_unstorable(
&mut self,
org_id: &str,
- records_by_stream: &mut RecordsByStream,
+ records_by_stream: &mut HashMap>,
) -> Vec<(String, String, usize)> {
let mut dropped = Vec::new();
records_by_stream.retain(|stream_name, records| {
diff --git a/src/core/src/metrics/prom.rs b/src/core/src/metrics/prom.rs
index c5d4294a25..59a701ea68 100644
--- a/src/core/src/metrics/prom.rs
+++ b/src/core/src/metrics/prom.rs
@@ -143,10 +143,15 @@ pub async fn remote_write(
// check if stream is deleting from cache
let mut stream_delete_status: HashMap = HashMap::new();
let mut skipped_records: u32 = 0;
+ // remote write has no partial success: a 4xx drops the whole batch, a 5xx retries it forever
+ let mut stream_key_checks = ingest::StreamKeyChecks::default();
// parse metadata
for item in request.metadata {
let metric_name = format_stream_name(item.metric_family_name.to_string());
+ if stream_key_checks.violation(org_id, &metric_name).is_some() {
+ continue;
+ }
let schema = infra::schema::get(org_id, &metric_name, StreamType::Metrics)
.await
.unwrap_or_else(|_| Schema::empty());
@@ -218,6 +223,8 @@ pub async fn remote_write(
}
}
+ unique_metrics.retain(|name| stream_key_checks.violation(org_id, name).is_none());
+
let mut preload_pipeline_time = 0u128;
let mut preload_uds_time = 0u128;
let mut preload_schema_time = 0u128;
@@ -379,6 +386,10 @@ pub async fn remote_write(
continue;
}
+ if stream_key_checks.violation(org_id, &metric_name).is_some() {
+ continue;
+ }
+
// Note: All configurations (pipeline, UDS, schema, partition, alerts) are now pre-loaded
// before the loop to avoid repeated async queries
@@ -552,6 +563,8 @@ pub async fn remote_write(
.map(|(record, timestamp)| (record, timestamp, None)),
);
}
+ // histogram bucket, exemplar and pipeline output streams can be too long when the metric fits
+ stream_key_checks.drop_unstorable(org_id, &mut json_data_by_stream);
let step_start = std::time::Instant::now();
for (stream_name, mut json_data) in json_data_by_stream {
@@ -2309,6 +2322,87 @@ mod tests {
assert!(matchers.iter().all(|m| m.is_match("http_requests")));
}
+ // cloud builds reject the unknown test org at the trial check
+ #[cfg(not(feature = "cloud"))]
+ #[tokio::test]
+ async fn remote_write_drops_only_the_streams_whose_key_is_too_long() {
+ use infra::file_list::STREAM_KEY_MAX_LEN;
+ use prost::Message;
+ use proto::prometheus_rpc;
+
+ let org_id = "test_org_id";
+ let now = Utc::now().timestamp_millis();
+ let series = |name: &str| prometheus_rpc::TimeSeries {
+ labels: vec![prometheus_rpc::Label {
+ name: NAME_LABEL.to_string(),
+ value: name.to_string(),
+ }],
+ samples: vec![prometheus_rpc::Sample {
+ value: 1.0,
+ timestamp: now,
+ }],
+ ..Default::default()
+ };
+ let long_name = "m".repeat(STREAM_KEY_MAX_LEN);
+ // `_sum` fills the key exactly; `_count`, `_bucket` and `_exemplars` overflow it
+ let histogram_name =
+ "h".repeat(STREAM_KEY_MAX_LEN - format!("{org_id}/metrics/_sum").len());
+ let histogram = prometheus_rpc::TimeSeries {
+ samples: vec![],
+ exemplars: vec![prometheus_rpc::Exemplar {
+ labels: vec![],
+ value: 1.0,
+ timestamp: now,
+ }],
+ histograms: vec![prometheus_rpc::Histogram {
+ sum: 1.0,
+ count: Some(prometheus_rpc::histogram::Count::CountInt(1)),
+ zero_count: Some(prometheus_rpc::histogram::ZeroCount::ZeroCountInt(1)),
+ timestamp: now,
+ ..Default::default()
+ }],
+ ..series(&histogram_name)
+ };
+ let request = prometheus_rpc::WriteRequest {
+ timeseries: vec![
+ series("test_prom_key_len_ok"),
+ series(&long_name),
+ histogram,
+ ],
+ metadata: vec![prometheus_rpc::MetricMetadata {
+ r#type: prometheus_rpc::metric_metadata::MetricType::Gauge as i32,
+ metric_family_name: long_name.clone(),
+ ..Default::default()
+ }],
+ };
+ let body = snap::raw::Encoder::new()
+ .compress_vec(&request.encode_to_vec())
+ .unwrap();
+ remote_write(
+ org_id,
+ Bytes::from(body),
+ IngestUser::User("root@example.com".to_string()),
+ )
+ .await
+ .unwrap();
+
+ let bounded =
+ infra::file_list::stream_key_violation(org_id, StreamType::Metrics, &long_name)
+ .is_some();
+ let expected = [
+ ("test_prom_key_len_ok".to_string(), true),
+ (format!("{histogram_name}_sum"), true),
+ (long_name, !bounded),
+ (format!("{histogram_name}_count"), !bounded),
+ (format!("{histogram_name}_bucket"), !bounded),
+ (exemplars_stream_name(&histogram_name), !bounded),
+ ];
+ for (name, created) in expected {
+ let exists = infra::schema::exists(org_id, StreamType::Metrics, &name).await;
+ assert_eq!(exists, created, "{name}");
+ }
+ }
+
#[tokio::test]
async fn remote_write_rejects_a_snappy_header_declaring_more_than_the_limit() {
let mut n = get_config().limit.req_payload_limit as u64 + 1;
diff --git a/src/core/src/org_cleanup/coverage.rs b/src/core/src/org_cleanup/coverage.rs
index d51dd436dc..c0f6a89d71 100644
--- a/src/core/src/org_cleanup/coverage.rs
+++ b/src/core/src/org_cleanup/coverage.rs
@@ -230,6 +230,8 @@ pub const COVERAGE: &[Coverage] = &[
table("workflow_errors", DB),
table("workflow_run_data", DB),
table("workflows", DB),
+ table("org_domain_ownership", DB),
+ table("org_domain_requests", DB),
covered(Kind::KvPrefix, "/cipher_keys/{org}/", DB),
covered(Kind::KvPrefix, "/compact/downsampling/{org}/", DB),
covered(Kind::KvPrefix, "/compact/files/{org}/", DB),
diff --git a/src/core/src/org_cleanup/mod.rs b/src/core/src/org_cleanup/mod.rs
index 2636dd1d8b..345a799722 100644
--- a/src/core/src/org_cleanup/mod.rs
+++ b/src/core/src/org_cleanup/mod.rs
@@ -32,6 +32,9 @@ use infra::{
};
use sea_orm::TransactionTrait;
+#[cfg(feature = "cloud")]
+use crate::{org_domain_ownership, org_domain_requests};
+
const LOCK_KEY: &str = "/org_cleanup/worker_lock";
const MAX_ATTEMPTS: i32 = 10;
@@ -1034,17 +1037,11 @@ async fn delete_org_offsets_and_caches(org_id: &str) -> Result<(), anyhow::Error
Ok(())
}
-// The mappings live in the `_meta` org's settings, so the org's own settings delete misses them.
#[cfg(feature = "cloud")]
async fn delete_org_domain_mappings(org_id: &str) -> Result<(), anyhow::Error> {
- // The shared writer serializes this with the settings API and other orgs' cleanups.
let org_id = org_id.to_string();
- crate::db::organization::update_org_setting(config::META_ORG_ID, move |settings| {
- let before = settings.domain_org_mappings.len();
- settings.domain_org_mappings.retain(|m| m.org_id != org_id);
- settings.domain_org_mappings.len() != before
- })
- .await?;
+ org_domain_requests::delete_by_org(&org_id).await?;
+ org_domain_ownership::delete_by_org(&org_id).await?;
Ok(())
}
diff --git a/src/core/src/org_domain_ownership.rs b/src/core/src/org_domain_ownership.rs
new file mode 100644
index 0000000000..05b91f2822
--- /dev/null
+++ b/src/core/src/org_domain_ownership.rs
@@ -0,0 +1,288 @@
+// 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 .
+
+use std::sync::{Arc, LazyLock};
+
+use hashbrown::HashMap;
+use infra::{
+ coordinator::get_coordinator,
+ db::Event,
+ table::{
+ entity::{org_domain_ownership::Model, status_page_custom_domains},
+ org_domain_ownership::{OwnershipRecord, OwnershipState},
+ },
+};
+
+static CACHE: LazyLock> =
+ LazyLock::new(|| tokio::sync::RwLock::new(HashMap::new()));
+
+pub const ODO_PREFIX: &str = "/org_domain_ownership/";
+const DOMAIN_EXPIRY_BUFFER_SEC: i64 = 60 * 30;
+
+pub async fn init() {
+ let items = match infra::table::org_domain_ownership::list_active_domain_org_map().await {
+ Ok(v) => v,
+ Err(e) => {
+ log::error!("error in listing active domain mapping for init caching : {e}");
+ return;
+ }
+ };
+ let mut lock = CACHE.write().await;
+ for (domain, org) in items {
+ lock.insert(domain, org);
+ }
+}
+
+pub async fn get_cached_org_for_domain(domain: &str) -> Option {
+ let lock = CACHE.read().await;
+ let org = lock.get(domain);
+ org.map(ToOwned::to_owned)
+}
+
+pub async fn remove_domain_from_cache(domain: &str) {
+ let mut lock = CACHE.write().await;
+ lock.remove(domain);
+}
+
+pub async fn add_domain_to_cache(domain: &str, org: &str) {
+ let mut lock = CACHE.write().await;
+ lock.insert(domain.to_string(), org.to_string());
+}
+
+pub async fn get_verified_org_for_domain(domain: &str) -> Result, anyhow::Error> {
+ let lock = CACHE.read().await;
+ let org = lock.get(domain);
+ if let Some(org) = org {
+ return Ok(Some(org.to_owned()));
+ }
+ drop(lock);
+ infra::table::org_domain_ownership::get_verified_org_for_domain(domain).await
+}
+
+pub async fn save_org_domain_mapping(org_id: &str, domain: &str) -> Result<(), anyhow::Error> {
+ let record = OwnershipRecord {
+ org_id: org_id.to_owned(),
+ domain: domain.to_owned(),
+ state: OwnershipState::Pending,
+ };
+ infra::table::org_domain_ownership::save_org_domain_mapping(record, false).await
+}
+
+pub async fn delete_linked_domain(record: Model) -> Result<(), anyhow::Error> {
+ infra::table::org_domain_ownership::delete_linked_domain(&record.org_id, &record.domain)
+ .await?;
+ // only sync cache for domain which is verified
+ // non verified domain is not going to be in cache
+ if record.verification_state == OwnershipState::Verified as i32 {
+ let mut lock = CACHE.write().await;
+ let cached = lock.get(&record.domain);
+ if cached.is_some_and(|v| v == &record.org_id) {
+ lock.remove(&record.domain);
+ }
+ drop(lock);
+ emit_sync_event(&record.org_id, &record.domain).await;
+ }
+ Ok(())
+}
+
+pub async fn get_domain_org_record(
+ org_id: &str,
+ domain: &str,
+) -> Result , anyhow::Error> {
+ let res = infra::table::org_domain_ownership::get_domain_org_record(org_id, domain).await?;
+ Ok(res)
+}
+
+pub async fn verify(mut record: Model) -> Result {
+ if record.meta_mapped {
+ return Ok(record);
+ }
+ let res = o2_enterprise::enterprise::status_pages::domain_verifier::check_domain_ownership(
+ status_page_custom_domains::Model {
+ domain: record.domain.clone(),
+ verification_token: record.verification_token.clone(),
+ last_checked_at: record.last_checked_at,
+ updated_at: record.updated_at,
+ verification_state: record.verification_state,
+ verification_failure_reason: record.verification_failure_reason,
+ verified_at: record.verified_at,
+ ..Default::default()
+ },
+ Some(DOMAIN_EXPIRY_BUFFER_SEC),
+ )
+ .await?;
+ record.last_checked_at = res.last_checked_at;
+ record.updated_at = res.updated_at;
+ record.verification_state = res.verification_state;
+ record.verification_failure_reason = res.verification_failure_reason;
+ record.verified_at = res.verified_at;
+
+ infra::table::org_domain_ownership::save_domain_org_record(record.clone()).await?;
+
+ Ok(record)
+}
+
+pub async fn verify_domain_now(org_id: &str, domain: &str) -> Result {
+ let record = infra::table::org_domain_ownership::get_domain_org_record(org_id, domain).await?;
+ let Some(record) = record else {
+ return Err(anyhow::anyhow!(
+ "no mapping for domain {domain} found for org {org_id}"
+ ));
+ };
+ if record.meta_mapped {
+ return Ok(record);
+ }
+ let updated_record = verify(record).await?;
+ if updated_record.verification_state == OwnershipState::Verified as i32 {
+ let mut lock = CACHE.write().await;
+ lock.insert(domain.to_owned(), org_id.to_owned());
+ }
+ emit_sync_event(org_id, domain).await;
+ Ok(updated_record)
+}
+
+pub async fn update_state(org_id: &str, domain: &str, new_state: i32) -> Result<(), anyhow::Error> {
+ let mut lock = CACHE.write().await;
+ let existing = lock.get(domain);
+ if new_state == OwnershipState::Verified as i32 {
+ lock.insert(domain.to_owned(), org_id.to_owned());
+ } else if existing.is_some_and(|v| org_id == v) {
+ lock.remove(domain);
+ }
+ drop(lock);
+ emit_sync_event(org_id, domain).await;
+ Ok(())
+}
+
+pub async fn get_domains_for_org(org_id: &str) -> Result, anyhow::Error> {
+ let records = infra::table::org_domain_ownership::get_domains_for_org(org_id).await?;
+ Ok(records)
+}
+
+pub async fn link_from_meta(org_id: &str, domain: &str) -> Result<(), anyhow::Error> {
+ let domains = get_domains_for_org(org_id).await?;
+ let mapped = domains
+ .into_iter()
+ .filter(|v| v.domain == domain)
+ .next()
+ .is_some();
+ if mapped {
+ infra::table::org_domain_ownership::mark_meta_mapped(org_id, domain).await?;
+ } else {
+ let record = OwnershipRecord {
+ org_id: org_id.to_owned(),
+ domain: domain.to_owned(),
+ state: OwnershipState::Verified,
+ };
+ infra::table::org_domain_ownership::save_org_domain_mapping(record, true).await?;
+ }
+ emit_sync_event(org_id, domain).await;
+ Ok(())
+}
+
+pub async fn delete_by_org(org_id: &str) -> Result<(), anyhow::Error> {
+ let domains = infra::table::org_domain_ownership::get_domains_for_org(org_id).await?;
+ for domain in domains {
+ delete_linked_domain(domain).await?;
+ }
+ Ok(())
+}
+
+async fn emit_sync_event(org_id: &str, domain: &str) {
+ let cluster_coordinator = get_coordinator().await;
+ if let Err(e) = cluster_coordinator
+ .put(
+ &format!("{ODO_PREFIX}{org_id}/{domain}"),
+ "".into(),
+ true,
+ None,
+ )
+ .await
+ {
+ log::error!(
+ "error sending cluster sync message for org domain ownership sync for org {org_id} : {e}"
+ );
+ }
+}
+
+pub async fn watch() -> Result<(), anyhow::Error> {
+ let cluster_coordinator = ::infra::db::get_coordinator().await;
+ let mut events = cluster_coordinator.watch(ODO_PREFIX).await?;
+ let events = Arc::get_mut(&mut events).unwrap();
+ log::info!("Start watching org_domain_ownership");
+
+ loop {
+ let ev = match events.recv().await {
+ Some(ev) => ev,
+ None => {
+ log::error!("watch_org_domain_ownership: event channel closed");
+ return Ok(());
+ }
+ };
+
+ if let Event::Put(ev) = ev {
+ let Some(key) = ev.key.strip_prefix(ODO_PREFIX) else {
+ log::error!("unexpected key for org domain prefix watch : {}", ev.key);
+ continue;
+ };
+ let Some((org, domain)) = key.split_once("/") else {
+ log::error!(
+ "invalid key received for org domain ownership sync : {}",
+ ev.key
+ );
+ continue;
+ };
+ log::info!("received sync event for org domain ownership for org {org}");
+ let record = match infra::table::org_domain_ownership::get_domain_org_record(
+ org, domain,
+ )
+ .await
+ {
+ Ok(v) => v,
+ Err(e) => {
+ log::error!(
+ "error in retrieving org domain ownership record from db for {org} domain {domain} : {e}"
+ );
+ continue;
+ }
+ };
+ let cached = get_cached_org_for_domain(domain).await;
+ let mut lock = CACHE.write().await;
+ match record {
+ None => {
+ if cached.is_some_and(|v| v == org) {
+ log::info!(
+ "removing org {org} domain {domain} from memory cache as record not found in db"
+ );
+ lock.remove(domain);
+ }
+ }
+ Some(v) => {
+ if v.verification_state == OwnershipState::Verified as i32 {
+ log::info!(
+ "added org {org} domain {domain} from memory cache as verified in db"
+ );
+ lock.insert(domain.to_owned(), org.to_owned());
+ } else if cached.is_some_and(|v| v == org) {
+ log::info!(
+ "removed org {org} domain {domain} from memory cache as not verified in db"
+ );
+ lock.remove(domain);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/core/src/org_domain_requests.rs b/src/core/src/org_domain_requests.rs
new file mode 100644
index 0000000000..c8b3d7d337
--- /dev/null
+++ b/src/core/src/org_domain_requests.rs
@@ -0,0 +1,72 @@
+// 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 .
+
+use config::utils::time::now_micros;
+use infra::table::{
+ entity::org_domain_requests::Model,
+ org_domain_requests::{self, Status},
+};
+
+pub async fn save_org_domain_request(
+ org_id: &str,
+ domain: &str,
+ req_by: &str,
+) -> Result<(), anyhow::Error> {
+ let now = now_micros();
+ let req = Model {
+ id: config::ider::generate(),
+ org_id: org_id.to_string(),
+ domain: domain.trim().to_lowercase(),
+ created_at: now,
+ updated_at: now,
+ requested_by: req_by.to_string(),
+ answered_by: None,
+ status: Status::Pending as i32,
+ reason: None,
+ };
+ org_domain_requests::save_org_domain_request(req).await?;
+ Ok(())
+}
+
+pub async fn list_all_req() -> Result, anyhow::Error> {
+ Ok(org_domain_requests::list_all().await?)
+}
+
+pub async fn list_by_org(org_id: &str) -> Result, anyhow::Error> {
+ Ok(org_domain_requests::list_by_org(org_id).await?)
+}
+pub async fn get_req_by_id(id: &str) -> Result, anyhow::Error> {
+ Ok(org_domain_requests::get_by_id(id).await?)
+}
+
+pub async fn get_req_by_org_domain(
+ org_id: &str,
+ domain: &str,
+) -> Result , anyhow::Error> {
+ Ok(org_domain_requests::get_by_org_domain(org_id, domain).await?)
+}
+
+pub async fn update_status(
+ id: &str,
+ user: &str,
+ new_status: Status,
+ reason: String,
+) -> Result<(), anyhow::Error> {
+ Ok(org_domain_requests::update_status(id, user, new_status, reason).await?)
+}
+
+pub async fn delete_by_org(org_id: &str) -> Result<(), anyhow::Error> {
+ org_domain_requests::delete_by_org(org_id).await
+}
diff --git a/src/core/src/organization.rs b/src/core/src/organization.rs
index 5762f683fd..831b06acb8 100644
--- a/src/core/src/organization.rs
+++ b/src/core/src/organization.rs
@@ -25,6 +25,8 @@ use config::{
},
utils::{json, rand::generate_random_string, time},
};
+#[cfg(feature = "cloud")]
+use db::organization::get_org_setting;
use db::{self, org_users, user::is_root_user};
use infra::table::{self, org_users::UserOrgExpandedRecord};
#[cfg(feature = "enterprise")]
@@ -46,6 +48,8 @@ use {
#[cfg(feature = "cloud")]
use super::self_reporting::cloud_events::{CloudEvent, EventType, enqueue_cloud_event};
+#[cfg(feature = "cloud")]
+use crate::org_domain_ownership::get_cached_org_for_domain;
use crate::{
auth::{delete_org_tuples, save_org_tuples},
common::{
@@ -1103,6 +1107,21 @@ pub async fn generate_invitation(
return Err(anyhow::anyhow!("Email Domain not allowed for {invitee}"));
}
+ let Some((_, domain)) = invitee.split_once('@') else {
+ return Err(anyhow::anyhow!("invalid email id {invitee}"));
+ };
+
+ if let Some(linked_org) = get_cached_org_for_domain(domain).await {
+ let settings = get_org_setting(&linked_org).await?;
+ for mapping in settings.domain_org_mappings {
+ if mapping.domain == domain && mapping.org_id == org_id {
+ return Err(anyhow::anyhow!(
+ "invitee '{invitee}' domain is already mapped to org {org_id} so cannot send invite"
+ ));
+ }
+ }
+ }
+
if get_billing_by_org_id(org_id).await?.is_none() {
// If the org we are inviting to is paid, its fine to send invitations
// irrespective of what other orgs invitees are part of.
diff --git a/src/core/src/public_dashboards/defaults.rs b/src/core/src/public_dashboards/defaults.rs
index 73466de1c1..a52dd85e17 100644
--- a/src/core/src/public_dashboards/defaults.rs
+++ b/src/core/src/public_dashboards/defaults.rs
@@ -113,6 +113,12 @@ impl<'a> Defaults<'a> {
self.values
}
+ /// Whether any value waits for a values query.
+ #[cfg(feature = "enterprise")]
+ pub fn has_pending(&self) -> bool {
+ !self.pending.is_empty()
+ }
+
/// The values queries `from` reaches through their stream, field and filter references.
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub fn reachable_queries(
diff --git a/src/core/src/public_dashboards/mask.rs b/src/core/src/public_dashboards/mask.rs
index 734a0eeae0..8f02ecfe7f 100644
--- a/src/core/src/public_dashboards/mask.rs
+++ b/src/core/src/public_dashboards/mask.rs
@@ -236,6 +236,7 @@ mod tests {
#[test]
fn gate02_a_variable_from_a_lost_values_stream_is_nulled() {
let var = |label: &str, value| PublicVariable {
+ name: label.into(),
label: label.into(),
value,
tab_id: None,
diff --git a/src/core/src/public_dashboards/materialize.rs b/src/core/src/public_dashboards/materialize.rs
index 575ed1bd7e..af1c283f7c 100644
--- a/src/core/src/public_dashboards/materialize.rs
+++ b/src/core/src/public_dashboards/materialize.rs
@@ -1457,6 +1457,7 @@ pub(super) mod tests {
Run {
panels,
variables: vec![PublicVariable {
+ name: "svc".into(),
label: "svc".into(),
value: json!("checkout"),
tab_id: None,
diff --git a/src/core/src/public_dashboards/mod.rs b/src/core/src/public_dashboards/mod.rs
index 5583e0fab5..fbf79a2c61 100644
--- a/src/core/src/public_dashboards/mod.rs
+++ b/src/core/src/public_dashboards/mod.rs
@@ -352,9 +352,7 @@ pub struct LinkValues {
impl LinkValues {
pub fn new(dash: &Dashboard, frozen: Option<&str>) -> Self {
let values = Defaults::new(dash, parse_frozen_vars(frozen)).into_values();
- Self {
- vars: QueryVars::new(dash, values, None),
- }
+ Self::from_values(dash, values)
}
/// The panel's query text as its build substitutes it; the window only sizes `$__interval`.
@@ -376,6 +374,12 @@ impl LinkValues {
let vars = self.vars.for_panel(tab_id, &panel.id);
plan_promql(panel, &vars, now - 3_600_000_000, now)
}
+
+ fn from_values(dash: &Dashboard, values: BTreeMap) -> Self {
+ Self {
+ vars: QueryVars::new(dash, values, None),
+ }
+ }
}
/// One authorizer health answer per build or deadline check, asked only when a check denies.
@@ -808,7 +812,14 @@ pub async fn update_link(
let now = now_micros();
validate_dashboard(&dash, &cfg.frozen_variables, now).map_err(Invalid)?;
#[cfg(feature = "enterprise")]
- authorize_variable_edit(&link, &dash, &cfg.frozen_variables, user_id).await?;
+ authorize_variable_edit(
+ &link,
+ &dash,
+ &cfg.frozen_variables,
+ range_changed(&link, &cfg.time_range).then_some(cfg.time_range.ranges.as_slice()),
+ user_id,
+ )
+ .await?;
let ranges = serde_json::to_string(&cfg.time_range.ranges)?;
let frozen = serde_json::to_string(&cfg.frozen_variables)?;
edit(&link.org_id, &link.id, |l| {
@@ -1508,15 +1519,29 @@ where
paused
}
-/// Enterprise: the editor must read every stream a changed frozen value makes a panel read.
+/// Whether an edit changes the set of ranges a link serves or its default range.
+#[cfg(feature = "enterprise")]
+fn range_changed(link: &PublicDashboard, policy: &TimeRangePolicy) -> bool {
+ let keys = |ranges: &[TimeRange]| ranges.iter().map(TimeRange::key).collect::>();
+ let stored = time_ranges(link);
+ keys(&stored.ranges) != keys(&policy.ranges) || stored.default.key() != policy.default.key()
+}
+
+/// Enterprise: the editor must read what a changed frozen value or range makes the link read.
#[cfg(feature = "enterprise")]
async fn authorize_variable_edit(
link: &PublicDashboard,
dash: &Dashboard,
frozen: &BTreeMap,
+ changed_ranges: Option<&[TimeRange]>,
user_id: &str,
) -> Result<(), anyhow::Error> {
- let sources = variable_edit_sources(link, dash, frozen);
+ let sources = match changed_ranges {
+ Some(ranges) => {
+ range_edit_sources(&ENGINE, link, dash, frozen, ranges, user_id, now_micros()).await?
+ }
+ None => variable_edit_sources(link, dash, frozen),
+ };
if sources.is_empty() {
return Ok(());
}
@@ -1560,57 +1585,190 @@ fn variable_edit_sources(
let values = LinkValues::new(dash, new_json.as_deref());
for tab in &v8.tabs {
for panel in &tab.panels {
- let texts = template_texts(panel);
- let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
- let reads_changed = values
- .vars
- .referenced(&tab.tab_id, &panel.id, &texts)
- .iter()
- .any(|(_, key)| reached.contains(key));
- if !reads_changed {
- continue;
- }
- let promql = panel.query_type == "promql";
- let planned = promql
- .then(|| values.plan_promql(&tab.tab_id, panel).ok())
- .flatten();
- for (qi, q) in panel.queries.iter().enumerate() {
- let lang = if promql { "promql" } else { "sql" };
- // A sent PromQL query is checked as the build sends it, a formula as its expansion.
- let text = match planned.as_ref().and_then(|p| p[qi].clone()) {
- Some(expr) => expr,
- None => {
- let Some(text) = query_text(q) else {
- continue;
- };
- values.substitute(&tab.tab_id, &panel.id, text, lang)
- }
- };
- sources.push(if promql {
- QuerySource::PromQl {
- org_id: link.org_id.clone(),
- query: text,
- }
- } else {
- QuerySource::Sql {
- org_id: link.org_id.clone(),
- sql: text,
- default_type: q.fields.stream_type,
- }
- });
- let vrl = q.vrl_function_query.as_deref().map(str::trim);
- if let Some(vrl) = vrl.filter(|f| !promql && !f.is_empty()) {
- sources.push(QuerySource::Vrl {
- org_id: link.org_id.clone(),
- source: vrl.to_string(),
- });
- }
+ if panel_keys(&values, tab, panel).any(|key| reached.contains(&key)) {
+ sources.extend(panel_sources(&link.org_id, &values, tab, panel));
}
}
}
+ dedup(sources)
+}
+
+/// Every query the link runs in each range, with its values queries run as the editor.
+#[cfg(feature = "enterprise")]
+async fn range_edit_sources(
+ search: &dyn PanelSearch,
+ link: &PublicDashboard,
+ dash: &Dashboard,
+ frozen: &BTreeMap,
+ ranges: &[TimeRange],
+ user_id: &str,
+ now: i64,
+) -> Result, anyhow::Error> {
+ use crate::authz::QuerySource;
+
+ let Some(v8) = dash.v8.as_ref() else {
+ return Ok(vec![]);
+ };
+ let editor = PublicDashboard {
+ published_by: user_id.to_string(),
+ ..link.clone()
+ };
+ let probe = AuthorizerProbe::default();
+ let family = TraceFamily::new(&link.org_id);
+ let mut sources = Vec::new();
+ // Values depend on the window only when a values query picks them.
+ let windows: Vec<&TimeRange> = if Defaults::new(dash, frozen.clone()).has_pending() {
+ ranges.iter().collect()
+ } else {
+ ranges.iter().take(1).collect()
+ };
+ for range in windows {
+ let range_key = range.key();
+ let (start, end) = materialize::window(range, now);
+ let ctx = Ctx {
+ search,
+ probe: &probe,
+ link: &editor,
+ dash,
+ identity: public_dashboard_identity(&link.org_id),
+ range_key: &range_key,
+ start,
+ end,
+ now,
+ family: &family,
+ };
+ let filled = fill_defaults(&ctx, frozen.clone()).await;
+ sources.extend(filled.deps.values().flat_map(|dep| {
+ dep.sources
+ .iter()
+ .map(|(stream_type, name)| QuerySource::Stream {
+ org_id: link.org_id.clone(),
+ stream_type: *stream_type,
+ name: name.clone(),
+ })
+ }));
+ if let Some(key) = filled.denied.first() {
+ return Err(Forbidden(format!(
+ "Unauthorized Access: no read permission for the values of variable {key}"
+ ))
+ .into());
+ }
+ // A later build may resolve the value, so an unchecked panel must not be saved.
+ if let Some(key) = filled.failed.first() {
+ return Err(Invalid(format!(
+ "The values of variable {key} could not be loaded to check stream access for this range. Try again."
+ ))
+ .into());
+ }
+ let values = LinkValues::from_values(dash, filled.values);
+ for tab in &v8.tabs {
+ for panel in &tab.panels {
+ sources.extend(panel_sources(&link.org_id, &values, tab, panel));
+ }
+ }
+ }
+ Ok(dedup(sources))
+}
+
+/// Keys of the scoped values the panel's queries read.
+#[cfg(feature = "enterprise")]
+fn panel_keys<'a>(
+ values: &'a LinkValues,
+ tab: &'a Tab,
+ panel: &'a Panel,
+) -> impl Iterator- + 'a {
+ let texts = template_texts(panel);
+ let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
+ values
+ .vars
+ .referenced(&tab.tab_id, &panel.id, &texts)
+ .into_iter()
+ .map(|(_, key)| key)
+}
+
+/// The queries and VRL functions a panel's build sends; a custom chart never runs.
+#[cfg(feature = "enterprise")]
+fn panel_sources(
+ org_id: &str,
+ values: &LinkValues,
+ tab: &Tab,
+ panel: &Panel,
+) -> Vec
{
+ use crate::authz::QuerySource;
+
+ let mut sources = Vec::new();
+ if panel.typ == "custom_chart" || withholds_vrl(org_id, panel) {
+ return sources;
+ }
+ let promql = panel.query_type == "promql";
+ let planned = promql
+ .then(|| values.plan_promql(&tab.tab_id, panel).ok())
+ .flatten();
+ for (qi, q) in panel.queries.iter().enumerate() {
+ let lang = if promql { "promql" } else { "sql" };
+ // A sent PromQL query is checked as the build sends it, a formula as its expansion.
+ let text = match planned.as_ref().map(|p| p[qi].clone()) {
+ Some(Some(expr)) => expr,
+ // A hidden or empty query is never sent; formulas carry their inputs.
+ Some(None) => continue,
+ None => {
+ let Some(text) = query_text(q) else {
+ continue;
+ };
+ values.substitute(&tab.tab_id, &panel.id, text, lang)
+ }
+ };
+ sources.push(if promql {
+ QuerySource::PromQl {
+ org_id: org_id.to_string(),
+ query: text,
+ }
+ } else {
+ QuerySource::Sql {
+ org_id: org_id.to_string(),
+ sql: text,
+ default_type: q.fields.stream_type,
+ }
+ });
+ let vrl = q.vrl_function_query.as_deref().map(str::trim);
+ if let Some(vrl) = vrl.filter(|f| !promql && !f.is_empty()) {
+ sources.push(QuerySource::Vrl {
+ org_id: org_id.to_string(),
+ source: vrl.to_string(),
+ });
+ }
+ }
sources
}
+/// Whether the build withholds the panel for a VRL whose reads it cannot prove, as `run_sql_panel`
+/// does.
+#[cfg(feature = "enterprise")]
+fn withholds_vrl(org_id: &str, panel: &Panel) -> bool {
+ panel.query_type != "promql"
+ && panel.queries.iter().any(|q| {
+ q.vrl_function_query
+ .as_deref()
+ .map(str::trim)
+ .filter(|f| !f.is_empty())
+ .is_some_and(|vrl| {
+ transform::enrichment_tables_read(vrl, org_id).is_err()
+ || skips_vrl_in_search(vrl)
+ })
+ })
+}
+
+#[cfg(feature = "enterprise")]
+fn dedup(sources: Vec) -> Vec {
+ let mut unique = Vec::with_capacity(sources.len());
+ for source in sources {
+ if !unique.contains(&source) {
+ unique.push(source);
+ }
+ }
+ unique
+}
+
/// The outermost explicit LIMIT of a SQL query; None without one or when it does not parse.
fn explicit_limit(sql: &str) -> Option {
use sqlparser::{
@@ -4208,6 +4366,113 @@ mod tests {
);
}
+ #[cfg(feature = "enterprise")]
+ #[test]
+ fn only_a_new_range_set_or_default_is_a_range_change() {
+ let (mut link, ..) = ctx_parts();
+ link.time_ranges = Some(
+ json!([{ "type": "relative", "secs": 86400 }, { "type": "relative", "secs": 3600 }])
+ .to_string(),
+ );
+ link.default_range_key = Some("r86400".into());
+ let policy = |ranges: serde_json::Value, default: usize| -> TimeRangePolicy {
+ let ranges: Vec = serde_json::from_value(ranges).unwrap();
+ TimeRangePolicy {
+ default: ranges[default],
+ ranges,
+ }
+ };
+ let sorted =
+ json!([{ "type": "relative", "secs": 3600 }, { "type": "relative", "secs": 86400 }]);
+ assert!(
+ !range_changed(&link, &policy(sorted.clone(), 1)),
+ "a reorder"
+ );
+ assert!(range_changed(&link, &policy(sorted, 0)), "a new default");
+ assert!(range_changed(
+ &link,
+ &policy(json!([{ "type": "relative", "secs": 2592000 }]), 0)
+ ));
+ }
+
+ #[cfg(feature = "enterprise")]
+ #[tokio::test]
+ async fn a_range_edit_checks_every_panel_with_the_values_its_build_picks() {
+ let (link, ..) = ctx_parts();
+ let dash = Dashboard {
+ version: 8,
+ v8: Some(
+ serde_json::from_value(json!({
+ "tabs": [{ "tabId": "t1", "name": "", "panels": [
+ panel("b", Some("SELECT * FROM fixed WHERE env = '$env'")),
+ panel("d", Some("SELECT * FROM \"$pick\"")),
+ panel("e", Some("SELECT * FROM \"$sub\"")),
+ panel("f", Some("SELECT * FROM \"$sub\"")),
+ typed_panel("g", "custom_chart", "sql", "SELECT * FROM never_run", None),
+ ]}],
+ "variables": { "list": [
+ { "type": "custom", "name": "env", "label": "E", "value": "prod" },
+ { "type": "query_values", "name": "pick", "label": "P",
+ "query_data": { "stream_type": "logs", "stream": "names", "field": "n" } },
+ { "type": "query_values", "name": "sub", "label": "U",
+ "query_data": { "stream_type": "logs", "stream": "$pick", "field": "n" } }
+ ]}
+ }))
+ .unwrap(),
+ ),
+ ..Default::default()
+ };
+ let frozen: BTreeMap =
+ serde_json::from_value(json!({ "env": "prod" })).unwrap();
+ let ranges: Vec =
+ serde_json::from_value(json!([{ "type": "relative", "secs": 3600 }])).unwrap();
+ let search = FakeSearch {
+ hits: 1,
+ ..Default::default()
+ };
+ let sources = range_edit_sources(&search, &link, &dash, &frozen, &ranges, "editor@x", 0)
+ .await
+ .unwrap();
+ let mut texts: Vec = sources
+ .into_iter()
+ .map(|s| match s {
+ crate::authz::QuerySource::Sql { sql, .. } => sql,
+ crate::authz::QuerySource::Stream { name, .. } => format!("stream {name}"),
+ other => panic!("{other:?}"),
+ })
+ .collect();
+ texts.sort();
+ assert_eq!(
+ texts,
+ [
+ "SELECT * FROM \"v0\"",
+ "SELECT * FROM fixed WHERE env = 'prod'",
+ "stream names",
+ "stream v0",
+ ],
+ "the dependent values query reads the stream its parent picks"
+ );
+ let denied = FakeSearch {
+ hits: 1,
+ refuse: Some((NotAvailableReason::Unauthorized, "FROM \"names\"")),
+ ..Default::default()
+ };
+ let err = range_edit_sources(&denied, &link, &dash, &frozen, &ranges, "editor@x", 0)
+ .await
+ .unwrap_err();
+ assert!(err.downcast_ref::().is_some(), "{err}");
+ // A failed lookup leaves the dependent panels unchecked, so the edit cannot be saved.
+ let failed = FakeSearch {
+ hits: 1,
+ fail_sql: Some("FROM \"names\""),
+ ..Default::default()
+ };
+ let err = range_edit_sources(&failed, &link, &dash, &frozen, &ranges, "editor@x", 0)
+ .await
+ .unwrap_err();
+ assert!(err.downcast_ref::().is_some(), "{err}");
+ }
+
#[cfg(all(feature = "enterprise", feature = "test-utils"))]
#[tokio::test]
async fn gate04_a_link_variable_edit_selecting_an_unreadable_stream_is_refused() {
@@ -4226,7 +4491,7 @@ mod tests {
let to = |value: &str| -> BTreeMap {
BTreeMap::from([("src.p.c".to_string(), json!(value))])
};
- let err = authorize_variable_edit(&link, &dash, &to("secret"), &editor)
+ let err = authorize_variable_edit(&link, &dash, &to("secret"), None, &editor)
.await
.unwrap_err();
assert!(err.downcast_ref::().is_some());
@@ -4235,7 +4500,7 @@ mod tests {
"Unauthorized Access: no read permission on logs/secret"
);
assert!(
- authorize_variable_edit(&link, &dash, &to("safe"), &editor)
+ authorize_variable_edit(&link, &dash, &to("safe"), None, &editor)
.await
.is_ok()
);
diff --git a/src/core/src/self_profiles/job.rs b/src/core/src/self_profiles/job.rs
index e393098aaa..589125cb63 100644
--- a/src/core/src/self_profiles/job.rs
+++ b/src/core/src/self_profiles/job.rs
@@ -48,9 +48,10 @@ pub async fn run() {
let cpu_secs = cfg.self_profiles.cpu_secs;
let lock = Arc::new(Mutex::new(()));
log::info!(
- "[SELF-PROFILES] starting background loop: interval={}s cpu={}s url={}",
+ "[SELF-PROFILES] starting background loop: interval={}s cpu={}s memory={} url={}",
cfg.self_profiles.interval_secs,
cpu_secs,
+ cfg!(feature = "profiling"),
if cfg.self_profiles.url.is_empty() {
"local"
} else {
@@ -73,32 +74,38 @@ pub async fn run() {
}
async fn run_cycle(cpu_secs: u64) -> Result<(), String> {
- let host = config::get_config().common.instance_name.clone();
- let extra = [("host.name", host.as_str())];
+ let cfg = config::get_config();
+ let extra = [
+ ("host.name", cfg.common.instance_name.as_str()),
+ ("cluster.name", cfg.common.cluster_name.as_str()),
+ ];
- match sample::dump_cpu_profile(cpu_secs, CPU_FREQUENCY_HZ).await {
- Ok(bytes) => match convert::pprof_bytes_to_otlp(&bytes, &extra) {
- Ok(req) => {
- if let Err(e) = sink::ingest(req).await {
- log::error!("[SELF-PROFILES] CPU ingest failed: {e}");
- }
- }
- Err(e) => log::error!("[SELF-PROFILES] CPU convert failed: {e}"),
- },
- Err(e) => log::error!("[SELF-PROFILES] CPU sample failed: {e}"),
- }
+ let cpu = sample::dump_cpu_profile(cpu_secs, CPU_FREQUENCY_HZ).await;
+ ingest_sample("CPU", cpu, &extra).await;
- match sample::dump_memory_pprof().await {
- Ok(bytes) => match convert::pprof_bytes_to_otlp(&bytes, &extra) {
- Ok(req) => {
- if let Err(e) = sink::ingest(req).await {
- log::error!("[SELF-PROFILES] memory ingest failed: {e}");
- }
- }
- Err(e) => log::error!("[SELF-PROFILES] memory convert failed: {e}"),
- },
- Err(e) => log::error!("[SELF-PROFILES] memory sample failed: {e}"),
+ #[cfg(feature = "profiling")]
+ {
+ let memory = sample::dump_memory_pprof().await;
+ ingest_sample("memory", memory, &extra).await;
}
Ok(())
}
+
+async fn ingest_sample(kind: &str, sampled: Result, String>, extra: &[(&str, &str)]) {
+ let bytes = match sampled {
+ Ok(bytes) => bytes,
+ Err(e) => {
+ log::error!("[SELF-PROFILES] {kind} sample failed: {e}");
+ return;
+ }
+ };
+ match convert::pprof_bytes_to_otlp(&bytes, extra) {
+ Ok(req) => {
+ if let Err(e) = sink::ingest(req).await {
+ log::error!("[SELF-PROFILES] {kind} ingest failed: {e}");
+ }
+ }
+ Err(e) => log::error!("[SELF-PROFILES] {kind} convert failed: {e}"),
+ }
+}
diff --git a/src/core/src/self_profiles/mod.rs b/src/core/src/self_profiles/mod.rs
index 2d3e782cc4..090248efed 100644
--- a/src/core/src/self_profiles/mod.rs
+++ b/src/core/src/self_profiles/mod.rs
@@ -13,7 +13,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//! Self CPU/memory profiling into `_meta.self_profiles` (feature `profiling`).
+//! Self CPU (feature `cpu-profiling`) and memory (feature `profiling`) profiling.
mod convert;
mod job;
@@ -21,8 +21,9 @@ mod sample;
mod sink;
pub use job::run;
-pub use sample::{dump_cpu_profile, dump_memory_flamegraph, dump_memory_pprof, jemalloc_stats};
+pub use sample::dump_cpu_profile;
+#[cfg(feature = "profiling")]
+pub use sample::{dump_memory_flamegraph, dump_memory_pprof, jemalloc_stats};
-pub(crate) const STREAM_NAME: &str = "self_profiles";
pub(crate) const SERVICE_NAME: &str = "openobserve";
pub(crate) const CPU_FREQUENCY_HZ: i32 = 100;
diff --git a/src/core/src/self_profiles/sample.rs b/src/core/src/self_profiles/sample.rs
index 76cdc86588..0d1c1af677 100644
--- a/src/core/src/self_profiles/sample.rs
+++ b/src/core/src/self_profiles/sample.rs
@@ -33,6 +33,7 @@ pub async fn dump_cpu_profile(duration_secs: u64, frequency: i32) -> Result Result, String> {
let prof_ctl = jemalloc_pprof::PROF_CTL
.as_ref()
@@ -52,6 +53,7 @@ pub async fn dump_memory_pprof() -> Result, String> {
}
/// Dump jemalloc heap flamegraph SVG bytes (for `/debug/profile/memory`).
+#[cfg(feature = "profiling")]
pub async fn dump_memory_flamegraph() -> Result, String> {
let prof_ctl = jemalloc_pprof::PROF_CTL
.as_ref()
@@ -71,6 +73,7 @@ pub async fn dump_memory_flamegraph() -> Result, String> {
}
/// Read current jemalloc stats (for `/debug/profile/stats`).
+#[cfg(feature = "profiling")]
pub fn jemalloc_stats() -> Result {
tikv_jemalloc_ctl::epoch::mib()
.map_err(|e| format!("Failed to get jemalloc epoch mib: {e}"))?
diff --git a/src/core/src/self_profiles/sink.rs b/src/core/src/self_profiles/sink.rs
index 92b6d81d3f..d31bd64b42 100644
--- a/src/core/src/self_profiles/sink.rs
+++ b/src/core/src/self_profiles/sink.rs
@@ -15,11 +15,11 @@
use std::time::Duration;
-use config::{META_ORG_ID, get_config, meta::otlp::OtlpRequestType};
+use config::{DEFAULT_STREAM_NAME, META_ORG_ID, get_config, meta::otlp::OtlpRequestType};
use ingestion_common::{IngestUser, SystemJobType};
use opentelemetry_proto::tonic::collector::profiles::v1development::ExportProfilesServiceRequest;
-use super::{STREAM_NAME, convert};
+use super::convert;
const REMOTE_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -40,7 +40,7 @@ async fn ingest_local(request: ExportProfilesServiceRequest) -> Result<(), Strin
META_ORG_ID,
request,
OtlpRequestType::HttpProtobuf,
- Some(STREAM_NAME),
+ Some(DEFAULT_STREAM_NAME),
IngestUser::SystemJob(SystemJobType::SelfReporting),
)
.await
diff --git a/src/core/src/traces/service_graph/api.rs b/src/core/src/traces/service_graph/api.rs
index ce39ce0b0d..ac4ab8a9c5 100644
--- a/src/core/src/traces/service_graph/api.rs
+++ b/src/core/src/traces/service_graph/api.rs
@@ -21,6 +21,17 @@ use axum::response::Response as HttpResponse;
use common::meta::http::HttpResponse as MetaHttpResponse;
use serde::Deserialize;
+#[cfg(feature = "enterprise")]
+const EDGE_HISTORY_WINDOW_MICROS: i64 = 24 * 60 * 60 * 1_000_000;
+// Allows clock skew while keeping every bound far from i64 overflow in the step alignment.
+#[cfg(any(feature = "enterprise", test))]
+const MAX_FUTURE_MICROS: i64 = 24 * 60 * 60 * 1_000_000;
+// A stream's own retention can outlast the global one, so the cap never drops below the default.
+#[cfg(any(feature = "enterprise", test))]
+const MIN_MAX_WINDOW_DAYS: i64 = 3650;
+#[cfg(any(feature = "enterprise", test))]
+const DAY_MICROS: i64 = 24 * 60 * 60 * 1_000_000;
+
/// Query parameters for service graph API
#[derive(Debug, Deserialize)]
pub struct ServiceGraphQuery {
@@ -53,6 +64,7 @@ pub struct ServiceGraphQuery {
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
+ (status = 400, description = "Invalid time window (negative start_time, end_time not after start_time, end_time more than a day ahead, or longer than the data retention, at least 3650 days) or a stream name the metrics query cannot quote", content_type = "application/json", body = String),
(status = 403, description = "Forbidden - Enterprise feature", content_type = "application/json", body = String),
(status = 500, description = "Internal Server Error", content_type = "application/json", body = ()),
),
@@ -65,15 +77,16 @@ pub async fn get_current_topology(
axum::extract::Path(org_id): axum::extract::Path,
axum::extract::Query(query): axum::extract::Query,
) -> HttpResponse {
- // Resolve the current window boundaries
- let (start_time, end_time) =
- if let (Some(start), Some(end)) = (query.start_time, query.end_time) {
- (start, end)
- } else {
- let now = chrono::Utc::now().timestamp_micros();
- let window_micros = super::DEFAULT_QUERY_WINDOW_MINUTES * 60 * 1_000_000;
- (now - window_micros, now)
- };
+ let (start_time, end_time) = match resolve_window(
+ query.start_time,
+ query.end_time,
+ chrono::Utc::now().timestamp_micros(),
+ super::DEFAULT_QUERY_WINDOW_MINUTES * 60 * 1_000_000,
+ max_window_days(config::get_config().compact.data_retention_days),
+ ) {
+ Ok(window) => window,
+ Err(e) => return MetaHttpResponse::bad_request(e),
+ };
let filter = v4_read_filter(&query);
if let Err(e) = filter.validate() {
@@ -152,7 +165,7 @@ pub struct EdgeHistoryQuery {
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
- (status = 400, description = "A service or stream name the metrics query cannot quote", content_type = "application/json", body = String),
+ (status = 400, description = "Invalid time window (negative start_time, end_time not after start_time, end_time more than a day ahead, or longer than the data retention, at least 3650 days), or a service or stream name the metrics query cannot quote", content_type = "application/json", body = String),
(status = 403, description = "Forbidden - Enterprise feature"),
(status = 500, description = "Internal Server Error", content_type = "application/json", body = ()),
),
@@ -166,13 +179,16 @@ pub async fn get_edge_history(
use super::v4::history::{EdgeFilter, fetch_edge_history};
- let (start_time, end_time) =
- if let (Some(start), Some(end)) = (query.start_time, query.end_time) {
- (start, end)
- } else {
- let now = chrono::Utc::now().timestamp_micros();
- (now - 24 * 60 * 60 * 1_000_000, now)
- };
+ let (start_time, end_time) = match resolve_window(
+ query.start_time,
+ query.end_time,
+ chrono::Utc::now().timestamp_micros(),
+ EDGE_HISTORY_WINDOW_MICROS,
+ max_window_days(config::get_config().compact.data_retention_days),
+ ) {
+ Ok(window) => window,
+ Err(e) => return MetaHttpResponse::bad_request(e),
+ };
let non_empty = |v: Option| v.filter(|s| !s.is_empty());
let filter = EdgeFilter {
client: non_empty(query.client_service),
@@ -228,10 +244,137 @@ pub async fn get_current_topology(
MetaHttpResponse::forbidden("Not Supported")
}
-#[cfg(all(test, feature = "enterprise"))]
+/// The longest window a request may ask for: the global data retention, at least 3650 days.
+#[cfg(any(feature = "enterprise", test))]
+fn max_window_days(retention_days: i64) -> i64 {
+ retention_days.max(MIN_MAX_WINDOW_DAYS)
+}
+
+#[cfg(any(feature = "enterprise", test))]
+fn resolve_window(
+ start: Option,
+ end: Option,
+ now: i64,
+ default_span_micros: i64,
+ max_days: i64,
+) -> Result<(i64, i64), String> {
+ let (Some(start), Some(end)) = (start, end) else {
+ return Ok((now - default_span_micros, now));
+ };
+ if start < 0 {
+ return Err("start_time must not be negative".to_string());
+ }
+ if end <= start {
+ return Err("end_time must be after start_time".to_string());
+ }
+ if end > now.saturating_add(MAX_FUTURE_MICROS) {
+ return Err("end_time must be at most one day after the current time".to_string());
+ }
+ if end - start > max_days.saturating_mul(DAY_MICROS) {
+ return Err(format!(
+ "the window from start_time to end_time must not exceed {max_days} days"
+ ));
+ }
+ Ok((start, end))
+}
+
+#[cfg(test)]
mod tests {
use super::*;
+ const NOW: i64 = 1_800_000_000_000_000;
+ const SPAN: i64 = 3_600_000_000;
+ const MAX_DAYS: i64 = 3650;
+
+ #[test]
+ fn test_resolve_window_uses_an_explicit_window() {
+ assert_eq!(
+ resolve_window(Some(1_000), Some(2_000), NOW, SPAN, MAX_DAYS),
+ Ok((1_000, 2_000))
+ );
+ assert_eq!(
+ resolve_window(Some(0), Some(1), NOW, SPAN, MAX_DAYS),
+ Ok((0, 1))
+ );
+ }
+
+ #[test]
+ fn test_resolve_window_rejects_a_negative_start() {
+ assert!(resolve_window(Some(-1), Some(2_000), NOW, SPAN, MAX_DAYS).is_err());
+ assert!(resolve_window(Some(i64::MIN), Some(i64::MAX), NOW, SPAN, MAX_DAYS).is_err());
+ }
+
+ #[test]
+ fn test_resolve_window_rejects_an_end_not_after_the_start() {
+ assert!(resolve_window(Some(2_189_567), Some(-1_358), NOW, SPAN, MAX_DAYS).is_err());
+ assert!(resolve_window(Some(2_000), Some(2_000), NOW, SPAN, MAX_DAYS).is_err());
+ assert!(resolve_window(Some(2_000), Some(1_999), NOW, SPAN, MAX_DAYS).is_err());
+ }
+
+ #[test]
+ fn test_resolve_window_rejects_an_end_beyond_tomorrow_and_near_i64_max() {
+ assert_eq!(
+ resolve_window(
+ Some(NOW),
+ Some(NOW + MAX_FUTURE_MICROS),
+ NOW,
+ SPAN,
+ MAX_DAYS
+ ),
+ Ok((NOW, NOW + MAX_FUTURE_MICROS))
+ );
+ assert!(
+ resolve_window(
+ Some(NOW),
+ Some(NOW + MAX_FUTURE_MICROS + 1),
+ NOW,
+ SPAN,
+ MAX_DAYS
+ )
+ .is_err()
+ );
+ assert!(resolve_window(Some(i64::MAX - 1), Some(i64::MAX), NOW, SPAN, MAX_DAYS).is_err());
+ assert!(resolve_window(Some(0), Some(i64::MAX), NOW, SPAN, MAX_DAYS).is_err());
+ assert!(resolve_window(Some(0), Some(1), i64::MAX, SPAN, MAX_DAYS).is_ok());
+ }
+
+ #[test]
+ fn test_resolve_window_caps_the_span_at_the_given_days() {
+ let max_span = MAX_DAYS * DAY_MICROS;
+ assert_eq!(
+ resolve_window(Some(NOW - max_span), Some(NOW), NOW, SPAN, MAX_DAYS),
+ Ok((NOW - max_span, NOW))
+ );
+ let err =
+ resolve_window(Some(NOW - max_span - 1), Some(NOW), NOW, SPAN, MAX_DAYS).unwrap_err();
+ assert!(err.contains("3650 days"), "{err}");
+ let longer = 7300 * DAY_MICROS;
+ assert!(resolve_window(Some(NOW - longer), Some(NOW), NOW, SPAN, 7300).is_ok());
+ assert!(resolve_window(Some(0), Some(NOW), NOW, SPAN, i64::MAX).is_ok());
+ }
+
+ #[test]
+ fn test_max_window_days_follows_a_longer_retention() {
+ assert_eq!(max_window_days(7300), 7300);
+ assert_eq!(max_window_days(3650), 3650);
+ assert_eq!(
+ max_window_days(30),
+ 3650,
+ "a stream may keep data longer than the default"
+ );
+ assert_eq!(max_window_days(0), 3650, "0 means unbounded retention");
+ assert_eq!(max_window_days(-1), 3650);
+ }
+
+ #[test]
+ fn test_resolve_window_defaults_when_a_bound_is_missing() {
+ let default = Ok((NOW - SPAN, NOW));
+ assert_eq!(resolve_window(None, None, NOW, SPAN, MAX_DAYS), default);
+ assert_eq!(resolve_window(Some(-5), None, NOW, SPAN, MAX_DAYS), default);
+ assert_eq!(resolve_window(None, Some(-5), NOW, SPAN, MAX_DAYS), default);
+ }
+
+ #[cfg(feature = "enterprise")]
#[test]
fn test_v4_read_filter_maps_stream_and_env_only() {
let query = |stream: Option<&str>, env: Option<&str>| ServiceGraphQuery {
@@ -253,4 +396,69 @@ mod tests {
assert_eq!(f.trace_stream.as_deref(), Some("default"));
assert_eq!(f.agent_env.as_deref(), Some("prod"));
}
+
+ #[cfg(feature = "enterprise")]
+ #[tokio::test]
+ async fn test_handlers_reject_an_invalid_window_with_400() {
+ use axum::extract::{Path, Query};
+
+ let topology = get_current_topology(
+ Path("default".to_string()),
+ Query(ServiceGraphQuery {
+ start_time: Some(2_189_567),
+ end_time: Some(-1_358),
+ filter: None,
+ stream_name: None,
+ agent_id: None,
+ agent_name: None,
+ agent_env: None,
+ }),
+ )
+ .await;
+ assert_eq!(topology.status(), axum::http::StatusCode::BAD_REQUEST);
+
+ let history = get_edge_history(
+ Path("default".to_string()),
+ Query(EdgeHistoryQuery {
+ client_service: None,
+ server_service: None,
+ start_time: Some(-1),
+ end_time: Some(10),
+ stream_name: None,
+ }),
+ )
+ .await;
+ assert_eq!(history.status(), axum::http::StatusCode::BAD_REQUEST);
+
+ let near_max = get_edge_history(
+ Path("default".to_string()),
+ Query(EdgeHistoryQuery {
+ client_service: None,
+ server_service: None,
+ start_time: Some(i64::MAX - 1),
+ end_time: Some(i64::MAX),
+ stream_name: None,
+ }),
+ )
+ .await;
+ assert_eq!(near_max.status(), axum::http::StatusCode::BAD_REQUEST);
+
+ let near_max_topology = get_current_topology(
+ Path("default".to_string()),
+ Query(ServiceGraphQuery {
+ start_time: Some(i64::MAX - 1),
+ end_time: Some(i64::MAX),
+ filter: None,
+ stream_name: None,
+ agent_id: None,
+ agent_name: None,
+ agent_env: None,
+ }),
+ )
+ .await;
+ assert_eq!(
+ near_max_topology.status(),
+ axum::http::StatusCode::BAD_REQUEST
+ );
+ }
}
diff --git a/src/core/src/traces/service_graph/v4/history.rs b/src/core/src/traces/service_graph/v4/history.rs
index e9b2cf04c0..a817003421 100644
--- a/src/core/src/traces/service_graph/v4/history.rs
+++ b/src/core/src/traces/service_graph/v4/history.rs
@@ -90,7 +90,7 @@ pub async fn fetch_edge_history(
/// Coarser than a minute only when the range would exceed `MAX_POINTS`; always whole minutes.
fn step_micros(start: i64, end: i64) -> i64 {
- let span_secs = ((end - start) / 1_000_000).max(1) as u64;
+ let span_secs = (end.saturating_sub(start) / 1_000_000).max(1) as u64;
let minutes = span_secs
.div_ceil(MAX_POINTS as u64)
.div_ceil(MIN_STEP_SECS as u64);
@@ -99,7 +99,7 @@ fn step_micros(start: i64, end: i64) -> i64 {
/// Evaluation times sit on step boundaries, where the rollup's samples are, inside the range.
fn eval_bounds(start: i64, end: i64, step: i64) -> Option<(i64, i64)> {
- let first = align_down(start + step - 1, step) + step;
+ let first = align_down(start.checked_add(step - 1)?, step).checked_add(step)?;
let last = align_down(end, step);
(first <= last).then_some((first, last))
}
@@ -217,6 +217,14 @@ mod tests {
assert_eq!(eval_bounds(0, MIN - 1, MIN), None);
}
+ #[test]
+ fn test_step_and_bounds_survive_windows_near_i64_max() {
+ let step = step_micros(i64::MAX - 1, i64::MAX);
+ assert_eq!(step, MIN);
+ assert_eq!(eval_bounds(i64::MAX - 1, i64::MAX, step), None);
+ assert!(step_micros(i64::MIN, i64::MAX) > 0);
+ }
+
#[test]
fn test_plan_queries() {
let plan = plan("server=\"b\"", MIN);
diff --git a/src/core/src/traces/service_graph/v4/read.rs b/src/core/src/traces/service_graph/v4/read.rs
index 42edd7c629..6e487c85fe 100644
--- a/src/core/src/traces/service_graph/v4/read.rs
+++ b/src/core/src/traces/service_graph/v4/read.rs
@@ -257,7 +257,7 @@ where
Fut: Future, anyhow::Error>>,
{
// Range selectors are left-open; +1ms keeps the `start` sample, not a baseline 1ms before it.
- let range_ms = ((end - start) / 1_000).max(1_000) + 1;
+ let range_ms = (end.saturating_sub(start) / 1_000).max(1_000) + 1;
let plan =
|v4_only| plan_queries(filter, v4_only, range_ms, start, end).map_err(anyhow::Error::msg);
let mut results = run_plan(&plan(true)?, &run).await;
diff --git a/src/core/src/users.rs b/src/core/src/users.rs
index ba1641cad1..31a6953ec9 100644
--- a/src/core/src/users.rs
+++ b/src/core/src/users.rs
@@ -1876,12 +1876,7 @@ mod tests {
}
async fn create_query_history_table() {
- use sea_orm::{ConnectionTrait, Schema};
- let conn = get_orm_client_rw().await;
- let backend = conn.get_database_backend();
- let mut stmt = Schema::new(backend)
- .create_table_from_entity(infra_table::entity::query_history::Entity);
- conn.execute(backend.build(stmt.if_not_exists()))
+ infra_table::query_history::create_table_for_tests(get_orm_client_rw().await)
.await
.unwrap();
}
@@ -1906,14 +1901,24 @@ mod tests {
};
create_new_user(user).await.unwrap();
for org in ["dummy", "other"] {
- infra_table::query_history::record(org, email, "up", serde_json::json!({}), 1)
- .await
- .unwrap();
+ infra_table::query_history::record(infra_table::query_history::NewRun {
+ org_id: org,
+ user_email: email,
+ source: "logs",
+ query: "up",
+ query_key: None,
+ streams: &[],
+ status: "ok",
+ context: serde_json::json!({}),
+ now_us: 1,
+ })
+ .await
+ .unwrap();
}
}
async fn history_count(org: &str, email: &str) -> usize {
- infra_table::query_history::list(org, email, None, None, 10, 0)
+ infra_table::query_history::list(org, email, &Default::default(), 10, 0)
.await
.unwrap()
.len()
diff --git a/src/db/src/authz.rs b/src/db/src/authz.rs
index e15fa4d278..ce046fac05 100644
--- a/src/db/src/authz.rs
+++ b/src/db/src/authz.rs
@@ -90,11 +90,31 @@ pub async fn list_objects_for_user(
permission: &str,
object_type: &str,
) -> anyhow::Result>> {
- let openfga_config = o2_openfga::config::get_config();
- if crate::user::is_root_user(user_id)
- || !openfga_config.enabled
- || !openfga_config.list_only_permitted
- {
+ if !o2_openfga::config::get_config().list_only_permitted {
+ return Ok(None);
+ }
+ list_permitted_objects(org_id, user_id, permission, object_type).await
+}
+
+#[cfg(not(feature = "enterprise"))]
+pub async fn list_objects_for_user(
+ _org_id: &str,
+ _user_id: &str,
+ _permission: &str,
+ _object_type: &str,
+) -> anyhow::Result >> {
+ Ok(None)
+}
+
+/// [`list_objects_for_user`] whatever `list_only_permitted` says, for lists that carry secrets.
+#[cfg(feature = "enterprise")]
+pub async fn list_permitted_objects(
+ org_id: &str,
+ user_id: &str,
+ permission: &str,
+ object_type: &str,
+) -> anyhow::Result >> {
+ if crate::user::is_root_user(user_id) || !o2_openfga::config::get_config().enabled {
return Ok(None);
}
@@ -113,20 +133,12 @@ pub async fn list_objects_for_user(
)
.await
.map_err(|_| anyhow::anyhow!("Unauthorized Access"))?;
- log::debug!("list_objects_for_user for user {user_id} from {org_id} org returns: {objects:#?}");
+ log::debug!(
+ "list_permitted_objects for user {user_id} from {org_id} org returns: {objects:#?}"
+ );
Ok(Some(objects))
}
-#[cfg(not(feature = "enterprise"))]
-pub async fn list_objects_for_user(
- _org_id: &str,
- _user_id: &str,
- _permission: &str,
- _object_type: &str,
-) -> anyhow::Result >> {
- Ok(None)
-}
-
/// OpenFGA object and parent type of `obj`; `None` when either type is unmapped.
#[cfg(feature = "enterprise")]
fn ofga_object(obj_type: &str, obj: &Authz) -> Option<(String, &'static str)> {
diff --git a/src/infra/src/table/alerts/mod.rs b/src/infra/src/table/alerts/mod.rs
index a31e3e0c6d..1a5e2f36a5 100644
--- a/src/infra/src/table/alerts/mod.rs
+++ b/src/infra/src/table/alerts/mod.rs
@@ -220,6 +220,7 @@ impl TryFrom for MetaAlert {
alert.notify_on_recovery = value.notify_on_recovery.unwrap_or(false);
alert.recovery_destinations = recovery_destinations;
alert.keep_firing_for = value.keep_firing_for_seconds.unwrap_or(0);
+ alert.suppress_repeats = value.suppress_repeats.unwrap_or(false);
Ok(alert)
}
@@ -958,6 +959,7 @@ fn update_mutable_fields(
Some(serde_json::to_string(&alert.recovery_destinations)?)
};
let keep_firing_for_seconds = alert.keep_firing_for;
+ let suppress_repeats = alert.suppress_repeats;
alert_am.name = Set(name);
alert_am.is_real_time = Set(is_real_time);
@@ -1016,6 +1018,7 @@ fn update_mutable_fields(
alert_am.notify_on_recovery = Set(Some(notify_on_recovery));
alert_am.recovery_destinations = Set(recovery_destinations);
alert_am.keep_firing_for_seconds = Set(Some(keep_firing_for_seconds));
+ alert_am.suppress_repeats = Set(Some(suppress_repeats));
Ok(())
}
@@ -1096,6 +1099,7 @@ pub(super) mod tests {
notify_on_recovery: None,
recovery_destinations: None,
keep_firing_for_seconds: None,
+ suppress_repeats: None,
}
}
diff --git a/src/infra/src/table/entity/alerts.rs b/src/infra/src/table/entity/alerts.rs
index 91a3545ac3..0b172fb41a 100644
--- a/src/infra/src/table/entity/alerts.rs
+++ b/src/infra/src/table/entity/alerts.rs
@@ -92,6 +92,8 @@ pub struct Model {
/// How long the condition must stay clear before the episode recovers.
/// NULL or 0 recovers on the first clear evaluation.
pub keep_firing_for_seconds: Option,
+ /// NULL predates the feature, which is the same as `false`.
+ pub suppress_repeats: Option,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -178,6 +180,7 @@ mod tests {
notify_on_recovery: None,
recovery_destinations: None,
keep_firing_for_seconds: None,
+ suppress_repeats: None,
};
assert_eq!(m.id, "alert-1");
assert_eq!(m.name, "High Error Rate");
diff --git a/src/infra/src/table/entity/mod.rs b/src/infra/src/table/entity/mod.rs
index 036f79d367..62c794dc0e 100644
--- a/src/infra/src/table/entity/mod.rs
+++ b/src/infra/src/table/entity/mod.rs
@@ -80,6 +80,8 @@ pub mod oncall_user_contacts;
pub mod online_eval_jobs;
pub mod org_ai_toolsets;
pub mod org_cleanup_tasks;
+pub mod org_domain_ownership;
+pub mod org_domain_requests;
pub mod org_ingestion_tokens;
pub mod org_storage_providers;
pub mod org_telephony;
diff --git a/src/infra/src/table/entity/org_domain_ownership.rs b/src/infra/src/table/entity/org_domain_ownership.rs
new file mode 100644
index 0000000000..963e3f8b2f
--- /dev/null
+++ b/src/infra/src/table/entity/org_domain_ownership.rs
@@ -0,0 +1,26 @@
+//! `SeaORM` Entity for the domain_org_ownership table.
+
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
+#[sea_orm(table_name = "org_domain_ownership")]
+pub struct Model {
+ #[sea_orm(primary_key, auto_increment = false)]
+ pub id: String,
+ pub org_id: String,
+ pub domain: String,
+ pub verification_token: String,
+ pub verification_state: i32,
+ pub verification_failure_reason: Option,
+ pub verified_at: Option,
+ pub released_at: Option,
+ pub last_checked_at: Option,
+ pub created_at: i64,
+ pub updated_at: i64,
+ pub meta_mapped: bool,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
+
+impl ActiveModelBehavior for ActiveModel {}
diff --git a/src/infra/src/table/entity/org_domain_requests.rs b/src/infra/src/table/entity/org_domain_requests.rs
new file mode 100644
index 0000000000..beef6b7a88
--- /dev/null
+++ b/src/infra/src/table/entity/org_domain_requests.rs
@@ -0,0 +1,23 @@
+//! `SeaORM` Entity for the domain_org_requests table.
+
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
+#[sea_orm(table_name = "org_domain_requests")]
+pub struct Model {
+ #[sea_orm(primary_key, auto_increment = false)]
+ pub id: String,
+ pub org_id: String,
+ pub domain: String,
+ pub created_at: i64,
+ pub updated_at: i64,
+ pub requested_by: String,
+ pub answered_by: Option,
+ pub status: i32,
+ pub reason: Option,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
+
+impl ActiveModelBehavior for ActiveModel {}
diff --git a/src/infra/src/table/entity/prelude.rs b/src/infra/src/table/entity/prelude.rs
index 20d0c3e4da..b093be72ea 100644
--- a/src/infra/src/table/entity/prelude.rs
+++ b/src/infra/src/table/entity/prelude.rs
@@ -21,7 +21,9 @@ pub use super::{
llm_prompt_label_events::Entity as LlmPromptLabelEvents,
llm_prompt_labels::Entity as LlmPromptLabels, llm_prompt_versions::Entity as LlmPromptVersions,
llm_prompt_webhook_deliveries::Entity as LlmPromptWebhookDeliveries,
- llm_prompts::Entity as LlmPrompts, org_ingestion_tokens::Entity as OrgIngestionTokens,
+ llm_prompts::Entity as LlmPrompts, org_domain_ownership::Entity as OrgDomainOwnership,
+ org_domain_requests::Entity as OrgDomainRequests,
+ org_ingestion_tokens::Entity as OrgIngestionTokens,
org_storage_providers::Entity as OrgStorageProviders, org_users::Entity as OrgUsers,
organizations::Entity as Organizations, pipeline_last_errors::Entity as PipelineLastErrors,
re_pattern_stream_map::Entity as RePatternStreamMap, re_patterns::Entity as RePatterns,
diff --git a/src/infra/src/table/entity/query_history.rs b/src/infra/src/table/entity/query_history.rs
index 1aa738ed85..9e24535a06 100644
--- a/src/infra/src/table/entity/query_history.rs
+++ b/src/infra/src/table/entity/query_history.rs
@@ -26,7 +26,20 @@ pub struct Model {
pub query: String,
pub context: Json,
pub starred: bool,
+ /// First run of this row; never changed after insert.
pub created_at: i64,
+ #[sea_orm(column_type = "Text")]
+ pub source: String,
+ /// Hex sha256 of the merge key; NULL on rows written before the key existed.
+ pub query_key: Option,
+ /// Comma-wrapped stream list (`,a,b,`), empty when there are none.
+ #[sea_orm(column_type = "Text")]
+ pub streams: String,
+ pub run_count: i64,
+ /// Last run of this row.
+ pub updated_at: i64,
+ #[sea_orm(column_type = "Text")]
+ pub status: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
diff --git a/src/infra/src/table/entity/status_page_custom_domains.rs b/src/infra/src/table/entity/status_page_custom_domains.rs
index d2da9838fb..fc37bae3fe 100644
--- a/src/infra/src/table/entity/status_page_custom_domains.rs
+++ b/src/infra/src/table/entity/status_page_custom_domains.rs
@@ -2,7 +2,9 @@
use sea_orm::entity::prelude::*;
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel, serde::Serialize, serde::Deserialize)]
+#[derive(
+ Clone, Default, Debug, PartialEq, DeriveEntityModel, serde::Serialize, serde::Deserialize,
+)]
#[sea_orm(table_name = "status_page_custom_domains")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
diff --git a/src/infra/src/table/entity/synthetics_checks.rs b/src/infra/src/table/entity/synthetics_checks.rs
index 358990742d..9d898286b4 100644
--- a/src/infra/src/table/entity/synthetics_checks.rs
+++ b/src/infra/src/table/entity/synthetics_checks.rs
@@ -31,7 +31,7 @@ pub struct Model {
/// When the scheduler last fanned out this check (microseconds).
pub last_triggered_at: i64,
/// Denormalised status from the most recent completed check.
- /// 0=Unknown, 1=Up, 2=Warning, 3=Down
+ /// 0=Unknown, 1=Up, 2=Warning, 3=Down, 4=Error (no verdict: dispatch/lease failure)
pub last_check_status: i32,
/// Runs that have failed back to back; reset to 0 by a pass. Compared
/// against `settings.alert_if_fails` to decide whether to notify.
diff --git a/src/infra/src/table/migration/m20261025_000005_add_held_action_to_slack_link_requests.rs b/src/infra/src/table/migration/m20261025_000005_add_held_action_to_slack_link_requests.rs
index 08242eab64..f5a9a83bf2 100644
--- a/src/infra/src/table/migration/m20261025_000005_add_held_action_to_slack_link_requests.rs
+++ b/src/infra/src/table/migration/m20261025_000005_add_held_action_to_slack_link_requests.rs
@@ -46,12 +46,6 @@ impl MigrationTrait for Migration {
}
}
-/// Adds the column to a freshly created table (test setups that skip the migrator).
-pub(crate) async fn add_column(db: &C) -> Result<(), DbErr> {
- let stmt = db.get_database_backend().build(&add_column_statement());
- db.execute(stmt).await.map(|_| ())
-}
-
fn add_column_statement() -> TableAlterStatement {
Table::alter()
.table(SlackLinkRequests::Table)
diff --git a/src/infra/src/table/migration/m20261027_000001_alter_query_history.rs b/src/infra/src/table/migration/m20261027_000001_alter_query_history.rs
new file mode 100644
index 0000000000..07f071eb54
--- /dev/null
+++ b/src/infra/src/table/migration/m20261027_000001_alter_query_history.rs
@@ -0,0 +1,644 @@
+// 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 .
+
+//! Adds source, merge key, streams, run count, last run and status to `query_history`.
+
+use sea_orm_migration::prelude::*;
+
+const TABLE: &str = "query_history";
+const OLD_ORG_USER_CREATED_AT_IDX: &str = "query_history_org_user_created_at_idx";
+const OLD_STARRED_CREATED_AT_IDX: &str = "query_history_starred_created_at_idx";
+const ORG_USER_UPDATED_AT_IDX: &str = "query_history_org_user_updated_at_idx";
+const ORG_USER_SOURCE_UPDATED_AT_IDX: &str = "query_history_org_user_source_updated_at_idx";
+const STARRED_UPDATED_AT_IDX: &str = "query_history_starred_updated_at_idx";
+const ORG_USER_SOURCE_KEY_UIDX: &str = "query_history_org_user_source_key_uidx";
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ // NOT NULL DEFAULT keeps v1.1.x nodes inserting during a rolling upgrade.
+ for mut def in [
+ ColumnDef::new(QueryHistory::Source)
+ .text()
+ .not_null()
+ .default("metrics")
+ .to_owned(),
+ ColumnDef::new(QueryHistory::QueryKey)
+ .char_len(64)
+ .null()
+ .to_owned(),
+ ColumnDef::new(QueryHistory::Streams)
+ .text()
+ .not_null()
+ .default("")
+ .to_owned(),
+ ColumnDef::new(QueryHistory::RunCount)
+ .big_integer()
+ .not_null()
+ .default(1)
+ .to_owned(),
+ ColumnDef::new(QueryHistory::UpdatedAt)
+ .big_integer()
+ .not_null()
+ .default(0)
+ .to_owned(),
+ ColumnDef::new(QueryHistory::Status)
+ .text()
+ .not_null()
+ .default("ok")
+ .to_owned(),
+ ] {
+ add_column(manager, &mut def).await?;
+ }
+ // v1.1.x stored each row's last run in created_at; the guard makes a rerun a no-op.
+ manager
+ .get_connection()
+ .execute_unprepared(
+ "UPDATE query_history SET updated_at = created_at WHERE updated_at < created_at",
+ )
+ .await?;
+ for name in [OLD_ORG_USER_CREATED_AT_IDX, OLD_STARRED_CREATED_AT_IDX] {
+ drop_index(manager, name).await?;
+ }
+ create_index(
+ manager,
+ ORG_USER_UPDATED_AT_IDX,
+ &[
+ QueryHistory::OrgId,
+ QueryHistory::UserEmail,
+ QueryHistory::UpdatedAt,
+ ],
+ false,
+ )
+ .await?;
+ create_index(
+ manager,
+ ORG_USER_SOURCE_UPDATED_AT_IDX,
+ &[
+ QueryHistory::OrgId,
+ QueryHistory::UserEmail,
+ QueryHistory::Source,
+ QueryHistory::UpdatedAt,
+ ],
+ false,
+ )
+ .await?;
+ create_index(
+ manager,
+ STARRED_UPDATED_AT_IDX,
+ &[QueryHistory::Starred, QueryHistory::UpdatedAt],
+ false,
+ )
+ .await?;
+ // NULL keys on v1.1.x rows are distinct, so they never collide here.
+ create_index(
+ manager,
+ ORG_USER_SOURCE_KEY_UIDX,
+ &[
+ QueryHistory::OrgId,
+ QueryHistory::UserEmail,
+ QueryHistory::Source,
+ QueryHistory::QueryKey,
+ ],
+ true,
+ )
+ .await
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ for name in [
+ ORG_USER_SOURCE_KEY_UIDX,
+ STARRED_UPDATED_AT_IDX,
+ ORG_USER_SOURCE_UPDATED_AT_IDX,
+ ORG_USER_UPDATED_AT_IDX,
+ ] {
+ drop_index(manager, name).await?;
+ }
+ // A repeated or partial rollback has already dropped updated_at.
+ if manager
+ .has_column(TABLE, &QueryHistory::UpdatedAt.to_string())
+ .await?
+ {
+ manager
+ .get_connection()
+ .execute_unprepared(
+ "UPDATE query_history SET created_at = updated_at WHERE updated_at > created_at",
+ )
+ .await?;
+ }
+ create_index(
+ manager,
+ OLD_ORG_USER_CREATED_AT_IDX,
+ &[
+ QueryHistory::OrgId,
+ QueryHistory::UserEmail,
+ QueryHistory::CreatedAt,
+ ],
+ false,
+ )
+ .await?;
+ create_index(
+ manager,
+ OLD_STARRED_CREATED_AT_IDX,
+ &[QueryHistory::Starred, QueryHistory::CreatedAt],
+ false,
+ )
+ .await?;
+ for column in [
+ QueryHistory::Status,
+ QueryHistory::UpdatedAt,
+ QueryHistory::RunCount,
+ QueryHistory::Streams,
+ QueryHistory::QueryKey,
+ QueryHistory::Source,
+ ] {
+ if manager.has_column(TABLE, &column.to_string()).await? {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(QueryHistory::Table)
+ .drop_column(column)
+ .to_owned(),
+ )
+ .await?;
+ }
+ }
+ Ok(())
+ }
+}
+
+#[derive(DeriveIden, Clone, Copy)]
+enum QueryHistory {
+ Table,
+ OrgId,
+ UserEmail,
+ Starred,
+ CreatedAt,
+ Source,
+ QueryKey,
+ Streams,
+ RunCount,
+ UpdatedAt,
+ Status,
+}
+
+async fn add_column(manager: &SchemaManager<'_>, def: &mut ColumnDef) -> Result<(), DbErr> {
+ if manager.has_column(TABLE, &def.get_column_name()).await? {
+ return Ok(());
+ }
+ manager
+ .alter_table(
+ Table::alter()
+ .table(QueryHistory::Table)
+ .add_column(def.to_owned())
+ .to_owned(),
+ )
+ .await
+}
+
+async fn drop_index(manager: &SchemaManager<'_>, name: &str) -> Result<(), DbErr> {
+ manager
+ .drop_index(
+ Index::drop()
+ .if_exists()
+ .name(name)
+ .table(QueryHistory::Table)
+ .to_owned(),
+ )
+ .await
+}
+
+async fn create_index(
+ manager: &SchemaManager<'_>,
+ name: &str,
+ columns: &[QueryHistory],
+ unique: bool,
+) -> Result<(), DbErr> {
+ let mut index = Index::create();
+ index.if_not_exists().name(name).table(QueryHistory::Table);
+ if unique {
+ index.unique();
+ }
+ for column in columns {
+ index.col(*column);
+ }
+ manager.create_index(index.to_owned()).await
+}
+
+#[cfg(test)]
+mod tests {
+ use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement};
+ use sea_orm_migration::{MigrationName, MigrationTrait};
+
+ use super::*;
+
+ type Row = (
+ String,
+ String,
+ Option,
+ String,
+ i64,
+ i64,
+ i64,
+ String,
+ );
+
+ async fn v1_1_table_with_rows() -> DatabaseConnection {
+ let db = Database::connect("sqlite::memory:").await.unwrap();
+ seed_v1_1_table(&db).await;
+ db
+ }
+
+ async fn seed_v1_1_table(db: &DatabaseConnection) {
+ super::super::m20261004_000001_create_query_history::Migration
+ .up(&SchemaManager::new(db))
+ .await
+ .unwrap();
+ for (id, starred, created_at) in [("old", false, 100), ("star", true, 200)] {
+ db.execute_unprepared(&format!(
+ "INSERT INTO query_history (id, org_id, user_email, query, context, starred, \
+ created_at) VALUES ('{id}', 'default', 'a@x.com', 'up', '{{\"step\":\"1m\"}}', \
+ {starred}, {created_at})"
+ ))
+ .await
+ .unwrap();
+ }
+ }
+
+ /// A throwaway schema on the CI Postgres; `None` where none is configured.
+ async fn postgres_schema() -> Option<(DatabaseConnection, DatabaseConnection, String)> {
+ let dsn = std::env::var("ZO_META_POSTGRES_DSN").ok()?;
+ // The SQLite lane sets the variable empty rather than unsetting it.
+ if dsn.trim().is_empty() {
+ return None;
+ }
+ let schema = format!(
+ "qh_migration_test_{}",
+ config::ider::generate().to_ascii_lowercase()
+ );
+ let admin = Database::connect(&dsn).await.unwrap();
+ admin
+ .execute_unprepared(&format!(r#"CREATE SCHEMA "{schema}""#))
+ .await
+ .unwrap();
+ let mut options = sea_orm::ConnectOptions::new(dsn);
+ options
+ .max_connections(1)
+ .set_schema_search_path(schema.clone());
+ Some((admin, Database::connect(options).await.unwrap(), schema))
+ }
+
+ async fn rows(db: &DatabaseConnection) -> Vec {
+ db.query_all(Statement::from_string(
+ DbBackend::Sqlite,
+ "SELECT id, source, query_key, streams, run_count, created_at, updated_at, status \
+ FROM query_history ORDER BY id",
+ ))
+ .await
+ .unwrap()
+ .iter()
+ .map(|r| {
+ (
+ r.try_get_by_index(0).unwrap(),
+ r.try_get_by_index(1).unwrap(),
+ r.try_get_by_index(2).unwrap(),
+ r.try_get_by_index(3).unwrap(),
+ r.try_get_by_index(4).unwrap(),
+ r.try_get_by_index(5).unwrap(),
+ r.try_get_by_index(6).unwrap(),
+ r.try_get_by_index(7).unwrap(),
+ )
+ })
+ .collect()
+ }
+
+ async fn index_columns(db: &DatabaseConnection, index: &str) -> Vec> {
+ db.query_all(Statement::from_string(
+ DbBackend::Sqlite,
+ format!("SELECT name FROM pragma_index_info('{index}') ORDER BY seqno"),
+ ))
+ .await
+ .unwrap()
+ .iter()
+ .map(|r| r.try_get:: >("", "name").unwrap())
+ .collect()
+ }
+
+ #[tokio::test]
+ async fn test_up_backfills_updated_at_from_created_at_on_a_populated_v1_1_table() {
+ let db = v1_1_table_with_rows().await;
+ let manager = SchemaManager::new(&db);
+ Migration.up(&manager).await.unwrap();
+
+ let to_row = |id: &str, created_at: i64, updated_at: i64| -> Row {
+ (
+ id.to_string(),
+ "metrics".to_string(),
+ None,
+ String::new(),
+ 1,
+ created_at,
+ updated_at,
+ "ok".to_string(),
+ )
+ };
+ assert_eq!(
+ rows(&db).await,
+ vec![to_row("old", 100, 100), to_row("star", 200, 200)],
+ "every v1.1.x row gets updated_at = created_at"
+ );
+
+ db.execute_unprepared("UPDATE query_history SET updated_at = 900 WHERE id = 'old'")
+ .await
+ .unwrap();
+ Migration.up(&manager).await.expect("idempotent");
+ assert_eq!(
+ rows(&db).await,
+ vec![to_row("old", 100, 900), to_row("star", 200, 200)],
+ "a rerun leaves a row that ran after the upgrade alone"
+ );
+
+ let listed = crate::table::query_history::list_with(
+ &db,
+ "default",
+ "a@x.com",
+ &crate::table::query_history::ListFilter {
+ sources: vec!["metrics".to_string()],
+ ..Default::default()
+ },
+ 10,
+ 0,
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ listed
+ .iter()
+ .map(|r| (r.id.as_str(), r.updated_at))
+ .collect::>(),
+ vec![("old", 900), ("star", 200)]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_up_swaps_indexes() {
+ let db = v1_1_table_with_rows().await;
+ let manager = SchemaManager::new(&db);
+ Migration.up(&manager).await.unwrap();
+
+ for old in [OLD_ORG_USER_CREATED_AT_IDX, OLD_STARRED_CREATED_AT_IDX] {
+ assert!(!manager.has_index(TABLE, old).await.unwrap(), "{old}");
+ }
+ let names = |cols: &[Option<&str>]| -> Vec> {
+ cols.iter().map(|c| c.map(str::to_string)).collect()
+ };
+ assert_eq!(
+ index_columns(&db, ORG_USER_UPDATED_AT_IDX).await,
+ names(&[Some("org_id"), Some("user_email"), Some("updated_at")])
+ );
+ assert_eq!(
+ index_columns(&db, ORG_USER_SOURCE_UPDATED_AT_IDX).await,
+ names(&[
+ Some("org_id"),
+ Some("user_email"),
+ Some("source"),
+ Some("updated_at")
+ ])
+ );
+ assert_eq!(
+ index_columns(&db, STARRED_UPDATED_AT_IDX).await,
+ names(&[Some("starred"), Some("updated_at")])
+ );
+ assert_eq!(
+ index_columns(&db, ORG_USER_SOURCE_KEY_UIDX).await,
+ names(&[
+ Some("org_id"),
+ Some("user_email"),
+ Some("source"),
+ Some("query_key")
+ ])
+ );
+ }
+
+ #[tokio::test]
+ async fn test_up_leaves_exactly_the_agreed_index_set() {
+ let db = v1_1_table_with_rows().await;
+ Migration.up(&SchemaManager::new(&db)).await.unwrap();
+ let indexes: Vec<(String, bool)> = db
+ .query_all(Statement::from_string(
+ DbBackend::Sqlite,
+ "SELECT name, \"unique\" FROM pragma_index_list('query_history') \
+ WHERE origin = 'c' ORDER BY name",
+ ))
+ .await
+ .unwrap()
+ .iter()
+ .map(|r| {
+ (
+ r.try_get_by_index(0).unwrap(),
+ r.try_get_by_index::(1).unwrap() == 1,
+ )
+ })
+ .collect();
+ assert_eq!(
+ indexes,
+ vec![
+ (ORG_USER_SOURCE_KEY_UIDX.to_string(), true),
+ (ORG_USER_SOURCE_UPDATED_AT_IDX.to_string(), false),
+ (ORG_USER_UPDATED_AT_IDX.to_string(), false),
+ (STARRED_UPDATED_AT_IDX.to_string(), false),
+ ]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_unique_key_allows_null_keys_and_refuses_a_duplicate_key() {
+ let db = v1_1_table_with_rows().await;
+ Migration.up(&SchemaManager::new(&db)).await.unwrap();
+ let insert = |id: &str, key: &str| {
+ format!(
+ "INSERT INTO query_history (id, org_id, user_email, query, context, created_at, \
+ query_key) VALUES ('{id}', 'default', 'a@x.com', 'up', '{{}}', 1, {key})"
+ )
+ };
+ db.execute_unprepared(&insert("legacy", "NULL"))
+ .await
+ .expect("a v1.1.x node inserts with no key");
+ db.execute_unprepared(&insert("k1", "'abc'")).await.unwrap();
+ assert!(db.execute_unprepared(&insert("k2", "'abc'")).await.is_err());
+ }
+
+ #[tokio::test]
+ async fn test_down_restores_the_v1_1_table() {
+ let db = v1_1_table_with_rows().await;
+ let manager = SchemaManager::new(&db);
+ Migration.up(&manager).await.unwrap();
+ Migration.down(&manager).await.unwrap();
+ for column in [
+ "source",
+ "query_key",
+ "streams",
+ "run_count",
+ "updated_at",
+ "status",
+ ] {
+ assert!(
+ !manager.has_column(TABLE, column).await.unwrap(),
+ "{column}"
+ );
+ }
+ assert!(
+ manager
+ .has_index(TABLE, OLD_ORG_USER_CREATED_AT_IDX)
+ .await
+ .unwrap()
+ );
+ assert!(
+ manager
+ .has_index(TABLE, OLD_STARRED_CREATED_AT_IDX)
+ .await
+ .unwrap()
+ );
+ }
+
+ #[tokio::test]
+ async fn test_down_keeps_the_last_run_of_rows_an_old_node_wrote() {
+ let db = v1_1_table_with_rows().await;
+ let manager = SchemaManager::new(&db);
+ Migration.up(&manager).await.unwrap();
+ db.execute_unprepared(
+ "INSERT INTO query_history (id, org_id, user_email, query, context, created_at) \
+ VALUES ('mid', 'default', 'a@x.com', 'up', '{}', 300)",
+ )
+ .await
+ .unwrap();
+ db.execute_unprepared("UPDATE query_history SET updated_at = 900 WHERE id = 'old'")
+ .await
+ .unwrap();
+ Migration.down(&manager).await.unwrap();
+
+ let created: Vec<(String, i64)> = db
+ .query_all(Statement::from_string(
+ DbBackend::Sqlite,
+ "SELECT id, created_at FROM query_history ORDER BY id",
+ ))
+ .await
+ .unwrap()
+ .iter()
+ .map(|r| {
+ (
+ r.try_get_by_index(0).unwrap(),
+ r.try_get_by_index(1).unwrap(),
+ )
+ })
+ .collect();
+ assert_eq!(
+ created,
+ vec![
+ ("mid".to_string(), 300),
+ ("old".to_string(), 900),
+ ("star".to_string(), 200)
+ ]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_down_runs_twice() {
+ let db = v1_1_table_with_rows().await;
+ let manager = SchemaManager::new(&db);
+ Migration.up(&manager).await.unwrap();
+ Migration.down(&manager).await.unwrap();
+ Migration
+ .down(&manager)
+ .await
+ .expect("a second rollback is a no-op");
+ }
+
+ #[tokio::test]
+ async fn test_postgres_up_and_down_on_a_populated_v1_1_table_when_ci_provides_it() {
+ let Some((admin, db, schema)) = postgres_schema().await else {
+ return;
+ };
+ seed_v1_1_table(&db).await;
+ let manager = SchemaManager::new(&db);
+ Migration.up(&manager).await.unwrap();
+ Migration.up(&manager).await.expect("idempotent");
+
+ for index in [
+ ORG_USER_UPDATED_AT_IDX,
+ ORG_USER_SOURCE_UPDATED_AT_IDX,
+ STARRED_UPDATED_AT_IDX,
+ ORG_USER_SOURCE_KEY_UIDX,
+ ] {
+ assert!(manager.has_index(TABLE, index).await.unwrap(), "{index}");
+ }
+ let listed = crate::table::query_history::list_with(
+ &db,
+ "default",
+ "a@x.com",
+ &crate::table::query_history::ListFilter {
+ sources: vec!["metrics".to_string()],
+ ..Default::default()
+ },
+ 10,
+ 0,
+ )
+ .await
+ .unwrap();
+ // Postgres pads the char(27) id.
+ assert_eq!(
+ listed
+ .iter()
+ .map(|r| (r.id.trim(), r.updated_at))
+ .collect::>(),
+ vec![("star", 200), ("old", 100)]
+ );
+ let insert = |id: &str| {
+ format!(
+ "INSERT INTO query_history (id, org_id, user_email, query, context, created_at, \
+ query_key) VALUES ('{id}', 'default', 'a@x.com', 'up', '{{}}', 1, 'abc')"
+ )
+ };
+ db.execute_unprepared(&insert("k1")).await.unwrap();
+ assert!(db.execute_unprepared(&insert("k2")).await.is_err());
+
+ Migration.down(&manager).await.unwrap();
+ Migration
+ .down(&manager)
+ .await
+ .expect("a second rollback is a no-op");
+ assert!(!manager.has_column(TABLE, "updated_at").await.unwrap());
+ assert!(
+ manager
+ .has_index(TABLE, OLD_ORG_USER_CREATED_AT_IDX)
+ .await
+ .unwrap()
+ );
+
+ db.close().await.unwrap();
+ admin
+ .execute_unprepared(&format!(r#"DROP SCHEMA "{schema}" CASCADE"#))
+ .await
+ .unwrap();
+ }
+
+ #[test]
+ fn test_migration_name() {
+ assert_eq!(Migration.name(), "m20261027_000001_alter_query_history");
+ }
+}
diff --git a/src/infra/src/table/migration/m20261030_000001_add_suppress_repeats_to_alerts.rs b/src/infra/src/table/migration/m20261030_000001_add_suppress_repeats_to_alerts.rs
new file mode 100644
index 0000000000..a24f3a941f
--- /dev/null
+++ b/src/infra/src/table/migration/m20261030_000001_add_suppress_repeats_to_alerts.rs
@@ -0,0 +1,92 @@
+// 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 .
+
+use sea_orm_migration::prelude::*;
+
+const TABLE: &str = "alerts";
+const COLUMN: &str = "suppress_repeats";
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ // SQLite emits a plain ADD COLUMN for add_column_if_not_exists, so guard explicitly.
+ if manager.has_column(TABLE, COLUMN).await? {
+ return Ok(());
+ }
+ manager.alter_table(add_column_statement()).await
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ if !manager.has_column(TABLE, COLUMN).await? {
+ return Ok(());
+ }
+ manager
+ .alter_table(
+ Table::alter()
+ .table(Alerts::Table)
+ .drop_column(Alerts::SuppressRepeats)
+ .to_owned(),
+ )
+ .await
+ }
+}
+
+#[derive(DeriveIden)]
+enum Alerts {
+ Table,
+ SuppressRepeats,
+}
+
+fn add_column_statement() -> TableAlterStatement {
+ Table::alter()
+ .table(Alerts::Table)
+ .add_column(ColumnDef::new(Alerts::SuppressRepeats).boolean().null())
+ .to_owned()
+}
+
+#[cfg(test)]
+mod tests {
+ use sea_orm::{ConnectionTrait, Database};
+
+ use super::*;
+
+ #[test]
+ fn postgres() {
+ assert_eq!(
+ &add_column_statement().to_string(PostgresQueryBuilder),
+ r#"ALTER TABLE "alerts" ADD COLUMN "suppress_repeats" bool NULL"#
+ );
+ }
+
+ #[tokio::test]
+ async fn test_up_reruns_without_adding_the_column_twice() {
+ let db = Database::connect("sqlite::memory:").await.unwrap();
+ db.execute_unprepared("CREATE TABLE alerts (id varchar(27) PRIMARY KEY)")
+ .await
+ .unwrap();
+ let manager = SchemaManager::new(&db);
+
+ Migration.up(&manager).await.expect("first run");
+ Migration.up(&manager).await.expect("second run");
+ assert!(manager.has_column(TABLE, COLUMN).await.unwrap());
+
+ Migration.down(&manager).await.unwrap();
+ Migration.down(&manager).await.unwrap();
+ assert!(!manager.has_column(TABLE, COLUMN).await.unwrap());
+ }
+}
diff --git a/src/infra/src/table/migration/m20261030_000001_create_org_domain_ownership_table.rs b/src/infra/src/table/migration/m20261030_000001_create_org_domain_ownership_table.rs
new file mode 100644
index 0000000000..193b6c575e
--- /dev/null
+++ b/src/infra/src/table/migration/m20261030_000001_create_org_domain_ownership_table.rs
@@ -0,0 +1,135 @@
+// 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 .
+
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager.create_table(create_domains_stmt()).await?;
+ manager.create_index(domain_org_idx()).await?;
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .drop_table(Table::drop().table(OrgDomainOwnership::Table).to_owned())
+ .await?;
+ Ok(())
+ }
+}
+
+fn create_domains_stmt() -> TableCreateStatement {
+ Table::create()
+ .table(OrgDomainOwnership::Table)
+ .if_not_exists()
+ .col(
+ ColumnDef::new(OrgDomainOwnership::Id)
+ .string_len(27)
+ .not_null()
+ .primary_key(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainOwnership::OrgId)
+ .string_len(100)
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainOwnership::Domain)
+ .string_len(253)
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainOwnership::VerificationToken)
+ .string_len(128)
+ .not_null(),
+ )
+ // 0 pending, 1 verified, 2 failed.
+ .col(
+ ColumnDef::new(OrgDomainOwnership::VerificationState)
+ .integer()
+ .not_null()
+ .default(0),
+ )
+ // 0 record-missing, 1 value-mismatch, 2 dns-resolution-failed.
+ .col(
+ ColumnDef::new(OrgDomainOwnership::VerificationFailureReason)
+ .integer()
+ .null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainOwnership::VerifiedAt)
+ .big_integer()
+ .null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainOwnership::ReleasedAt)
+ .big_integer()
+ .null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainOwnership::LastCheckedAt)
+ .big_integer()
+ .null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainOwnership::CreatedAt)
+ .big_integer()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainOwnership::UpdatedAt)
+ .big_integer()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainOwnership::MetaMapped)
+ .boolean()
+ .not_null()
+ .default(false),
+ )
+ .to_owned()
+}
+
+fn domain_org_idx() -> IndexCreateStatement {
+ sea_query::Index::create()
+ .if_not_exists()
+ .name("org_domain_ownership_org_domain_idx")
+ .table(OrgDomainOwnership::Table)
+ .col(OrgDomainOwnership::OrgId)
+ .col(OrgDomainOwnership::Domain)
+ .col(OrgDomainOwnership::VerificationState)
+ .to_owned()
+}
+
+#[derive(DeriveIden)]
+enum OrgDomainOwnership {
+ Table,
+ Id,
+ OrgId,
+ Domain,
+ VerificationToken,
+ VerificationState,
+ VerificationFailureReason,
+ VerifiedAt,
+ ReleasedAt,
+ LastCheckedAt,
+ CreatedAt,
+ UpdatedAt,
+ MetaMapped,
+}
diff --git a/src/infra/src/table/migration/m20261030_000002_create_org_domain_ownership_req_table.rs b/src/infra/src/table/migration/m20261030_000002_create_org_domain_ownership_req_table.rs
new file mode 100644
index 0000000000..96a950bc32
--- /dev/null
+++ b/src/infra/src/table/migration/m20261030_000002_create_org_domain_ownership_req_table.rs
@@ -0,0 +1,98 @@
+// 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 .
+
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager.create_table(create_domaim_req_stmt()).await?;
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .drop_table(Table::drop().table(OrgDomainRequests::Table).to_owned())
+ .await?;
+ Ok(())
+ }
+}
+
+fn create_domaim_req_stmt() -> TableCreateStatement {
+ Table::create()
+ .table(OrgDomainRequests::Table)
+ .if_not_exists()
+ .col(
+ ColumnDef::new(OrgDomainRequests::Id)
+ .string_len(27)
+ .not_null()
+ .primary_key(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainRequests::OrgId)
+ .string_len(100)
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainRequests::Domain)
+ .string_len(253)
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainRequests::CreatedAt)
+ .big_integer()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainRequests::UpdatedAt)
+ .big_integer()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainRequests::RequestedBy)
+ .string_len(100)
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(OrgDomainRequests::AnsweredBy)
+ .string_len(100)
+ .null(),
+ )
+ .col(ColumnDef::new(OrgDomainRequests::Reason).string().null())
+ .col(
+ ColumnDef::new(OrgDomainRequests::Status)
+ .integer()
+ .not_null()
+ .default(0),
+ )
+ .to_owned()
+}
+
+#[derive(DeriveIden)]
+enum OrgDomainRequests {
+ Table,
+ Id,
+ OrgId,
+ Domain,
+ CreatedAt,
+ UpdatedAt,
+ RequestedBy,
+ AnsweredBy,
+ Reason,
+ Status,
+}
diff --git a/src/infra/src/table/migration/mod.rs b/src/infra/src/table/migration/mod.rs
index de27c92e44..66549dfe4d 100644
--- a/src/infra/src/table/migration/mod.rs
+++ b/src/infra/src/table/migration/mod.rs
@@ -241,8 +241,12 @@ mod m20261025_000003_add_mute_targets_to_downtimes;
mod m20261025_000004_create_slack_alert_messages;
mod m20261025_000005_add_held_action_to_slack_link_requests;
mod m20261026_000001_backfill_v1_rca_pointer;
+mod m20261027_000001_alter_query_history;
mod m20261028_000001_add_group_label_set_to_alert_states;
mod m20261029_000001_widen_alert_dedup_fingerprint;
+mod m20261030_000001_add_suppress_repeats_to_alerts;
+mod m20261030_000001_create_org_domain_ownership_table;
+mod m20261030_000002_create_org_domain_ownership_req_table;
/// Shared body of the two `folder_id` migrations above; not a migration itself.
mod workflow_folder_id;
@@ -435,10 +439,13 @@ pub(crate) async fn create_rum_pa_tables(db: &sea_orm::DatabaseConnection) -> Re
/// Apply only the Slack app tables, idempotently, for tests in any crate.
#[cfg(any(test, feature = "test-utils"))]
-pub(crate) async fn create_slack_tables(db: &C) -> Result<(), DbErr> {
+pub(crate) async fn create_slack_tables(db: &sea_orm::DatabaseConnection) -> Result<(), DbErr> {
+ use sea_orm_migration::MigrationTrait;
m20261018_000001_create_slack_tables::create_all(db).await?;
m20261025_000004_create_slack_alert_messages::create_all(db).await?;
- m20261025_000005_add_held_action_to_slack_link_requests::add_column(db).await
+ m20261025_000005_add_held_action_to_slack_link_requests::Migration
+ .up(&SchemaManager::new(db))
+ .await
}
/// Apply only `alert_acks`, idempotently, for tests in any crate.
@@ -501,6 +508,20 @@ pub(crate) async fn backfill_v1_rca_pointer(db: &sea_orm::DatabaseConnection) ->
.await
}
+/// Apply the query_history table and its columns, idempotently, for tests in any crate.
+#[cfg(any(test, feature = "test-utils"))]
+pub(crate) async fn create_query_history_table(
+ db: &sea_orm::DatabaseConnection,
+) -> Result<(), DbErr> {
+ let manager = SchemaManager::new(db);
+ m20261004_000001_create_query_history::Migration
+ .up(&manager)
+ .await?;
+ m20261027_000001_alter_query_history::Migration
+ .up(&manager)
+ .await
+}
+
pub struct Migrator;
#[async_trait::async_trait]
@@ -732,6 +753,10 @@ impl MigratorTrait for Migrator {
Box::new(m20261025_000005_add_held_action_to_slack_link_requests::Migration),
Box::new(m20261028_000001_add_group_label_set_to_alert_states::Migration),
Box::new(m20261029_000001_widen_alert_dedup_fingerprint::Migration),
+ Box::new(m20261027_000001_alter_query_history::Migration),
+ Box::new(m20261030_000001_add_suppress_repeats_to_alerts::Migration),
+ Box::new(m20261030_000001_create_org_domain_ownership_table::Migration),
+ Box::new(m20261030_000002_create_org_domain_ownership_req_table::Migration),
]
}
}
@@ -825,6 +850,12 @@ mod tests {
),
(127, "m20261028_000001_add_group_label_set_to_alert_states"),
(128, "m20261029_000001_widen_alert_dedup_fingerprint"),
+ (129, "m20261027_000001_alter_query_history"),
+ (130, "m20261030_000001_add_suppress_repeats_to_alerts"),
+ (
+ 131,
+ "m20261030_000002_create_org_domain_ownership_req_table",
+ ),
];
// Two branches that each claim the next version merge cleanly, so a reused number must fail
diff --git a/src/infra/src/table/mod.rs b/src/infra/src/table/mod.rs
index 3bfbc17c3f..feb95ad2d2 100644
--- a/src/infra/src/table/mod.rs
+++ b/src/infra/src/table/mod.rs
@@ -82,6 +82,8 @@ pub mod oncall_user_contacts;
pub mod online_eval_jobs;
pub mod org_ai_toolsets;
pub mod org_cleanup_tasks;
+pub mod org_domain_ownership;
+pub mod org_domain_requests;
pub mod org_ingestion_tokens;
pub mod org_storage_providers;
pub mod org_telephony;
diff --git a/src/infra/src/table/org_domain_ownership.rs b/src/infra/src/table/org_domain_ownership.rs
new file mode 100644
index 0000000000..42ee704cef
--- /dev/null
+++ b/src/infra/src/table/org_domain_ownership.rs
@@ -0,0 +1,150 @@
+// 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 .
+
+use std::collections::HashMap;
+
+use config::utils::{rand::generate_random_string, time::now_micros};
+use sea_orm::{
+ ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter,
+ QuerySelect, sea_query::Expr,
+};
+
+use super::entity::{org_domain_ownership::*, prelude::OrgDomainOwnership};
+use crate::db::{get_orm_client_ro, get_orm_client_rw};
+
+pub enum OwnershipState {
+ Pending = 0,
+ Verified = 1,
+ Failed = 2,
+}
+pub struct OwnershipRecord {
+ pub org_id: String,
+ pub domain: String,
+ pub state: OwnershipState,
+}
+
+pub async fn list_active_domain_org_map() -> Result, anyhow::Error> {
+ let client = get_orm_client_ro().await;
+ let records = OrgDomainOwnership::find()
+ .select_only()
+ .column(Column::Domain)
+ .column(Column::OrgId)
+ .filter(Column::VerificationState.eq(OwnershipState::Verified as i32))
+ .into_tuple::<(String, String)>()
+ .all(client)
+ .await?;
+ Ok(records.into_iter().collect())
+}
+
+pub async fn get_verified_org_for_domain(domain: &str) -> Result, anyhow::Error> {
+ let client = get_orm_client_ro().await;
+ let record = OrgDomainOwnership::find()
+ .select_only()
+ .column(Column::OrgId)
+ .filter(Column::Domain.eq(domain))
+ .filter(Column::VerificationState.eq(OwnershipState::Verified as i32))
+ .into_tuple::<(String,)>()
+ .one(client)
+ .await?;
+ Ok(record.map(|v| v.0))
+}
+
+pub async fn get_domains_for_org(org_id: &str) -> Result, anyhow::Error> {
+ let client = get_orm_client_ro().await;
+ let records = OrgDomainOwnership::find()
+ .filter(Column::OrgId.eq(org_id))
+ .all(client)
+ .await?;
+ Ok(records)
+}
+
+pub async fn save_org_domain_mapping(
+ record: OwnershipRecord,
+ meta_mapped: bool,
+) -> Result<(), anyhow::Error> {
+ let client = get_orm_client_rw().await;
+ let now = now_micros();
+ let token = format!("o2v_{}_{}", record.org_id, generate_random_string(32));
+
+ let m = ActiveModel {
+ id: Set(config::ider::generate()),
+ org_id: Set(record.org_id),
+ domain: Set(record.domain),
+ verification_token: Set(token),
+ verification_state: Set(record.state as i32),
+ verification_failure_reason: Set(None),
+ verified_at: Set(None),
+ released_at: Set(None),
+ last_checked_at: Set(None),
+ created_at: Set(now),
+ updated_at: Set(now),
+ meta_mapped: Set(meta_mapped),
+ };
+ OrgDomainOwnership::insert(m).exec(client).await?;
+ Ok(())
+}
+
+pub async fn delete_linked_domain(org_id: &str, domain: &str) -> Result<(), anyhow::Error> {
+ let client = get_orm_client_rw().await;
+ OrgDomainOwnership::delete_many()
+ .filter(Column::OrgId.eq(org_id))
+ .filter(Column::Domain.eq(domain))
+ .exec(client)
+ .await?;
+ Ok(())
+}
+
+pub async fn get_domain_org_record(
+ org_id: &str,
+ domain: &str,
+) -> Result, anyhow::Error> {
+ let client = get_orm_client_ro().await;
+ let res = OrgDomainOwnership::find()
+ .filter(Column::OrgId.eq(org_id))
+ .filter(Column::Domain.eq(domain))
+ .one(client)
+ .await?;
+ Ok(res)
+}
+
+pub async fn save_domain_org_record(model: Model) -> Result<(), anyhow::Error> {
+ let client = get_orm_client_rw().await;
+ let am = model.clone().into_active_model().reset_all();
+ am.update(client).await?;
+ Ok(())
+}
+
+pub async fn list_all() -> Result, anyhow::Error> {
+ let client = get_orm_client_ro().await;
+ let res = OrgDomainOwnership::find().all(client).await?;
+ Ok(res)
+}
+
+pub async fn mark_meta_mapped(org_id: &str, domain: &str) -> Result<(), anyhow::Error> {
+ let now = now_micros();
+ let client = get_orm_client_rw().await;
+ OrgDomainOwnership::update_many()
+ .filter(Column::OrgId.eq(org_id))
+ .filter(Column::Domain.eq(domain))
+ .col_expr(Column::MetaMapped, Expr::value(true))
+ .col_expr(Column::LastCheckedAt, Expr::value(now))
+ .col_expr(
+ Column::VerificationState,
+ Expr::value(OwnershipState::Verified as i32),
+ )
+ .exec(client)
+ .await?;
+ Ok(())
+}
diff --git a/src/infra/src/table/org_domain_requests.rs b/src/infra/src/table/org_domain_requests.rs
new file mode 100644
index 0000000000..b482055ec5
--- /dev/null
+++ b/src/infra/src/table/org_domain_requests.rs
@@ -0,0 +1,107 @@
+// 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 .
+
+use config::utils::time::now_micros;
+use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, sea_query::Expr};
+
+use super::entity::{org_domain_requests::*, prelude::OrgDomainRequests};
+use crate::db::{get_orm_client_ro, get_orm_client_rw};
+
+#[derive(Debug)]
+pub enum Status {
+ Pending = 0,
+ Approved = 1,
+ Rejected = 2,
+}
+impl From for Status {
+ fn from(value: i32) -> Self {
+ match value {
+ 0 => Self::Pending,
+ 1 => Self::Approved,
+ 2 => Self::Rejected,
+ _ => Self::Pending,
+ }
+ }
+}
+
+pub async fn save_org_domain_request(req: Model) -> Result<(), anyhow::Error> {
+ let client = get_orm_client_rw().await;
+ let m = req.into_active_model();
+ OrgDomainRequests::insert(m).exec(client).await?;
+ Ok(())
+}
+
+pub async fn list_all() -> Result, anyhow::Error> {
+ let client = get_orm_client_ro().await;
+ let res = OrgDomainRequests::find().all(client).await?;
+ Ok(res)
+}
+
+pub async fn list_by_org(org_id: &str) -> Result, anyhow::Error> {
+ let client = get_orm_client_ro().await;
+ let res = OrgDomainRequests::find()
+ .filter(Column::OrgId.eq(org_id))
+ .all(client)
+ .await?;
+ Ok(res)
+}
+
+pub async fn update_status(
+ id: &str,
+ user: &str,
+ new_status: Status,
+ reason: String,
+) -> Result<(), anyhow::Error> {
+ let now = now_micros();
+ let client = get_orm_client_rw().await;
+ OrgDomainRequests::update_many()
+ .filter(Column::Id.eq(id))
+ .col_expr(Column::Status, Expr::value(new_status as i32))
+ .col_expr(Column::AnsweredBy, Expr::value(user))
+ .col_expr(Column::Reason, Expr::value(reason))
+ .col_expr(Column::UpdatedAt, Expr::value(now))
+ .exec(client)
+ .await?;
+ Ok(())
+}
+
+pub async fn get_by_id(id: &str) -> Result, anyhow::Error> {
+ let client = get_orm_client_ro().await;
+ let res = OrgDomainRequests::find()
+ .filter(Column::Id.eq(id))
+ .one(client)
+ .await?;
+ Ok(res)
+}
+
+pub async fn get_by_org_domain(org_id: &str, domain: &str) -> Result , anyhow::Error> {
+ let client = get_orm_client_ro().await;
+ let res = OrgDomainRequests::find()
+ .filter(Column::OrgId.eq(org_id))
+ .filter(Column::Domain.eq(domain))
+ .filter(Column::Status.ne(Status::Approved as i32))
+ .one(client)
+ .await?;
+ Ok(res)
+}
+
+pub async fn delete_by_org(org_id: &str) -> Result<(), anyhow::Error> {
+ let client = get_orm_client_rw().await;
+ OrgDomainRequests::delete_many()
+ .filter(Column::OrgId.eq(org_id))
+ .exec(client)
+ .await?;
+ Ok(())
+}
diff --git a/src/infra/src/table/query_history.rs b/src/infra/src/table/query_history.rs
index 0e99051af3..a83d21635e 100644
--- a/src/infra/src/table/query_history.rs
+++ b/src/infra/src/table/query_history.rs
@@ -14,199 +14,173 @@
// along with this program. If not, see .
use sea_orm::{
- ColumnTrait, Condition, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
- QuerySelect, Set, TransactionTrait,
- sea_query::{Expr, Func, LikeExpr, SimpleExpr},
+ ColumnTrait, Condition, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult,
+ PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, QueryTrait, Set, Statement,
+ TransactionTrait,
+ sea_query::{Expr, Func, LikeExpr, OnConflict, Query, SimpleExpr},
};
use super::entity::query_history::{ActiveModel, Column, Entity, Model};
use crate::{
db::{get_orm_client_ro, get_orm_client_rw},
- errors,
+ errors, orm_err,
};
-/// Recording prunes the user's oldest unstarred entries beyond this many.
-pub const MAX_UNSTARRED_PER_USER: u64 = 1000;
-/// Starring past this many is refused; recording never stars, so it is unaffected.
+/// Recording a new row prunes the user's oldest unstarred rows of that source beyond this many.
+pub const MAX_UNSTARRED_PER_SOURCE: u64 = 500;
+/// Starring past this many across all sources is refused; recording never stars.
pub const MAX_STARRED_PER_USER: u64 = 200;
+/// Pages that record history; the handler refuses any other source.
+pub const SOURCES: [&str; 5] = ["logs", "traces", "metrics", "dbm", "profiles"];
+/// What v1.1.x rows and clients that send no source are.
+pub const DEFAULT_SOURCE: &str = "metrics";
+pub const STATUS_OK: &str = "ok";
+pub const STATUS_ERROR: &str = "error";
/// Outcome of starring or unstarring an entry.
pub enum SetStarred {
- Updated(Model),
+ Updated(Box),
NotFound,
/// Starring would take the user past `MAX_STARRED_PER_USER`.
StarredCapReached,
}
-/// Records a query for this org and user; the same query as their latest entry refreshes it.
-pub async fn record(
- org_id: &str,
- user_email: &str,
- query: &str,
- context: serde_json::Value,
- now_us: i64,
-) -> Result {
- let client = get_orm_client_rw().await;
- record_with(client, org_id, user_email, query, context, now_us).await
+/// One run to record; repeats of the same source and key merge into one row.
+pub struct NewRun<'a> {
+ pub org_id: &'a str,
+ pub user_email: &'a str,
+ pub source: &'a str,
+ pub query: &'a str,
+ /// The client's merge key; `None` or empty falls back to the query text.
+ pub query_key: Option<&'a str>,
+ pub streams: &'a [String],
+ pub status: &'a str,
+ pub context: serde_json::Value,
+ pub now_us: i64,
}
-pub async fn record_with(
+/// List filters; every `None` or empty field matches all rows.
+#[derive(Default)]
+pub struct ListFilter {
+ pub sources: Vec,
+ pub status: Option,
+ /// Exact stream name.
+ pub stream: Option,
+ /// Case-insensitive literal substring of the query or the streams.
+ pub q: Option,
+ pub starred: Option,
+ /// Inclusive bounds on `updated_at`, in microseconds.
+ pub ran_from: Option,
+ pub ran_to: Option,
+}
+
+/// Records a run: inserts a row, or merges into the row with the same org, user, source and key.
+pub async fn record(run: NewRun<'_>) -> Result {
+ let client = get_orm_client_rw().await;
+ record_with(client, run).await
+}
+
+pub async fn record_with(
conn: &C,
- org_id: &str,
- user_email: &str,
- query: &str,
- context: serde_json::Value,
- now_us: i64,
+ run: NewRun<'_>,
) -> Result {
- let txn = conn.begin().await?;
- let latest = Entity::find()
- .filter(Column::OrgId.eq(org_id))
- .filter(Column::UserEmail.eq(user_email))
- .select_only()
- .column(Column::Id)
- .column(Column::Query)
- .column(Column::Starred)
- .order_by_desc(Column::CreatedAt)
- .order_by_desc(Column::Id)
- .into_tuple::<(String, String, bool)>()
- .one(&txn)
- .await?;
- let model = match latest {
- Some((id, latest_query, starred)) if latest_query == query => {
- Entity::update_many()
- .col_expr(Column::CreatedAt, Expr::value(now_us))
- .col_expr(Column::Context, Expr::value(context.clone()))
- .filter(Column::Id.eq(&id))
- .exec(&txn)
- .await?;
- Model {
- id,
- org_id: org_id.to_string(),
- user_email: user_email.to_string(),
- query: latest_query,
- context,
- starred,
- created_at: now_us,
- }
- }
- _ => {
- let model = Model {
- id: config::ider::uuid(),
- org_id: org_id.to_string(),
- user_email: user_email.to_string(),
- query: query.to_string(),
- context,
- starred: false,
- created_at: now_us,
- };
- Entity::insert(ActiveModel {
- id: Set(model.id.clone()),
- org_id: Set(model.org_id.clone()),
- user_email: Set(model.user_email.clone()),
- query: Set(model.query.clone()),
- context: Set(model.context.clone()),
- starred: Set(false),
- created_at: Set(now_us),
- })
- .exec(&txn)
- .await?;
- prune_unstarred_over_cap(&txn, org_id, user_email).await?;
- model
- }
+ let entry = ActiveModel {
+ id: Set(config::ider::uuid()),
+ org_id: Set(run.org_id.to_string()),
+ user_email: Set(run.user_email.to_string()),
+ query: Set(run.query.to_string()),
+ context: Set(run.context),
+ starred: Set(false),
+ created_at: Set(run.now_us),
+ source: Set(run.source.to_string()),
+ query_key: Set(Some(query_key_hash(run.query_key, run.query))),
+ streams: Set(encode_streams(run.streams)),
+ run_count: Set(1),
+ updated_at: Set(run.now_us),
+ status: Set(run.status.to_string()),
};
- txn.commit().await?;
+ // One statement, so concurrent identical runs merge instead of racing to insert.
+ let mut stmt = Entity::insert(entry)
+ .on_conflict(
+ OnConflict::columns([
+ Column::OrgId,
+ Column::UserEmail,
+ Column::Source,
+ Column::QueryKey,
+ ])
+ .value(
+ Column::RunCount,
+ Expr::col((Entity, Column::RunCount)).add(1),
+ )
+ .update_columns([Column::UpdatedAt, Column::Context, Column::Status])
+ .to_owned(),
+ )
+ .into_query();
+ stmt.returning(Query::returning().all());
+
+ let Some(row) = conn
+ .query_one(conn.get_database_backend().build(&stmt))
+ .await?
+ else {
+ return orm_err!("query history upsert returned no row");
+ };
+ let model = Model::from_query_result(&row, "")?;
+ // After the upsert has committed, so a prune also sees rows that concurrent runs inserted.
+ if model.run_count == 1 {
+ prune_unstarred_over_cap(conn, run.org_id, run.user_email, run.source, &model.id).await?;
+ }
Ok(model)
}
-/// Lists one user's entries in one org, newest first.
+/// Lists one user's entries in one org, most recently run first.
pub async fn list(
org_id: &str,
user_email: &str,
- starred: Option,
- q: Option<&str>,
+ filter: &ListFilter,
limit: u64,
offset: u64,
) -> Result, errors::Error> {
let client = get_orm_client_ro().await;
- list_with(client, org_id, user_email, starred, q, limit, offset).await
+ list_with(client, org_id, user_email, filter, limit, offset).await
}
pub async fn list_with(
conn: &C,
org_id: &str,
user_email: &str,
- starred: Option,
- q: Option<&str>,
+ filter: &ListFilter,
limit: u64,
offset: u64,
) -> Result, errors::Error> {
- let mut select = Entity::find()
- .filter(Column::OrgId.eq(org_id))
- .filter(Column::UserEmail.eq(user_email));
- if let Some(starred) = starred {
- select = select.filter(Column::Starred.eq(starred));
- }
- if let Some(q) = q.filter(|q| !q.is_empty()) {
- select = select.filter(query_matches(q));
- }
- Ok(select
- .order_by_desc(Column::CreatedAt)
- .order_by_desc(Column::Id)
+ Ok(list_query(org_id, user_email, filter)
.limit(limit)
.offset(offset)
.all(conn)
.await?)
}
-/// Deletes the user's unstarred rows from the `MAX_UNSTARRED_PER_USER + 1`-th newest onward.
-async fn prune_unstarred_over_cap(
+/// How many entries `list` would return with no limit.
+pub async fn count(
+ org_id: &str,
+ user_email: &str,
+ filter: &ListFilter,
+) -> Result {
+ let client = get_orm_client_ro().await;
+ count_with(client, org_id, user_email, filter).await
+}
+
+pub async fn count_with(
conn: &C,
org_id: &str,
user_email: &str,
-) -> Result<(), errors::Error> {
- let unstarred = Condition::all()
- .add(Column::OrgId.eq(org_id))
- .add(Column::UserEmail.eq(user_email))
- .add(Column::Starred.eq(false));
- let Some((created_at, id)) = Entity::find()
- .filter(unstarred.clone())
- .select_only()
- .column(Column::CreatedAt)
- .column(Column::Id)
- .order_by_desc(Column::CreatedAt)
- .order_by_desc(Column::Id)
- .offset(MAX_UNSTARRED_PER_USER)
- .into_tuple::<(i64, String)>()
- .one(conn)
- .await?
- else {
- return Ok(());
- };
- let at_or_before_boundary = Condition::any().add(Column::CreatedAt.lt(created_at)).add(
- Condition::all()
- .add(Column::CreatedAt.eq(created_at))
- .add(Column::Id.lte(id)),
- );
- Entity::delete_many()
- .filter(unstarred)
- .filter(at_or_before_boundary)
- .exec(conn)
- .await?;
- Ok(())
-}
-
-/// Case-insensitive literal substring match: `%`, `_` and `\` match only themselves.
-fn query_matches(q: &str) -> SimpleExpr {
- let mut pattern = String::with_capacity(q.len() + 2);
- pattern.push('%');
- for c in q.to_lowercase().chars() {
- if matches!(c, '\\' | '%' | '_') {
- pattern.push('\\');
- }
- pattern.push(c);
- }
- pattern.push('%');
- Expr::expr(Func::lower(Expr::col(Column::Query))).like(LikeExpr::new(pattern).escape('\\'))
+ filter: &ListFilter,
+) -> Result {
+ Ok(Entity::find()
+ .filter(Column::OrgId.eq(org_id))
+ .filter(Column::UserEmail.eq(user_email))
+ .filter(list_condition(filter))
+ .count(conn)
+ .await?)
}
/// Stars or unstars one of the user's entries; starring is refused at `MAX_STARRED_PER_USER`.
@@ -220,29 +194,35 @@ pub async fn set_starred(
set_starred_with(client, org_id, user_email, id, starred).await
}
-pub async fn set_starred_with(
+pub async fn set_starred_with(
conn: &C,
org_id: &str,
user_email: &str,
id: &str,
starred: bool,
) -> Result {
- // MySQL rows_affected counts changed rows, so a re-star reports 0; "not found" needs a lookup.
+ let txn = conn.begin().await?;
+ let users_rows = Condition::all()
+ .add(Column::OrgId.eq(org_id))
+ .add(Column::UserEmail.eq(user_email));
+ // Only a star can pass the cap, so an unstar takes no lock.
+ if starred && let Some(lock) = star_lock(txn.get_database_backend(), org_id, user_email) {
+ txn.execute(lock).await?;
+ }
+ // MySQL rows_affected counts changed rows, so a re-star reports 0; the response needs the row.
let Some(entry) = Entity::find()
- .filter(Column::OrgId.eq(org_id))
- .filter(Column::UserEmail.eq(user_email))
+ .filter(users_rows.clone())
.filter(Column::Id.eq(id))
- .one(conn)
+ .one(&txn)
.await?
else {
return Ok(SetStarred::NotFound);
};
if starred && !entry.starred {
let starred_count = Entity::find()
- .filter(Column::OrgId.eq(org_id))
- .filter(Column::UserEmail.eq(user_email))
+ .filter(users_rows)
.filter(Column::Starred.eq(true))
- .count(conn)
+ .count(&txn)
.await?;
if starred_count >= MAX_STARRED_PER_USER {
return Ok(SetStarred::StarredCapReached);
@@ -251,9 +231,10 @@ pub async fn set_starred_with(
Entity::update_many()
.col_expr(Column::Starred, Expr::value(starred))
.filter(Column::Id.eq(id))
- .exec(conn)
+ .exec(&txn)
.await?;
- Ok(SetStarred::Updated(Model { starred, ..entry }))
+ txn.commit().await?;
+ Ok(SetStarred::Updated(Box::new(Model { starred, ..entry })))
}
/// Deletes one of the user's entries in the org; false when it is not theirs or does not exist.
@@ -277,7 +258,37 @@ pub async fn delete_with(
Ok(res.rows_affected > 0)
}
-/// Deletes unstarred entries older than `cutoff_us` across every org and user; starred are kept.
+/// Deletes the user's entries in the org for `sources` (all when empty); starred only on request.
+pub async fn clear(
+ org_id: &str,
+ user_email: &str,
+ sources: &[String],
+ include_starred: bool,
+) -> Result {
+ let client = get_orm_client_rw().await;
+ clear_with(client, org_id, user_email, sources, include_starred).await
+}
+
+pub async fn clear_with(
+ conn: &C,
+ org_id: &str,
+ user_email: &str,
+ sources: &[String],
+ include_starred: bool,
+) -> Result {
+ let mut delete = Entity::delete_many()
+ .filter(Column::OrgId.eq(org_id))
+ .filter(Column::UserEmail.eq(user_email));
+ if !sources.is_empty() {
+ delete = delete.filter(Column::Source.is_in(sources.iter().cloned()));
+ }
+ if !include_starred {
+ delete = delete.filter(Column::Starred.eq(false));
+ }
+ Ok(delete.exec(conn).await?.rows_affected)
+}
+
+/// Deletes unstarred entries first and last run before `cutoff_us` across every org and user.
pub async fn delete_unstarred_before(cutoff_us: i64) -> Result