feat(insights): citation footnote on recommendations with medical reference

When a recommendation carries a `referenceId` pointing into the
curated medical-reference bundle, render a small footnote underneath
linking to the guideline. Format: "Source: ESH 2023 — ESH 2023
Guidelines for the Management of Arterial Hypertension" (locale-
routed title), with a tiny external-link icon, opens in a new tab via
target="_blank" rel="noreferrer". Per the v1.4.16 research doc §7
the URL never opens in an in-app webview.

`InsightResult.recommendations` widens to accept either the legacy
plain-string shape OR a structured `{ text, referenceId? }` object so
existing rendering paths keep working while the new citation surface
plugs in through the same component. `getMedicalReferenceById()`
returns undefined for unknown ids — the UI silently drops the
footnote in that case rather than rendering a broken link (defence
in depth on top of the schema validation).

i18n keys `insights.recommendation.source` and
`insights.recommendation.viewSource` land in both EN and DE.

6 component tests pin the no-footnote / EN-locale / DE-locale /
unknown-id-defence / multi-rec paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:04:24 +02:00
1 parent 94c748d804
commit a66c128dd9
14 files changed
+1301 -11

No files matched your search

+4
View File
@@ -658,6 +658,10 @@
"heroFindingWarning": "Wichtigster Alarm",
"correlationsTitle": "Zusammenhänge",
"recommendationsTitle": "Empfehlungen",
"recommendation": {
"source": "Quelle",
"viewSource": "Leitlinie in neuem Tab öffnen"
},
"dataFoundation": "Datengrundlage",
"dataGaps": "Datenlücken:",
"lastUpdated": "Zuletzt aktualisiert",
+4
View File
@@ -658,6 +658,10 @@
"heroFindingWarning": "Top alert",
"correlationsTitle": "Correlations",
"recommendationsTitle": "Recommendations",
"recommendation": {
"source": "Source",
"viewSource": "Open guideline in a new tab"
},
"dataFoundation": "Data coverage",
"dataGaps": "Data gaps:",
"lastUpdated": "Last updated",
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
import { GET } from "../route";
import { requireAdmin } from "@/lib/api-handler";
import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer";
import type { WideEvent } from "@/lib/logging/types";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
function makeEvent(overrides: Partial<WideEvent> = {}): WideEvent {
return {
timestamp: new Date().toISOString(),
duration_ms: 12,
request_id: "req-" + Math.random().toString(16).slice(2),
trace_id: "trace-" + Math.random().toString(16).slice(2),
level: "info",
kind: "http",
service: "healthlog",
environment: "test",
...overrides,
};
}
function req(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/admin/app-logs${query ? "?" + query : ""}`,
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
clearLogBuffer();
});
describe("GET /api/admin/app-logs", () => {
it("returns the buffered events newest-first", async () => {
appendLogEvent(makeEvent({ trace_id: "first" }));
appendLogEvent(makeEvent({ trace_id: "second" }));
const res = await GET(req());
const json = (await res.json()) as {
data: { events: WideEvent[]; meta: { total: number } };
};
expect(json.data.events).toHaveLength(2);
expect(json.data.events[0].trace_id).toBe("second");
expect(json.data.meta.total).toBe(2);
});
it("filters by traceId", async () => {
appendLogEvent(makeEvent({ trace_id: "alpha" }));
appendLogEvent(makeEvent({ trace_id: "beta" }));
const res = await GET(req("traceId=alpha"));
const json = (await res.json()) as { data: { events: WideEvent[] } };
expect(json.data.events).toHaveLength(1);
expect(json.data.events[0].trace_id).toBe("alpha");
});
it("filters by level + action substring", async () => {
appendLogEvent(
makeEvent({ level: "info", action: { name: "measurement.create" } }),
);
appendLogEvent(
makeEvent({ level: "error", action: { name: "auth.login.failed" } }),
);
const res = await GET(req("level=error&action=auth"));
const json = (await res.json()) as { data: { events: WideEvent[] } };
expect(json.data.events).toHaveLength(1);
expect(json.data.events[0].action?.name).toBe("auth.login.failed");
});
it("redacts secrets in error.message before egress", async () => {
appendLogEvent(
makeEvent({
level: "error",
error: {
type: "Error",
// Bearer token in the error path — must be redacted on render.
message: "Authorization: Bearer hlk_abc123def456 invalid",
},
}),
);
const res = await GET(req());
const json = (await res.json()) as { data: { events: WideEvent[] } };
expect(json.data.events[0].error?.message).not.toContain(
"hlk_abc123def456",
);
expect(json.data.events[0].error?.message).toContain("[REDACTED]");
});
it("supports the `limit` cap", async () => {
for (let i = 0; i < 60; i++) {
appendLogEvent(makeEvent({ request_id: `r-${i}` }));
}
const res = await GET(req("limit=10"));
const json = (await res.json()) as { data: { events: WideEvent[] } };
expect(json.data.events).toHaveLength(10);
});
it("rejects an invalid level value", async () => {
appendLogEvent(makeEvent());
// Invalid `level` — Zod parse should throw and the apiHandler test
// mock rethrows it (we mock only `requireAdmin`, not the wrapper
// error-translator). We simply assert the call rejects.
await expect(GET(req("level=critical"))).rejects.toThrow();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer";
import { redactSecrets } from "@/lib/logging/redact";
import type { WideEvent } from "@/lib/logging/types";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
export const dynamic = "force-dynamic";
const querySchema = z.object({
traceId: z.string().trim().min(1).max(200).optional(),
level: z.enum(["debug", "info", "warn", "error"]).optional(),
action: z.string().trim().min(1).max(200).optional(),
since: z.string().datetime().optional(),
until: z.string().datetime().optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
});
/**
* Admin endpoint: tail of the per-process in-memory wide-event ring buffer.
*
* The buffer is populated by `transports.emitEvent()` so the result mirrors
* what would be shipped to Loki (sampler-gated). Per-process and volatile —
* see `src/lib/logging/in-memory-buffer.ts` for the limitations.
*
* Privacy: every event is run through `redactSecrets()` before egress so a
* stray Bearer / hlk_ / sk- token in an `error.message` never reaches the
* admin UI. Storage stays raw to keep diagnostics intact when shipped to
* Loki, but the egress path here is the user-facing one.
*/
export const GET = apiHandler(async (request: NextRequest) => {
await requireAdmin();
annotate({ action: { name: "admin.app-logs.list" } });
const { searchParams } = new URL(request.url);
const parsed = querySchema.parse({
traceId: searchParams.get("traceId") ?? undefined,
level: searchParams.get("level") ?? undefined,
action: searchParams.get("action") ?? undefined,
since: searchParams.get("since") ?? undefined,
until: searchParams.get("until") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
});
const events = readLogBuffer({
traceId: parsed.traceId,
level: parsed.level,
action: parsed.action,
since: parsed.since ? new Date(parsed.since) : undefined,
until: parsed.until ? new Date(parsed.until) : undefined,
limit: parsed.limit,
});
const redacted = events.map(redactEventForEgress);
return apiSuccess({
events: redacted,
meta: {
total: redacted.length,
bufferMax: LOG_BUFFER_MAX,
},
});
});
/**
* Apply `redactSecrets()` to every string-shaped field that could carry a
* leaked credential. Keeps the structure intact so the UI can still render
* the JSON tree.
*/
function redactEventForEgress(event: WideEvent): WideEvent {
// structuredClone keeps us from mutating the live buffer entry.
const out = structuredClone(event);
if (out.error?.message) {
out.error.message = redactSecrets(out.error.message);
}
if (out.error?.stack) {
out.error.stack = redactSecrets(out.error.stack);
}
if (out.warnings?.length) {
out.warnings = out.warnings.map((w) => redactSecrets(w));
}
if (out.http?.user_agent) {
// UA strings are public, but a misbehaving client could shove a token
// into one. Cheap to redact, free to keep correct.
out.http.user_agent = redactSecrets(out.http.user_agent);
}
if (out.external_calls?.length) {
out.external_calls = out.external_calls.map((c) => ({
...c,
error: c.error ? redactSecrets(c.error) : c.error,
}));
}
return out;
}
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: {
findMany: vi.fn(),
count: vi.fn(),
groupBy: vi.fn(),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/api-handler";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
function req(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/admin/audit-log${query ? "?" + query : ""}`,
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([]);
vi.mocked(prisma.auditLog.count).mockResolvedValue(0);
vi.mocked(prisma.auditLog.groupBy).mockResolvedValue([]);
});
describe("GET /api/admin/audit-log — extended filters", () => {
it("supports `page` + `perPage` pagination", async () => {
await GET(req("page=3&perPage=25"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
// skip = (page - 1) * perPage = 50; take = perPage = 25
expect(call.skip).toBe(50);
expect(call.take).toBe(25);
});
it("clamps perPage to the allowed set {25, 50, 100}", async () => {
await GET(req("perPage=999"));
// any out-of-range value falls back to the default 50
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
expect(call.take).toBe(50);
});
it("filters by `actor` against userId OR user.username", async () => {
await GET(req("actor=marc"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
// Expect an OR clause covering userId + user.username (contains)
expect(where.OR).toBeDefined();
const or = where.OR as Array<Record<string, unknown>>;
const matchesUserId = or.some((c) => c.userId === "marc");
const matchesUsername = or.some(
(c) =>
typeof c.user === "object" &&
c.user !== null &&
// user: { username: { contains: "marc", mode: "insensitive" } }
JSON.stringify(c).toLowerCase().includes("marc"),
);
expect(matchesUserId).toBe(true);
expect(matchesUsername).toBe(true);
});
it("filters by `action` exact match", async () => {
await GET(req("action=auth.login.failed"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
expect(where.action).toBe("auth.login.failed");
});
it("filters by `target` (substring on `details` JSON string)", async () => {
await GET(req("target=measurement-42"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
expect(where.details).toBeDefined();
expect(JSON.stringify(where.details)).toContain("measurement-42");
});
it("filters by `since` and `until` window", async () => {
const since = "2026-05-01T00:00:00.000Z";
const until = "2026-05-09T23:59:59.000Z";
await GET(req(`since=${since}&until=${until}`));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
expect(where.createdAt).toEqual({
gte: new Date(since),
lte: new Date(until),
});
});
it("returns paginated meta", async () => {
vi.mocked(prisma.auditLog.count).mockResolvedValue(123);
const res = await GET(req("page=2&perPage=25"));
const json = (await res.json()) as {
data: { meta: { total: number; page: number; perPage: number } };
};
expect(json.data.meta.total).toBe(123);
expect(json.data.meta.page).toBe(2);
expect(json.data.meta.perPage).toBe(25);
});
it("preserves the legacy `limit`/`offset` shape (back-compat)", async () => {
await GET(req("limit=10&offset=5"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
expect(call.take).toBe(10);
expect(call.skip).toBe(5);
});
it("supports the legacy `filter=auth` shortcut", async () => {
await GET(req("filter=auth"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
// startsWith "auth." is the legacy contract
expect(JSON.stringify(where.action)).toContain("auth.");
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: {
groupBy: vi.fn(),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/api-handler";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
});
describe("GET /api/admin/audit-log/actions", () => {
it("returns the distinct list of actions sorted ascending", async () => {
vi.mocked(prisma.auditLog.groupBy).mockResolvedValue([
{ action: "auth.login" },
{ action: "auth.login.failed" },
{ action: "measurement.create" },
] as never);
const res = await (GET as () => Promise<Response>)();
const json = (await res.json()) as { data: { actions: string[] } };
expect(json.data.actions).toEqual([
"auth.login",
"auth.login.failed",
"measurement.create",
]);
});
});
@@ -0,0 +1,30 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
/**
* Distinct list of `action` values currently present in the audit log,
* sorted alphabetically. Powers the action-dropdown on the
* `/admin/login-overview` filter UI so admins don't have to memorise the
* action vocabulary (`auth.login`, `auth.bearer.failure`, `device.register.denied`,
* etc.).
*
* `groupBy` is the cheapest way to get distinct values without sucking the
* whole table into Node memory.
*/
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.audit-log.actions" } });
const grouped = await prisma.auditLog.groupBy({
by: ["action"],
orderBy: { action: "asc" },
});
return apiSuccess({
actions: grouped.map((g) => g.action),
});
});
@@ -0,0 +1,301 @@
"use client";
/**
* `<AppLogPreviewSection>` — surfaces the in-process wide-event ring buffer
* (`src/lib/logging/in-memory-buffer.ts`) so admins can drill into the most
* recent ~500 structured events without standing up a Loki stack just to
* read the last hour.
*
* Limitations (covered by the section header):
* - Per-process. Under split web/worker deployments, only the web buffer
* is visible here. Worker logs ship to Loki when configured.
* - Volatile. Restart drops the buffer.
*
* Privacy: every event passes through `redactSecrets()` server-side before
* reaching this component; storage stays raw so the diagnostic value
* survives shipping to Loki.
*/
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
FileText,
Info,
Loader2,
RefreshCw,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import type { WideEvent, LogLevel } from "@/lib/logging/types";
type RangePreset = "15m" | "1h" | "6h" | "all";
interface AppLogsResponse {
events: WideEvent[];
meta: {
total: number;
bufferMax: number;
};
}
function rangeToSince(range: RangePreset): string | undefined {
if (range === "all") return undefined;
const now = Date.now();
const minutes = range === "15m" ? 15 : range === "1h" ? 60 : 6 * 60;
return new Date(now - minutes * 60 * 1000).toISOString();
}
function levelIcon(level: LogLevel) {
switch (level) {
case "error":
return <AlertCircle className="text-destructive h-4 w-4" />;
case "warn":
return <AlertTriangle className="text-dracula-yellow h-4 w-4" />;
case "debug":
return <Info className="text-muted-foreground h-4 w-4" />;
case "info":
default:
return <CheckCircle2 className="text-dracula-green h-4 w-4" />;
}
}
export function AppLogPreviewSection() {
const { t } = useTranslations();
const [traceId, setTraceId] = useState("");
const [actionFilter, setActionFilter] = useState("");
const [level, setLevel] = useState<LogLevel | "__all__">("__all__");
const [range, setRange] = useState<RangePreset>("1h");
const [selected, setSelected] = useState<WideEvent | null>(null);
const params = useMemo(() => {
const p = new URLSearchParams();
if (traceId.trim()) p.set("traceId", traceId.trim());
if (actionFilter.trim()) p.set("action", actionFilter.trim());
if (level !== "__all__") p.set("level", level);
const since = rangeToSince(range);
if (since) p.set("since", since);
return p;
}, [traceId, actionFilter, level, range]);
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["admin", "app-logs", traceId, actionFilter, level, range],
queryFn: async () => {
const res = await fetch(`/api/admin/app-logs?${params.toString()}`);
if (!res.ok) throw new Error("Failed to load app logs");
return (await res.json()).data as AppLogsResponse;
},
refetchInterval: 30_000,
});
const events = data?.events ?? [];
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<header className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<FileText className="text-primary mt-0.5 h-5 w-5" />
<div>
<h2 className="text-lg font-semibold">
{t("admin.section.app-logs.title")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.section.app-logs.processNote")}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t("admin.section.app-logs.refresh")}
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
</Button>
</header>
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
<Input
type="search"
placeholder={t("admin.section.app-logs.filterTraceId")}
value={traceId}
onChange={(e) => setTraceId(e.target.value)}
aria-label={t("admin.section.app-logs.filterTraceId")}
/>
<Input
type="search"
placeholder={t("admin.section.app-logs.filterAction")}
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
aria-label={t("admin.section.app-logs.filterAction")}
/>
<Select
value={level}
onValueChange={(v) => setLevel(v as LogLevel | "__all__")}
>
<SelectTrigger aria-label={t("admin.section.app-logs.filterLevel")}>
<SelectValue
placeholder={t("admin.section.app-logs.filterLevel")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
{t("admin.section.app-logs.levelAll")}
</SelectItem>
<SelectItem value="debug">debug</SelectItem>
<SelectItem value="info">info</SelectItem>
<SelectItem value="warn">warn</SelectItem>
<SelectItem value="error">error</SelectItem>
</SelectContent>
</Select>
<Select value={range} onValueChange={(v) => setRange(v as RangePreset)}>
<SelectTrigger aria-label={t("admin.section.app-logs.filterRange")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15m">
{t("admin.section.app-logs.range15m")}
</SelectItem>
<SelectItem value="1h">
{t("admin.section.app-logs.range1h")}
</SelectItem>
<SelectItem value="6h">
{t("admin.section.app-logs.range6h")}
</SelectItem>
<SelectItem value="all">
{t("admin.section.app-logs.rangeAll")}
</SelectItem>
</SelectContent>
</Select>
</div>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : events.length === 0 ? (
<EmptyState
icon={<FileText className="size-6" />}
title={t("admin.section.app-logs.empty")}
description={t("admin.section.app-logs.emptyDescription")}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colLevel")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colTimestamp")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colAction")}
</th>
<th className="px-2 py-2 text-right font-medium">
{t("admin.section.app-logs.colDuration")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colTraceId")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{events.map((event, i) => (
<tr
key={`${event.request_id}-${i}`}
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
onClick={() => setSelected(event)}
>
<td className="px-2 py-2">{levelIcon(event.level)}</td>
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
{formatDateTime(event.timestamp)}
</td>
<td className="px-2 py-2 text-xs">
{event.action?.name ??
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
event.kind)}
</td>
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
{event.duration_ms} ms
</td>
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
{event.trace_id.slice(0, 8)}…
</td>
</tr>
))}
</tbody>
</table>
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
<span>
{t("admin.section.app-logs.showing", {
count: events.length,
bufferMax: data?.meta.bufferMax ?? 500,
})}
</span>
</div>
</div>
)}
<Dialog
open={selected !== null}
onOpenChange={(open) => !open && setSelected(null)}
>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selected && levelIcon(selected.level)}
<span className="font-mono text-sm">
{selected?.action?.name ??
selected?.http?.path ??
t("admin.section.app-logs.eventDetails")}
</span>
</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto">
{selected && (
<pre className="bg-muted text-foreground rounded-md p-3 font-mono text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selected, null, 2)}
</pre>
)}
</div>
<DialogClose asChild>
<Button
variant="outline"
size="sm"
className="absolute top-3 right-3"
aria-label={t("admin.section.app-logs.closeDetails")}
>
<X className="h-4 w-4" />
</Button>
</DialogClose>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,164 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// HealthChart pulls in TanStack Query + recharts. Stub it for the
// same reasons the sibling card test does.
vi.mock("@/components/charts/health-chart", () => ({
HealthChart: ({ types, title }: { types: string[]; title: string }) => (
<div data-testid="health-chart" data-types={types.join(",")}>
chart:{title}
</div>
),
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: () => <div data-testid="mood-chart" />,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { InsightAdvisorCard } from "../insight-advisor-card";
import type { InsightResult } from "@/lib/ai/types";
import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references";
const knownRef = MEDICAL_REFERENCES[0];
function buildInsight(overrides: Partial<InsightResult> = {}): InsightResult {
return {
insightType: "general",
summary: "All good",
classification: "gut",
classificationLabel: "Good",
findings: [
{
label: "Heart rate stable",
value: "72 bpm",
assessment: "positive",
},
],
correlations: [],
primaryRecommendation: "Keep going",
recommendations: [],
dataQuality: {
coverage: "good",
gaps: [],
confidence: "hoch",
},
disclaimer: "Not a substitute for medical advice.",
...overrides,
};
}
function render(insight: InsightResult, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<InsightAdvisorCard title="Test card" insight={insight} />
</I18nProvider>,
);
}
describe("<InsightAdvisorCard> — recommendation citation footnote (B5a)", () => {
it("renders no footnote for legacy string recommendations", () => {
const html = render(
buildInsight({
recommendations: ["Continue daily logging."],
}),
);
expect(html).toContain("Continue daily logging.");
expect(html).not.toContain('data-slot="insight-recommendation-source"');
expect(html).not.toContain("Source:");
});
it("renders no footnote for structured rec WITHOUT a referenceId", () => {
const html = render(
buildInsight({
recommendations: [{ text: "Keep an eye on your sleep." }],
}),
);
expect(html).toContain("Keep an eye on your sleep.");
expect(html).not.toContain('data-slot="insight-recommendation-source"');
});
it("renders the footnote with EN title + URL when referenceId resolves", () => {
const html = render(
buildInsight({
recommendations: [
{
text: "Aim for a target below 140/90.",
referenceId: knownRef.id,
},
],
}),
"en",
);
expect(html).toContain('data-slot="insight-recommendation-source"');
expect(html).toContain(`data-reference-id="${knownRef.id}"`);
expect(html).toContain(knownRef.title);
expect(html).toContain(`${knownRef.publishedYear}`);
expect(html).toContain(knownRef.org);
expect(html).toContain(`href="${knownRef.url}"`);
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noreferrer"');
expect(html).toContain("Source:");
});
it("renders the German title under de locale", () => {
const html = render(
buildInsight({
recommendations: [
{
text: "Aim for a target below 140/90.",
referenceId: knownRef.id,
},
],
}),
"de",
);
expect(html).toContain(knownRef.titleDe);
// German "Source" label
expect(html).toContain("Quelle:");
});
it("silently drops the footnote for an unknown referenceId (defence in depth)", () => {
// The schema would normally reject this payload, but if a stale
// payload sneaks through, the UI must not render a broken link.
const html = render(
buildInsight({
recommendations: [
{
text: "Aim for something.",
referenceId: "fabricated-2099",
},
],
}),
);
expect(html).toContain("Aim for something.");
expect(html).not.toContain('data-slot="insight-recommendation-source"');
expect(html).not.toContain("Source:");
});
it("renders multiple recs, each with their own footnote when applicable", () => {
const second = MEDICAL_REFERENCES[1] ?? knownRef;
const html = render(
buildInsight({
recommendations: [
{ text: "First rec", referenceId: knownRef.id },
{ text: "Second rec", referenceId: second.id },
"Third plain rec",
],
}),
);
expect(html).toContain("First rec");
expect(html).toContain("Second rec");
expect(html).toContain("Third plain rec");
expect(html).toContain(knownRef.url);
if (second !== knownRef) expect(html).toContain(second.url);
// The plain string rec produces no footnote.
const matches = html.match(/data-slot="insight-recommendation-source"/g);
expect(matches?.length ?? 0).toBe(second !== knownRef ? 2 : 2);
});
});
@@ -1,7 +1,11 @@
"use client";
import { useState } from "react";
import type { InsightResult, InsightFinding } from "@/lib/ai/types";
import type {
InsightResult,
InsightFinding,
InsightRecommendation,
} from "@/lib/ai/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -16,6 +20,7 @@ import {
ChevronDown,
ChevronUp,
Info,
ExternalLink,
} from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { HealthChart } from "@/components/charts/health-chart";
@@ -27,6 +32,8 @@ import {
tokenToMetric,
type ChartToken,
} from "@/lib/insights/chart-tokens";
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
import type { Locale } from "@/lib/i18n/config";
// ─── Types ────────────────────────────────────────────────
@@ -263,6 +270,62 @@ function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
// ─── Section Separator ────────────────────────────────────
// ─── Recommendation Citation Footnote ─────────────────────
//
// v1.4.16 phase B5a — render a small "Source: …" footnote under a
// recommendation when the model emitted a `referenceId` pointing into
// the curated medical-reference bundle. The link opens the guideline
// in a new tab; per Marc's research §7 we never open the URL inside
// an in-app webview.
//
// `getMedicalReferenceById()` returns undefined for unknown ids — in
// that case we silently drop the footnote rather than render a broken
// link. The schema check would already have rejected the payload, so
// this is defence in depth.
function CitationFootnote({
referenceId,
locale,
}: {
referenceId: string;
locale: Locale;
}) {
const { t } = useTranslations();
const ref = getMedicalReferenceById(referenceId);
if (!ref) return null;
const label = locale === "de" ? ref.titleDe : ref.title;
return (
<a
href={ref.url}
target="_blank"
rel="noreferrer"
title={t("insights.recommendation.viewSource")}
className="text-muted-foreground hover:text-foreground mt-0.5 ml-6 inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
data-slot="insight-recommendation-source"
data-reference-id={referenceId}
>
<span className="font-medium">
{t("insights.recommendation.source")}:
</span>
<span>
{ref.org} {ref.publishedYear} — {label}
</span>
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
</a>
);
}
/** Normalise the legacy string-rec OR new structured-rec into a uniform shape. */
function normaliseRecommendation(rec: InsightRecommendation): {
text: string;
referenceId?: string;
} {
if (typeof rec === "string") return { text: rec };
return { text: rec.text, referenceId: rec.referenceId };
}
// ─── Section Separator ────────────────────────────────────
function SectionSeparator({ label }: { label: string }) {
return (
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
@@ -284,7 +347,7 @@ export function InsightAdvisorCard({
regenerating = false,
cachedAt,
}: InsightAdvisorCardProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const fmt = useFormatters();
const [dataQualityOpen, setDataQualityOpen] = useState(false);
@@ -507,14 +570,25 @@ export function InsightAdvisorCard({
<div className="space-y-3">
<SectionSeparator label={t("insights.recommendationsTitle")} />
<ol className="space-y-1.5 pl-0">
{insight.recommendations.map((rec, i) => (
<li key={i} className="flex gap-2 text-sm">
<span className="text-muted-foreground shrink-0 font-medium">
{i + 1}.
</span>
<span>{rec}</span>
</li>
))}
{insight.recommendations.map((rec, i) => {
const { text, referenceId } = normaliseRecommendation(rec);
return (
<li key={i} className="flex flex-col gap-0.5 text-sm">
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0 font-medium">
{i + 1}.
</span>
<span>{text}</span>
</div>
{referenceId && (
<CitationFootnote
referenceId={referenceId}
locale={locale}
/>
)}
</li>
);
})}
</ol>
</div>
)}
+18 -1
View File
@@ -21,6 +21,23 @@ export const insightDataQualitySchema = z.object({
confidence: z.enum(["hoch", "mittel", "gering"]),
});
/**
* v1.4.16 — recommendations can be either the legacy plain-string
* shape OR a structured object carrying an optional medical-reference
* citation. The card renders a footnote with a labelled link when
* `referenceId` is set and resolves to a known entry in
* `MEDICAL_REFERENCES`.
*/
export const insightRecommendationSchema = z.union([
z.string(),
z.object({
text: z.string(),
referenceId: z.string().optional(),
}),
]);
export type InsightRecommendation = z.infer<typeof insightRecommendationSchema>;
export const insightResultSchema = z.object({
insightType: z.string().optional(),
summary: z.string(),
@@ -35,7 +52,7 @@ export const insightResultSchema = z.object({
findings: z.array(insightFindingSchema),
correlations: z.array(insightCorrelationSchema),
primaryRecommendation: z.string().optional(),
recommendations: z.array(z.string()),
recommendations: z.array(insightRecommendationSchema),
dataQuality: insightDataQualitySchema,
disclaimer: z.string(),
});
@@ -0,0 +1,119 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
appendLogEvent,
readLogBuffer,
clearLogBuffer,
LOG_BUFFER_MAX,
} from "../in-memory-buffer";
import type { WideEvent } from "../types";
function makeEvent(overrides: Partial<WideEvent> = {}): WideEvent {
return {
timestamp: new Date().toISOString(),
duration_ms: 12,
request_id: "req-" + Math.random().toString(16).slice(2),
trace_id: "trace-" + Math.random().toString(16).slice(2),
level: "info",
kind: "http",
service: "healthlog",
environment: "test",
...overrides,
};
}
describe("in-memory log buffer", () => {
beforeEach(() => {
clearLogBuffer();
});
it("returns nothing when empty", () => {
expect(readLogBuffer({})).toEqual([]);
});
it("stores appended events and returns them newest-first", () => {
const a = makeEvent({ timestamp: "2026-05-09T20:00:00.000Z" });
const b = makeEvent({ timestamp: "2026-05-09T20:01:00.000Z" });
const c = makeEvent({ timestamp: "2026-05-09T20:02:00.000Z" });
appendLogEvent(a);
appendLogEvent(b);
appendLogEvent(c);
const out = readLogBuffer({});
expect(out).toHaveLength(3);
// Newest-first
expect(out[0].timestamp).toBe(c.timestamp);
expect(out[1].timestamp).toBe(b.timestamp);
expect(out[2].timestamp).toBe(a.timestamp);
});
it("FIFO-evicts when capacity is exceeded (cap = LOG_BUFFER_MAX)", () => {
expect(LOG_BUFFER_MAX).toBe(500);
for (let i = 0; i < 510; i++) {
appendLogEvent(makeEvent({ request_id: `req-${i}` }));
}
const out = readLogBuffer({});
expect(out).toHaveLength(500);
// oldest 10 events were dropped — req-0..req-9 must NOT be present
const ids = new Set(out.map((e) => e.request_id));
expect(ids.has("req-0")).toBe(false);
expect(ids.has("req-9")).toBe(false);
expect(ids.has("req-10")).toBe(true);
expect(ids.has("req-509")).toBe(true);
});
it("filters by traceId", () => {
appendLogEvent(makeEvent({ trace_id: "alpha" }));
appendLogEvent(makeEvent({ trace_id: "beta" }));
appendLogEvent(makeEvent({ trace_id: "alpha" }));
const out = readLogBuffer({ traceId: "alpha" });
expect(out).toHaveLength(2);
expect(out.every((e) => e.trace_id === "alpha")).toBe(true);
});
it("filters by level", () => {
appendLogEvent(makeEvent({ level: "info" }));
appendLogEvent(makeEvent({ level: "warn" }));
appendLogEvent(makeEvent({ level: "error" }));
expect(readLogBuffer({ level: "warn" })).toHaveLength(1);
expect(readLogBuffer({ level: "error" })).toHaveLength(1);
});
it("filters by action substring (case-insensitive)", () => {
appendLogEvent(makeEvent({ action: { name: "measurement.create" } }));
appendLogEvent(makeEvent({ action: { name: "auth.login" } }));
appendLogEvent(makeEvent({ action: { name: "measurement.delete" } }));
const out = readLogBuffer({ action: "measurement" });
expect(out).toHaveLength(2);
expect(out.every((e) => e.action?.name?.startsWith("measurement"))).toBe(
true,
);
// Case-insensitive
expect(readLogBuffer({ action: "AUTH" })).toHaveLength(1);
});
it("filters by since/until window", () => {
appendLogEvent(makeEvent({ timestamp: "2026-05-09T10:00:00.000Z" }));
appendLogEvent(makeEvent({ timestamp: "2026-05-09T12:00:00.000Z" }));
appendLogEvent(makeEvent({ timestamp: "2026-05-09T14:00:00.000Z" }));
const out = readLogBuffer({
since: new Date("2026-05-09T11:00:00.000Z"),
until: new Date("2026-05-09T13:00:00.000Z"),
});
expect(out).toHaveLength(1);
expect(out[0].timestamp).toBe("2026-05-09T12:00:00.000Z");
});
it("respects the limit option", () => {
for (let i = 0; i < 50; i++) {
appendLogEvent(makeEvent({ request_id: `r-${i}` }));
}
expect(readLogBuffer({ limit: 5 })).toHaveLength(5);
});
it("clears the buffer", () => {
appendLogEvent(makeEvent());
appendLogEvent(makeEvent());
expect(readLogBuffer({})).toHaveLength(2);
clearLogBuffer();
expect(readLogBuffer({})).toHaveLength(0);
});
});
@@ -0,0 +1,57 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import {
clearLogBuffer,
readLogBuffer,
getLogBufferSize,
} from "../in-memory-buffer";
import { emitEvent } from "../transports";
import { resetLoggingConfig } from "../config";
import type { WideEvent } from "../types";
function makeEvent(overrides: Partial<WideEvent> = {}): WideEvent {
return {
timestamp: new Date().toISOString(),
duration_ms: 42,
request_id: "req-buf-test",
trace_id: "trace-buf-test",
level: "info",
kind: "http",
service: "healthlog",
environment: "test",
...overrides,
};
}
describe("emitEvent → in-memory buffer wiring", () => {
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
clearLogBuffer();
resetLoggingConfig();
// Suppress the JSON line so the test runner stays readable.
stdoutSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true);
});
afterEach(() => {
stdoutSpy.mockRestore();
});
it("pushes the emitted event into the in-memory buffer", () => {
expect(getLogBufferSize()).toBe(0);
emitEvent(makeEvent({ trace_id: "trace-A" }));
emitEvent(makeEvent({ trace_id: "trace-B" }));
expect(getLogBufferSize()).toBe(2);
const events = readLogBuffer({});
// Newest-first: trace-B was pushed second.
expect(events[0].trace_id).toBe("trace-B");
expect(events[1].trace_id).toBe("trace-A");
});
it("still writes to stdout (transport contract preserved)", () => {
emitEvent(makeEvent());
expect(stdoutSpy).toHaveBeenCalledTimes(1);
const arg = stdoutSpy.mock.calls[0][0];
expect(typeof arg).toBe("string");
expect(arg as string).toContain("trace-buf-test");
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* Per-process in-memory ring buffer for the most recent wide events.
*
* Used by `/admin/app-logs` so admins can drill into structured wide-events
* without standing up a Loki stack just to read the last hour of traffic.
* Events are pushed by `transports.emitEvent()` after they pass the sampler
* gate (so the buffer mirrors what a Loki sink would receive, not the raw
* pre-sampling firehose).
*
* Limitations:
* - Per-process. Under `HEALTHLOG_PROCESS_TYPE=web,worker` split, the
* web buffer doesn't see worker events and vice-versa. The UI surfaces
* this caveat in the section header.
* - 500-entry cap (~500KB max @ ~1KB/event). FIFO eviction on overflow.
* - Volatile — restart drops the buffer. For durable storage configure
* `LOKI_ENDPOINT` and tail Loki instead.
*
* Privacy: events stored raw. The API render path (`/api/admin/app-logs`)
* runs `redactSecrets()` on egress so a stray Bearer token in an error
* message never leaks to the admin UI.
*/
import type { WideEvent, LogLevel } from "./types";
export const LOG_BUFFER_MAX = 500;
// Plain array with FIFO eviction. A linked list would be technically tighter
// but for 500 entries the array shift overhead (~microseconds) is invisible
// next to the rest of the request lifecycle.
let buffer: WideEvent[] = [];
/** Append an event to the buffer; evict the oldest entry once cap is hit. */
export function appendLogEvent(event: WideEvent): void {
buffer.push(event);
if (buffer.length > LOG_BUFFER_MAX) {
buffer.shift();
}
}
export interface ReadLogBufferOptions {
/** Exact match on `event.trace_id`. */
traceId?: string;
/** Exact match on `event.level`. */
level?: LogLevel;
/** Case-insensitive substring match on `event.action.name`. */
action?: string;
/** Inclusive lower bound on `event.timestamp`. */
since?: Date;
/** Inclusive upper bound on `event.timestamp`. */
until?: Date;
/** Cap the number of returned entries. */
limit?: number;
}
/**
* Read the buffer with optional filters. Results are returned newest-first
* (descending by insertion order, which matches `event.timestamp` since we
* push at request-finish time).
*/
export function readLogBuffer(opts: ReadLogBufferOptions): WideEvent[] {
const sinceMs = opts.since?.getTime();
const untilMs = opts.until?.getTime();
const actionLower = opts.action?.toLowerCase();
// Walk back-to-front so the result is newest-first without an extra reverse.
const out: WideEvent[] = [];
for (let i = buffer.length - 1; i >= 0; i--) {
const event = buffer[i];
if (opts.traceId && event.trace_id !== opts.traceId) continue;
if (opts.level && event.level !== opts.level) continue;
if (actionLower) {
const name = event.action?.name?.toLowerCase() ?? "";
if (!name.includes(actionLower)) continue;
}
if (sinceMs !== undefined || untilMs !== undefined) {
const ts = Date.parse(event.timestamp);
if (sinceMs !== undefined && ts < sinceMs) continue;
if (untilMs !== undefined && ts > untilMs) continue;
}
out.push(event);
if (opts.limit !== undefined && out.length >= opts.limit) break;
}
return out;
}
/** Reset the buffer (test-only — not exported from the package index). */
export function clearLogBuffer(): void {
buffer = [];
}
/** Current buffer size (test-only). */
export function getLogBufferSize(): number {
return buffer.length;
}