From eee1f4fa2cc4329be12d037609b7e5e86c0b945d Mon Sep 17 00:00:00 2001 From: aamoghS Date: Wed, 7 Oct 2026 17:52:54 -0400 Subject: [PATCH] feat(metrics): publish live hackathon counts for the weekend The analytics page and the metrics scrape share registrations, badge scans, and judging totals for the edition that is actually running. --- monitoring/README.md | 8 + monitoring/clickhouse/init/02-hackathon.sql | 57 +++ .../grafana/dashboards/dsgt-portal.json | 115 ++++- monitoring/prometheus.yml | 5 +- monitoring/rules/hackathon.yml | 26 ++ .../hackathon-admin-edge.test.ts | 10 +- packages/api/src/routers/admin.ts | 25 +- .../src/services/hackathon-metrics.test.ts | 181 ++++++++ packages/api/src/services/metrics.ts | 392 +++++++++++++++--- packages/db/scripts/export-clickhouse.mts | 229 ++++++++-- .../app/(portal)/admin/analytics/page.tsx | 97 ++++- 11 files changed, 1018 insertions(+), 127 deletions(-) create mode 100644 monitoring/clickhouse/init/02-hackathon.sql create mode 100644 monitoring/rules/hackathon.yml create mode 100644 packages/api/src/services/hackathon-metrics.test.ts diff --git a/monitoring/README.md b/monitoring/README.md index 69cb9edc..46503c91 100644 --- a/monitoring/README.md +++ b/monitoring/README.md @@ -42,6 +42,12 @@ number of instances is irrelevant: - `dsgt_members_active`, `dsgt_members_lapsed` - `dsgt_bootcamp_enrolled{term}` - `dsgt_payments_by_plan{plan}` — the yearly/semester split +- `dsgt_hackathon_participants{edition,phase,status}`, `dsgt_hackathon_projects`, + `dsgt_hackathon_checkins_today`, `dsgt_hackathon_judging_projects`, + `dsgt_hackathon_votes`, `dsgt_hackathon_judging_active` — only editions that + are `open`, `closed`, or `in_progress`. A completed edition disappears on + the next refresh. Badge scans are hackathon QR rows since midnight Eastern, + not club door check-ins. - `dsgt_payments_unlinked` — paid, claimed by nobody. This is **not** zero and is not supposed to be: as of August 2026 it sits around 428 of 443 paid rows, nearly all from the August–September 2025 and January 2026 drives. Those are @@ -85,6 +91,8 @@ The alerts that matter: - `MembershipGrantFailing` — a payment was recorded and the membership was not. - `UnlinkedPaymentsClimbing` — the link paths are not claiming payments. - `ReconcileRecoveringOften` — the backstop is carrying the fast path. +- `HackathonJudgingIdle` — judging is on, tables are assigned, and no scores + have landed for 30 minutes. To route them into ClickUp, run Alertmanager with a webhook receiver and pipe its payload to `node scripts/clickup-task.mjs --stdin`. That step is not built diff --git a/monitoring/clickhouse/init/02-hackathon.sql b/monitoring/clickhouse/init/02-hackathon.sql new file mode 100644 index 00000000..12d1c5fa --- /dev/null +++ b/monitoring/clickhouse/init/02-hackathon.sql @@ -0,0 +1,57 @@ +-- Hackathon history. Same reason as the payment tables: aggregate it here, +-- not on the 0.5 GB application database. `export:clickhouse` creates these +-- too, so a volume that already ran 01-schema.sql still picks them up. + +CREATE DATABASE IF NOT EXISTS dsgt; + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_registrations +( + id String, + hackathon String, + status LowCardinality(String), + registered_at DateTime64(3), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_projects +( + id String, + hackathon String, + status LowCardinality(String), + submitted_at Nullable(DateTime64(3)), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_scans +( + id String, + hackathon String, + event_name String, + checked_in_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(checked_in_at) +ORDER BY (hackathon, id); + +CREATE TABLE IF NOT EXISTS dsgt.hackathon_votes +( + id String, + hackathon String, + score Int32, + voted_at DateTime64(3), + updated_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(updated_at) +ORDER BY (hackathon, id); + +CREATE VIEW IF NOT EXISTS dsgt.hackathon_registrations_by_status AS +SELECT + hackathon, + status, + count() AS registrations +FROM dsgt.hackathon_registrations FINAL +GROUP BY hackathon, status +ORDER BY hackathon, status; diff --git a/monitoring/grafana/dashboards/dsgt-portal.json b/monitoring/grafana/dashboards/dsgt-portal.json index 1adf141f..c7a726b0 100644 --- a/monitoring/grafana/dashboards/dsgt-portal.json +++ b/monitoring/grafana/dashboards/dsgt-portal.json @@ -144,17 +144,112 @@ }, { "type": "row", - "title": "Payments (per-instance counters — a sample in production)", + "title": "Hackathon (read from the database — correct in production)", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 }, "collapsed": false, "panels": [] }, + { + "type": "stat", + "title": "Checked in", + "description": "Accepted participants whose badge was scanned, for each live edition.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 0, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_participants{env=~\"$env\",status=\"checked_in\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Approved, not checked in", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 4, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_participants{env=~\"$env\",status=\"approved\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Projects submitted", + "description": "Not drafts, and not withdrawn by an organiser.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 8, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_projects{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Badge scans today", + "description": "Hackathon QR scans since midnight Eastern. Club door check-ins are not in this number.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 12, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_checkins_today{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Judging tables", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 16, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_judging_projects{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "stat", + "title": "Judge scores", + "description": "Zero while judging is switched on is what HackathonJudgingIdle fires on.", + "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, + "gridPos": { "h": 5, "w": 4, "x": 20, "y": 7 }, + "fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }, + "targets": [ + { + "refId": "A", + "expr": "max by (edition) (dsgt_hackathon_votes{env=~\"$env\"})", + "legendFormat": "{{edition}}" + } + ] + }, + { + "type": "row", + "title": "Payments (per-instance counters — a sample in production)", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 }, + "collapsed": false, + "panels": [] + }, { "type": "timeseries", "title": "Grant failures", "description": "Payment recorded, membership not. Non-zero means money taken with nothing given.", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 0, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 0, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1 }, @@ -174,7 +269,7 @@ "type": "timeseries", "title": "Grants by source", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 8, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50, "stacking": { "mode": "normal" } } }, "overrides": [] @@ -191,7 +286,7 @@ "type": "timeseries", "title": "Intents minted, by what was bought", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 16, "y": 7 }, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 13 }, "fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50 } }, "overrides": [] @@ -207,7 +302,7 @@ { "type": "row", "title": "App health", - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 20 }, "collapsed": false, "panels": [] }, @@ -215,7 +310,7 @@ "type": "timeseries", "title": "Portal API calls / sec", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 0, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 0, "y": 21 }, "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, "targets": [ { @@ -229,7 +324,7 @@ "type": "timeseries", "title": "Latency p50 / p95 / p99", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 8, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 8, "y": 21 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { @@ -253,7 +348,7 @@ "type": "timeseries", "title": "Slowest procedures (p99)", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 7, "w": 8, "x": 16, "y": 15 }, + "gridPos": { "h": 7, "w": 8, "x": 16, "y": 21 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { @@ -267,7 +362,7 @@ "type": "timeseries", "title": "Process memory", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 6, "w": 12, "x": 0, "y": 22 }, + "gridPos": { "h": 6, "w": 12, "x": 0, "y": 28 }, "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, "targets": [ { @@ -286,7 +381,7 @@ "type": "timeseries", "title": "Event loop lag (p99)", "datasource": { "type": "prometheus", "uid": "dsgt-prometheus" }, - "gridPos": { "h": 6, "w": 12, "x": 12, "y": 22 }, + "gridPos": { "h": 6, "w": 12, "x": 12, "y": 28 }, "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "targets": [ { diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml index fd446485..dc988376 100644 --- a/monitoring/prometheus.yml +++ b/monitoring/prometheus.yml @@ -29,8 +29,9 @@ scrape_configs: # # Read the numbers it returns with the instance model in mind. App Hosting # autoscales, so each scrape lands on one arbitrary instance: - # - dsgt_members_*, dsgt_payments_* are read from the database on scrape and - # are therefore correct regardless of which instance answered. + # - dsgt_members_*, dsgt_payments_*, and dsgt_hackathon_* are read from the + # database on scrape and are therefore correct regardless of which + # instance answered. # - counters and histograms are that one instance's, since its own start, # and vanish when it scales down. Treat them as a sample, not a total. # diff --git a/monitoring/rules/hackathon.yml b/monitoring/rules/hackathon.yml new file mode 100644 index 00000000..2f275809 --- /dev/null +++ b/monitoring/rules/hackathon.yml @@ -0,0 +1,26 @@ +# Alerts for a live hackathon edition. The series are database gauges, so +# they are the same number on every instance. See monitoring/README.md. + +groups: + - name: hackathon + rules: + # Judging switched on, tables assigned, and still no scores. Thirty + # minutes is long enough that flipping the switch before judges reach + # the room does not page, and short enough that a broken submit does + # not sit through a whole judging block. + - alert: HackathonJudgingIdle + expr: >- + ( + dsgt_hackathon_judging_active > 0 + and on (edition) (dsgt_hackathon_judging_projects > 0) + ) + unless on (edition) (dsgt_hackathon_votes > 0) + for: 30m + labels: + severity: page + annotations: + summary: "Judging is on for {{ $labels.edition }} and no scores have landed" + description: >- + The edition has judging switched on and projects on the floor, and + the vote count is still zero. Judges may be blocked from + submitting, or judging was opened before anyone was at a table. diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts index e4fefad7..50f9a94d 100644 --- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts +++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts @@ -1417,17 +1417,17 @@ describe("Hackathon admin management edge cases", () => { // ===================================================================== describe("Day-of dashboard", () => { - // BUG: analyticsOverview counts eventCheckIns (the club check-in table), - // not hackathonEventAttendees, which is what scanParticipantPass writes. - it("counts today's hackathon badge scans", async () => { + it("counts today's hackathon badge scans apart from club door check-ins", async () => { const caller = adminCaller({}, "admin"); mockCount.mockImplementation((t: string) => - t === "hackathonEventAttendees" ? 7 : 0, + t === "hackathonEventAttendees" ? 7 : t === "eventCheckIns" ? 2 : 0, ); const res = await caller.admin.analyticsOverview(); - expect(res.checkinsToday).toBe(7); + expect(res.badgeScansToday).toBe(7); + expect(res.doorCheckinsToday).toBe(2); + expect(res.checkinsToday).toBe(9); }); }); }); diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts index 96de8cdb..c180ea82 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -18,6 +18,7 @@ import { compareTerms, currentTerm } from "@query/db/services/membership"; import { isExpiredAdmin, isStaffRole } from "../types/portal-context"; import type { DrizzleDB } from "@query/db"; import { startOfEasternDay } from "../services/eastern-time"; +import { readLiveHackathons } from "../services/metrics"; export const adminRouter = createTRPCRouter({ isAdmin: protectedProcedure.query(async ({ ctx }) => { @@ -78,6 +79,8 @@ export const adminRouter = createTRPCRouter({ totalEvents: number; totalHackathons: number; checkinsToday: number; + badgeScansToday: number; + doorCheckinsToday: number; }>( cacheKey, async () => { @@ -114,13 +117,19 @@ export const adminRouter = createTRPCRouter({ .where(gte(eventCheckIns.checkedInAt, startOfToday)), ]); + const badgeScansToday = badgeScansResult[0]?.count ?? 0; + const doorCheckinsToday = doorCheckinsResult[0]?.count ?? 0; + const result = { totalParticipants: participantsResult[0]?.count ?? 0, totalEvents: eventsResult[0]?.count ?? 0, totalHackathons: hackathonsResult[0]?.count ?? 0, - checkinsToday: - (badgeScansResult[0]?.count ?? 0) + - (doorCheckinsResult[0]?.count ?? 0), + // Kept as the sum so a caller that only knew the blended number + // still moves. The page reads the two halves, because a hackathon + // weekend and a club meeting are not the same turnout. + badgeScansToday, + doorCheckinsToday, + checkinsToday: badgeScansToday + doorCheckinsToday, }; return result; @@ -129,6 +138,16 @@ export const adminRouter = createTRPCRouter({ ); }), + // The same counts the Prometheus gauges use, for the analytics page. A + // scrape is optional; this is what staff open during the weekend. + hackathonLive: isAdmin.query(async ({ ctx }) => { + return await ctx.cache.getOrSet( + "admin:hackathon-live", + () => readLiveHackathons(ctx.db as DrizzleDB), + 15, + ); + }), + // Membership growth for the Insights page. One pass over the member table // instead of a bucket query per month: the club is a few thousand rows, and // the cumulative series has to see everything before the window anyway. diff --git a/packages/api/src/services/hackathon-metrics.test.ts b/packages/api/src/services/hackathon-metrics.test.ts new file mode 100644 index 00000000..d7a52982 --- /dev/null +++ b/packages/api/src/services/hackathon-metrics.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { getTableName } from "drizzle-orm"; +import type { DrizzleDB } from "@query/db"; +import { readLiveHackathons, refreshDbGauges, registry } from "./metrics"; + +const rules = readFileSync( + fileURLToPath( + new URL("../../../../monitoring/rules/hackathon.yml", import.meta.url), + ), + "utf-8", +); + +type GaugeReading = { + values: { value: number; labels: Record }[]; +}; + +/** Drizzle conditions point back at their tables, so this only follows SQL text. */ +function sqlText(condition: unknown) { + const parts: string[] = []; + const visit = (value: unknown, seen: Set) => { + if (typeof value === "string") { + parts.push(value); + return; + } + if (!value || typeof value !== "object" || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) visit(item, seen); + return; + } + const record = value as { value?: unknown; queryChunks?: unknown }; + if ("value" in record) visit(record.value, seen); + if ("queryChunks" in record) visit(record.queryChunks, seen); + }; + visit(condition, new Set()); + return parts.join(" "); +} + +function readings(name: string) { + return registry.getSingleMetric(name) as { + get: () => Promise | GaugeReading; + set: (labels: Record, value: number) => void; + }; +} + +describe("hackathon gauges", () => { + it("counts a live edition and drops one that is no longer live", async () => { + readings("dsgt_hackathon_projects").set( + { edition: "Hacklytics 2026", phase: "completed" }, + 40, + ); + + const queries: string[] = []; + const rowsFor = (table: string) => { + if (table === "hackathon") { + return [ + { + id: "hack_1", + edition: "Hacklytics 2027", + phase: "in_progress", + judgingActive: true, + }, + ]; + } + if (table === "hackathon_participant") { + return [ + { hackathonId: "hack_1", status: "approved", total: 12 }, + { hackathonId: "hack_1", status: "checked_in", total: 3 }, + ]; + } + if (table === "hackathon_project") { + return [{ hackathonId: "hack_1", total: 4 }]; + } + if (table === "hackathon_event_attendee") { + return [{ hackathonId: "hack_1", total: 9 }]; + } + if (table === "judging_project") { + return [{ hackathonId: "hack_1", total: 4 }]; + } + return []; + }; + + const db = { + select() { + return { + from(table: Parameters[0]) { + const pending: any = Promise.resolve(rowsFor(getTableName(table))); + pending.from = () => pending; + pending.innerJoin = () => pending; + pending.groupBy = () => pending; + pending.where = (condition: unknown) => { + queries.push(sqlText(condition)); + return pending; + }; + return pending; + }, + }; + }, + execute: async (query: unknown) => { + const text = JSON.stringify(query); + if (text.includes("member_resume")) { + return { rows: [{ files: "1", bytes: "10" }] }; + } + if (text.includes("metadata")) { + return { rows: [{ plan: "annual", total: "2" }] }; + } + return { + rows: [{ active: "5", lapsed: "1", bootcamp: "2", unlinked: "0" }], + }; + }, + }; + + const live = await readLiveHackathons(db as unknown as DrizzleDB); + expect(live).toEqual([ + { + edition: "Hacklytics 2027", + phase: "in_progress", + judgingActive: true, + projects: 4, + checkinsToday: 9, + judgingProjects: 4, + votes: 0, + participants: { + pending: 0, + approved: 12, + rejected: 0, + waitlisted: 0, + checked_in: 3, + }, + }, + ]); + + await refreshDbGauges(db as unknown as DrizzleDB); + + const projects = (await readings("dsgt_hackathon_projects").get()).values; + expect(projects).toEqual([ + { + labels: { edition: "Hacklytics 2027", phase: "in_progress" }, + value: 4, + }, + ]); + + const participants = (await readings("dsgt_hackathon_participants").get()) + .values; + expect( + participants.find((row) => row.labels.status === "checked_in")?.value, + ).toBe(3); + expect( + participants.find((row) => row.labels.status === "pending")?.value, + ).toBe(0); + expect( + participants.find((row) => row.labels.edition === "Hacklytics 2026"), + ).toBeUndefined(); + + expect( + (await readings("dsgt_hackathon_checkins_today").get()).values[0]?.value, + ).toBe(9); + expect( + (await readings("dsgt_hackathon_votes").get()).values[0]?.value, + ).toBe(0); + expect( + (await readings("dsgt_hackathon_judging_active").get()).values[0]?.value, + ).toBe(1); + + const filters = queries.join("\n"); + expect(filters).toContain("in_progress"); + expect(filters).toContain("draft"); + expect(filters).toContain("is null"); + expect(filters).not.toContain("completed"); + }); + + it("alerts when judging is on, tables are assigned, and no scores exist", () => { + expect(rules).toContain("alert: HackathonJudgingIdle"); + expect(rules).toContain("dsgt_hackathon_judging_active"); + expect(rules).toContain("dsgt_hackathon_judging_projects"); + expect(rules).toContain("dsgt_hackathon_votes"); + expect(rules).toContain("for: 30m"); + }); +}); diff --git a/packages/api/src/services/metrics.ts b/packages/api/src/services/metrics.ts index f73541b0..6fa6508c 100644 --- a/packages/api/src/services/metrics.ts +++ b/packages/api/src/services/metrics.ts @@ -12,8 +12,33 @@ import { Gauge, collectDefaultMetrics, } from "prom-client"; -import { sql } from "drizzle-orm"; -import type { DrizzleDB } from "@query/db"; +import { + and, + count, + eq, + gt, + gte, + inArray, + isNotNull, + isNull, + lte, + ne, + sum, +} from "drizzle-orm"; +import { + type DrizzleDB, + hackathonEventAttendees, + hackathonEvents, + hackathonParticipants, + hackathonProjects, + hackathons, + judgeVotes, + judgingProjects, + memberResumes, + members, + stripePayments, +} from "@query/db"; +import { startOfEasternDay } from "./eastern-time"; export const registry = new Registry(); @@ -137,6 +162,215 @@ const gaugeRefreshFailures = new Counter({ registers: [registry], }); +// Live editions only: open (registration), closed (registration shut, event +// not finished), in progress (the weekend). A completed edition drops off on +// the next refresh, so last year does not sit on the dashboard as current. +// Same rule as the other gauges — counted from the database, so which instance +// Prometheus scraped does not matter. + +const PARTICIPANT_STATUSES = [ + "pending", + "approved", + "rejected", + "waitlisted", + "checked_in", +] as const; + +const participantGauge = new Gauge({ + name: "dsgt_hackathon_participants", + help: "Registrations for a live hackathon edition, by status.", + labelNames: ["edition", "phase", "status"] as const, + registers: [registry], +}); + +const projectGauge = new Gauge({ + name: "dsgt_hackathon_projects", + help: "Submitted projects for a live edition, excluding admin withdrawals and drafts.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonCheckinsToday = new Gauge({ + name: "dsgt_hackathon_checkins_today", + help: "Hackathon badge scans since midnight Eastern, for a live edition.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonJudgingProjects = new Gauge({ + name: "dsgt_hackathon_judging_projects", + help: "Projects on the judging floor for a live edition, excluding withdrawals.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonVotes = new Gauge({ + name: "dsgt_hackathon_votes", + help: "Judge scores recorded for a live edition.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +const hackathonJudgingActive = new Gauge({ + name: "dsgt_hackathon_judging_active", + help: "1 when judging is switched on for a live edition, otherwise 0.", + labelNames: ["edition", "phase"] as const, + registers: [registry], +}); + +export type LiveHackathon = { + edition: string; + phase: string; + judgingActive: boolean; + projects: number; + checkinsToday: number; + judgingProjects: number; + votes: number; + participants: Record<(typeof PARTICIPANT_STATUSES)[number], number>; +}; + +const LIVE_PHASES = ["open", "closed", "in_progress"] as const; + +const blankParticipants = (): LiveHackathon["participants"] => ({ + pending: 0, + approved: 0, + rejected: 0, + waitlisted: 0, + checked_in: 0, +}); + +const countByEdition = ( + rows: { hackathonId: string; total: number }[], +) => { + const totals = new Map(); + for (const row of rows) totals.set(row.hackathonId, Number(row.total)); + return totals; +}; + +// Shared with the admin analytics page. Prometheus is optional; the portal +// has to be able to show the same counts when nothing is scraping. +export async function readLiveHackathons( + db: DrizzleDB, + now = new Date(), +): Promise { + const startOfToday = startOfEasternDay(now); + + const editions = await db + .select({ + id: hackathons.id, + edition: hackathons.name, + phase: hackathons.status, + judgingActive: hackathons.judgingActive, + }) + .from(hackathons) + .where(inArray(hackathons.status, [...LIVE_PHASES])); + + if (editions.length === 0) return []; + + const ids = editions.map((edition) => edition.id); + + const [byStatus, projectRows, scanRows, judgingRows, voteRows] = + await Promise.all([ + db + .select({ + hackathonId: hackathonParticipants.hackathonId, + status: hackathonParticipants.registrationStatus, + total: count(), + }) + .from(hackathonParticipants) + .where(inArray(hackathonParticipants.hackathonId, ids)) + .groupBy( + hackathonParticipants.hackathonId, + hackathonParticipants.registrationStatus, + ), + db + .select({ + hackathonId: hackathonProjects.hackathonId, + total: count(), + }) + .from(hackathonProjects) + .where( + and( + inArray(hackathonProjects.hackathonId, ids), + ne(hackathonProjects.status, "draft"), + isNull(hackathonProjects.withdrawnByAdminAt), + ), + ) + .groupBy(hackathonProjects.hackathonId), + db + .select({ + hackathonId: hackathonEvents.hackathonId, + total: count(), + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .where( + and( + inArray(hackathonEvents.hackathonId, ids), + gte(hackathonEventAttendees.checkedInAt, startOfToday), + ), + ) + .groupBy(hackathonEvents.hackathonId), + db + .select({ + hackathonId: judgingProjects.hackathonId, + total: count(), + }) + .from(judgingProjects) + .where( + and( + inArray(judgingProjects.hackathonId, ids), + isNull(judgingProjects.withdrawnAt), + ), + ) + .groupBy(judgingProjects.hackathonId), + db + .select({ + hackathonId: judgingProjects.hackathonId, + total: count(), + }) + .from(judgeVotes) + .innerJoin( + judgingProjects, + eq(judgingProjects.id, judgeVotes.projectId), + ) + .where( + and( + inArray(judgingProjects.hackathonId, ids), + isNull(judgingProjects.withdrawnAt), + ), + ) + .groupBy(judgingProjects.hackathonId), + ]); + + const participantsByEdition = new Map(); + for (const row of byStatus) { + const bucket = + participantsByEdition.get(row.hackathonId) ?? blankParticipants(); + if (row.status in bucket) bucket[row.status] = Number(row.total); + participantsByEdition.set(row.hackathonId, bucket); + } + + const projects = countByEdition(projectRows); + const scans = countByEdition(scanRows); + const judging = countByEdition(judgingRows); + const votes = countByEdition(voteRows); + + return editions.map((edition) => ({ + edition: edition.edition, + phase: edition.phase, + judgingActive: edition.judgingActive, + projects: projects.get(edition.id) ?? 0, + checkinsToday: scans.get(edition.id) ?? 0, + judgingProjects: judging.get(edition.id) ?? 0, + votes: votes.get(edition.id) ?? 0, + participants: participantsByEdition.get(edition.id) ?? blankParticipants(), + })); +} + /** Long enough that a 30s scrape loop cannot turn into a query loop. */ const GAUGE_TTL_MS = 60_000; @@ -171,6 +405,14 @@ const gaugeCache = new GaugeCache(); // Eastern time like currentTerm, or the label flips four hours early at the // May and December boundaries. If the two disagree the gauge is mislabelled, // nothing more. +/** Null metadata is a year. Anything that is not a JSON object is left out. */ +function planFromMetadata(metadata: string | null) { + if (metadata == null) return "annual"; + if (!metadata.startsWith("{")) return null; + const parsed = JSON.parse(metadata) as { plan?: unknown }; + return typeof parsed.plan === "string" && parsed.plan ? parsed.plan : "annual"; +} + const currentTermLabel = (now = new Date()) => { const parts = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", @@ -191,65 +433,103 @@ export async function refreshDbGauges(db: DrizzleDB | null | undefined) { await gaugeCache.refresh(async () => { try { const term = currentTermLabel(); - - const totals = await db.execute<{ - active: string; - lapsed: string; - bootcamp: string; - unlinked: string; - }>(sql` - select - count(*) filter ( - where "is_active" and "membership_end_date" > now() - ) as active, - count(*) filter ( - where "membership_end_date" is not null - and "membership_end_date" <= now() - ) as lapsed, - count(*) filter (where "bootcamp_term" = ${term}) as bootcamp, - ( - select count(*) from "stripe_payment" - where "payment_status" = 'paid' and "linked_user_id" is null - ) as unlinked - from "member" - `); - - const counts = totals.rows[0]; - - if (counts) { - membersActive.set(Number(counts.active)); - membersLapsed.set(Number(counts.lapsed)); - bootcampEnrolled.set({ term }, Number(counts.bootcamp)); - paymentsUnlinked.set(Number(counts.unlinked)); + const now = new Date(); + + const [ + [active], + [lapsed], + [bootcamp], + [unlinked], + paidRows, + [resumeTotals], + ] = await Promise.all([ + db + .select({ total: count() }) + .from(members) + .where( + and(eq(members.isActive, true), gt(members.membershipEndDate, now)), + ), + db + .select({ total: count() }) + .from(members) + .where( + and( + isNotNull(members.membershipEndDate), + lte(members.membershipEndDate, now), + ), + ), + db + .select({ total: count() }) + .from(members) + .where(eq(members.bootcampTerm, term)), + db + .select({ total: count() }) + .from(stripePayments) + .where( + and( + eq(stripePayments.paymentStatus, "paid"), + isNull(stripePayments.linkedUserId), + ), + ), + db + .select({ metadata: stripePayments.metadata }) + .from(stripePayments) + .where(eq(stripePayments.paymentStatus, "paid")), + db + .select({ + files: count(), + bytes: sum(memberResumes.sizeBytes), + }) + .from(memberResumes), + ]); + + membersActive.set(Number(active?.total ?? 0)); + membersLapsed.set(Number(lapsed?.total ?? 0)); + bootcampEnrolled.set({ term }, Number(bootcamp?.total ?? 0)); + paymentsUnlinked.set(Number(unlinked?.total ?? 0)); + + // Plan lives in the payment metadata string. Rows that are not JSON are + // skipped; a missing plan is the only product that existed then, a year. + // One unparseable object still fails the refresh, same as a bad cast did. + const plans = new Map(); + for (const row of paidRows) { + const plan = planFromMetadata(row.metadata); + if (!plan) continue; + plans.set(plan, (plans.get(plan) ?? 0) + 1); } - // `like '{%'` before the jsonb cast, deliberately: one row of unparseable - // metadata would error the whole statement, and rows written before the plan - // existed have no `plan` key — those bought the only thing on offer, a year. - const byPlan = await db.execute<{ plan: string; total: string }>(sql` - select - coalesce(("metadata"::jsonb ->> 'plan'), 'annual') as plan, - count(*) as total - from "stripe_payment" - where "payment_status" = 'paid' - and ("metadata" is null or "metadata" like '{%') - group by 1 - `); - paymentsByPlan.reset(); - for (const row of byPlan.rows) { - paymentsByPlan.set({ plan: row.plan }, Number(row.total)); + for (const [plan, total] of plans) { + paymentsByPlan.set({ plan }, total); } - const resumes = await db.execute<{ files: string; bytes: string }>(sql` - select count(*) as files, coalesce(sum("size_bytes"), 0) as bytes - from "member_resume" - `); - - const resumeTotals = resumes.rows[0]; - if (resumeTotals) { - resumesStored.set(Number(resumeTotals.files)); - resumeBytesStored.set(Number(resumeTotals.bytes)); + resumesStored.set(Number(resumeTotals?.files ?? 0)); + resumeBytesStored.set(Number(resumeTotals?.bytes ?? 0)); + + const live = await readLiveHackathons(db); + + // reset before set: an edition that left the live set has to disappear, + // or the dashboard keeps yesterday's registration count. + participantGauge.reset(); + projectGauge.reset(); + hackathonCheckinsToday.reset(); + hackathonJudgingProjects.reset(); + hackathonVotes.reset(); + hackathonJudgingActive.reset(); + + for (const edition of live) { + const labels = { edition: edition.edition, phase: edition.phase }; + projectGauge.set(labels, edition.projects); + hackathonCheckinsToday.set(labels, edition.checkinsToday); + hackathonJudgingProjects.set(labels, edition.judgingProjects); + hackathonVotes.set(labels, edition.votes); + hackathonJudgingActive.set(labels, edition.judgingActive ? 1 : 0); + for (const status of PARTICIPANT_STATUSES) { + participantGauge.set( + { ...labels, status }, + edition.participants[status], + ); + } } } catch { // Stale gauges beat a 500 on the scrape endpoint. diff --git a/packages/db/scripts/export-clickhouse.mts b/packages/db/scripts/export-clickhouse.mts index b4941d00..77535c6e 100644 --- a/packages/db/scripts/export-clickhouse.mts +++ b/packages/db/scripts/export-clickhouse.mts @@ -1,16 +1,19 @@ /** - * Copies payment and membership history from Postgres into ClickHouse. + * Copies payment, membership, and hackathon history from Postgres into ClickHouse. * * pnpm --filter @query/db export:clickhouse * - * Read-only against Postgres — it runs two SELECTs and writes nothing back. + * Read-only against Postgres — it runs SELECTs and writes nothing back. * That is deliberate: the application database is a 0.5 GB Neon instance, so * history that exists to be aggregated belongs somewhere that is not it. * - * Safe to re-run. Both ClickHouse tables are ReplacingMergeTree keyed on the + * Safe to re-run. The ClickHouse tables are ReplacingMergeTree keyed on the * row id, so a second run replaces rows rather than duplicating them, and the * whole thing can go on a cron without any watermark bookkeeping. * + * Hackathon rows are ids, edition names, and statuses. Names and emails stay + * in Postgres. + * * Env: * DATABASE_URL Postgres to read from. Prod is fine — nothing is written. * CLICKHOUSE_URL default http://localhost:8123 @@ -18,7 +21,22 @@ * CLICKHOUSE_PASSWORD default dsgt * CLICKHOUSE_DB default dsgt */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { eq } from "drizzle-orm"; import { Pool } from "pg"; +import { + hackathonEventAttendees, + hackathonEvents, + hackathonParticipants, + hackathonProjects, + hackathons, + judgeVotes, + judgingProjects, + membershipHistory, + stripePayments, +} from "../src/schemas"; const { DATABASE_URL, @@ -64,6 +82,42 @@ async function insert(table: string, rows: unknown[]) { const stamp = (value: Date | null | undefined) => (value ?? new Date(0)).toISOString().replace("T", " ").replace("Z", ""); +const stampOrNull = (value: Date | null | undefined) => + value ? stamp(value) : null; + +/** Applies monitoring/clickhouse/init/02-hackathon.sql. Init only runs on an empty volume. */ +async function ensureHackathonTables() { + const file = fileURLToPath( + new URL( + "../../../monitoring/clickhouse/init/02-hackathon.sql", + import.meta.url, + ), + ); + const statements = readFileSync(file, "utf-8") + .split("\n") + .filter((line) => !line.trim().startsWith("--")) + .join("\n") + .split(";") + .map((statement) => statement.trim()) + .filter(Boolean); + + for (const statement of statements) { + const url = `${CLICKHOUSE_URL}/?query=${encodeURIComponent(statement)}`; + const res = await fetch(url, { + method: "POST", + headers: { + "X-ClickHouse-User": CLICKHOUSE_USER, + "X-ClickHouse-Key": CLICKHOUSE_PASSWORD, + }, + }); + if (!res.ok) { + throw new Error( + `ClickHouse rejected schema setup: ${res.status} ${await res.text()}`, + ); + } + } +} + const readJson = (raw: string | null) => { if (!raw) return {} as Record; try { @@ -74,76 +128,161 @@ const readJson = (raw: string | null) => { }; const pool = new Pool({ connectionString: DATABASE_URL, max: 2 }); +const db = drizzle(pool); try { - const payments = await pool.query<{ - id: string; - created_at: Date; - updated_at: Date | null; - amount_total: number | null; - currency: string | null; - payment_status: string; - metadata: string | null; - linked_user_id: string | null; - customer_email: string; - }>( - `select id, created_at, updated_at, amount_total, currency, - payment_status, metadata, linked_user_id, customer_email - from "stripe_payment"`, - ); + await ensureHackathonTables(); + const payments = await db + .select({ + id: stripePayments.id, + createdAt: stripePayments.createdAt, + updatedAt: stripePayments.updatedAt, + amountTotal: stripePayments.amountTotal, + currency: stripePayments.currency, + paymentStatus: stripePayments.paymentStatus, + metadata: stripePayments.metadata, + linkedUserId: stripePayments.linkedUserId, + customerEmail: stripePayments.customerEmail, + }) + .from(stripePayments); - const paymentRows = payments.rows.map((row) => { + const paymentRows = payments.map((row) => { const meta = readJson(row.metadata); return { id: row.id, - created_at: stamp(row.created_at), - updated_at: stamp(row.updated_at ?? row.created_at), - amount_cents: row.amount_total ?? 0, + created_at: stamp(row.createdAt), + updated_at: stamp(row.updatedAt ?? row.createdAt), + amount_cents: row.amountTotal ?? 0, currency: row.currency ?? "usd", - payment_status: row.payment_status, + payment_status: row.paymentStatus, // Rows written before the plan existed bought the only thing on offer. plan: meta.plan === "semester" ? "semester" : "annual", bootcamp: meta.bootcamp === "true" ? 1 : 0, addon_only: meta.type === "bootcamp_addon" ? 1 : 0, - linked: row.linked_user_id ? 1 : 0, - customer_email: row.customer_email, + linked: row.linkedUserId ? 1 : 0, + customer_email: row.customerEmail, }; }); - const history = await pool.query<{ - id: string; - member_id: string; - action: string; - start_date: Date | null; - end_date: Date | null; - created_at: Date; - }>( - `select id, member_id, action, start_date, end_date, created_at - from "membership_history"`, - ); + const history = await db + .select({ + id: membershipHistory.id, + memberId: membershipHistory.memberId, + action: membershipHistory.action, + startDate: membershipHistory.startDate, + endDate: membershipHistory.endDate, + createdAt: membershipHistory.createdAt, + }) + .from(membershipHistory); const DAY_MS = 24 * 60 * 60 * 1000; - const historyRows = history.rows.map((row) => ({ + const historyRows = history.map((row) => ({ id: row.id, - member_id: row.member_id, + member_id: row.memberId, action: row.action, - start_date: stamp(row.start_date), - end_date: stamp(row.end_date), - created_at: stamp(row.created_at), + start_date: stamp(row.startDate), + end_date: stamp(row.endDate), + created_at: stamp(row.createdAt), term_days: - row.start_date && row.end_date + row.startDate && row.endDate ? Math.round( - (row.end_date.getTime() - row.start_date.getTime()) / DAY_MS, + (row.endDate.getTime() - row.startDate.getTime()) / DAY_MS, ) : 0, })); + const registrations = await db + .select({ + id: hackathonParticipants.id, + hackathon: hackathons.name, + status: hackathonParticipants.registrationStatus, + registeredAt: hackathonParticipants.registeredAt, + updatedAt: hackathonParticipants.updatedAt, + }) + .from(hackathonParticipants) + .innerJoin( + hackathons, + eq(hackathons.id, hackathonParticipants.hackathonId), + ); + + const registrationRows = registrations.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + status: row.status, + registered_at: stamp(row.registeredAt), + updated_at: stamp(row.updatedAt), + })); + + const projects = await db + .select({ + id: hackathonProjects.id, + hackathon: hackathons.name, + status: hackathonProjects.status, + submittedAt: hackathonProjects.submittedAt, + updatedAt: hackathonProjects.updatedAt, + }) + .from(hackathonProjects) + .innerJoin(hackathons, eq(hackathons.id, hackathonProjects.hackathonId)); + + const projectRows = projects.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + status: row.status, + submitted_at: stampOrNull(row.submittedAt), + updated_at: stamp(row.updatedAt), + })); + + const scans = await db + .select({ + id: hackathonEventAttendees.id, + hackathon: hackathons.name, + eventName: hackathonEvents.name, + checkedInAt: hackathonEventAttendees.checkedInAt, + }) + .from(hackathonEventAttendees) + .innerJoin( + hackathonEvents, + eq(hackathonEvents.id, hackathonEventAttendees.eventId), + ) + .innerJoin(hackathons, eq(hackathons.id, hackathonEvents.hackathonId)); + + const scanRows = scans.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + event_name: row.eventName, + checked_in_at: stamp(row.checkedInAt), + })); + + const votes = await db + .select({ + id: judgeVotes.id, + hackathon: hackathons.name, + score: judgeVotes.score, + votedAt: judgeVotes.votedAt, + updatedAt: judgeVotes.updatedAt, + }) + .from(judgeVotes) + .innerJoin(judgingProjects, eq(judgingProjects.id, judgeVotes.projectId)) + .innerJoin(hackathons, eq(hackathons.id, judgingProjects.hackathonId)); + + const voteRows = votes.map((row) => ({ + id: row.id, + hackathon: row.hackathon, + score: row.score, + voted_at: stamp(row.votedAt), + updated_at: stamp(row.updatedAt), + })); + const written = (await insert("payments", paymentRows)) + - (await insert("membership_events", historyRows)); + (await insert("membership_events", historyRows)) + + (await insert("hackathon_registrations", registrationRows)) + + (await insert("hackathon_projects", projectRows)) + + (await insert("hackathon_scans", scanRows)) + + (await insert("hackathon_votes", voteRows)); console.log( - `Exported ${paymentRows.length} payments and ${historyRows.length} membership events (${written} rows).`, + `Exported ${paymentRows.length} payments, ${historyRows.length} membership events, ${registrationRows.length} registrations, ${projectRows.length} projects, ${scanRows.length} scans, and ${voteRows.length} votes (${written} rows).`, ); } catch (error) { console.error("Export failed:", error); diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx index 13b57a95..ea777b3d 100644 --- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx +++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx @@ -12,6 +12,7 @@ import { itemTitle, label, meta, + body, pageDek, sectionTitle, } from "@/components/portal/ui"; @@ -69,6 +70,13 @@ function readTokens(element: HTMLElement): ChartTokens { }; } +function phaseLabel(phase: string) { + if (phase === "open") return "Registration open"; + if (phase === "closed") return "Registration closed"; + if (phase === "in_progress") return "In progress"; + return phase; +} + /** `2026-fall` is how it is stored; nobody should have to read it that way. */ function termLabel(term: string) { const [year, season] = term.split("-"); @@ -145,6 +153,11 @@ export default function AnalyticsPage() { { enabled: !!session, refetchInterval: 15000 }, ); + const live = trpc.admin.hackathonLive.useQuery(undefined, { + enabled: !!session, + refetchInterval: 15000, + }); + // Growth moves on a monthly clock, so it is fetched once rather than polled. const growth = trpc.admin.growth.useQuery(undefined, { enabled: !!session }); @@ -475,15 +488,15 @@ export default function AnalyticsPage() { {/* Events and hackathons */}

Events and hackathons

-
+
{isLoading ? ( - [1, 2, 3, 4].map((i) => ) + [1, 2, 3, 4, 5].map((i) => ) ) : ( <> + )}
+ +
+

Live hackathon

+

+ Editions that are open, registration-closed, or in progress. These + are the same counts the metrics endpoint publishes. +

+ {live.isPending ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : !live.data?.length ? ( +

+ No edition is open, registration-closed, or in progress. +

+ ) : ( + live.data.map((edition) => { + const judgingIdle = + edition.judgingActive && + edition.judgingProjects > 0 && + edition.votes === 0; + return ( +
+

{edition.edition}

+

{phaseLabel(edition.phase)}

+ {judgingIdle && ( +

+ Judging is on and no scores have landed. +

+ )} +
+ + + + + + + +
+

+ Waitlisted {edition.participants.waitlisted} · Rejected{" "} + {edition.participants.rejected} +

+
+ ); + }) + )} +
); }