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

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.
This commit is contained in:
Julien Gautier
2026-05-16 03:34:58 +02:00
parent 6f26bcdd65
commit 06ea1feabf
5 changed files with 169 additions and 3 deletions
@@ -144,12 +144,33 @@
> >
</td> </td>
<td class="cell-actor"> <td class="cell-actor">
<div class="actor-hash">{{ event.actorIdHash ?? '(anonymous)' }}</div> @if (event.actorIdHash; as hash) {
@if (event.subject; as subject) { <button
type="button"
class="actor-hash actor-hash--clickable"
(click)="filterByActor(hash)"
[attr.title]="'Filter the table on ' + hash"
>
{{ hash }}
</button>
} @else {
<div class="actor-hash">(anonymous)</div>
} @if (event.subject; as subject) {
<div class="subject">{{ subject }}</div> <div class="subject">{{ subject }}</div>
} }
</td> </td>
<td class="cell-trace">{{ event.traceId ?? '—' }}</td> <td class="cell-trace">
@if (event.traceId; as traceId) {
<a
class="trace-link"
[href]="jaegerUrl(traceId)"
target="_blank"
rel="noopener noreferrer"
[attr.title]="'Open trace ' + traceId + ' in Jaeger'"
>{{ traceId }}</a
>
} @else { — }
</td>
<td class="cell-payload"> <td class="cell-payload">
@if (event.payload) { @if (event.payload) {
<details> <details>
@@ -273,6 +273,65 @@
color: #9ca3af; color: #9ca3af;
} }
// The clickable variant of `.actor-hash` (rendered as a <button> so
// keyboard activation works) reuses the inline-hash typography but
// strips the native button chrome and surfaces a subtle hover
// affordance. Filter pivot per the same row's actor.
.actor-hash--clickable {
display: inline;
padding: 0;
margin: 0;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
text-decoration: underline dotted transparent;
text-underline-offset: 2px;
transition:
color 0.12s ease-out,
text-decoration-color 0.12s ease-out;
&:hover {
color: var(--color-brand-primary-600, #1d4ed8);
text-decoration-color: currentColor;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .actor-hash--clickable {
&:hover {
color: var(--color-brand-primary-300, #93c5fd);
}
}
// Trace-id deep-link into Jaeger. Same hash typography as the actor
// cell (already inherited via `.cell-trace`), plus a brand-coloured
// underline so investigators read it as "follow this".
.trace-link {
color: var(--color-brand-primary-600, #1d4ed8);
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
text-decoration-thickness: 2px;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .trace-link {
color: var(--color-brand-primary-300, #93c5fd);
}
.aud-badge, .aud-badge,
.outcome-badge { .outcome-badge {
display: inline-block; display: inline-block;
@@ -197,4 +197,52 @@ describe('AuditPage', () => {
expect(rows[0]?.querySelector('details')).not.toBeNull(); expect(rows[0]?.querySelector('details')).not.toBeNull();
expect(rows[1]?.querySelector('details')).toBeNull(); expect(rows[1]?.querySelector('details')).toBeNull();
}); });
it('renders trace_id as a Jaeger deep link (anchor with target=_blank)', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
const link = rows[0]?.querySelector<HTMLAnchorElement>('.trace-link');
expect(link).not.toBeNull();
expect(link?.getAttribute('href')).toContain('/trace/trace-abc');
expect(link?.getAttribute('target')).toBe('_blank');
expect(link?.getAttribute('rel')).toContain('noopener');
});
it('renders the dash placeholder for rows without a trace_id', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
expect(rows[1]?.querySelector('.trace-link')).toBeNull();
expect(rows[1]?.querySelector('.cell-trace')?.textContent?.trim()).toBe('—');
});
it('clicking an actor hash re-runs the query filtered on that hash + resets offset', async () => {
const { fixture, query } = setup();
await flush(fixture);
query.mockClear();
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
rows[0]?.querySelector<HTMLButtonElement>('.actor-hash--clickable')?.click();
await flush(fixture);
expect(query).toHaveBeenCalledTimes(1);
const filters = query.mock.calls[0]?.[0] as AdminAuditQuery;
expect(filters.actorIdHash).toBe('hash(jane)');
expect(filters.offset).toBe(0);
});
it('renders the anonymous actor as plain text (not a button)', async () => {
const anonymousPage: AdminAuditPage = {
...PAGE,
items: [{ ...PAGE.items[0]!, actorIdHash: null }],
};
const { fixture } = setup({ initial: anonymousPage });
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const row = root.querySelector<HTMLTableRowElement>('.audit-table tbody tr');
expect(row?.querySelector('.actor-hash--clickable')).toBeNull();
expect(row?.querySelector('.actor-hash')?.textContent?.trim()).toBe('(anonymous)');
});
}); });
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment';
import { import {
AuditEventsService, AuditEventsService,
type AdminAuditPage, type AdminAuditPage,
@@ -143,6 +144,31 @@ export class AuditPage {
} }
} }
/**
* Build a Jaeger-UI deep link for the given trace id — the audit
* table's `trace_id` cell becomes a clickable link to the
* trace-explorer per ADR-0012's "join audit and app logs by
* trace_id" promise. Anonymous events (`traceId === null`) don't
* get a link; the cell stays a dash.
*/
protected jaegerUrl(traceId: string): string {
return `${environment.jaegerBaseUrl}/trace/${encodeURIComponent(traceId)}`;
}
/**
* Pivot the table on a single actor: set the `actorIdHash` filter
* to the clicked hash, reset paging to 0, and re-query. Lets an
* investigator click any row's actor cell to "show me everything
* else this actor did". Per-query audit row (`admin.audit.query`)
* is still emitted server-side so the pivot is itself auditable.
*/
async filterByActor(hash: string): Promise<void> {
if (!hash) return;
this.actorIdHash.set(hash);
this.offset.set(0);
await this.fetch();
}
private async fetch(): Promise<void> { private async fetch(): Promise<void> {
this.loading.set(true); this.loading.set(true);
this.error.set(null); this.error.set(null);
@@ -43,4 +43,16 @@ export const environment = {
* not an Angular route. * not an Angular route.
*/ */
shellAppUrl: 'http://localhost:4200', shellAppUrl: 'http://localhost:4200',
/**
* Base origin of the Jaeger UI — the trace-explorer the admin
* audit-log viewer links each `trace_id` to. Dev points at the
* compose-managed Jaeger from the `observability` profile (see
* `infra/local/dev.compose.yml`). Prod will switch to whatever
* trace backend the future infrastructure ADR picks (Tempo,
* Grafana Cloud, on-prem Jaeger…) via the per-env file replacement.
*
* Trace URLs are built as `${jaegerBaseUrl}/trace/<id>`.
*/
jaegerBaseUrl: 'http://localhost:16686',
}; };