Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

traffic.tsBlame242 lines · 1 contributor
8f50ed0Claude1/**
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
10import { and, eq, gte, sql } from "drizzle-orm";
11import { createHash } from "node:crypto";
12import { db } from "../db";
13import { repoTrafficEvents, repositories, users } from "../db/schema";
14
15export type TrafficKind = "view" | "clone" | "api" | "ui";
16
17export 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
27function 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 */
39export 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 */
58export async function trackView(
59 args: Omit<TrackArgs, "kind">
60): Promise<void> {
61 return track({ ...args, kind: "view" });
62}
63
64export 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 */
74export 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
94export 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 */
107export 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 */
227export 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}