Files
civitai__civitai/apps/moderator/src/lib/server/page-visits.ts
T
Justin Maier 7e15bca183 chore(clickhouse): upgrade @clickhouse/client from 0.2.10 to 1.23.1 (#4972)
Upgrades @clickhouse/client from 0.2.10 to 1.23.1 in the root package.json and
packages/civitai-clickhouse. apps/event-engine was already on 1.x, so the workspace
now holds one version of the driver.

A version upgrade, not a fix for the ClickHouse socket hang-ups. The pre-upgrade
rate was recorded before this change so the post-deploy rate can be compared.

- ResultSet.json<T>() returns T[] in 1.x: 13 call sites, 3 in src/ and 10 in
  apps/moderator, which the root typecheck does not cover.
- host -> url; keep_alive.socket_ttl + retry_on_expired_socket -> idle_socket_ttl.
- Three 1.x default changes held at their 0.2.x values: max_open_connections
  Infinity, request_timeout 300000, response compression on.
- keep_alive.eagerly_destroy_stale_sockets: true is a deliberate non-default,
  more permissive than 0.2.x's retry, standing in for it. It confounds the
  before/after comparison.
- 1.x adds ~1ms per request (await sleep(0)); not configurable.
- apps/moderator ships on its own release.
- New src/server/clickhouse/__tests__/client-config-pins.test.ts pins the values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:22:38 -06:00

81 lines
2.3 KiB
TypeScript

import { getClickhouse } from './clickhouse';
import { usersByIds } from './users.service';
const TABLE = 'moderator_page_views';
export type PageVisit = {
userId: number;
/** Route id (the pattern, not the resolved pathname) so dynamic pages roll up to one row. */
location: string;
};
// Call ONLY after the auth guard has authorized the moderator, or unauthorized requests get recorded.
export async function recordPageVisit({ userId, location }: PageVisit): Promise<void> {
try {
await getClickhouse().insert({
table: TABLE,
values: [{ userId, location }],
format: 'JSONEachRow',
});
} catch (err) {
console.error('[page-visits] failed to record visit', err);
}
}
export type PageVisitSummaryRow = {
location: string;
visits: number;
distinctMods: number;
lastVisit: string;
};
export async function getPageVisitSummary(days = 30): Promise<PageVisitSummaryRow[]> {
return getClickhouse().$query<PageVisitSummaryRow>`
SELECT location,
count() AS visits,
uniqExact(userId) AS distinctMods,
max(visitedAt) AS lastVisit
FROM ${TABLE}
WHERE visitedAt >= now() - INTERVAL ${days} DAY
GROUP BY location
ORDER BY visits ASC
`;
}
export type RouteUserBreakdownRow = {
userId: number;
username: string | null;
visits: number;
lastVisit: string;
};
// `location` is user-supplied — pass it as a bound ClickHouse parameter, never interpolated.
export async function getRouteUserBreakdown(
location: string,
days = 30
): Promise<RouteUserBreakdownRow[]> {
const resultSet = await getClickhouse().query({
query: `
SELECT userId, count() AS visits, max(visitedAt) AS lastVisit
FROM ${TABLE}
WHERE location = {location:String}
AND visitedAt >= subtractDays(now(), {days:UInt32})
GROUP BY userId
ORDER BY visits DESC
`,
query_params: { location, days },
format: 'JSONEachRow',
});
const rows = await resultSet.json<{ userId: number; visits: number; lastVisit: string }>();
if (!rows.length) return [];
const nameById = await usersByIds(rows.map((r) => r.userId));
return rows.map((r) => ({
userId: r.userId,
username: nameById.get(r.userId)?.username ?? null,
visits: r.visits,
lastVisit: r.lastVisit,
}));
}