mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 21:26:33 +00:00
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:
parent
4880dc32fc
commit
c91f9cb450
6 changed files with 84 additions and 10 deletions
|
|
@ -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" } });
|
||||||
|
|
|
||||||
|
|
@ -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" } });
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue