diff --git a/server/src/api/analytics/getLiveUsercount.ts b/server/src/api/analytics/getLiveUsercount.ts index 37a11048b..3a505d00d 100644 --- a/server/src/api/analytics/getLiveUsercount.ts +++ b/server/src/api/analytics/getLiveUsercount.ts @@ -1,7 +1,16 @@ import { FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; import { clickhouse } from "../../db/clickhouse/clickhouse.js"; import { processResults } from "./utils/utils.js"; +const paramsSchema = z.object({ + siteId: z.coerce.number().int().positive(), +}); + +const querySchema = z.object({ + minutes: z.coerce.number().int().positive().default(5), +}); + export const getLiveUsercount = async ( req: FastifyRequest<{ Params: { siteId: string }; @@ -9,19 +18,34 @@ export const getLiveUsercount = async ( }>, res: FastifyReply ) => { - const { siteId } = req.params; - const { minutes } = req.query; + const paramsValidation = paramsSchema.safeParse(req.params); + const queryValidation = querySchema.safeParse(req.query); + + if (!paramsValidation.success || !queryValidation.success) { + return res.status(400).send({ + error: "Invalid request parameters", + details: paramsValidation.success ? queryValidation.error : paramsValidation.error, + }); + } + + const { siteId } = paramsValidation.data; + const { minutes } = queryValidation.data; - const query = await clickhouse.query({ - query: `SELECT COUNT(DISTINCT(session_id)) AS count FROM events WHERE timestamp > now() - interval {minutes:Int32} minute AND site_id = {siteId:Int32}`, - format: "JSONEachRow", - query_params: { - siteId: Number(siteId), - minutes: Number(minutes || 5), - }, - }); + try { + const query = await clickhouse.query({ + query: `SELECT COUNT(DISTINCT(session_id)) AS count FROM events WHERE timestamp > now() - interval {minutes:Int32} minute AND site_id = {siteId:Int32}`, + format: "JSONEachRow", + query_params: { + siteId, + minutes, + }, + }); - const result = await processResults<{ count: number }>(query); + const result = await processResults<{ count: number }>(query); - return res.send({ count: result[0].count }); + return res.send({ count: result[0]?.count ?? 0 }); + } catch (error) { + req.log.error({ err: error, siteId }, "getLiveUsercount: ClickHouse query failed"); + return res.status(500).send({ error: "Failed to fetch live user count" }); + } }; diff --git a/server/src/index.ts b/server/src/index.ts index f73b9a310..d02aa1512 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -188,6 +188,27 @@ const server = Fastify({ bodyLimit: 10 * 1024 * 1024, // 10MB limit for session replay data }); +// Global error handler — ensures uncaught route errors are always logged with +// request context, even on routes that opt out of request logging via +// `logLevel: "silent"` (e.g. /sites/:siteId/live-user-count, /health). Without +// this, transient ClickHouse / Postgres outages return 500 to the dashboard +// with no log signal. +server.setErrorHandler((error, request, reply) => { + request.log.error( + { err: error, reqId: request.id, method: request.method, url: request.url }, + "Unhandled route error" + ); + const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500; + reply.status(statusCode).send({ + error: statusCode >= 500 ? "Internal Server Error" : error.message, + statusCode, + }); +}); + +server.setNotFoundHandler((request, reply) => { + reply.status(404).send({ error: "Not Found", statusCode: 404, path: request.url }); +}); + server.register(cors, { origin: (_origin, callback) => { callback(null, true);