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
+1
View File
@@ -37,6 +37,7 @@ export const scanStarted = builder("scan_started");
export const scanUploaded = builder("scan_uploaded");
export const scanProcessingCompleted = builder("scan_processing_completed");
export const scanProcessingFailed = builder("scan_processing_failed");
export const scanFailed = builder("scan_failed");
export const scanReviewOpened = builder("scan_review_opened");
export const scanItemConfirmed = builder("scan_item_confirmed");
export const scanItemCorrected = builder("scan_item_corrected");
+3
View File
@@ -6,7 +6,9 @@
"description": "Drizzle-schema, migrationer, seed och databasklient (separat databas, spec §52)",
"exports": {
".": "./src/index.ts",
"./analytics": "./src/analytics.ts",
"./client": "./src/client.ts",
"./ops-summary": "./src/ops-summary.ts",
"./schema": "./src/schema/index.ts",
"./seed": "./src/seed/index.ts"
},
@@ -21,6 +23,7 @@
"db:test-setup": "tsx src/migrate.ts --test && tsx src/seed/run.ts --test"
},
"dependencies": {
"@app/analytics": "workspace:*",
"@app/nutrition-engine": "workspace:*",
"@app/shared-types": "workspace:*",
"dotenv": "^16.4.0",
+42
View File
@@ -0,0 +1,42 @@
import { and, eq } from "drizzle-orm";
import type { AnalyticsEvent } from "@app/analytics";
import type { Database } from "./client.js";
import { schema } from "./index.js";
/**
* Track a product analytics event server-side if the user has opted in.
* Moved to @app/database so both API routes and workers can write events
* without depending on apps/api internals.
*/
export async function trackProductAnalytics(
db: Database,
userId: string,
event: AnalyticsEvent,
): Promise<void> {
const optedIn = await db
.select({ status: schema.userConsents.status })
.from(schema.userConsents)
.where(
and(
eq(schema.userConsents.userId, userId),
eq(schema.userConsents.kind, "product_analytics"),
),
)
.limit(1);
if (optedIn[0] && optedIn[0].status !== "granted") return;
await db.insert(schema.productAnalyticsEvents).values({
occurredAt: event.occurredAt ? new Date(event.occurredAt) : new Date(),
receivedAt: new Date(),
eventName: event.name,
anonymousId: event.anonymousId ?? null,
sessionId: event.sessionId ?? null,
userId,
householdId: event.householdId ?? null,
appVersion: event.appVersion ?? null,
platform: event.platform ?? null,
locale: event.locale ?? null,
experimentVariant: event.experimentVariant ?? null,
properties: event.properties ?? {},
});
}
+1
View File
@@ -1,6 +1,7 @@
export * from "./client.js";
export * as schema from "./schema/index.js";
export * from "./schema/index.js";
export * from "./analytics.js";
export * from "./analytics-gdpr.js";
export * from "./gdpr-erasure.js";
export * from "./release-gates.js";
+383
View File
@@ -0,0 +1,383 @@
import { sql } from "drizzle-orm";
import type {
OpsActivationBlock,
OpsAiScanBlock,
OpsEngagementBlock,
OpsPaymentBlock,
OpsQueueBlock,
OpsSafetyBlock,
OpsSummary,
} from "@app/shared-types";
import type { Database } from "./client.js";
import { schema } from "./index.js";
export interface OpsQueueSummary {
vantande: number;
aktiva: number;
misslyckade_24h: number;
aldsta_vantande_sek: number | null;
workers_ok: boolean;
}
export interface ComputeOpsSummaryOptions {
db: Database;
/** Gemini daily budget in USD (0 = disabled). */
budgetUsd: number;
/** Current daily spend in USD, from Redis budget store. */
dailySpendUsd?: number | null;
queueSummary?: OpsQueueSummary;
}
const MICROCENTS_PER_USD = 100_000_000;
const ESTIMATED_SCAN_COST_USD = 0.0015;
const ESTIMATED_SCAN_COST_MICROCENTS = Math.round(ESTIMATED_SCAN_COST_USD * MICROCENTS_PER_USD);
function asNumber(value: unknown): number {
return Number(value ?? 0);
}
function safeDiv(numerator: number, denominator: number): number | null {
if (denominator === 0) return null;
return numerator / denominator;
}
function roundRate(value: number | null): number | null {
if (value === null || Number.isNaN(value)) return null;
return Math.round(value * 1_000_000) / 1_000_000;
}
async function countEvents(db: Database, name: string, hours: number): Promise<number> {
const result = await db.execute(sql`
SELECT count(*)::int AS n
FROM product_analytics_events
WHERE event_name = ${name}
AND occurred_at >= now() - make_interval(hours => ${hours})`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as { n: number } | undefined;
return asNumber(row?.n);
}
async function aiScanBlock(
db: Database,
budgetUsd: number,
dailySpendUsd: number | null | undefined,
): Promise<OpsAiScanBlock> {
const scans24h = await countEvents(db, "scan_completed", 24);
const scans7d = await countEvents(db, "scan_completed", 24 * 7);
const started24h = await countEvents(db, "scan_started", 24);
const failed24h = await countEvents(db, "scan_failed", 24);
const latency = await db.execute(sql`
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY (properties->>'latencyMs')::int) AS p50,
percentile_cont(0.95) WITHIN GROUP (ORDER BY (properties->>'latencyMs')::int) AS p95
FROM product_analytics_events
WHERE event_name = 'scan_completed'
AND occurred_at >= now() - interval '24 hours'
AND properties->>'latencyMs' IS NOT NULL
`);
const latencyRow = (Array.isArray(latency) ? latency[0] : latency.rows[0]) as
{ p50: string | number | null; p95: string | number | null } | undefined;
const latestErrors = await db.execute(sql`
SELECT properties->>'errorCode' AS code, occurred_at AS tid
FROM product_analytics_events
WHERE event_name = 'scan_failed'
AND occurred_at >= now() - interval '24 hours'
ORDER BY occurred_at DESC
LIMIT 5
`);
const latestErrorsRows = (
Array.isArray(latestErrors) ? latestErrors : latestErrors.rows
) as Array<{
code: string | null;
tid: string | Date;
}>;
const month = await db.execute(sql`
SELECT COALESCE(sum(ai_cost_usd_microcents), 0)::bigint AS total
FROM ai_usage_counters
WHERE month = to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM')
`);
const monthRow = (Array.isArray(month) ? month[0] : month.rows[0]) as
{ total: bigint | number } | undefined;
const monthlyCostMicrocents =
typeof monthRow?.total === "bigint" ? Number(monthRow.total) : asNumber(monthRow?.total);
const exact24h = await db.execute(sql`
SELECT COALESCE(sum(cost_usd), 0)::float AS total
FROM scan_jobs
WHERE cost_usd IS NOT NULL
AND updated_at >= now() - interval '24 hours'
AND status IN ('awaiting_confirmation', 'completed')
`);
const exact24hRow = (Array.isArray(exact24h) ? exact24h[0] : exact24h.rows[0]) as
{ total: number } | undefined;
const exactCostUsd = asNumber(exact24hRow?.total);
let cost24hMicrocents: number | null = null;
let cost24hApprox = false;
if (exactCostUsd > 0) {
cost24hMicrocents = Math.round(exactCostUsd * MICROCENTS_PER_USD);
} else if (scans24h > 0) {
cost24hMicrocents = scans24h * ESTIMATED_SCAN_COST_MICROCENTS;
cost24hApprox = true;
}
let budgetAndel: number | null = null;
if (budgetUsd > 0 && dailySpendUsd != null && !Number.isNaN(dailySpendUsd)) {
budgetAndel = Math.min(1, dailySpendUsd / budgetUsd);
}
return {
scans_24h: scans24h,
scans_7d: scans7d,
lyckandegrad_24h: roundRate(safeDiv(scans24h, started24h)),
latens_p50_ms: latencyRow?.p50 != null ? Math.round(Number(latencyRow.p50)) : null,
latens_p95_ms: latencyRow?.p95 != null ? Math.round(Number(latencyRow.p95)) : null,
felfrekvens_24h: roundRate(safeDiv(failed24h, started24h)),
senaste_fel:
latestErrorsRows.length > 0
? latestErrorsRows.map((r) => ({
kod: r.code,
tid: r.tid instanceof Date ? r.tid.toISOString() : String(r.tid),
}))
: null,
kostnad_usd_mikrocent_manad: monthlyCostMicrocents,
kostnad_usd_mikrocent_24h: cost24hMicrocents,
kostnad_usd_mikrocent_24h_uppskattad: cost24hApprox,
budget_andel: roundRate(budgetAndel),
as_of: new Date().toISOString(),
};
}
async function conversionWithin(
db: Database,
fromEvent: string,
toEvent: string,
cohortDays: number,
withinHours: number,
): Promise<number | null> {
const result = await db.execute(sql`
WITH cohort AS (
SELECT DISTINCT user_id, occurred_at AS created_at
FROM product_analytics_events
WHERE event_name = ${fromEvent}
AND occurred_at >= now() - make_interval(days => ${cohortDays})
AND user_id IS NOT NULL
),
converted AS (
SELECT DISTINCT c.user_id
FROM cohort c
INNER JOIN product_analytics_events e ON e.user_id = c.user_id
WHERE e.event_name = ${toEvent}
AND e.occurred_at >= c.created_at
AND e.occurred_at < c.created_at + make_interval(hours => ${withinHours})
)
SELECT
(SELECT count(*) FROM cohort) AS total,
(SELECT count(*) FROM converted) AS converted
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
{ total: number; converted: number } | undefined;
if (!row || asNumber(row.total) === 0) return null;
return asNumber(row.converted) / asNumber(row.total);
}
async function householdConversionWithin(
db: Database,
fromEvent: string,
toEvent: string,
cohortDays: number,
): Promise<number | null> {
const result = await db.execute(sql`
WITH cohort AS (
SELECT DISTINCT household_id, occurred_at AS created_at
FROM product_analytics_events
WHERE event_name = ${fromEvent}
AND occurred_at >= now() - make_interval(days => ${cohortDays})
AND household_id IS NOT NULL
),
converted AS (
SELECT DISTINCT c.household_id
FROM cohort c
INNER JOIN product_analytics_events e ON e.household_id = c.household_id
WHERE e.event_name = ${toEvent}
AND e.occurred_at >= c.created_at
AND e.occurred_at < c.created_at + make_interval(days => ${cohortDays})
)
SELECT
(SELECT count(*) FROM cohort) AS total,
(SELECT count(*) FROM converted) AS converted
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
{ total: number; converted: number } | undefined;
if (!row || asNumber(row.total) === 0) return null;
return asNumber(row.converted) / asNumber(row.total);
}
async function cohortRetention(db: Database, day: number): Promise<number | null> {
const result = await db.execute(sql`
WITH cohort AS (
SELECT DISTINCT user_id,
(occurred_at AT TIME ZONE 'UTC')::date AS cohort_date
FROM product_analytics_events
WHERE event_name = 'account_created'
AND occurred_at >= now() - interval '30 days'
AND user_id IS NOT NULL
),
active AS (
SELECT DISTINCT c.user_id
FROM cohort c
INNER JOIN product_analytics_events e ON e.user_id = c.user_id
WHERE (e.occurred_at AT TIME ZONE 'UTC')::date = c.cohort_date + ${day}::int
)
SELECT
(SELECT count(DISTINCT user_id) FROM cohort) AS total,
(SELECT count(*) FROM active) AS active
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
{ total: number; active: number } | undefined;
if (!row || asNumber(row.total) === 0) return null;
return asNumber(row.active) / asNumber(row.total);
}
async function activationBlock(db: Database): Promise<OpsActivationBlock> {
const [scan24h, cook7d, householdSecond, d1, d7, d30] = await Promise.all([
conversionWithin(db, "account_created", "scan_completed", 7, 24),
conversionWithin(db, "account_created", "cooking_session_completed", 30, 24 * 7),
householdConversionWithin(db, "household_created", "second_member_first_action", 30),
cohortRetention(db, 1),
cohortRetention(db, 7),
cohortRetention(db, 30),
]);
return {
scan_inom_24h_andel: roundRate(scan24h),
lagad_maltid_inom_7d_andel: roundRate(cook7d),
hushall_andra_medlem_aktiv_andel: roundRate(householdSecond),
retention_d1: roundRate(d1),
retention_d7: roundRate(d7),
retention_d30: roundRate(d30),
};
}
async function engagementBlock(db: Database): Promise<OpsEngagementBlock> {
const result = await db.execute(sql`
SELECT
count(*) FILTER (WHERE event_name = 'cooking_session_completed' AND occurred_at >= now() - interval '24 hours')::int AS c24,
count(*) FILTER (WHERE event_name = 'cooking_session_completed' AND occurred_at >= now() - interval '7 days')::int AS c7,
count(*) FILTER (WHERE event_name = 'recommendation_opened' AND occurred_at >= now() - interval '7 days')::int AS opened,
count(*) FILTER (WHERE event_name = 'recommendations_viewed' AND occurred_at >= now() - interval '7 days')::int AS viewed
FROM product_analytics_events
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
{ c24: number; c7: number; opened: number; viewed: number } | undefined;
const tips = await db.execute(sql`
SELECT count(*)::int AS n
FROM notifications
WHERE type = 'proactive_tip'
AND created_at >= now() - interval '24 hours'
`);
const tipsRow = (Array.isArray(tips) ? tips[0] : tips.rows[0]) as { n: number } | undefined;
return {
lagade_maltider_24h: asNumber(row?.c24),
lagade_maltider_7d: asNumber(row?.c7),
rek_ctr_7d: roundRate(safeDiv(asNumber(row?.opened), asNumber(row?.viewed))),
puffar_skickade_24h: asNumber(tipsRow?.n),
puffar_atgardsgrad_7d: null,
};
}
async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
const result = await db.execute(sql`
SELECT
count(*) FILTER (WHERE status = 'expired' OR (status != 'active' AND grace_period_expires_at < now()))::int AS failed,
count(*) FILTER (WHERE status = 'in_grace' OR (grace_period_expires_at IS NOT NULL AND grace_period_expires_at >= now() AND status != 'active'))::int AS grace
FROM subscriptions
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
{ failed: number; grace: number } | undefined;
const trials = await db.execute(sql`
SELECT count(*)::int AS n
FROM trials
WHERE ends_at >= now()
AND ends_at <= now() + interval '48 hours'
`);
const trialsRow = (Array.isArray(trials) ? trials[0] : trials.rows[0]) as
{ n: number } | undefined;
const store = await db.execute(sql`
SELECT
count(*) FILTER (WHERE lower(notification_type) LIKE '%refund%')::int AS refunds,
count(*) FILTER (WHERE lower(notification_type) LIKE '%chargeback%' OR lower(notification_type) LIKE '%revoke%')::int AS chargebacks
FROM store_notifications
WHERE created_at >= now() - interval '7 days'
`);
const storeRow = (Array.isArray(store) ? store[0] : store.rows[0]) as
{ refunds: number; chargebacks: number } | undefined;
return {
failed_nu: asNumber(row?.failed),
grace_period_nu: asNumber(row?.grace),
trials_utgar_48h: asNumber(trialsRow?.n),
aterbetalningar_7d: storeRow ? asNumber(storeRow.refunds) : null,
chargebacks_7d: storeRow ? asNumber(storeRow.chargebacks) : null,
};
}
function defaultQueueBlock(): OpsQueueBlock {
return {
vantande: 0,
aktiva: 0,
misslyckade_24h: 0,
aldsta_vantande_sek: null,
workers_ok: false,
};
}
async function safetyBlock(db: Database): Promise<OpsSafetyBlock> {
const result = await db
.select()
.from(schema.opsSafetyCanary)
.orderBy(sql`${schema.opsSafetyCanary.occurredAt} DESC`)
.limit(1);
const row = result[0];
if (!row) {
return {
allergen_invariant_brott: 0,
overifierade_visade: 0,
food_safety_lint_avvisade_7d: null,
senaste_kontroll: null,
};
}
return {
allergen_invariant_brott: row.allergenInvariantBrott,
overifierade_visade: row.overifieradeVisade,
food_safety_lint_avvisade_7d: row.foodSafetyLintAvvisade7d,
senaste_kontroll: row.computedAt.toISOString(),
};
}
export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise<OpsSummary> {
const { db, budgetUsd, dailySpendUsd, queueSummary } = options;
const [ai, activation, engagement, payment, safety] = await Promise.all([
aiScanBlock(db, budgetUsd, dailySpendUsd),
activationBlock(db),
engagementBlock(db),
paymentBlock(db),
safetyBlock(db),
]);
return {
ai_scan: ai,
aktivering: activation,
engagemang: engagement,
betalning: payment,
jobb: queueSummary ?? defaultQueueBlock(),
sakerhet: safety,
as_of: new Date().toISOString(),
};
}
+1
View File
@@ -18,5 +18,6 @@ export * from "./memory.js";
export * from "./seasons.js";
export * from "./subscriptions.js";
export * from "./analytics.js";
export * from "./ops.js";
export * from "./platform.js";
export * from "./releaseGates.js";
+17
View File
@@ -0,0 +1,17 @@
import { integer, pgTable, timestamp, uuid } from "drizzle-orm/pg-core";
/**
* Safety canary: hourly deterministic checks written by a worker job and
* read by the ops endpoint. The endpoint never runs heavy computations.
*/
export const opsSafetyCanary = pgTable("ops_safety_canary", {
id: uuid("id").primaryKey().defaultRandom(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull().defaultNow(),
/** Recipes where stored allergens differ from freshly derived allergens. */
allergenInvariantBrott: integer("allergen_invariant_brott").notNull().default(0),
/** Publicly visible recipes that are not verificationStatus=verified. */
overifieradeVisade: integer("overifierade_visade").notNull().default(0),
/** Recipes rejected by the food-safety lint in the last 7 days. */
foodSafetyLintAvvisade7d: integer("food_safety_lint_avvisade_7d"),
computedAt: timestamp("computed_at", { withTimezone: true }).notNull().defaultNow(),
});
+6 -1
View File
@@ -29,6 +29,7 @@ export const ANALYTICS_INVENTORY_EVENT_NAMES = [
"scan_uploaded",
"scan_processing_completed",
"scan_processing_failed",
"scan_failed",
"scan_review_opened",
"scan_item_confirmed",
"scan_item_corrected",
@@ -129,7 +130,11 @@ export const FUNNELS = {
cooking_to_updated_inventory: ["cooking_session_started", "cooking_session_completed"],
activated_to_trial: ["cooking_session_completed", "trial_started"],
trial_to_paid: ["trial_started", "subscription_started"],
single_to_two_members: ["household_invite_sent", "household_invite_accepted", "second_member_first_action"],
single_to_two_members: [
"household_invite_sent",
"household_invite_accepted",
"second_member_first_action",
],
} as const;
export type FunnelName = keyof typeof FUNNELS;
+1
View File
@@ -7,4 +7,5 @@ export * from "./locale.js";
export * from "./money.js";
export * from "./measurement.js";
export * from "./analytics.js";
export * from "./ops.js";
export * from "./release-gates.js";
+64
View File
@@ -0,0 +1,64 @@
export interface OpsAiScanBlock {
scans_24h: number;
scans_7d: number;
lyckandegrad_24h: number | null;
latens_p50_ms: number | null;
latens_p95_ms: number | null;
felfrekvens_24h: number | null;
senaste_fel: Array<{ kod: string | null; tid: string }> | null;
kostnad_usd_mikrocent_manad: number;
kostnad_usd_mikrocent_24h: number | null;
kostnad_usd_mikrocent_24h_uppskattad: boolean;
budget_andel: number | null;
as_of: string;
}
export interface OpsActivationBlock {
scan_inom_24h_andel: number | null;
lagad_maltid_inom_7d_andel: number | null;
hushall_andra_medlem_aktiv_andel: number | null;
retention_d1: number | null;
retention_d7: number | null;
retention_d30: number | null;
}
export interface OpsEngagementBlock {
lagade_maltider_24h: number;
lagade_maltider_7d: number;
rek_ctr_7d: number | null;
puffar_skickade_24h: number;
puffar_atgardsgrad_7d: number | null;
}
export interface OpsPaymentBlock {
failed_nu: number;
grace_period_nu: number;
trials_utgar_48h: number;
aterbetalningar_7d: number | null;
chargebacks_7d: number | null;
}
export interface OpsQueueBlock {
vantande: number;
aktiva: number;
misslyckade_24h: number;
aldsta_vantande_sek: number | null;
workers_ok: boolean;
}
export interface OpsSafetyBlock {
allergen_invariant_brott: number;
overifierade_visade: number;
food_safety_lint_avvisade_7d: number | null;
senaste_kontroll: string | null;
}
export interface OpsSummary {
ai_scan: OpsAiScanBlock;
aktivering: OpsActivationBlock;
engagemang: OpsEngagementBlock;
betalning: OpsPaymentBlock;
jobb: OpsQueueBlock;
sakerhet: OpsSafetyBlock;
as_of: string;
}