## Summary
Replaces the AWS Translate i18n pipeline with a DeepSeek (LLM) backend,
standardizes locale files to BCP-47 naming with an `en-US` base, adds
new languages, and backfills **every locale to 100%**.
## What changed
### Translation engine (DeepSeek)
- Swap AWS Translate → DeepSeek via its OpenAI-compatible API
(`scripts/translations/`). Auth via the `DEEPSEEK_API_KEY` secret;
per-request 60s timeout.
- **Batched** translation (50/call) with **per-string validation**:
interpolation placeholders (`{count}`, `%s`, `@:key`), vue-i18n
**pluralization pipes** (`{n} Day \| {n} Days`), and literal escapes
(`{'@'}`, `{'{'}`, `{'\|'}`) are preserved or the string is rejected and
retried.
- Robust recovery: index-aligned batches, split-on-mismatch to isolate a
single bad string, temperature-perturbed retries, and rejection of
empty/whitespace output (never blanks a label). Removed the old
`TRANSLATION_MAX_KEYS` safety cap.
### Locale naming + base language
- All 15 locale files renamed to **BCP-47 hyphen** form (`en-US.json`,
`zh-CN.json`, `zh-TW.json`, `fr-FR.json`, …) — matching
`navigator.language` / `Intl` / HTML `lang`.
- **English base switched `en-gb` → `en-us`** (app code,
`fallbackLocale`, BCP-47 map, selector, defaults). British-Pound
currency and date-formatting `en-GB` usages left untouched. Existing
`en-gb` cookies fall back cleanly to English — no migration needed.
### New languages
- **Russian (`ru`), Polish (`pl`), Vietnamese (`vi`)** — fully generated
and wired into `localeFileMap`, `APP_LOCALE_TO_BCP47`, and the language
selector.
- **Traditional Chinese (`zh-TW`)** now maintained by the translator
alongside Simplified (`zh-CN`).
- RTL languages (`ar`, `fa`) intentionally **deferred** until the app
has `dir="rtl"` support.
### Full backfill (one-time)
- Every pre-existing locale filled from ~5.9k up to the full **8,269
keys** (~26.9k strings). `.translation_state.json` rebuilt complete, so
future runs are incremental.
### Nav i18n bug fix
- The **Dashboards** and **Data** nav group labels were hardcoded
English (bypassing i18n) and stayed English in every locale. Now
resolved via `t()` (`menu.dashboard`, new `menu.data`), localized and
re-resolving on language change.
## Verification
- Full web unit suite: **39,035 passed / 414 skipped / 0 failed** (1,167
files).
- `type-check` clean (0 TS errors).
- Locale registry sync spec green; all 15 files = 8,269 leaves.
- Translation engine verified live against `deepseek-v4-flash`.
## Notes
- Since all locales are now 100%, the post-merge `update-translations`
workflow will find nothing pending (no follow-up translation PR).
- `ar`/`fa` to be added with RTL support in a follow-up.
## What
Cleans up the wording and i18n coverage around **service accounts** in
the web UI so the credential is consistently called a **token**, and
fixes a mislabeled toast.
### Changes
- **Standardized credential wording** — `en.json` service-account labels
drifted between "Service Token", "Service Account Token", and "Token".
Unified on **"Token"** (kept "Service Account Token" only as the
standalone reveal-dialog title).
- **Localized hardcoded strings** — moved all `ServiceAccountsList.vue`
toasts (create / update / delete / rotate / loading / copy /
bulk-delete) into a new `serviceAccounts.toast.*` namespace; localized
the tab labels in `EditRole.vue` / `EditGroup.vue` and the search
placeholder in `GroupServiceAccounts.vue` (added `iam.permissions`).
- **Fixed a mislabeled toast** — the org-passcode flow in
`Ingestion.vue` showed `"API Key not found."`; it's the org **passcode**
(sibling button is "Update Passcode"), so it now reads `"Passcode not
found."` (spec updated to match).
### Notes
- `en.json` is updated as the source of truth; the other locale files
sync via the normal translation flow.
- Dead `apikeyLabel` / `copyapikey` / `deleteAPIKeyMessage` keys (no
component references) were left untouched — out of scope for this
change.
## Testing
- `vitest` unit suite for the touched files passes (134/134, incl.
updated `Ingestion.spec.ts` assertions).
## Related
- Docs counterpart (new Service Accounts page + credential comparison):
openobserve/openobserve-docs PR.
## Summary
Three pieces of frontend work from one session, all verified green.
### 1. i18n migration (the bulk)
Replaced **~1,250 hardcoded user-facing strings** across **157
components** with `vue-i18n` `t()` calls and added **1,373 English
keys** to `web/src/locales/languages/en.json`. Other languages are
populated by CI.
Coverage by area: dashboards (36), traces (26), logs (24), settings
(23), IAM (21), metrics/promql (10), RUM error tracking (8).
- Parameterized messages use named interpolation (`t('key', { n })` →
`"Deleted {n} item(s)"`).
- Keys are namespaced by area + component to avoid collisions.
- Deliberately **not** translated: copy/paste code/config snippets,
query-syntax examples (Syntax Guides), technical enum tokens (OTEL
statuses, metric types), config-default fallbacks, and download
filenames.
### 2. RUM Error Tracking UX
- **Auto-size the Issue column** so Trend / Events / Users /
First-Last-seen / Status stay visible at any screen width; long messages
and file paths truncate with tooltips instead of pushing columns
off-screen.
- **Removed the redundant refresh button** (it called the same
`runQuery` as Run query); folded its loading state and keyboard shortcut
into the Run query button.
### 3. MCP Server (IAM)
- Added **Codex** to the supported MCP clients (TOML config for
`~/.codex/config.toml`).
- Added **one-click "Add to …" install buttons** for **Cursor**, **VS
Code**, and **Claude Desktop** via editor deep links (respecting the
selected OAuth / access-token auth mode).
### Tests
Components that newly call `useI18n()` broke specs that mounted them
without an i18n provider (or with an inline message subset / a
key-returning mock). Fixed **33 spec files** by providing the real app
i18n — via `config.global` plugin, a full-locale `createI18n`, or a
key-resolving `vue-i18n` mock — **without weakening any assertion or
deleting any test**.
## Verification
- ✅ Full frontend unit suite: **39,007 passed**, 414 skipped, **0
failed** (`vitest run`).
- ✅ Type-check clean: **0 errors** (`vue-tsc`).
- ✅ `en.json` valid, canonical, and every `t()` key referenced by
changed files resolves (only 3 pre-existing dangling keys remain,
unrelated to this change).
## Notes
- 3 **pre-existing** broken key refs surfaced during validation but
predate this work and were left alone: `common.loading` (AiToolsets),
`search.vrlOnlyForTable` (SearchBar),
`settings.correlation.identityConfigNoSets` (ServiceIdentitySetup).
- An unrelated pre-existing local change to `OIcon.icons.ts` was
intentionally excluded from this PR.
## What
Adds an **Agent Graph** to AI Observability that renders AI agents,
tools, and models as first-class nodes in the service graph — unified
with the real downstream services an agent talks to. It answers "what
does my agent actually interact with?" (tools, models, services,
datastores) instead of showing GenAI spans in isolation.
## Why
LLM Insights shows plenty of per-call data but no picture of the flow —
where a request starts, where the agent sits, and everything it fans out
to. This surfaces the full agent topology, works across every framework
OpenObserve documents, and correctly attributes tools/models to the
owning agent even when the instrumentation nests them several spans
deep.
## Backend (`src/core`)
- **Agent/tool/model edge families in the service-graph processor**
(`traces/service_graph/processor.rs`): derives `service → agent`, `agent
→ model`, `agent → tool` edges from the canonical `gen_ai_*` columns,
keyed on column presence (open operation-name vocabulary), schema-gated
so streams missing a column don't error.
- **Multi-level agent inheritance.** The owning agent name usually lives
on an *ancestor* span (real Google ADK nests `generate_content(agent) →
chat → execute_tool`), not on the tool/LLM span. The `from` climbs the
parent chain up to `AGENT_INHERIT_DEPTH` (4) levels —
`COALESCE(child.agent, p1…pN, service_name)` + chained ancestor `LEFT
JOIN`s — taking the nearest ancestor with an agent name. Extracted
`build_agent_or_service` / `build_ancestor_joins` as pure, unit-tested
helpers. (Recursive CTE was tried first but panics in the search engine;
a bounded chained join is supported and shallow-tree-cheap.)
- **Extractor coverage** for the framework conventions OpenObserve
documents (gen_ai semconv, OpenInference, traceloop, CrewAI, Google ADK,
LangChain, Langfuse): `is_llm_trace` gate now admits
OpenInference/framework agent+tool spans; `extract_tool_name` reads
`tool.name` / `langchain.tool.name` / `crewai.task.tools` / Langfuse
metadata; new attribute constants. All normalize into the canonical
`gen_ai.*` columns at ingest, so the query layer reads one column set
regardless of vendor.
## Frontend (`web/src`)
- **Standalone Agent Graph page** under AI Observability
(`AgentGraphPage.vue`) with Stream/Agent selectors and a DateTime
picker, matching the LLM Insights UX; route + rail item + i18n.
- **Graph rendering** (`convertTraceData.ts`, `ServiceGraph.vue`):
agent/tool/model node kinds, vendor brand logos on model nodes (reusing
the AI-integrations catalog logos), an animated "radar-ping" halo baked
into the agent symbol SVG so it stays centered and pans/zooms with the
node, and a layered-layout depth fix so `service < agent < model/tool`
renders correctly. Agent highlighting is gated to the Agent Graph page
(the Service Graph tab is unchanged).
- **Side-panel caller consistency** (`ServiceGraphNodeSidePanel.vue`):
the Operations table now resolves a tool/model node's caller with the
*same* ancestor-agent climb the graph edge uses, so the panel and graph
agree (previously the panel showed the host app as the caller).
Refactored the identity-field builder to emit aliased columns directly
(no fragile regex post-processing).
- **Design tokens** (`theme.ts`): added `cssToken(name, fallback)` to
resolve `--color-*` tokens at runtime for chart/SVG contexts (where
`var(--x)` can't be used), and routed the agent-accent + exact-match
health colors through it instead of hardcoded hex.
## Consistency
Service classification is mirrored TS ⇄ Rust; the shared
`classification_cases.json` vector (both copies kept identical) drives
unit tests in both languages.
## Testing
- Rust: multi-level inheritance + edge-attribution unit tests; `cargo
build` clean.
- Frontend: new specs for the graph conversion, layout,
model-vendor-logo, side-panel caller climb, and `cssToken`; full traces
suite green.
- Verified end-to-end on real production traces (SRE agent) and on
synthetic fixtures for CrewAI / OpenAI-Agents / Google ADK /
genai-semconv / LangChain — every framework attributes agent →
model/tool correctly.
## Notes
- Topology is read from the pre-aggregated `_o2_service_graph` stream
(the hourly incremental job), so it scales to large trace volumes — no
per-load raw-trace scan.
- The enterprise `classify_entity` / `build_topology` changes live in
the o2-enterprise repo and are required for the enterprise build.
## Problem
Opening the Streams list → filtering to a non-logs tab
(Metrics/Traces/Enrichment) → clicking the explore icon on a row lands
the user in the **Logs explorer** with that stream type. The only way
back to Logs was an icon-only ghost button in the sidebar that:
- rendered the **current** stream type's icon (e.g. bar-chart for
metrics), so it read as a passive *label*, not an action;
- exposed its meaning only via a tooltip;
- did the *opposite* of the icon shown (icon = where you are, click =
leave).
Meanwhile the prominent, labeled **Reset** button doesn't reset stream
type, so it's a decoy. Result: users get stranded in the Logs explorer
showing a non-logs stream (this was hit during DevRel testing).
## Fix
Replace the ambiguous badge with a clear **`swap-horiz` (⇄) switcher
icon** button, separated from the stream picker by a gap, with an
action-first **"Switch back to Logs"** tooltip.
**Behavior is unchanged** — the control is still gated on `streamType
!== 'logs'`, and one click still calls `onStreamTypeChange('logs')`. No
routing, service, or store changes.
### Before / After
| | |
|---|---|
| Before | ambiguous type-icon badge, tooltip-only meaning, cramped |
| After | `⇄` switcher icon, gap before the picker, action-first tooltip
|
## Housekeeping in the same file
- Migrated the banned legacy `--o2-border` token → `border-default`
design token.
- Removed the now-unused `streamTypes` / `streamTypeIcon` /
`streamTypeLabel` helpers.
## Tests
- Added focused unit tests in `IndexList.spec.ts`: control hidden when
type is logs, shown for non-logs, uses the `swap-horiz` icon, and click
still triggers `onStreamTypeChange('logs')`.
- Full `IndexList.spec.ts` suite passes (125 passed).
## Scope
One component (`web/src/plugins/logs/IndexList.vue`) + one i18n key.
Pure OSS frontend — no backend / enterprise gating.
| Metric | OLD | NEW | Improvement |
|------------------------------------------|----------|----------|------------------------|
| Blank screen until first content (FCP) | 15.0 s | 9.1 s | −5.9 s (≈40%
faster) |
| Page usable (LCP) | 16.2 s | 10.3 s | −5.9 s |
| Main bundle download done | 13.8 s | 7.5 s | −6.3 s |
| Data downloaded | 2,258 KB | 1,213 KB | −1,045 KB (~half) |
## Problem
All 12 locale files were statically imported into `locales/index.ts` and
spread
into a single `messages` object. Static imports get inlined, so the
entire set of
translations landed in the entry chunk — every user downloaded and
parsed all 12
languages in order to use 1.
## Fix
Only the `en-gb` fallback stays bundled. The other 11 are code-split via
`import.meta.glob` and fetched on demand; `main.ts` loads the active
locale
before mounting, falling back to the bundled `en-gb` if that chunk
fails.
## Impact (measured — prod builds of `main` vs this branch)
| `index.js` | main | this PR | delta |
|---|---|---|---|
| raw | 7,335.9 KB | 4,276.0 KB | **−3,059.9 KB (−41.7%)** |
| brotli | 1,468.2 KB | 874.6 KB | **−593.6 KB (−40.4%)** |
- **English users** (most traffic): −594 KB transfer, ~3 MB less JSON to
parse
before first render, no extra request.
- **Non-English users**: same entry saving, plus one cacheable ~71 KB
(brotli)
locale chunk — net ~−523 KB.
The parse-time saving matters more than transfer: decode cost scales
with
decoded size, not compressed size, and it blocks first render.
## Also
- Removed `hi.json` — unreachable since #1857 (absent from the language
picker
and `localeFileMap`, referenced nowhere) and incomplete: 658 keys
missing vs
`en.json`, 1,905 of 5,280 still holding the English source string.
- Added guard tests asserting the locale registries stay in sync — a
file with no
mapping, a mapping with no file, and a locale missing from the
number-format
map all now fail CI. Each was verified to fail against an injected
regression.
- `GeneralDangerZone.spec.ts` set the i18n locale directly and relied on
all
locales being preloaded. It now awaits `loadLocaleMessages()` first,
mirroring
`main.ts`. Assertions unchanged — it still catches keys missing from a
translation (#13092).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What changed
- **Traces duration filter fix**: Centralized SQL parser loading into a
shared module-level singleton (`tracesParser` / `loadTracesParser` in
`useTraces.ts`) instead of each child component (`IndexList.vue`,
`SearchBar.vue`, `TracesMetricsDashboard.vue`) loading its own parser
instance. `Index.vue` now calls `loadTracesParser()` once in
`onBeforeMount` and all consumers use the shared `tracesParser` ref.
- **SearchBar.vue cleanup**: Removed unused local `parser` variable and
orphaned `importSqlParser` / `onBeforeUnmount` hook; applied Prettier
formatting pass.
- **TracesMetricsDashboard.vue cleanup**: Removed unused `useParser`
import.
## Why
Duration filters were intermittently failing because `SearchBar.vue`,
`IndexList.vue`, and `TracesMetricsDashboard.vue` each loaded their own
WASM SQL parser instance in `onMounted`. The parser wasn't guaranteed to
be ready when `parseDurationWhereClause` (called from `Index.vue`'s
`buildSearch`) ran — particularly on fast navigations or when
`loadDashboard` triggered before the child component's parser finished
loading.
---------
Co-authored-by: Claude <noreply@anthropic.com>
## Summary
- add a dedicated promql workspace crate for parsing and evaluation
- keep cluster routing, cache, WAL, file-list, and request orchestration
in openobserve-core
- preserve existing service::promql call sites through a compatibility
facade; the facade re-exports an explicit item list (not a glob), and
the promql search orchestration in core depends on the promql crate
directly
- inject enterprise query cancellation through TableProvider instead of
coupling the evaluator to core search state
- replace the core regex-UDF dependency with DataFusion regexp_like
expressions
## Companion PR
- openobserve/o2-enterprise#2198 updates the enterprise workspace
override for the new promql crate
## Validation
- cargo fmt --all -- --check
- cargo check --workspace
- cargo clippy -p promql --all-targets -- -D warnings
- cargo clippy -p openobserve-core
- cargo test -p promql: 363 passed
- verified the promql dependency tree has no openobserve-core or search
dependency
- cargo check -p o2_enterprise --lib in the paired private workspace
## Notes
The evaluator moves atomically in this PR. The existing
service::promql::search subtree remains in core because it owns
OpenObserve-specific orchestration rather than PromQL evaluation.
The two web/ file changes only update source-path references in comments
that pointed at the moved Rust files (src/service/promql/... ->
src/promql/src/...).
## Problem
In an **OSS local build on latest main**, clicking **Settings → GenAI
Agent Mapping** shows `Request failed with status code 404`.
## Root cause
GenAI Agent Mapping is an **enterprise-only** feature. The backend
routes are registered only behind `#[cfg(feature = "enterprise")]` in
`src/handler/http/router/mod.rs`, so in OSS builds `GET
/api/{org}/settings/gen_ai/agent_mapping` is not registered and axum
returns 404.
The frontend, however, exposed the feature unconditionally:
- The settings hub tile in `web/src/components/settings/index.vue` had
**no `visible` gate** (defaults to shown).
- The route in `web/src/composables/shared/useManagementRoutes.ts` was
added to the **base** routes array rather than the enterprise/cloud
block.
So OSS users could open the page, which fires the enterprise-only API on
mount → 404. The backend 404 is correct; the defect is purely frontend
gating.
## Fix
Gate both the tile and the route exactly like the sibling **LLM
Providers** feature:
- `index.vue`: `visible: (isEnt || isCloud) && !!z.online_evals_enabled`
- `useManagementRoutes.ts`: move the `genAiAgentMapping` route into the
`if (isEnterprise || isCloud)` block.
Backend behavior is unchanged — the feature stays enterprise/cloud-only.
## Tests
- Updated `useManagementRoutes.spec.ts`: OSS base-route count 7 → 6,
corrected comments, and added an assertion that the `genAiAgentMapping`
route is absent in OSS builds.
- Enterprise/cloud/combined totals (18 / 9 / 19) are unchanged — the
route only moved from the base array into the gated block.
- `npx vitest run src/composables/shared/useManagementRoutes.spec.ts` →
**72/72 passing**.
## Note for reviewer
The tile is gated on `online_evals_enabled` (matching LLM Providers), so
it won't appear on enterprise/cloud unless online evals are enabled. If
GenAI Agent Mapping should be independent of that flag, say the word and
I'll drop it to `(isEnt || isCloud)`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
A pass of UI-consistency and empty-state fixes across the AI
Observability, Logs, and Settings surfaces. Brings several one-off /
hand-rolled UI patterns in line with the app's shared components
(`OEmptyState`, `OTable` selection, badge groups).
## Changes
| Area | Change | Files |
|---|---|---|
| LLM Model Pricing | Replaced the small faded `$` icon empty state with
the standard `OEmptyState` illustration; added `no-model-pricing` preset
and made it filter-aware (no-results + Clear filters vs. first-run card)
| `ModelPricingList.vue`, `presets.ts`, `en.json`, `hi.json` |
| Scorers list | Moved the "no LLM providers" empty state into the
table's `#empty` slot so the search bar + column header stay visible
(was hiding the whole table) | `ScorerList.vue` |
| Correlation → Traces | "No Traces Found" now uses `OEmptyState` with
the `trace` illustration instead of a small icon + text (both embedded
and drawer paths) | `TelemetryCorrelationDashboard.vue` |
| Logs "No events found" | Added `h-full` so the empty state centers
vertically in the results panel instead of sitting near the top |
`Index.vue` |
| Eval Jobs | Added multi-select checkboxes + bulk **Delete** action
with confirm dialog; selection pruned after reload | `EvalJobList.vue`,
`OnlineEvals.vue`, `en.json`, `hi.json` |
| Score Config → "Not declared" chip | Cramped custom `xs` badge →
default `sm`/`pill` sizing, matching sibling `Categorical` / `Active`
chips | `badgeGroups.ts` |
| Search History → More Details | Added spacing between the tab strip
and the JSON editor | `SearchHistory.vue` |
---------
Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
## What changed
Fixed a mismatch between the request count displayed on a service graph
node label and the request count shown in the tooltip when hovering over
that node in the tree view.
## Why
In the service graph tree view, node labels use a direction-aware
`value` (e.g., requests from service A → service B), while the tooltip
was reading `node.requests` — the backend's node-level aggregate (sum of
all directions). This caused the two displays to show different numbers
for the same node.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
## Problem
Fixes#7228 (新建函数查询时数据流类型无效 — "Data stream type is invalid when creating
a new function query").
In the function editor's **Run query** test panel, selecting **Traces**
(or **Metrics**) as the stream type still queries **logs**. The
user-selected stream type is ignored.
## Root cause
`TestFunction.vue` → `getResults()` hardcoded `page_type: "logs"` when
calling `searchService.search()`:
```js
searchService.search({
org_identifier: store.state.selectedOrganization.identifier,
query,
page_type: "logs", // ignored the dropdown selection
})
```
`page_type` becomes the `type=` query param in `search.ts`
(`/api/{org}/_search?type=logs&...`), so the request always hit the logs
store regardless of the Stream Type dropdown.
## Fix
Pass the user-selected stream type instead — the same value the rest of
the component already uses for the stream list, field keywords, and
auto-suggest:
```diff
- page_type: "logs",
+ page_type: selectedStream.value.type,
```
`selectedStream.value.type` is typed `"logs" | "metrics" | "traces"` and
is bound to the Stream Type `OSelect`.
## Blast radius (reviewed)
- **Only consumer of the hardcoded value:** `getResults()` (the "Run
query" button), which fetches sample events into the Events panel to
feed the VRL test. Line 636 was the only hardcoded `page_type`/`"logs"`
in the file — every other reference already uses
`selectedStream.value.type`.
- **Traces:** now correctly reaches the `page_type === "traces"` branch
in `search.ts`, which applies the nanosecond-timestamp response patch
(`patchNsFieldsInJson`) — previously never triggered from this panel.
- **Metrics:** SQL search against metrics streams via
`_search?type=metrics` is valid.
- **Not affected:** the toolbar "Test Function" button (runs the VRL
against the events JSON via the jstransform API — independent of stream
type), saving the function, and the multi-stream `_search_multi` branch
(unused here since `query.query.sql` is always a string).
- **Tests:** `TestFunction.spec.js` / `.spec.ts` mock
`searchService.search` and don't assert `page_type`, so no test changes
are required.
## How to verify
1. **Pipelines → Functions → Add Function** → expand the **Query**
section in the test panel.
2. Set **Stream Type → Traces**, pick a traces stream, enter a query,
click **Run query**.
3. Network tab: the request now goes to `POST
/api/{org}/_search?type=traces...` (was `type=logs`) and returns trace
data.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
OSelect's trigger lacked role="combobox", so a focused select wasn't
recognized as an input by the shortcut guard — pressing a single-letter
key (e.g. "s" on logs) fired the page-level shortcut instead of typing
into the select. ODialog's auto-focus also skipped selects entirely and
never re-focused after a body swap (e.g. Create/Update toggle), leaving
focus on a button where keystrokes leaked past the modal.
- OSelect: mark the trigger role="combobox" and add type-to-search so a
focused, closed trigger opens the dropdown and seeds the filter.
- ODialog: auto-focus falls back to a combobox trigger when there's no
text field, re-focuses the first field after body content swaps, and now
swallows any bare printable keydown that doesn't land in an input-like
element so page shortcuts can never fire while the dialog is open,
regardless of what has focus.
design at: cloud/org%20deletion/org-deletion-design.md
## Summary
Self-service cloud org deletion with a **recoverable grace period**,
followed by complete, retryable, auditable hard cleanup.
**Lifecycle:** `active → pending_deletion → deleting → gone`
- **Delete** soft-deletes into `pending_deletion` — data intact, org
hidden from the switcher + all API access blocked, `deleted_at`
recorded. With `grace=0` it deletes immediately (legacy path).
- A **promotion scheduler** flips expired pending orgs to `deleting`
after the grace window; the background **cleanup worker** then tears
down all data across object store, file_list, DB, OpenFGA, and billing —
per-step tracked and retried (`MAX_ATTEMPTS`).
- **Resurrect** (`_meta`/root only) flips `pending_deletion` back to
`active` — no data was ever touched.
## Key changes
- New `org_cleanup_tasks` ledger table +
`organizations.status`/`deleted_at` columns (2 migrations);
`DB_SCHEMA_VERSION` 47→48.
- `OrgStatus::PendingDeletion` cache + coordinator events + watch;
`is_blocked` middleware hides & blocks pending/deleting orgs.
- Grace window from enterprise `O2_ORG_DELETION_GRACE_PERIOD_DAYS`
(default 30), read from OSS behind `#[cfg]`.
- Cleanup worker + promotion scheduler run on **compactor** nodes;
dist-lock + atomic CAS make execution exactly-once cluster-wide. Task
rows purged immediately on hard-delete completion and on resurrect (no
retention sweep).
- Audit-trail entry to `_meta` on every status transition
(fire-and-forget).
- Resurrect endpoint (dual-impl, cloud-gated) + list surfaces
`deleted_at`/grace days.
- UI: pending-deletion badge + countdown + resurrect action,
deletion-progress dialog, Danger Zone restyle with type-to-confirm.
## RBAC
In-handler role checks (no OFGA middleware): **delete** = root OR org
Admin; **resurrect** = root only via `_meta`.
## Companion PR
Enterprise side: `openobserve/o2-enterprise` #2128 (config + ofga +
cloud billing).
## Testing
Unit suites pass; full flow live-verified end-to-end (soft-delete →
resurrect with data intact, grace=0 immediate delete, audit entries,
RBAC 403s).
> Requires ≥1 compactor node running for deletions to progress (always
true for single-binary / `ZO_NODE_ROLE=all`).