Gap 1 — click and served share one label: trackDownload now takes a
stage ("click" | "served") carried on a new VisitorAnalytics.stage
column; the Nook door and the tRPC proxy record clicks, the DMG file
route records served. summariseDownloads() and the /analytics downloads
block are untouched — the stage is a separate column, so the path
grouping cannot change.
Gap 2 — checkout start records nothing: the checkout route records a
created Stripe session through the shared trackRequest writer (same
referrer, campaign, country dimensions as every door) under its own
path, and STAGES.checkout reads it instead of the /checkout page proxy.
Gap 3 — loopback addresses, no host dimension: every writer now
resolves the client address through resolveClientIp, which trusts
x-forwarded-for only when the direct peer is the loopback production
proxy (trust boundary stated in request-client.ts), and records the
host on each row through the same site-context extractor routing uses.
stage and host reach the table through VISITOR_ANALYTICS_ADDED_COLUMNS;
the weekly job applies the same bootstrap before reading so it renders
seven stages against a database no deploy has migrated yet.
The weekly read splits downloadClick/dmgServed, scopes visits to
nook.freno.me, and computes its caveats from the window: pre-instrumentation
rows are stated and excluded, and the loopback warning now fires only
when rows recorded since the instrumentation still carry it.
255 lines
7.7 KiB
TypeScript
255 lines
7.7 KiB
TypeScript
import { createTRPCRouter, adminProcedure, publicProcedure, csrfProtectedProcedure } from "../utils";
|
|
import { z } from "zod";
|
|
import {
|
|
queryAnalytics,
|
|
getAnalyticsSummary,
|
|
getPathAnalytics,
|
|
cleanupOldAnalytics,
|
|
getPerformanceStats,
|
|
enrichAnalyticsEntry
|
|
} from "~/server/analytics";
|
|
import { ConnectionFactory } from "~/server/database";
|
|
import { v4 as uuid } from "uuid";
|
|
import { resolveClientIp, requestHost } from "~/server/request-client";
|
|
import { parseUtm } from "~/server/utm";
|
|
import { VISITOR_ANALYTICS_INSERT } from "~/server/analytics-schema";
|
|
|
|
/** Safely get a header value from either Fetch API Headers or Node.js IncomingHttpHeaders */
|
|
function getHeader(
|
|
headers: Record<string, string | string[] | undefined> | Headers | undefined,
|
|
name: string
|
|
): string | undefined {
|
|
if (!headers) return undefined;
|
|
|
|
// Check if it's a Fetch API Headers object (has .get method)
|
|
if (typeof (headers as Headers).get === "function") {
|
|
return (headers as Headers).get(name) || undefined;
|
|
}
|
|
|
|
// Otherwise treat as Node.js IncomingHttpHeaders (plain object)
|
|
const value = (headers as Record<string, string | string[] | undefined>)[
|
|
name.toLowerCase()
|
|
];
|
|
if (Array.isArray(value)) return value[0];
|
|
return value;
|
|
}
|
|
|
|
export const analyticsRouter = createTRPCRouter({
|
|
logPerformance: csrfProtectedProcedure
|
|
.input(
|
|
z.object({
|
|
path: z.string(),
|
|
metrics: z.object({
|
|
fcp: z.number().optional(),
|
|
lcp: z.number().optional(),
|
|
cls: z.number().optional(),
|
|
fid: z.number().optional(),
|
|
inp: z.number().optional(),
|
|
ttfb: z.number().optional(),
|
|
domLoad: z.number().optional(),
|
|
loadComplete: z.number().optional()
|
|
})
|
|
})
|
|
)
|
|
.mutation(async ({ input, ctx }) => {
|
|
try {
|
|
const conn = ConnectionFactory();
|
|
|
|
// First, try to find a recent entry for this path without performance data
|
|
const checkQuery = await conn.execute({
|
|
sql: `SELECT id, path, created_at FROM VisitorAnalytics
|
|
WHERE path = ?
|
|
AND created_at >= datetime('now', '-5 minutes')
|
|
AND fcp IS NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
args: [input.path]
|
|
});
|
|
|
|
if (checkQuery.rows.length > 0) {
|
|
const result = await conn.execute({
|
|
sql: `UPDATE VisitorAnalytics
|
|
SET fcp = ?, lcp = ?, cls = ?, fid = ?, inp = ?, ttfb = ?, dom_load = ?, load_complete = ?
|
|
WHERE id = ?`,
|
|
args: [
|
|
input.metrics.fcp || null,
|
|
input.metrics.lcp || null,
|
|
input.metrics.cls || null,
|
|
input.metrics.fid || null,
|
|
input.metrics.inp || null,
|
|
input.metrics.ttfb || null,
|
|
input.metrics.domLoad || null,
|
|
input.metrics.loadComplete || null,
|
|
(checkQuery.rows[0] as any).id
|
|
]
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
rowsAffected: result.rowsAffected,
|
|
action: "updated"
|
|
};
|
|
} else {
|
|
const req = ctx.event.nativeEvent.node?.req || ctx.event.nativeEvent;
|
|
const userAgent =
|
|
getHeader(req.headers, "user-agent") ||
|
|
getHeader(ctx.event.request?.headers, "user-agent");
|
|
const referrer =
|
|
getHeader(req.headers, "referer") ||
|
|
getHeader(req.headers, "referrer") ||
|
|
getHeader(ctx.event.request?.headers, "referer");
|
|
const ipAddress = resolveClientIp(ctx.event) || undefined;
|
|
const host = requestHost(ctx.event) || undefined;
|
|
const enriched = enrichAnalyticsEntry({
|
|
userId: ctx.userId,
|
|
path: input.path,
|
|
method: "GET",
|
|
userAgent,
|
|
referrer,
|
|
ipAddress,
|
|
host,
|
|
// The client beacon sends `pathname + search`, so the page URL's
|
|
// own query string is the campaign source; the referrer fills any
|
|
// gap (e.g. clicks that arrive on a clean path).
|
|
...parseUtm(input.path, referrer),
|
|
fcp: input.metrics.fcp,
|
|
lcp: input.metrics.lcp,
|
|
cls: input.metrics.cls,
|
|
fid: input.metrics.fid,
|
|
inp: input.metrics.inp,
|
|
ttfb: input.metrics.ttfb,
|
|
domLoad: input.metrics.domLoad,
|
|
loadComplete: input.metrics.loadComplete
|
|
});
|
|
|
|
await conn.execute({
|
|
sql: VISITOR_ANALYTICS_INSERT,
|
|
args: [
|
|
uuid(),
|
|
enriched.userId || null,
|
|
enriched.path,
|
|
enriched.method,
|
|
enriched.referrer || null,
|
|
enriched.userAgent || null,
|
|
enriched.ipAddress || null,
|
|
enriched.country || null,
|
|
enriched.deviceType || null,
|
|
enriched.browser || null,
|
|
enriched.os || null,
|
|
enriched.utmSource || null,
|
|
enriched.utmMedium || null,
|
|
enriched.utmCampaign || null,
|
|
enriched.durationMs || null,
|
|
enriched.fcp || null,
|
|
enriched.lcp || null,
|
|
enriched.cls || null,
|
|
enriched.fid || null,
|
|
enriched.inp || null,
|
|
enriched.ttfb || null,
|
|
enriched.domLoad || null,
|
|
enriched.loadComplete || null,
|
|
enriched.stage || null,
|
|
enriched.host || null
|
|
]
|
|
});
|
|
|
|
return { success: true, rowsAffected: 1, action: "created" };
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to log performance metrics:", error);
|
|
return { success: false };
|
|
}
|
|
}),
|
|
|
|
getLogs: adminProcedure
|
|
.input(
|
|
z.object({
|
|
userId: z.string().optional(),
|
|
path: z.string().optional(),
|
|
startDate: z.string().optional(),
|
|
endDate: z.string().optional(),
|
|
limit: z.number().min(1).max(1000).default(100),
|
|
offset: z.number().min(0).default(0)
|
|
})
|
|
)
|
|
.query(async ({ input }) => {
|
|
const logs = await queryAnalytics({
|
|
userId: input.userId,
|
|
path: input.path,
|
|
startDate: input.startDate,
|
|
endDate: input.endDate,
|
|
limit: input.limit,
|
|
offset: input.offset
|
|
});
|
|
|
|
return {
|
|
logs,
|
|
count: logs.length,
|
|
offset: input.offset,
|
|
limit: input.limit
|
|
};
|
|
}),
|
|
|
|
getSummary: adminProcedure
|
|
.input(
|
|
z.object({
|
|
days: z.number().min(1).max(365).default(30)
|
|
})
|
|
)
|
|
.query(async ({ input }) => {
|
|
const summary = await getAnalyticsSummary(input.days);
|
|
|
|
return {
|
|
...summary,
|
|
timeWindow: `${input.days} days`
|
|
};
|
|
}),
|
|
|
|
getPathStats: adminProcedure
|
|
.input(
|
|
z.object({
|
|
path: z.string(),
|
|
days: z.number().min(1).max(365).default(30)
|
|
})
|
|
)
|
|
.query(async ({ input }) => {
|
|
const stats = await getPathAnalytics(input.path, input.days);
|
|
|
|
return {
|
|
path: input.path,
|
|
...stats,
|
|
timeWindow: `${input.days} days`
|
|
};
|
|
}),
|
|
|
|
cleanup: adminProcedure
|
|
.input(
|
|
z.object({
|
|
olderThanDays: z.number().min(1).max(365).default(90)
|
|
})
|
|
)
|
|
.mutation(async ({ input }) => {
|
|
const deleted = await cleanupOldAnalytics(input.olderThanDays);
|
|
|
|
return {
|
|
deleted,
|
|
olderThanDays: input.olderThanDays
|
|
};
|
|
}),
|
|
|
|
getPerformanceStats: adminProcedure
|
|
.input(
|
|
z.object({
|
|
days: z.number().min(1).max(365).default(30)
|
|
})
|
|
)
|
|
.query(async ({ input }) => {
|
|
const stats = await getPerformanceStats(input.days);
|
|
|
|
return {
|
|
...stats,
|
|
timeWindow: `${input.days} days`
|
|
};
|
|
})
|
|
});
|