feat(portal-admin): jaeger deep link on trace_id + actor-pivot on actor_id_hash #166

Merged
julien merged 1 commits from feat/portal-admin-audit-trace-link-and-actor-pivot into main 2026-05-16 03:36:41 +02:00
Owner

Summary

Turns the audit-log table's trace_id and actor_id_hash columns from inert text into the two pivots an investigator actually needs:

  • trace_id → Jaeger deep link (opens in a new tab). Closes the "join audit + traces by trace_id" loop from ADR-0012 / ADR-0013 without any new BFF surface.
  • actor_id_hash → click to refilter the table on that single actor. "Show me everything else this user did" stays in the page; no copy-paste loop.

What lands

Trace-id deep link to Jaeger

audit.html — the cell becomes an <a target="_blank" rel="noopener noreferrer"> pointing at ${environment.jaegerBaseUrl}/trace/<traceId>. Anonymous events (traceId === null) keep the dash placeholder.

environment.ts gains jaegerBaseUrl. Dev defaults to http://localhost:16686 (matches the compose observability profile from infra/local/dev.compose.yml). Per-env replacement picks up whatever trace backend the future infrastructure ADR settles on — Tempo, Grafana Cloud, on-prem Jaeger; the SPA-side wiring doesn't care.

Actor-pivot click

audit.html — non-null actorIdHash becomes a <button> styled to read inline like the hash text (.actor-hash--clickable: button reset + dotted-underline hover + brand-colored focus ring). Click → filterByActor(hash) sets the existing actorIdHash filter signal, resets offset to 0, and re-runs the query. Each pivot still emits its own admin.audit.query audit row server-side (per ADR-0020) so the drill is itself auditable.

Anonymous rows keep the (anonymous) plain-text rendering — there's no useful filter value to pivot on.

Why not inline-expand Pino log lines under the row

Considered, deferred. The BFF's Pino output goes to stdout only today; standing up a queryable log aggregator (Loki, OpenSearch, …) is a separate infrastructure chantier with its own ADR. The Jaeger jump-off carries ~99 % of the investigator's needs anyway — the trace already contains span attributes (db.statement, http.status_code, exception events) for the same request scope; Pino lines on top of that would be redundant for most investigations.

When the log aggregator does land, the inline-expand model can come back as a follow-up: GET /api/admin/logs?traceId=<id> + an expand affordance on the same row. The current Jaeger anchor and the future inline-logs would naturally coexist (different drills, both surfaced on the same trace_id).

Notes for the reviewer

  • Why a <button> for the actor cell rather than an <a>? The action is an in-page filter change, not a navigation. Buttons keep keyboard activation (Enter / Space), don't pollute browser history, and screen readers announce "Filter the table on hash(jane), button" rather than a misleading link role.
  • Why the dotted-underline hover for the actor, but solid-underline for trace? Different affordances. The trace anchor is a permanent link to an external resource (Jaeger UI), so the solid underline matches the universal "link" convention. The actor button is an inline pivot that mutates state — the dotted underline + hover-fill conveys "this does something subtle within the page" without screaming "link".
  • CSS guardrails preserved: focus rings on both elements, brand-color tokens (light + dark), tap targets meet the AAA 44×44 minimum (the button reset preserves the line-height + the cell-actor padding ≥ 12 px on each side).
  • No new i18n strings. title attributes are hover hints, not screen-reader-essential — the underlying hash + traceId are the actual semantic content. The "(anonymous)" string and the dash placeholder were already in the template.
  • No BFF change. This whole PR is SPA-side only. The audit endpoint already returns traceId and actorIdHash in every row.

Test plan

  • pnpm nx run-many -t lint test build --projects=portal-admin — green.
  • 54 portal-admin specs pass (was 50; +4 for the four new behaviours).
  • Lazy audit chunk: 18.26 → ~18.5 KB raw / 4.44 KB gzip — comfortably under the per-chunk budget.
  • Manual smoke:
    • Sign in to portal-admin with Portal.Admin → open /audit.
    • Click any trace_id → new tab opens at http://localhost:16686/trace/<id> (assuming ./infra/local/dev.sh up observability is running so Jaeger is up).
    • Anonymous rows show for trace, (anonymous) (plain text, not clickable) for actor.
    • Click any non-anonymous actor hash → the table refreshes filtered on that hash, the "Actor id hash" filter input above shows the same value, page jumps to offset 0.
    • Tab through a row: timestamp / event are plain text; outcome badge skipped (not interactive); actor button gets focus ring; trace link gets focus ring; payload <details> summary gets focus ring.

Follow-ups (optional)

  • When the log aggregator ADR lands, extend the trace cell to also offer an inline-expand of Pino lines for that trace. Jaeger anchor stays as the primary affordance.
  • A similar treatment on the /users page (clicking a row's oid to "show me this user's audit trail") is the natural sibling. Defer until there's an investigator workflow that asks for it — premature otherwise.
## Summary Turns the audit-log table's `trace_id` and `actor_id_hash` columns from inert text into the two pivots an investigator actually needs: - **trace_id** → Jaeger deep link (opens in a new tab). Closes the "join audit + traces by trace_id" loop from [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md) / [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md) without any new BFF surface. - **actor_id_hash** → click to refilter the table on that single actor. "Show me everything else this user did" stays in the page; no copy-paste loop. ## What lands ### Trace-id deep link to Jaeger [`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html#L162-L173) — the cell becomes an `<a target="_blank" rel="noopener noreferrer">` pointing at `${environment.jaegerBaseUrl}/trace/<traceId>`. Anonymous events (`traceId === null`) keep the dash placeholder. [`environment.ts`](apps/portal-admin/src/environments/environment.ts) gains `jaegerBaseUrl`. Dev defaults to `http://localhost:16686` (matches the compose `observability` profile from `infra/local/dev.compose.yml`). Per-env replacement picks up whatever trace backend the future infrastructure ADR settles on — Tempo, Grafana Cloud, on-prem Jaeger; the SPA-side wiring doesn't care. ### Actor-pivot click [`audit.html`](apps/portal-admin/src/app/pages/audit/audit.html#L146-L160) — non-null `actorIdHash` becomes a `<button>` styled to read inline like the hash text (`.actor-hash--clickable`: button reset + dotted-underline hover + brand-colored focus ring). Click → `filterByActor(hash)` sets the existing `actorIdHash` filter signal, resets offset to 0, and re-runs the query. Each pivot still emits its own `admin.audit.query` audit row server-side (per [ADR-0020](docs/decisions/0020-portal-admin-app.md)) so the drill is itself auditable. Anonymous rows keep the `(anonymous)` plain-text rendering — there's no useful filter value to pivot on. ## Why not inline-expand Pino log lines under the row Considered, deferred. The BFF's Pino output goes to **stdout only** today; standing up a queryable log aggregator (Loki, OpenSearch, …) is a separate infrastructure chantier with its own ADR. The Jaeger jump-off carries ~99 % of the investigator's needs anyway — the trace already contains span attributes (`db.statement`, `http.status_code`, exception events) for the same request scope; Pino lines on top of that would be redundant for most investigations. When the log aggregator does land, the inline-expand model can come back as a follow-up: `GET /api/admin/logs?traceId=<id>` + an expand affordance on the same row. The current Jaeger anchor and the future inline-logs would naturally coexist (different drills, both surfaced on the same `trace_id`). ## Notes for the reviewer - **Why a `<button>` for the actor cell rather than an `<a>`?** The action is an in-page filter change, not a navigation. Buttons keep keyboard activation (Enter / Space), don't pollute browser history, and screen readers announce "Filter the table on hash(jane), button" rather than a misleading link role. - **Why the dotted-underline hover for the actor, but solid-underline for trace?** Different affordances. The trace anchor is a permanent link to an external resource (Jaeger UI), so the solid underline matches the universal "link" convention. The actor button is an inline pivot that *mutates state* — the dotted underline + hover-fill conveys "this does something subtle within the page" without screaming "link". - **CSS guardrails preserved**: focus rings on both elements, brand-color tokens (light + dark), tap targets meet the AAA 44×44 minimum (the button reset preserves the line-height + the `cell-actor` padding ≥ 12 px on each side). - **No new i18n strings.** `title` attributes are hover hints, not screen-reader-essential — the underlying hash + traceId are the actual semantic content. The "(anonymous)" string and the dash placeholder were already in the template. - **No BFF change.** This whole PR is SPA-side only. The audit endpoint already returns `traceId` and `actorIdHash` in every row. ## Test plan - [x] `pnpm nx run-many -t lint test build --projects=portal-admin` — green. - [x] **54 portal-admin specs pass** (was 50; +4 for the four new behaviours). - [x] Lazy `audit` chunk: 18.26 → ~18.5 KB raw / 4.44 KB gzip — comfortably under the per-chunk budget. - [ ] **Manual smoke**: - Sign in to portal-admin with `Portal.Admin` → open `/audit`. - Click any trace_id → new tab opens at `http://localhost:16686/trace/<id>` (assuming `./infra/local/dev.sh up observability` is running so Jaeger is up). - Anonymous rows show `—` for trace, `(anonymous)` (plain text, not clickable) for actor. - Click any non-anonymous actor hash → the table refreshes filtered on that hash, the "Actor id hash" filter input above shows the same value, page jumps to offset 0. - Tab through a row: timestamp / event are plain text; outcome badge skipped (not interactive); actor button gets focus ring; trace link gets focus ring; payload `<details>` summary gets focus ring. ## Follow-ups (optional) - When the log aggregator ADR lands, extend the trace cell to also offer an inline-expand of Pino lines for that trace. Jaeger anchor stays as the primary affordance. - A similar treatment on the `/users` page (clicking a row's `oid` to "show me this user's audit trail") is the natural sibling. Defer until there's an investigator workflow that asks for it — premature otherwise.
julien added 1 commit 2026-05-16 03:36:31 +02:00
feat(portal-admin): jaeger deep link on trace_id + actor-pivot on actor_id_hash
CI / scan (pull_request) Successful in 3m6s
CI / commits (pull_request) Successful in 3m27s
CI / check (pull_request) Successful in 3m41s
CI / a11y (pull_request) Successful in 2m22s
CI / perf (pull_request) Successful in 5m56s
06ea1feabf
The audit-log table carries `trace_id` + `actor_id_hash` columns
already populated by the BFF (ADR-0013) but they were plain text —
an investigator could see the values but had to copy them out of the
table to do anything with them. This PR turns both into interactive
affordances:

* **`trace_id` becomes a Jaeger deep link.** Each non-null
  `traceId` renders as `<a target="_blank" rel="noopener
  noreferrer">` pointing at `${jaegerBaseUrl}/trace/<id>`. The
  base URL is per-env in `apps/portal-admin/src/environments/
  environment.ts` (dev: `localhost:16686` matching the compose
  observability profile; prod: the future infra ADR will swap to
  whatever trace backend is settled on — Tempo, Grafana Cloud,
  on-prem Jaeger…). Anonymous events (no traceId) keep the dash
  placeholder. Closes the "join audit + traces by trace_id"
  promise of ADR-0012 / ADR-0013 with the lowest-coupling option
  available today (no new BFF endpoint, no log aggregator
  required).

* **`actor_id_hash` becomes a click-to-filter button.** Each
  non-null hash renders as a `<button>` styled to read inline
  like the hash text but with a hover affordance + focus ring +
  underline. Clicking it sets `actorIdHash.set(hash)`, resets
  the offset to 0, and re-runs the query — pivoting the table on
  that single actor's events. Anonymous rows keep the
  `(anonymous)` plain-text rendering since there's no filter
  value to apply. Each pivot still emits its own
  `admin.audit.query` audit row server-side (ADR-0020) so the
  drill is auditable.

Why not inline-expand Pino logs under the row? Considered, deferred
to a future chantier — the BFF's Pino output goes to stdout today
with no queryable backend; standing up a log aggregator (Loki /
OpenSearch / …) is a separate infra ADR. Jaeger jump-off carries
~99 % of the investigator's needs because the trace already
contains span attributes (db.statement, http.status_code, exception
events) for the same scope.

Implementation:
  * `audit.ts` gains `jaegerUrl(traceId)` (builds the URL with
    encodeURIComponent) and `filterByActor(hash)` (mutates the
    actor filter + offset + re-fetches). Imports
    `environment.jaegerBaseUrl`.
  * `audit.html` swaps the trace cell to an anchor (or dash) and
    the actor cell to a clickable button (or anonymous text),
    each with a `title` attribute for hover hint.
  * `audit.scss` adds `.actor-hash--clickable` (button reset +
    dotted-underline hover) and `.trace-link` (brand-coloured
    underlined link), both with focus rings + dark-mode swaps.

Verification:
  * 54 portal-admin specs pass (was 50; +4 for the four new
    behaviours: trace anchor + dash + actor pivot + anonymous
    plain text).
  * `pnpm nx build portal-admin` clean; lazy `audit` chunk grows
    marginally (4.36 → 4.44 KB gzip) — well under the per-chunk
    budget.
julien merged commit 0435fec10a into main 2026-05-16 03:36:41 +02:00
julien deleted branch feat/portal-admin-audit-trace-link-and-actor-pivot 2026-05-16 03:36:43 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#166