Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions server/src/api/analytics/getLiveUsercount.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,51 @@
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 };
Querystring: { minutes: number };
}>,
Comment on lines 15 to 18
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fastify type annotations don't match the raw request shape.

The type annotations should reflect the pre-validation request types:

  • siteId is correctly typed as string (URL params are strings)
  • minutes should be typed as string | undefined or marked optional with minutes?: string because:
    • Query parameters arrive as strings (or undefined) before zod coerces them
    • The zod schema's .default(5) makes this parameter optional

The current minutes: number suggests it's already a required number, which is misleading and reduces type safety for anyone accessing req.query.minutes directly.

🔧 Proposed fix
  req: FastifyRequest<{
    Params: { siteId: string };
-   Querystring: { minutes: number };
+   Querystring: { minutes?: string };
  }>,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/getLiveUsercount.ts` around lines 15 - 18, The
Fastify request typing is incorrect: update the req type annotation so Params
stays { siteId: string } but Querystring types reflect pre-validation strings,
e.g. make minutes optional as string (minutes?: string or minutes: string |
undefined) instead of number; adjust the FastifyRequest generic where you see
"Params" and "Querystring" so callers reading req.query.minutes see the raw
string/undefined (the zod schema's .default(5) will handle coercion later).

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" });
}
};
21 changes: 21 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down