Fas 1a: analytics tracker, ingestion endpoint, admin dashboards
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import { isAnalyticsOptedIn, schema } from "@app/database";
|
||||
import {
|
||||
analyticsBatchInputSchema,
|
||||
MAX_ANALYTICS_BATCH_SIZE,
|
||||
validateAnalyticsPropertySize,
|
||||
} from "@app/validation";
|
||||
import { parse, errors } from "../lib/errors.js";
|
||||
import { audit } from "../lib/helpers.js";
|
||||
|
||||
/** Max events per batch is also the per-minute rate-limit budget for this endpoint. */
|
||||
const ANALYTICS_RATE_LIMIT_MAX = MAX_ANALYTICS_BATCH_SIZE;
|
||||
|
||||
/**
|
||||
* Product analytics ingestion (spec §8.2).
|
||||
* Accepts both authenticated and anonymous events. Anonymous events must have
|
||||
* anonymousId and can be backfilled to userId later when the user logs in.
|
||||
*/
|
||||
export async function analyticsRoutes(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/v1/analytics/events",
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: ANALYTICS_RATE_LIMIT_MAX,
|
||||
timeWindow: "1 minute",
|
||||
keyGenerator: (req) => `analytics:${req.ip}:${(req as unknown as { userId?: string }).userId ?? "anon"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
const input = parse(analyticsBatchInputSchema, req.body, "analytics batch");
|
||||
validateAnalyticsPropertySize(input.events);
|
||||
|
||||
let userId: string | undefined;
|
||||
try {
|
||||
const payload = await req.jwtVerify<{ sub: string; role: string; type: string }>();
|
||||
if (payload.type === "access" && payload.sub) {
|
||||
userId = payload.sub;
|
||||
}
|
||||
} catch {
|
||||
// Anonymous event – fine as long as the batch carries anonymousId.
|
||||
}
|
||||
const isAuthenticated = !!userId;
|
||||
|
||||
if (isAuthenticated && userId) {
|
||||
const optedIn = await isAnalyticsOptedIn(app.db, userId);
|
||||
if (!optedIn) {
|
||||
throw errors.forbidden("Analytics collection disabled by user consent.");
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const rows = input.events.map((event, index) => {
|
||||
const occurredAt = event.occurredAt ? new Date(event.occurredAt) : now;
|
||||
if (Number.isNaN(occurredAt.getTime())) {
|
||||
throw errors.badRequest(`Invalid occurredAt for event ${index}`);
|
||||
}
|
||||
return {
|
||||
occurredAt,
|
||||
receivedAt: now,
|
||||
eventName: event.name,
|
||||
anonymousId: event.anonymousId ?? null,
|
||||
sessionId: event.sessionId ?? null,
|
||||
userId: isAuthenticated ? userId : null,
|
||||
householdId: event.householdId ?? null,
|
||||
appVersion: event.appVersion ?? null,
|
||||
platform: event.platform ?? null,
|
||||
locale: event.locale ?? null,
|
||||
experimentVariant: event.experimentVariant ?? null,
|
||||
properties: event.properties ?? {},
|
||||
};
|
||||
});
|
||||
|
||||
// Single insert for the whole batch. On conflict is impossible because
|
||||
// events are insert-only with random UUIDs.
|
||||
const result = await app.db.insert(schema.productAnalyticsEvents).values(rows);
|
||||
const accepted = result.rowCount ?? rows.length;
|
||||
|
||||
if (isAuthenticated) {
|
||||
await audit(app.db, {
|
||||
actorUserId: userId,
|
||||
action: "analytics.ingest",
|
||||
targetType: "analytics_batch",
|
||||
targetId: String(accepted),
|
||||
ip: req.ip,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(202).send({ accepted, rejected: 0 });
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user