feat(ops): /ops/v1/summary med ai/aktivering/engagemang/betalning/jobb/sakerhet + canary

- Nya toppnivåblock i /ops/v1/summary: ai_scan, aktivering, engagemang,
  betalning, jobb, sakerhet. Alla värden läses från DB/cache; null där data
  saknas, inga påhittade värden.
- AI-kostnad i USD mikrocent (native); intäkter fortsatt SEK-öre.
- product_analytics_events är källa för volym, latens, lyckandegrad,
  felfrekvens, aktivering, retention och engagemang.
- Nya händelser: scan_started (API) och scan_failed/scan_completed med
  latencyMs + felkod (worker). latencyMs flödar nu in i scan_completed.
- Safety canary-jobb varje timme: re-härleder allergener för alla recept,
  räknar överifierade publika recept och food-safety-lint; skriver EN rad till
  ops_safety_canary. Endpointen läser endast sista raden.
- Cache-refresh-jobb var 60 s skriver hela summariet till Redis; endpointen
  serverar cachen med 503 vid cache-miss.
- Bearer-token-skydd med OPS_TOKEN; HTTPS-tvång i produktion; ingen PII.
- Tester för endpoint, auth, cache-miss och safety canary.
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 05:47:43 +07:00
parent f4a603e977
commit 8f20b2ef1b
27 changed files with 1169 additions and 41 deletions
+77
View File
@@ -0,0 +1,77 @@
import { Queue } from "bullmq";
import type { Redis } from "ioredis";
import { computeOpsSummary, type OpsQueueSummary } from "@app/database/ops-summary";
import { JOB_QUEUE_NAME } from "@app/shared-types";
import type { WorkerContext } from "../context.js";
const CACHE_KEY = "ops:summary:cache";
const CACHE_TS_KEY = "ops:summary:computed_at";
const CACHE_TTL_SECONDS = 120;
const BUDGET_KEY_PREFIX = "gemini:daily:budget:";
function budgetKey(): string {
return `${BUDGET_KEY_PREFIX}${new Date().toISOString().slice(0, 10)}`;
}
async function fetchQueueSummary(redis: Redis): Promise<OpsQueueSummary> {
const queue = new Queue(JOB_QUEUE_NAME, { connection: redis });
try {
const [counts, waiting, workers, failed] = await Promise.all([
queue.getJobCounts("wait", "active", "delayed", "completed", "failed"),
queue.getJobs(["wait"], 0, 0, true),
queue.getWorkers(),
queue.getJobs(["failed"], 0, 1000, true),
]);
const oldestWaiting = waiting[0];
const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
const failed24h = failed.filter(
(job) =>
(typeof job.finishedOn === "number" && job.finishedOn >= dayAgo) ||
(typeof job.timestamp === "number" && job.timestamp >= dayAgo),
).length;
return {
vantande: counts.wait ?? 0,
aktiva: counts.active ?? 0,
misslyckade_24h: failed24h,
aldsta_vantande_sek: oldestWaiting
? Math.max(0, Math.floor((Date.now() - oldestWaiting.timestamp) / 1000))
: null,
workers_ok: workers.length > 0,
};
} finally {
await queue.close();
}
}
/**
* Compute the full ops summary and write it to Redis. Called by the
* REFRESH_OPS_SUMMARY_CACHE worker job every 60 s.
*/
export async function refreshOpsSummaryCache(ctx: WorkerContext): Promise<void> {
if (!ctx.redis) {
throw new Error("[refreshOpsSummaryCache] Redis krävs för cache- och kö-aggregering.");
}
const redis = ctx.redis;
const [queueSummary, dailySpendRaw] = await Promise.all([
fetchQueueSummary(redis),
redis.get(budgetKey()),
]);
const dailySpendUsd = dailySpendRaw ? Number(dailySpendRaw) : null;
const budgetUsd = Number(process.env.GEMINI_DAILY_BUDGET_USD ?? 0);
const summary = await computeOpsSummary({
db: ctx.db,
budgetUsd,
dailySpendUsd,
queueSummary,
});
const json = JSON.stringify(summary);
await redis.set(CACHE_KEY, json, "EX", CACHE_TTL_SECONDS);
await redis.set(CACHE_TS_KEY, summary.as_of, "EX", CACHE_TTL_SECONDS);
}
export { CACHE_KEY, CACHE_TS_KEY };