Addresses the mechanism half of #13966 — auto-translations were
context-blind because `translate_batch()` sent bare string arrays with a
one-line system prompt.
## What changed
- **Key-path context**: each string is now sent as `{"key":
"alerts.incidents.statusOpen", "text": "Open"}` and the prompt tells the
model to use the key to disambiguate ("Open" there is an incident
status, not the verb). Keys are context only and never echoed into the
output; the response format is unchanged.
- **Product background + `TERMS` glossary** (`translator.py`): the
prompt now introduces OpenObserve and carries one term table — an
English `sense` line per ambiguous term that steers all 14 languages at
once (an "event" is a record, not a party; a "stream" is a data stream,
not a brook; "live" is real-time, not a broadcast), plus optional
per-locale pinned renderings (zh-CN/zh-TW seeded: event → 事件, stream →
数据流, live → 实时, trace → 追踪, report → 报表, …).
- **Module vocabulary mined from the locale itself**:
`build_module_glossary()` extracts the committed `menu.*` translations
(Alerts → 告警, Pipelines → 流水线, Dashboards → 仪表盘) and merges them with
the pinned terms into a single conflict-free renderings table — pinned
terms win, so the prompt can never mandate two renderings for one word.
Works for every locale with no hand-maintained tables.
- The system prompt is built once per locale and is byte-stable (sorted
renderings), so DeepSeek's prefix caching keeps the added prompt tokens
cheap. Change detection, validation, batching/split-retry, and the state
file are untouched.
## Testing
- Stubbed the API client and verified: payload shape, batch/index
alignment (string + array leaves), failed-item isolation via split
retry, prompt assembly for locales with and without pinned terms,
byte-stability of the prompt, and the pinned-vs-module dedup (`Streams`
module entry absorbed by pinned `stream`).
- `python3 main.py --check` (the pure comparison the merge-queue gate
runs) is unaffected.
## Not in this PR
Existing wrong translations are not re-sent (state hashes unchanged);
the backfill pass and the bootstrap-run gap are tracked as follow-ups in
#13966.
Fixes#3764
Supersedes #13195 (stale base, unsigned CLA, and non-rendering — see
Units below). The event-parsing and exception-detection approach from
that PR is reused, credited via `Co-authored-by`.
## What this adds
- **Trace waterfall** — a marker per span event, positioned against the
trace window alongside the span bar. Clicking one selects its span.
- **Sidebar mini-timeline** — the same events re-plotted against the
*span's own* duration, above the Events tab. Answers "where within the
span did this fire?", which the waterfall can't show for short spans and
the table only shows as absolute timestamps. Clicking a marker expands
the matching row.
- **Exceptions stand out** — OTel semconv detection (`exception` name or
any flattened `exception.*` attribute) renders in the error token; other
events in amber. Both are existing registered tokens, so theming is
automatic and no new tokens were added.
Issue #3764 asks for "dots for events **and** exceptions", so all events
are marked with exceptions emphasised, rather than filtering to
exceptions only.
## Units — why the previous attempt rendered nothing
A span event's `_timestamp` is raw OTLP `time_unix_nano`
(**nanoseconds**), stored unconverted:
Frontend span timing is **microseconds**. The same field name therefore
means different units on a span than on its events. #13195 compared the
two directly, so every marker landed ~1000x outside the window and was
silently dropped by the range filter — matching the "I couldn't see the
UI changes" report on that PR. The `ns → µs` conversion is now confined
to one composable and pinned by a regression test using nanosecond
fixtures.
Corroboration that events are nanoseconds: the sidebar's own events
table already divides the same field by `1e6` to render milliseconds.
## Structure
`web/src/composables/traces/useSpanEvents.ts` owns event semantics —
parse (array or JSON string), convert, classify — while each caller
supplies its own window. The two surfaces differ only in that window.
Events with no usable timestamp, or outside the window, are skipped
rather than pinned to an invented position.
## Verification
- `npm run type-check` — clean
- ESLint on changed files — 0 errors, 0 warnings
- `prettier --check` — conforms
- Vitest — **184 passed**: 27 new (`useSpanEvents.spec.ts`), 7 new
(`SpanBlock.spec.ts`), 7 new (`TraceDetailsSidebar.spec.ts`), rest
pre-existing
Pre-existing and untouched: three `no-dupe-keys` errors in
`eslint.config.js`, and a `vue/no-dupe-keys` warning for `activeTab` in
`TraceDetailsSidebar.vue` (confirmed present on unmodified `main`).
## Reviewer notes
- **Not yet validated against a live ingested trace.** That step is what
would have caught #13195, and it is the one gap here.
- Specifically worth checking: `gen_ai.*` events are constructed with
`_timestamp: 0` in the OTel processor's test fixtures. If real SDK data
does the same, LLM spans will show no markers (dropped rather than
mispositioned, by design). If that turns out to be real, the follow-up
is an explicit "N events without timestamps" affordance, not a
fabricated position.
- Marker clustering for very dense event sets is deliberately deferred
until real traces show it is needed.
---------
Co-authored-by: coyaSONG <66289470+coyaSONG@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Design At: designs/dashboards/interactive-table-cell-drilldown
What changed
New component: DashboardLogDrawer.vue
Clicking a cell in a dashboard table panel now opens a side drawer
scoped to that field/value filter. The drawer shows filtered log events
with a SQL editor (Ctrl+Enter to run, Reset to restore default),
server-side pagination with configurable page size (50/100/200/500), and
an event detail sub-drawer on row click.
Event detail has three tabs:
Insights — Field Anomaly Profile (rarity scoring vs current page), Event
Timeline (SVG histogram of event frequency across the selected time
range), Log Patterns (grouped by structure via /patterns/extract, each
with "Open in Logs" that prefills the log explorer), and Surrounding
Events (events from the same stream in a configurable ±1–10 min window,
paginated 20/page with per-row expand)
Details — key/value field list with copy buttons
JSON — raw event JSON
Modified: PanelSchemaRenderer.vue
Fixed type mismatch: route.query.cell_panel returns string; panel schema
IDs may be number — comparison now uses String() on both sides
Added cell_event_ts to the URL param cleanup list so it's cleared when
the outer log drawer closes
Modified: en-US.json — added complete panel.logExplorer key tree
including the new insights subsection
Modified: design-debt-baseline.json — updated baseline after audit (new
file's rawVarInComponent uses are all in sanctioned color-mix()
positions)
URL persistence
All drawer state is encoded in URL params (cell_panel, cell_field,
cell_value, cell_stream, cell_stype, cell_t0, cell_t1, cell_event_ts).
Sharing the URL reopens both drawers and restores the exact event — on
mount it does a WHERE _timestamp = {ts} targeted fetch (size 1) rather
than searching through paginated results.
Zero backend changes
Uses only existing endpoints: search (SQL), search (histogram via
histogram() function), and patterns/extract.
---------
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
Co-authored-by: ktx-abhay <abhay.padamani@kiara.tech>
Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
## Problem
Four defects around traces and the trace↔log correlation flows:
1. **Trace Graph is clipped.** The service map renders inside a 200px
band, vertically centred in an otherwise empty pane, instead of using
the available height.
2. **A span row's View Logs goes nowhere.** In OSS builds it navigates
to `/logs?stream=&from=NaN&to=NaN`; the logs page drops the filter and
falls back to its default window, so the user gets an unfiltered result
set instead of that span's logs.
3. **The log detail drawer has no View Trace button.** Opening the trace
for a log record only works from the inline expanded row.
4. **View Trace lived inside the JSON tab**, so it disappeared when
switching to Table.
## Root causes
**Trace Graph.** The ancestor chain was already correct — the sibling
Waterfall tab fills the same box. Only the leaf was clamped: the chart
carried `h-50! min-h-50!` (12.5rem, `!important`), which beat its own
root `h-full w-full`. That clamp dates to when the map lived in a small
collapsible timeline strip, where 200px was the intended design. When it
was promoted to a full-height tab, the scoped rule `.trace-details
.trace-chart-height { height: 12.5rem }` was left behind and its
two-class specificity silently defeated the `h-full!` added at the time.
The later design-token migration deleted the rule but preserved the
rendered result by translating it into utilities, which made the clamp
look deliberate.
**Span View Logs.** `buildQueryDetails()` reads the raw API shape
(`start_time`/`end_time`, nanoseconds), but the waterfall passes
formatted nodes, which `getFormattedSpan()` rebuilds as a fresh object
literal — camelCase, times as `startTimeUs`/`endTimeUs` in microseconds,
snake_case originals dropped. Reading `start_time` off one yields
`undefined`, hence `NaN`. The helper already had a `spanId || span_id`
fallback for the id, which is why the query string was correct while the
window was not.
**Drawer View Trace.** `JsonPreview` decides whether to offer the action
once, in `onBeforeMount`. `DetailTable` fills `rowData` in an `async
created()` — the `await` defers assignment past the child's mount even
though the work is synchronous — so the preview mounted with an empty
record, the check failed, the traces streams were never fetched, and
nothing recomputed it.
## Changes
- Trace Graph chart uses `h-full`; the orphaned `trace-chart-height`
class is dropped (its rule no longer exists) and two nested wrappers
collapse into one. Matches the full-bleed pattern in `ServiceGraph.vue`.
- The span row resolves the raw span from `spanMap` before calling the
helper — the same lookup this component already does for span data, HTTP
status and events — rather than teaching the shared helper to accept two
shapes. Guarded, since a span missing `span_id` gets a synthetic id
absent from the map.
- The span row also warns instead of navigating when no logs stream is
selected. The header's own View Logs button is disabled in that state
but the row-level one is not, so it was a silent dead end.
- `JsonPreview` re-evaluates the View Trace gate when the previewed
record changes, so the flag tracks its input rather than a single
snapshot.
- The action moves to the drawer's header row beside the wrap toggle,
reachable from every tab, with `hide-view-trace` on `JsonPreview`
(mirroring the existing `hide-view-related`). The gate, stream fetch and
filter move into `useViewTraceAction` rather than being copied into
`DetailTable` — `service_streams_enabled` is already hand-rolled in
three files and drifted. `JsonPreview` keeps exposing the same names, so
its specs are untouched.
Two latent issues surfaced while moving the action:
- `hiddenMenus` is seeded as an array and only becomes a `Set` once
`MainLayout` has read `custom_hide_menus`, so `.has()` is not always
available. Both shapes are now accepted.
- `OSelect`'s root sets `w-full`, which won a plain `w-40` and pushed
the button outside the drawer — the same class-collision shape as defect
1. Pinned with `w-40!`.
## Tests
No behaviour is asserted by new specs; existing suites cover the touched
paths and pass unmodified:
- `JsonPreview.spec.ts` — 91 passed (unchanged, including the assertions
on `tracesStreams`)
- `DetailTable.spec.ts` — 70 passed, 3 skipped
- `TraceTree.spec.ts`, `useTraces.spec.ts`,
`TraceDetailsSidebar.spec.ts` — 297 passed
- `TraceDetails` suites — 234 passed
- `SearchResult.viewTrace.spec.ts`,
`CorrelatedLogsTable.viewTrace.spec.ts` — passed
Prettier and ESLint are clean on every touched file.
## Verification
Checked against live trace and log data sharing a trace id:
- Trace Graph fills its pane (measured 549px where it previously
measured 200px), survives a tab round-trip, and reflows on window
resize.
- A span row's View Logs lands on `/logs` with the span+trace filter and
a ±60s window whose bounds match the span's real timestamps exactly;
with no stream selected it warns and does not navigate.
- The drawer's header shows the picker and button on both the JSON and
Table tabs with no overflow, and opens the trace with the correct id and
a ±15 min window.
- Console clean across every navigation.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## What
Makes the evidence a synthetic browser check captures readable **where
you already are**, instead of one tab away.
Two related changes on the run-detail view:
**1. An evidence row opens to its full record.** The table renders three
fields the probe already captures and nothing displayed: the stack trace
on a page error, the resource type, and the first-party flag. Plus the
full URL — the row strips the host and then truncates, so a login URL
died mid-query-string with no way to see the rest.
**2. The step expansion's "Page activity" card now hosts that same
table.** It used to show five severity-ranked rows and a `5 of 136`
count — a card whose headline metric was its own incompleteness. It now
renders the step's whole bucket with the same header, sorting, row
expansion, filters, wrap and paging the Evidence tab has.
Supporting work: the filter row is extracted into one component shared
by both surfaces, so they cannot drift onto different controls; and the
Evidence tab's step scope is a select you can change rather than a
banner you can only dismiss.
## Why
Every question past "was there anything?" cost a tab switch, and the
correlation you arrived with — this step, this failure — had to be
rebuilt by hand against a step filter.
## Notable fixes found along the way
- **The Step column was sortable but sorted nothing.** It had no
accessor, so the comparator read a field the row does not have, compared
`undefined` to `undefined`, and left every row in place while the header
showed a working arrow.
- **The step expansion's content column had no width clamp.** As a flex
item it kept its content's intrinsic width, so a long URL pushed it past
the container and every card in it overflowed — and `truncate` and
`overflow-x-auto` both went inert, since each needs a bounded parent.
Measured at 1487px inside a 1000px container.
- **Expand chevrons did not line up.** The row status rail's class also
insets the first cell, so rows without a rail sat half a rem left.
Benign rows now reserve the slot transparently.
- **"Took" was never right-aligned** — the class sat on an inline span,
where it aligns nothing.
## Testing
`components/synthetics/`, `views/synthetics/`,
`composables/synthetics/`, `utils/synthetics/` — **1398 passing**.
`type-check`, `lint:design:strict` and `format:check` all clean.
New behavioural coverage where it was thin: sorting is driven through
the header and asserted on rendered row order rather than on column
config, which is what let the non-functional Step sort ship.
## Reviewer notes
- `EvidenceEvents.vue` is shared by both surfaces. The only differences
left are the Step column and the page size — everything else is
deliberately identical.
- The step select is gated on being passed options, which is how it
stays off the step card, where the step is already the scope. A test on
each side pins that.
- The per-step card's five-row cap is deliberately removed; pagination
bounds the height instead, which bounds the card *and* keeps every event
reachable in place.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
> ### ⚠️ This PR carries four changes — review alongside
openobserve/o2-enterprise#2420
>
> 1. **Super-cluster support (consumer half)** — `8ceb041c43 …
5885c57b3a`.
> 2. **Synthetics OSS migration** — `3a380087a3 … 2596300dbf`, nine
commits — one per build-order step, plus a ninth for fixes found by
running the binary. Moves synthetics out of `o2_enterprise` into
`src/synthetics` (`openobserve-synthetics`), leaving only the
private-VPC-agent path enterprise: `job_api::lease`, `agent/*`, private
locations and the agent-setup install flow. Paired with
openobserve/o2-enterprise#2420.
>
> 3. **Last-check columns read from the results stream** — `6af411e808`,
`e7a3f2c759`. Outside the region whose scheduler runs a check, the list
showed a stale status and a blank Last check / response time:
`last_check_status` replicates only on change, `last_triggered_at` is a
runtime column that does not replicate, and `last_response_ms` was never
populated anywhere. The `synthetics_results` stream already carries all
three and is federated, so the list API reads it instead of replicating
the columns — no queue traffic, no schema change. Best-effort: any miss
keeps the stored DB values, so search being unavailable cannot take the
list down. OSS-only; no enterprise counterpart.
>
> 4. **Check secrets are readable in every region** — `d47a6b2345` (+
o2-enterprise `da71d7ec`). The per-org DEK is minted by each region for
itself, so a check created outside the scheduler region replicated fine
and then silently never ran — `resolve` could not decrypt its
credentials (o2-enterprise#2451). Secrets now cross the queue **in
clear** and the applier re-encrypts them under its own DEK on arrival,
fixing the class at the replication boundary with no key to distribute
or keep in sync.
>
> ⚠️ **This puts customer secrets in plaintext on the inter-region bus**
— a deliberate, recorded trade-off (`o2oi_` ingest tokens and probe
tokens already travel it in clear). Securing the bus itself (TLS on the
gateway links, short retention on secret-carrying subjects) is the real
fix and is **not** in this PR.
>
> ⚠️ **Deploy consumers before producers.** The payload's bytes did not
change, only the meaning of the secret strings, so an un-upgraded
consumer handed the new payload would write customer passwords to disk
in clear. The plaintext-carrying messages therefore use new variant
names (old ones retired as `CreateEncrypted`/`UpdateEncrypted`), so an
old region fails serde and redelivers until upgraded instead of silently
persisting plaintext.
>
> An OSS build can now create, schedule, dispatch to Lambda, take
`resolve`/`ack` and report a check. 7 393 lines moved into the new
crate; 679 lines stay enterprise.
>
> **🛑 Deployment prerequisite (2 of 2).** `ZO_SYNTHETICS_ENCRYPTION_KEY`
must be set to the **same** base64 64-byte value in every region of a
super cluster. Without it synthetics falls back to the per-org DEK and
checks carrying secrets silently fail to run outside the scheduler
region. It is read once at startup and pinned — changing it later
requires a restart, and changing it at all orphans every secret already
encrypted under the old value.
>
> **🛑 Deployment prerequisite (1 of 2).** 14 `O2_SYNTHETICS_*` env vars
are hard-renamed to `ZO_SYNTHETICS_*` with **no fallback read**, and
`ZO_SYNTHETICS_ENABLED` defaults to `false`. A deployment still setting
the old names does not lose tuning — **synthetics stops running
entirely, and the first symptom is checks silently not firing.** Update
env vars *before* rolling the binary.
>
> #### Things a reviewer should look at first
>
> - **`super_cluster_queue` does not depend on
`openobserve-synthetics`** (`cargo tree` → 0), which is what keeps the
applier's loop-prevention guarantee a property of the crate graph rather
than a convention. `a7d5fdd969` adds the assertion that fires if that
ever changes.
> - **`job_upload` was skipping authorization in OSS builds**
(`4c3a0b8f0d`). Unreachable before, because no synthetics route was
registered in OSS; registering it would have made it an unauthenticated
artifact upload. `authorize_probe` is ungated and the bypass is gone.
> - **The dependency edge had to reverse in a single commit**
(`3247d4093f`). Cargo rejects an `o2_enterprise` ↔ OSS package cycle at
the manifest level — `optional` and feature gating do not help — so the
two edges could never coexist, and everything needed for the reversal
lands together. This is why the spec's steps 4 and 5 are one commit and
the config moved earlier than planned.
>
> #### Accepted OSS limitations, all documented in the commits
>
> - No notifications: `notify_check_result` ends in
`alerts::alert::dispatch_notification`, which is enterprise. An OSS
build runs the check and records the run; it has nowhere to send an
alert, exactly as for alerts themselves.
> - No per-resource RBAC on synthetics, and no OpenFGA ownership tuples
— both are enterprise capabilities across the platform.
> - Private locations are refused with a message rather than created.
> - Agent-served public `net-*` pools are unavailable, so an OSS user
needs a Lambda for every public pool (spec §1).
---
Consumer half of synthetics super-cluster support. **Depends on
openobserve/o2-enterprise#2420** (producer, message types, queue struct)
— and **this side must deploy first**: `MessageType` bytes 83–85 hit a
panicking `unreachable!()` in `From<u8>` on a consumer that does not
know them, which takes down the subscriber task rather than failing one
message.
Based on `fix/lease-claimed-by-2270` (#13785), not `main`, so the diff
is only this work.
## What this fixes
Synthetics writes are not querier routes, so a create lands on ingesters
in whichever region received the request and commits to that region's
Postgres. There is no synthetics topic on the super-cluster queue, so a
check created in a region that runs no scheduler is never seen by the
region that does: the UI shows it enabled, the scheduler has never heard
of it, and nothing reports the gap.
## Commits
| commit | what |
| --- | --- |
| `8ceb041c43` | orphan-detection query + metrics |
| `1c3e10da48` | apply replicated check config |
| `79e6abc052` | apply replicated locations and probe tokens |
| `16d4fa0fde` | render unknown live status instead of a false pending |
| `5885c57b3a` | share the job-cluster predicate |
## Design notes worth reviewing
**Config columns only, and the list is not restated.**
`synthetics_checks::update` rebuilds its `ActiveModel` from the row
already in this region, and `update_mutable_fields` writes only
user-editable columns — so `next_run_at` and the alerting counters keep
local values for free. Hand-listing them in the consumer would duplicate
a list that already exists and let the two drift. A test asserts every
runtime column comes back `Unchanged`.
**`put()` is deliberately unused.** Its insert branch builds from
`build_active_model`, which ends in `..Default::default()` and never
assigns `owner`, `tz_offset` or `next_run_at` — a replicated create
through it would drop the owner and fire a scheduled-later check
immediately.
**`create()` gains `use_given_id`**, matching `table::alerts::create`.
It generated its own uuid, which would have made every region invent a
different primary key for the same check.
**Consumers apply through `infra::table` only.** The publish lives one
layer up in the enterprise service, and `infra` cannot reach the
enterprise crate — that layering is what stops a region re-broadcasting
what it just applied. `source_cluster` does not cover this: it only
stops a region re-consuming its *own* message. Each consumer greps its
own source to keep this true.
**Runtime tables are never replicated** — `synthetics_jobs`,
`synthetics_runs`, `synthetics_agents`, and the locations
`down_notified_at` claim. Asserted at both producer and consumer.
## Known gaps, named not hidden
- If two regions mint a token (or location) with the same name for one
org before either message crosses, the UNIQUE constraint rejects the
arriving row. Local row wins, logged loudly. Erroring would redeliver to
dead-letter, which is worse.
- Pre-existing and untouched:
`write_triggers_stream`/`write_results_stream` in the enterprise reaper
post to `_meta` with the *org's* ingest token, which 401s, and neither
checks response status. Orphan detection fixes this in its own path
only.
- Pre-existing: `create` never sets `tz_offset`, so a new check gets DB
default 0 until its first edit. Replicates identically everywhere, so it
causes no divergence.
## Spec corrections
The spec (`docs/synthetics-lcl/2026-08-11-p1-3-super-cluster-spec.md`,
local notes) is wrong in five places, all found by reading the tree:
§1's invariant evidence (see the enterprise PR), §10's never-run guard,
§4's column list (and it lists six runtime columns — there are seven,
`alerting` is missing), §3's "private rows read-only when remote", and
§7's claim that non-scheduler regions render OFFLINE (they render
`pending`).
## Verification
infra 42, super_cluster_queue 32, jobs 21, web 111/111 vitest +
type-check + design lint, `cargo fmt --check` and clippy clean, full
`--features enterprise` build.
Pre-existing failures on this branch, unrelated and untouched:
`super_cluster_queue::schemas::tests::test_parse_key_{valid,metrics}`,
`infra::table::ratelimit::tests::test_add_batch_missing_rule_id`, and 8
web vitest failures in `buildPayload.spec.ts`/`payloadContract.spec.ts`.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
## What
Shows where recorded steps will land in a browser journey — as a
labelled divider above the target row, previewed on hover and held for
the whole recording session.
## Why
Each step row carries a Record control meaning "restore the journey up
to here, then record new steps before this row". It was the most
expensive click in the editor — it opens an incognito window and replays
every preceding step — and nothing said where the result would land
until after that had already started.
The only signal was a "Recording here" badge rendered inline in the
anchored row's details cell, between the action icon and the action
label. Sitting inside the row, it read as *this row is recording* when
it means *new steps go above this row*.
## What changed
- The badge is replaced by a divider on the row's top edge: two rule
segments with a centred label between them, straddling the boundary so
it names a position **between** rows rather than a property of one.
- Hovering or focusing a row's Record control draws the same marker in a
lighter tone, so the destination is legible while the click is still
free.
- The preview is suppressed wherever the control is disabled — the first
row, a locked table, or an extension too old to restore. A preview of an
action you cannot take is worse than no preview.
- Focus is bound alongside hover, so the affordance is not mouse-only.
Two edge cases surfaced while testing and are fixed here:
- A replay started from the toolbar locks the table while the pointer
rests on a row control. Nothing moves, so no `mouseleave` arrives, and
the preview sat on a locked table offering a click that was now refused.
- Both render modes share the details cell slot, so an anchor id
reaching results mode drew a marker on a finished run — over a cell with
no positioning to hold it.
## Implementation notes
No `OTable` change. The marker is absolutely positioned against the
details `<td>`, which becomes a containing block via the column's
`meta.cellClass`. Two consequences worth knowing:
- **Nothing reflows.** Toggling the marker on hover repaints only. A
rule that took layout would jitter the whole table as the pointer
crossed the action column.
- **The gap is real, not painted.** The label sits between two `flex-1`
segments rather than over a continuous rule. That centres it without
measuring anything, and means it needs no background to avoid being
struck through.
`getCellStyle` lifts the cell's `overflow: hidden` on the marked row
only, so the label can overhang the boundary. Step-name truncation is
unaffected — that ellipsis lives on the inner wrapper, not the `<td>`.
## Testing
- `JourneySteps.spec.ts`: 43 tests (18 new), covering the committed
marker, the hover and focus preview, suppression on every disabled path,
tone precedence when a committed anchor and a hover coexist, anchor
reassignment, the positioning contract, and results mode.
- Full journey suite green: 336 passed, 1 skipped,
`BrowserJourney.spec.ts` unmodified — which is what shows the marker
rides the existing `anchorId` prop rather than duplicating state.
- Guards were mutation-checked: deleting each one turns the suite red,
so none of them pass vacuously.
- `lint`, `type-check`, `format:check` and
`check-design-consistency.mjs` all clean.
## Not covered
jsdom has no layout, so positioning is pinned by class assertions only —
this has not been verified visually. Worth an eye on the label at the
dense row height, in both themes.
Out of scope, and unchanged by this PR:
- The marker does not survive into recording — `BrowserJourney` replaces
the whole journey table with a capture-only table while recording, so
there is no row to mark.
- The toolbar Record button draws no marker below the last row.
- Downstream steps keep stale pass ticks after a splice, and the passed
banner recomputes its total from the new step count.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Problem
A **successful** AI review shows up as a **red 25-minute CI timeout**.
Two plumbing bugs, both proven from run logs:
**1. Trace-export hang** (OSS #13875)
```
10:19:53 AI Code Review complete ← review posted in 147s
10:19:53 Exporting 14 trace spans to OpenObserve...
10:20:53 Trace export failed: Span processor did not complete within 60000 ms
Error: The operation was canceled ← job killed 25 min later
```
After the comment is posted, `TRACE.flush()` exports OTel spans. If
OpenObserve is slow/unreachable, `forceFlush()` times out and throws, so
`shutdown()` never runs — the `BatchSpanProcessor` timer + exporter
sockets stay open and keep Node alive until the job's 25-min timeout.
**2. Update-404** (OSS #13871)
```
Updating existing review comment #5336607219
Fatal error: gh api ... PATCH ... gh: Not Found (HTTP 404)
```
If the captured review comment is deleted mid-run, the PATCH 404s and
fatal-errors the whole run.
## Fix (plumbing only — review logic untouched)
1. Bound `forceFlush()` **and** `shutdown()` against a 15s deadline
(deadline timer `.unref()`'d), always run `shutdown()` in a `finally`,
and **`process.exit()` explicitly** after flush so lingering OTel
handles can't keep the process alive.
2. On a 404 from the comment PATCH, **fall back to posting a fresh
comment**.
## Verified
- `node --check` passes.
- Runtime repro with an **unreachable trace endpoint**: the process
exits cleanly (code 1) instead of hanging to the 25-min job timeout.
## Review feedback (addressed)
- 🟡 **Flush deadline no longer hardcoded to 15s.** It's now
`Math.max(traceExportTimeoutMs() + 5_000, 15_000)`, so the outer race
sits above the configured `forceFlushTimeoutMillis` — a healthy-but-slow
OpenObserve gets its full `O2_TRACE_EXPORT_TIMEOUT_MS` window (default
60s) and the precise inner timeout fires first. **Behavior note:** an
unreachable endpoint now exits in ~65s at the default config instead of
15s (still well under the 25-min cap); lower
`O2_TRACE_EXPORT_TIMEOUT_MS` for a faster exit.
- 🔵 **Accurate comment-span attribution.**
`updateReviewComment`/`postReviewComment` now return the real `{action,
id}`, so the `github.pr.review_comment` span records `create` + the new
comment id on a 404 fallback instead of the stale `update` + deleted id.
Engine logic in this change is identical across OSS/ENT. (Note: the ENT
branch has since merged the OpenObserve Code Review branding from
`main`, so the full file is no longer byte-identical to OSS — only the
plumbing touched here is.) No change to what the reviewer finds or
decides.
## What & why
Repackages our existing automated PR reviewer under a branded identity —
**OpenObserve Code Reviewer** — and gives its comment a colorful,
attention-grabbing format. This is **purely aesthetic + repackaging**.
> [!IMPORTANT]
> The review **engine is frozen**. The 5 specialist agents, coordinator
judgment/rubric, risk tiering, XML finding contract, re-review
PATCH-in-place logic, total-outage guard, DeepSeek/opencode wiring,
read-only security model, and OTel tracing are all **unchanged**. Only
*who signs the comment* and *how it looks* change.
## The two things that move
**1. Identity (fail-safe).** The workflow mints a GitHub App
installation token via `actions/create-github-app-token`, **gated on the
`OO_REVIEWER_APP_ID` variable**. If the app isn't configured, it skips
and falls back to `github.token` (posts as `github-actions[bot]`,
exactly as today). So this PR is **safe to merge before the app exists**
and auto-activates once the secrets are provisioned.
**2. Presentation.** The coordinator now emits:
- Decision as a native GitHub **callout box** — 🟢 `[!TIP]` approved · 🔵
`[!NOTE]` approved-with-comments · 🟡 `[!WARNING]` minor · 🔴 `[!CAUTION]`
significant.
- Rebranded heading `## 🔎 OpenObserve Code Review`.
- Per-category glyphs 🔒⚡🧩📝📦 on each finding line.
`sanitizeReviewBody` normalizes the heading (tolerates a missing emoji /
legacy heading, idempotent on re-review) and the deterministic fallback
renderer gets the same cosmetic treatment.
## To activate the brand (one-time, org admin)
Register a GitHub App "OpenObserve Code Reviewer", install on this repo,
then set:
- Variable `OO_REVIEWER_APP_ID` (numeric App ID — this is the gate)
- Secret `OO_REVIEWER_APP_PRIVATE_KEY` (the app's `.pem`)
Remove the variable to instantly roll back to `github-actions[bot]`.
## Verification
- JS parses; both workflows valid YAML.
- Engine files byte-identical with the o2-enterprise counterpart PR.
- 6/6 sanitizer heading cases pass (fresh, no-emoji, legacy, preamble,
idempotent, trailing-words).
- This PR's own review comment will preview the new format.
Paired with the identical o2-enterprise PR (same branch name).
## Summary
This PR combines the SLO detail cleanup and the Traces navigation fixes
into one change set.
### SLO cleanup
- remove the internal definition generation value from the SLO detail
title
- remove the unused translation key and update generated locale files
- normalize SLO health icon identifiers
- avoid rewriting unchanged locale files during translation updates
### Traces navigation
- add Spans as the first item in the Traces flyout
- route Spans, Traces, Service Graph, and Service Catalog through
/traces?tab=...
- redirect legacy Service Graph and Service Catalog paths while
preserving query state
- support same-route menu switching and retain stream, time-range, and
query context
- remove the deprecated search_mode parameter from generated and legacy
URLs
- keep Traces as the default for tabless URLs
## Validation
- focused Traces Vitest suites: 436 passed, 2 skipped
- app TypeScript check
- full frontend ESLint check
- Prettier checks
- live browser verification of Traces and Spans menu switching
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>