CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
traffic.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 8f50ed0 | 1 | /** |
| 2 | * Block F1 — Traffic analytics helpers. | |
| 3 | * | |
| 4 | * Records + rolls up per-repo visit / clone / API hits. We record a row per | |
| 5 | * event (cheap) and aggregate in-memory for the chart. `trackView` and | |
| 6 | * `trackClone` are fire-and-forget — they never throw and never slow the | |
| 7 | * user-facing request. | |
| 8 | */ | |
| 9 | ||
| 10 | import { and, eq, gte, sql } from "drizzle-orm"; | |
| 11 | import { createHash } from "node:crypto"; | |
| 12 | import { db } from "../db"; | |
| 13 | import { repoTrafficEvents, repositories, users } from "../db/schema"; | |
| 14 | ||
| 15 | export type TrafficKind = "view" | "clone" | "api" | "ui"; | |
| 16 | ||
| 17 | export interface TrackArgs { | |
| 18 | repositoryId: string; | |
| 19 | kind: TrafficKind; | |
| 20 | path?: string | null; | |
| 21 | userId?: string | null; | |
| 22 | ip?: string | null; | |
| 23 | userAgent?: string | null; | |
| 24 | referer?: string | null; | |
| 25 | } | |
| 26 | ||
| 27 | function hashIp(ip: string | null | undefined): string | null { | |
| 28 | if (!ip) return null; | |
| 29 | return createHash("sha256") | |
| 30 | .update(ip) | |
| 31 | .digest("hex") | |
| 32 | .slice(0, 16); // 64 bits is plenty for uniqueness within a day | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * Record a traffic event. Never throws. Returns quickly — callers don't need | |
| 37 | * to await, but may if they want backpressure. | |
| 38 | */ | |
| 39 | export async function track(args: TrackArgs): Promise<void> { | |
| 40 | try { | |
| 41 | await db.insert(repoTrafficEvents).values({ | |
| 42 | repositoryId: args.repositoryId, | |
| 43 | kind: args.kind, | |
| 44 | path: args.path ? args.path.slice(0, 256) : null, | |
| 45 | userId: args.userId || null, | |
| 46 | ipHash: hashIp(args.ip), | |
| 47 | userAgent: args.userAgent ? args.userAgent.slice(0, 128) : null, | |
| 48 | referer: args.referer ? args.referer.slice(0, 256) : null, | |
| 49 | }); | |
| 50 | } catch { | |
| 51 | // swallow | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | /** | |
| 56 | * Convenience wrappers. Kept separate so call sites read semantically. | |
| 57 | */ | |
| 58 | export async function trackView( | |
| 59 | args: Omit<TrackArgs, "kind"> | |
| 60 | ): Promise<void> { | |
| 61 | return track({ ...args, kind: "view" }); | |
| 62 | } | |
| 63 | ||
| 64 | export async function trackClone( | |
| 65 | args: Omit<TrackArgs, "kind"> | |
| 66 | ): Promise<void> { | |
| 67 | return track({ ...args, kind: "clone" }); | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Look up `(owner, repo)` and record a traffic event. Safe to fire-and-forget | |
| 72 | * from request handlers; never throws. Returns void. | |
| 73 | */ | |
| 74 | export async function trackByName( | |
| 75 | owner: string, | |
| 76 | repo: string, | |
| 77 | kind: TrafficKind, | |
| 78 | meta: Omit<TrackArgs, "kind" | "repositoryId"> = {} | |
| 79 | ): Promise<void> { | |
| 80 | try { | |
| 81 | const [row] = await db | |
| 82 | .select({ id: repositories.id }) | |
| 83 | .from(repositories) | |
| 84 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 85 | .where(and(eq(users.username, owner), eq(repositories.name, repo))) | |
| 86 | .limit(1); | |
| 87 | if (!row) return; | |
| 88 | await track({ ...meta, kind, repositoryId: row.id }); | |
| 89 | } catch { | |
| 90 | // swallow | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | export interface TrafficSummary { | |
| 95 | totalViews: number; | |
| 96 | totalClones: number; | |
| 97 | uniqueVisitorsApprox: number; | |
| 98 | daily: Array<{ day: string; views: number; clones: number }>; | |
| 99 | topReferers: Array<{ referer: string; n: number }>; | |
| 100 | topPaths: Array<{ path: string; n: number }>; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Build a 14-day traffic summary for a repo. Approximation for | |
| 105 | * uniqueVisitorsApprox: distinct `ip_hash` over the window. | |
| 106 | */ | |
| 107 | export async function summarise( | |
| 108 | repositoryId: string, | |
| 109 | windowDays = 14 | |
| 110 | ): Promise<TrafficSummary> { | |
| 111 | const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); | |
| 112 | const empty: TrafficSummary = { | |
| 113 | totalViews: 0, | |
| 114 | totalClones: 0, | |
| 115 | uniqueVisitorsApprox: 0, | |
| 116 | daily: [], | |
| 117 | topReferers: [], | |
| 118 | topPaths: [], | |
| 119 | }; | |
| 120 | ||
| 121 | try { | |
| 122 | const rows = await db | |
| 123 | .select({ | |
| 124 | day: sql<string>`to_char(date_trunc('day', ${repoTrafficEvents.createdAt}), 'YYYY-MM-DD')`, | |
| 125 | kind: repoTrafficEvents.kind, | |
| 126 | n: sql<number>`count(*)::int`, | |
| 127 | }) | |
| 128 | .from(repoTrafficEvents) | |
| 129 | .where( | |
| 130 | and( | |
| 131 | eq(repoTrafficEvents.repositoryId, repositoryId), | |
| 132 | gte(repoTrafficEvents.createdAt, since) | |
| 133 | ) | |
| 134 | ) | |
| 135 | .groupBy( | |
| 136 | sql`date_trunc('day', ${repoTrafficEvents.createdAt})`, | |
| 137 | repoTrafficEvents.kind | |
| 138 | ); | |
| 139 | ||
| 140 | const dayMap = new Map<string, { views: number; clones: number }>(); | |
| 141 | let totalViews = 0; | |
| 142 | let totalClones = 0; | |
| 143 | for (const r of rows) { | |
| 144 | const day = r.day; | |
| 145 | const bucket = dayMap.get(day) || { views: 0, clones: 0 }; | |
| 146 | if (r.kind === "view" || r.kind === "ui") { | |
| 147 | bucket.views += Number(r.n); | |
| 148 | totalViews += Number(r.n); | |
| 149 | } else if (r.kind === "clone") { | |
| 150 | bucket.clones += Number(r.n); | |
| 151 | totalClones += Number(r.n); | |
| 152 | } | |
| 153 | dayMap.set(day, bucket); | |
| 154 | } | |
| 155 | ||
| 156 | const daily = Array.from(dayMap.entries()) | |
| 157 | .map(([day, v]) => ({ day, views: v.views, clones: v.clones })) | |
| 158 | .sort((a, b) => a.day.localeCompare(b.day)); | |
| 159 | ||
| 160 | const [uv] = await db | |
| 161 | .select({ | |
| 162 | n: sql<number>`count(distinct ${repoTrafficEvents.ipHash})::int`, | |
| 163 | }) | |
| 164 | .from(repoTrafficEvents) | |
| 165 | .where( | |
| 166 | and( | |
| 167 | eq(repoTrafficEvents.repositoryId, repositoryId), | |
| 168 | gte(repoTrafficEvents.createdAt, since) | |
| 169 | ) | |
| 170 | ); | |
| 171 | ||
| 172 | const refRows = await db | |
| 173 | .select({ | |
| 174 | referer: repoTrafficEvents.referer, | |
| 175 | n: sql<number>`count(*)::int`, | |
| 176 | }) | |
| 177 | .from(repoTrafficEvents) | |
| 178 | .where( | |
| 179 | and( | |
| 180 | eq(repoTrafficEvents.repositoryId, repositoryId), | |
| 181 | gte(repoTrafficEvents.createdAt, since), | |
| 182 | sql`${repoTrafficEvents.referer} IS NOT NULL AND ${repoTrafficEvents.referer} <> ''` | |
| 183 | ) | |
| 184 | ) | |
| 185 | .groupBy(repoTrafficEvents.referer) | |
| 186 | .orderBy(sql`count(*) desc`) | |
| 187 | .limit(8); | |
| 188 | ||
| 189 | const pathRows = await db | |
| 190 | .select({ | |
| 191 | path: repoTrafficEvents.path, | |
| 192 | n: sql<number>`count(*)::int`, | |
| 193 | }) | |
| 194 | .from(repoTrafficEvents) | |
| 195 | .where( | |
| 196 | and( | |
| 197 | eq(repoTrafficEvents.repositoryId, repositoryId), | |
| 198 | gte(repoTrafficEvents.createdAt, since), | |
| 199 | sql`${repoTrafficEvents.path} IS NOT NULL` | |
| 200 | ) | |
| 201 | ) | |
| 202 | .groupBy(repoTrafficEvents.path) | |
| 203 | .orderBy(sql`count(*) desc`) | |
| 204 | .limit(8); | |
| 205 | ||
| 206 | return { | |
| 207 | totalViews, | |
| 208 | totalClones, | |
| 209 | uniqueVisitorsApprox: Number(uv?.n || 0), | |
| 210 | daily, | |
| 211 | topReferers: refRows | |
| 212 | .filter((r) => !!r.referer) | |
| 213 | .map((r) => ({ referer: r.referer as string, n: Number(r.n) })), | |
| 214 | topPaths: pathRows | |
| 215 | .filter((r) => !!r.path) | |
| 216 | .map((r) => ({ path: r.path as string, n: Number(r.n) })), | |
| 217 | }; | |
| 218 | } catch { | |
| 219 | return empty; | |
| 220 | } | |
| 221 | } | |
| 222 | ||
| 223 | /** | |
| 224 | * Pure helper for unit tests — turn a list of events into the same daily | |
| 225 | * bucket structure as `summarise` without needing a DB. | |
| 226 | */ | |
| 227 | export function bucketDaily( | |
| 228 | events: Array<{ createdAt: Date | string; kind: string }> | |
| 229 | ): Array<{ day: string; views: number; clones: number }> { | |
| 230 | const dayMap = new Map<string, { views: number; clones: number }>(); | |
| 231 | for (const e of events) { | |
| 232 | const t = typeof e.createdAt === "string" ? new Date(e.createdAt) : e.createdAt; | |
| 233 | const day = t.toISOString().slice(0, 10); | |
| 234 | const bucket = dayMap.get(day) || { views: 0, clones: 0 }; | |
| 235 | if (e.kind === "view" || e.kind === "ui") bucket.views++; | |
| 236 | else if (e.kind === "clone") bucket.clones++; | |
| 237 | dayMap.set(day, bucket); | |
| 238 | } | |
| 239 | return Array.from(dayMap.entries()) | |
| 240 | .map(([day, v]) => ({ day, ...v })) | |
| 241 | .sort((a, b) => a.day.localeCompare(b.day)); | |
| 242 | } |