feat(obs): add observability tracing throughout server

Repository _run/_get/_all now create child spans with db.statement
when an Observable is set via withObs(). Bundle generation and pairing
confirmation accept optional obs for span-based tracing. Key admin
route handlers (camera/layout/kiosk CRUD, cloud sync) log structured
info lines with actor and resource id. Kiosk API routes (heartbeat,
bundle, event, firmware check, OS check) log kiosk_id on entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-26 01:47:24 +02:00
parent 4880dc32fc
commit c91f9cb450
No known key found for this signature in database
6 changed files with 84 additions and 10 deletions

View file

@ -504,6 +504,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/cameras/new", async (event) => { app.post("/admin/cameras/new", async (event) => {
event.context.obs?.log.info("camera create by {user}", { user: event.context.user?.username ?? "unknown" });
const user = event.context.user!; const user = event.context.user!;
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const name = (body?.["name"] ?? "").trim(); const name = (body?.["name"] ?? "").trim();
@ -842,6 +843,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/kiosks/pair", async (event) => { app.post("/admin/kiosks/pair", async (event) => {
event.context.obs?.log.info("kiosk pair by {user}", { user: event.context.user?.username ?? "unknown" });
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const code = (body?.["code"] ?? "").trim().toUpperCase(); const code = (body?.["code"] ?? "").trim().toUpperCase();
const nameOverride = (body?.["name_override"] ?? "").trim() || undefined; const nameOverride = (body?.["name_override"] ?? "").trim() || undefined;
@ -858,7 +860,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
initialLabels, initialLabels,
replaceKioskId, replaceKioskId,
force, force,
}); }, event.context.obs);
await audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", { await audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", {
resource_type: "kiosk", resource_type: "kiosk",
resource_id: result.kioskId, resource_id: result.kioskId,
@ -898,6 +900,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/layouts/new", async (event) => { app.post("/admin/layouts/new", async (event) => {
event.context.obs?.log.info("layout create by {user}", { user: event.context.user?.username ?? "unknown" });
const user = event.context.user!; const user = event.context.user!;
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const name = (body?.["name"] ?? "").trim(); const name = (body?.["name"] ?? "").trim();
@ -949,6 +952,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/layouts/:id", async (event) => { app.post("/admin/layouts/:id", async (event) => {
event.context.obs?.log.info("layout update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = Number(getRouterParam(event, "id")); const id = Number(getRouterParam(event, "id"));
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const coolingStr = body?.["cooling_timeout_seconds"] ?? ""; const coolingStr = body?.["cooling_timeout_seconds"] ?? "";
@ -1235,6 +1239,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/layouts/:id/delete", async (event) => { app.post("/admin/layouts/:id/delete", async (event) => {
event.context.obs?.log.info("layout delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = Number(getRouterParam(event, "id")); const id = Number(getRouterParam(event, "id"));
await deps.repo.deleteLayout(id); await deps.repo.deleteLayout(id);
notifyKiosks(); notifyKiosks();
@ -1403,6 +1408,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/cameras/:id", async (event) => { app.post("/admin/cameras/:id", async (event) => {
event.context.obs?.log.info("camera update {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = Number(getRouterParam(event, "id")); const id = Number(getRouterParam(event, "id"));
const cam = await deps.repo.getCameraById(id); const cam = await deps.repo.getCameraById(id);
if (cam?.type === "cloud") { if (cam?.type === "cloud") {
@ -1547,6 +1553,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/cameras/:id/delete", async (event) => { app.post("/admin/cameras/:id/delete", async (event) => {
event.context.obs?.log.info("camera delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = Number(getRouterParam(event, "id")); const id = Number(getRouterParam(event, "id"));
await deps.repo.deleteCamera(id); await deps.repo.deleteCamera(id);
notifyKiosks(); notifyKiosks();
@ -1804,6 +1811,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/kiosks/:id/delete", async (event) => { app.post("/admin/kiosks/:id/delete", async (event) => {
event.context.obs?.log.info("kiosk delete {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = Number(getRouterParam(event, "id")); const id = Number(getRouterParam(event, "id"));
await deps.repo.deleteKiosk(id); await deps.repo.deleteKiosk(id);
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });

View file

@ -133,6 +133,7 @@ export function registerCloudRoutes(app: H3, deps: AdminDeps): void {
}); });
app.post("/admin/cloud-accounts/:id/sync", async (event) => { app.post("/admin/cloud-accounts/:id/sync", async (event) => {
event.context.obs?.log.info("cloud sync {id} by {user}", { id: getRouterParam(event, "id") ?? "?", user: event.context.user?.username ?? "unknown" });
const id = String(getRouterParam(event, "id")); const id = String(getRouterParam(event, "id"));
await syncCloudAccount(id, deps); await syncCloudAccount(id, deps);
return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } });

View file

@ -371,8 +371,9 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token); const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
event.context.obs?.log.info("bundle fetch for kiosk {id}", { id: String(kiosk.id) });
const clusterKey = await getClusterKey(repo, secrets); const clusterKey = await getClusterKey(repo, secrets);
const bundle = await generateBundle(repo, secrets, kiosk.id, clusterKey); const bundle = await generateBundle(repo, secrets, kiosk.id, clusterKey, event.context.obs);
if (!bundle) throw createError({ statusCode: 404, statusMessage: "Kiosk not found" }); if (!bundle) throw createError({ statusCode: 404, statusMessage: "Kiosk not found" });
// Content-hash ETag: kiosk sends If-None-Match on subsequent fetches. // Content-hash ETag: kiosk sends If-None-Match on subsequent fetches.
@ -402,6 +403,7 @@ function registerKioskRoutes(
const kiosk = await auth.verifyKioskKey(token); const kiosk = await auth.verifyKioskKey(token);
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
event.context.obs?.log.info("heartbeat from kiosk {id}", { id: String(kiosk.id) });
const body = await readBody<{ const body = await readBody<{
bundle_version?: string; bundle_version?: string;
@ -607,6 +609,7 @@ function registerKioskRoutes(
}>(event); }>(event);
if (!body?.topic) throw createError({ statusCode: 400, statusMessage: "topic required" }); if (!body?.topic) throw createError({ statusCode: 400, statusMessage: "topic required" });
event.context.obs?.log.info("event from kiosk {id} topic {topic}", { id: String(kiosk.id), topic: body.topic });
// Dedup: Hikvision cameras send duplicate ONVIF events within ~1s. // Dedup: Hikvision cameras send duplicate ONVIF events within ~1s.
// Key = kiosk_id:camera_id:topic:source_keys_hash. Window = 2s. // Key = kiosk_id:camera_id:topic:source_keys_hash. Window = 2s.
@ -747,6 +750,7 @@ function registerKioskRoutes(
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
const verified = await auth.verifyKioskKey(token); const verified = await auth.verifyKioskKey(token);
if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
event.context.obs?.log.info("firmware check for kiosk {id}", { id: String(verified.id) });
const kiosk = await repo.getKioskById(verified.id); const kiosk = await repo.getKioskById(verified.id);
if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" }); if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" });
@ -875,6 +879,7 @@ function registerKioskRoutes(
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
const verified = await auth.verifyKioskKey(token); const verified = await auth.verifyKioskKey(token);
if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
event.context.obs?.log.info("os update check for kiosk {id}", { id: String(verified.id) });
const kiosk = await repo.getKioskById(verified.id); const kiosk = await repo.getKioskById(verified.id);
if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" }); if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" });

View file

@ -5,6 +5,7 @@
* No label filtering for v0.1. * No label filtering for v0.1.
*/ */
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { Observable } from "@bsb/base";
import type { Repository } from "./db/repository.js"; import type { Repository } from "./db/repository.js";
import type { SecretsApi } from "./secrets.js"; import type { SecretsApi } from "./secrets.js";
@ -126,9 +127,15 @@ export async function generateBundle(
secrets: SecretsApi, secrets: SecretsApi,
kioskId: number, kioskId: number,
clusterKey: string | undefined, clusterKey: string | undefined,
obs?: Observable,
): Promise<KioskBundle | null> { ): Promise<KioskBundle | null> {
const span = obs?.startSpan("generateBundle", { "kiosk.id": kioskId });
const kiosk = await repo.getKioskById(kioskId); const kiosk = await repo.getKioskById(kioskId);
if (!kiosk) return null; if (!kiosk) {
span?.log.info("bundle: kiosk {id} not found", { id: String(kioskId) });
span?.end();
return null;
}
// Per-kiosk encryption key (preferred) — decrypt from server storage. // Per-kiosk encryption key (preferred) — decrypt from server storage.
let kioskEncryptKey: string | undefined; let kioskEncryptKey: string | undefined;
@ -149,7 +156,11 @@ export async function generateBundle(
// Admin can disable a display — kiosk must never open a window on it. // Admin can disable a display — kiosk must never open a window on it.
const displays = allDisplays.filter((d) => d.is_enabled); const displays = allDisplays.filter((d) => d.is_enabled);
if (displays.length === 0) return null; if (displays.length === 0) {
span?.log.info("bundle: kiosk {id} has no enabled displays", { id: String(kioskId) });
span?.end();
return null;
}
// Collect camera IDs across ALL displays' layouts (de-duped). // Collect camera IDs across ALL displays' layouts (de-duped).
const allLayoutIds = new Set<number>(); const allLayoutIds = new Set<number>();
@ -348,5 +359,10 @@ export async function generateBundle(
.update(JSON.stringify(bundle)) .update(JSON.stringify(bundle))
.digest("hex"); .digest("hex");
span?.log.info("bundle generated for kiosk {id} version {ver}", {
id: String(kioskId),
ver: bundle.version.slice(0, 12),
});
span?.end();
return bundle; return bundle;
} }

View file

@ -9,6 +9,7 @@
* cross workers with the same handle. * cross workers with the same handle.
*/ */
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { Observable } from "@bsb/base";
import type { DbAdapter, RunResult, Row } from "./db-adapter.js"; import type { DbAdapter, RunResult, Row } from "./db-adapter.js";
import type { import type {
@ -92,23 +93,63 @@ type NotifyFn = (
export class Repository { export class Repository {
readonly adapter: DbAdapter; readonly adapter: DbAdapter;
private readonly notify: NotifyFn; private readonly notify: NotifyFn;
private _obs?: Observable;
constructor(adapter: DbAdapter, notify: NotifyFn) { constructor(adapter: DbAdapter, notify: NotifyFn) {
this.adapter = adapter; this.adapter = adapter;
this.notify = notify; this.notify = notify;
} }
/** Set a per-request observable for DB call tracing. */
withObs(obs: Observable): this {
this._obs = obs;
return this;
}
/** Clear the per-request observable. */
clearObs(): this {
this._obs = undefined;
return this;
}
/** Run a write statement. Params are passed as an array. */ /** Run a write statement. Params are passed as an array. */
private _run(sql: string, params: unknown[] = []): Promise<RunResult> { private async _run(sql: string, params: unknown[] = []): Promise<RunResult> {
return this.adapter.run(sql, params as any); const span = this._obs?.startSpan("db.run", { "db.statement": sql.slice(0, 100) });
try {
const result = await this.adapter.run(sql, params as any);
span?.end();
return result;
} catch (err) {
span?.log.error("db error: {err}", { err: (err as Error).message });
span?.end();
throw err;
}
} }
/** Single-row query. */ /** Single-row query. */
private _get<T = Row>(sql: string, params: unknown[] = []): Promise<T | undefined> { private async _get<T = Row>(sql: string, params: unknown[] = []): Promise<T | undefined> {
return this.adapter.get<T>(sql, params as any); const span = this._obs?.startSpan("db.get", { "db.statement": sql.slice(0, 100) });
try {
const result = await this.adapter.get<T>(sql, params as any);
span?.end();
return result;
} catch (err) {
span?.log.error("db error: {err}", { err: (err as Error).message });
span?.end();
throw err;
}
} }
/** Multi-row query. */ /** Multi-row query. */
private _all<T = Row>(sql: string, params: unknown[] = []): Promise<T[]> { private async _all<T = Row>(sql: string, params: unknown[] = []): Promise<T[]> {
return this.adapter.all<T>(sql, params as any); const span = this._obs?.startSpan("db.all", { "db.statement": sql.slice(0, 100) });
try {
const result = await this.adapter.all<T>(sql, params as any);
span?.end();
return result;
} catch (err) {
span?.log.error("db error: {err}", { err: (err as Error).message });
span?.end();
throw err;
}
} }
/** Execute DDL. */ /** Execute DDL. */
private _exec(sql: string): Promise<void> { private _exec(sql: string): Promise<void> {

View file

@ -125,7 +125,9 @@ export async function confirmPairing(
auth: AuthApi, auth: AuthApi,
secrets: SecretsApi, secrets: SecretsApi,
input: PairingConfirmInput, input: PairingConfirmInput,
obs?: Observable,
): Promise<{ kioskId: number; kioskName: string }> { ): Promise<{ kioskId: number; kioskName: string }> {
obs?.log.info("confirm pairing for code {code}", { code: input.code });
const pc = await repo.getPairingCode(input.code); const pc = await repo.getPairingCode(input.code);
if (!pc) throw new Error("pairing code not found"); if (!pc) throw new Error("pairing code not found");
if (pc.consumed_at) throw new Error("pairing code already used"); if (pc.consumed_at) throw new Error("pairing code already used");
@ -237,5 +239,6 @@ export async function confirmPairing(
encrypt_key: kioskEncryptKey, encrypt_key: kioskEncryptKey,
}); });
obs?.log.info("created kiosk {name} id {id}", { name: kioskName, id: String(kioskId) });
return { kioskId, kioskName }; return { kioskId, kioskName };
} }