From c91f9cb450d41b469918f779153382ce0654de75 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Tue, 26 May 2026 01:47:24 +0200 Subject: [PATCH] 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) --- .../service-admin-http/routes-admin.ts | 10 +++- .../service-admin-http/routes-cloud.ts | 1 + server/src/plugins/service-api-http/index.ts | 7 ++- server/src/shared/bundle.ts | 20 ++++++- server/src/shared/db/repository.ts | 53 ++++++++++++++++--- server/src/shared/pairing.ts | 3 ++ 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 69644f6..c2b6bcf 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -504,6 +504,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); 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 body = await readBody>(event); const name = (body?.["name"] ?? "").trim(); @@ -842,6 +843,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); 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>(event); const code = (body?.["code"] ?? "").trim().toUpperCase(); const nameOverride = (body?.["name_override"] ?? "").trim() || undefined; @@ -858,7 +860,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { initialLabels, replaceKioskId, force, - }); + }, event.context.obs); await audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", { resource_type: "kiosk", resource_id: result.kioskId, @@ -898,6 +900,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); 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 body = await readBody>(event); const name = (body?.["name"] ?? "").trim(); @@ -949,6 +952,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); 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 body = await readBody>(event); 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) => { + 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")); await deps.repo.deleteLayout(id); notifyKiosks(); @@ -1403,6 +1408,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); 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 cam = await deps.repo.getCameraById(id); if (cam?.type === "cloud") { @@ -1547,6 +1553,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); 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")); await deps.repo.deleteCamera(id); notifyKiosks(); @@ -1804,6 +1811,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); 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")); await deps.repo.deleteKiosk(id); return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); diff --git a/server/src/plugins/service-admin-http/routes-cloud.ts b/server/src/plugins/service-admin-http/routes-cloud.ts index 1359354..58a833c 100644 --- a/server/src/plugins/service-admin-http/routes-cloud.ts +++ b/server/src/plugins/service-admin-http/routes-cloud.ts @@ -133,6 +133,7 @@ export function registerCloudRoutes(app: H3, deps: AdminDeps): void { }); 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")); await syncCloudAccount(id, deps); return new Response(null, { status: 302, headers: { location: "/admin/cloud-accounts" } }); diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 34e1e82..cb81848 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -371,8 +371,9 @@ function registerKioskRoutes( const kiosk = await auth.verifyKioskKey(token); 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 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" }); // Content-hash ETag: kiosk sends If-None-Match on subsequent fetches. @@ -402,6 +403,7 @@ function registerKioskRoutes( const kiosk = await auth.verifyKioskKey(token); 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<{ bundle_version?: string; @@ -607,6 +609,7 @@ function registerKioskRoutes( }>(event); 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. // 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" }); const verified = await auth.verifyKioskKey(token); 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); 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" }); const verified = await auth.verifyKioskKey(token); 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); if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" }); diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index 1763bb0..44fc233 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -5,6 +5,7 @@ * No label filtering for v0.1. */ import { createHash } from "node:crypto"; +import type { Observable } from "@bsb/base"; import type { Repository } from "./db/repository.js"; import type { SecretsApi } from "./secrets.js"; @@ -126,9 +127,15 @@ export async function generateBundle( secrets: SecretsApi, kioskId: number, clusterKey: string | undefined, + obs?: Observable, ): Promise { + const span = obs?.startSpan("generateBundle", { "kiosk.id": 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. 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. 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). const allLayoutIds = new Set(); @@ -348,5 +359,10 @@ export async function generateBundle( .update(JSON.stringify(bundle)) .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; } diff --git a/server/src/shared/db/repository.ts b/server/src/shared/db/repository.ts index 6918866..41b05ec 100644 --- a/server/src/shared/db/repository.ts +++ b/server/src/shared/db/repository.ts @@ -9,6 +9,7 @@ * cross workers with the same handle. */ import { randomBytes } from "node:crypto"; +import type { Observable } from "@bsb/base"; import type { DbAdapter, RunResult, Row } from "./db-adapter.js"; import type { @@ -92,23 +93,63 @@ type NotifyFn = ( export class Repository { readonly adapter: DbAdapter; private readonly notify: NotifyFn; + private _obs?: Observable; constructor(adapter: DbAdapter, notify: NotifyFn) { this.adapter = adapter; 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. */ - private _run(sql: string, params: unknown[] = []): Promise { - return this.adapter.run(sql, params as any); + private async _run(sql: string, params: unknown[] = []): Promise { + 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. */ - private _get(sql: string, params: unknown[] = []): Promise { - return this.adapter.get(sql, params as any); + private async _get(sql: string, params: unknown[] = []): Promise { + const span = this._obs?.startSpan("db.get", { "db.statement": sql.slice(0, 100) }); + try { + const result = await this.adapter.get(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. */ - private _all(sql: string, params: unknown[] = []): Promise { - return this.adapter.all(sql, params as any); + private async _all(sql: string, params: unknown[] = []): Promise { + const span = this._obs?.startSpan("db.all", { "db.statement": sql.slice(0, 100) }); + try { + const result = await this.adapter.all(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. */ private _exec(sql: string): Promise { diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index 8f42456..2763d0f 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -125,7 +125,9 @@ export async function confirmPairing( auth: AuthApi, secrets: SecretsApi, input: PairingConfirmInput, + obs?: Observable, ): Promise<{ kioskId: number; kioskName: string }> { + obs?.log.info("confirm pairing for code {code}", { code: input.code }); const pc = await repo.getPairingCode(input.code); if (!pc) throw new Error("pairing code not found"); if (pc.consumed_at) throw new Error("pairing code already used"); @@ -237,5 +239,6 @@ export async function confirmPairing( encrypt_key: kioskEncryptKey, }); + obs?.log.info("created kiosk {name} id {id}", { name: kioskName, id: String(kioskId) }); return { kioskId, kioskName }; }