Skip to content

Commit 51a38d0

Browse files
authored
Merge pull request #995 from AgentWorkforce/codex/posthog-github-traffic
Track GitHub traffic in PostHog
2 parents 8238054 + 11fe736 commit 51a38d0

8 files changed

Lines changed: 609 additions & 1 deletion

File tree

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
#!/usr/bin/env node
2+
3+
const DEFAULT_GITHUB_API_VERSION = '2022-11-28';
4+
const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com';
5+
const GITHUB_TRAFFIC_WINDOW_DAYS = 14;
6+
const DEFAULT_DAILY_LAG_HOURS = 8;
7+
const REQUEST_TIMEOUT_MS = 15_000;
8+
9+
function readEnv(name, fallback = undefined) {
10+
const value = process.env[name];
11+
return value && value.trim() ? value.trim() : fallback;
12+
}
13+
14+
function requireEnv(name) {
15+
const value = readEnv(name);
16+
if (!value) {
17+
throw new Error(`${name} is required`);
18+
}
19+
return value;
20+
}
21+
22+
function normalizeHost(host) {
23+
return host.replace(/\/+$/, '');
24+
}
25+
26+
function parseRepository(repository) {
27+
const [owner, repo, ...rest] = repository.split('/');
28+
if (!owner || !repo || rest.length > 0) {
29+
throw new Error(`Expected repository in owner/name form, got "${repository}"`);
30+
}
31+
return { owner, repo };
32+
}
33+
34+
function dayFromTimestamp(timestamp) {
35+
return timestamp.slice(0, 10);
36+
}
37+
38+
function utcNoonForDay(day) {
39+
return `${day}T12:00:00Z`;
40+
}
41+
42+
function parseNonNegativeNumber(value, name) {
43+
const parsed = Number(value);
44+
if (!Number.isFinite(parsed) || parsed < 0) {
45+
throw new Error(`${name} must be a non-negative number`);
46+
}
47+
return parsed;
48+
}
49+
50+
function dailyCutoffDay(lagHours) {
51+
const cutoff = new Date(Date.now() - lagHours * 60 * 60 * 1000);
52+
return dayFromTimestamp(cutoff.toISOString());
53+
}
54+
55+
function runIdentity() {
56+
const runId = readEnv('GITHUB_RUN_ID');
57+
const runAttempt = readEnv('GITHUB_RUN_ATTEMPT');
58+
if (runId && runAttempt) {
59+
return `${runId}:${runAttempt}`;
60+
}
61+
return readEnv('GITHUB_TRAFFIC_RUN_ID', new Date().toISOString());
62+
}
63+
64+
function baseProperties({ repository, owner, repo, capturedAt, distinctId, apiVersion }) {
65+
return {
66+
source: 'github_actions',
67+
repository,
68+
github_owner: owner,
69+
github_repo: repo,
70+
github_api_version: apiVersion,
71+
github_traffic_window_days: GITHUB_TRAFFIC_WINDOW_DAYS,
72+
captured_at: capturedAt,
73+
distinct_id: distinctId,
74+
$groups: {
75+
repository,
76+
},
77+
$process_person_profile: false,
78+
};
79+
}
80+
81+
function postHogEvent({ event, distinctId, timestamp, properties }) {
82+
return {
83+
event,
84+
distinct_id: distinctId,
85+
timestamp,
86+
properties: {
87+
...properties,
88+
distinct_id: distinctId,
89+
},
90+
};
91+
}
92+
93+
async function githubGetJson({ owner, repo, path, githubToken, apiVersion }) {
94+
const url = new URL(`https://api.github.com/repos/${owner}/${repo}${path}`);
95+
const response = await fetch(url, {
96+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
97+
headers: {
98+
Accept: 'application/vnd.github+json',
99+
Authorization: `Bearer ${githubToken}`,
100+
'X-GitHub-Api-Version': apiVersion,
101+
'User-Agent': 'agent-relay-github-traffic-posthog',
102+
},
103+
});
104+
105+
if (!response.ok) {
106+
const body = await response.text();
107+
const hint =
108+
response.status === 401 || response.status === 403
109+
? '\nGitHub traffic endpoints require repository write access or a fine-grained token with Administration: read. Configure GH_TRAFFIC_TOKEN as a repository secret.'
110+
: '';
111+
throw new Error(`GitHub API ${response.status} for ${path}: ${body}${hint}`);
112+
}
113+
114+
return response.json();
115+
}
116+
117+
function buildDailyTrafficEvents({
118+
repository,
119+
owner,
120+
repo,
121+
distinctId,
122+
capturedAt,
123+
apiVersion,
124+
views,
125+
clones,
126+
includePartialDay,
127+
dailyLagHours,
128+
}) {
129+
const today = dayFromTimestamp(new Date().toISOString());
130+
const cutoffDay = dailyCutoffDay(dailyLagHours);
131+
const byDay = new Map();
132+
133+
for (const item of views.views ?? []) {
134+
const day = dayFromTimestamp(item.timestamp);
135+
byDay.set(day, {
136+
...(byDay.get(day) ?? {}),
137+
day,
138+
github_timestamp: item.timestamp,
139+
view_count: item.count,
140+
view_uniques: item.uniques,
141+
has_views: true,
142+
});
143+
}
144+
145+
for (const item of clones.clones ?? []) {
146+
const day = dayFromTimestamp(item.timestamp);
147+
byDay.set(day, {
148+
...(byDay.get(day) ?? {}),
149+
day,
150+
github_timestamp: item.timestamp,
151+
clone_count: item.count,
152+
clone_uniques: item.uniques,
153+
has_clones: true,
154+
});
155+
}
156+
157+
const base = baseProperties({ repository, owner, repo, capturedAt, distinctId, apiVersion });
158+
return [...byDay.values()]
159+
.filter((item) => includePartialDay || item.day < cutoffDay)
160+
.sort((a, b) => a.day.localeCompare(b.day))
161+
.map((item) =>
162+
postHogEvent({
163+
event: 'github_repo_traffic_daily',
164+
distinctId,
165+
timestamp: utcNoonForDay(item.day),
166+
properties: {
167+
...base,
168+
traffic_period: 'day',
169+
traffic_date: item.day,
170+
daily_cutoff_date: cutoffDay,
171+
daily_lag_hours: dailyLagHours,
172+
github_timestamp: item.github_timestamp ?? `${item.day}T00:00:00Z`,
173+
view_count: item.view_count ?? 0,
174+
view_uniques: item.view_uniques ?? 0,
175+
clone_count: item.clone_count ?? 0,
176+
clone_uniques: item.clone_uniques ?? 0,
177+
has_views: Boolean(item.has_views),
178+
has_clones: Boolean(item.has_clones),
179+
includes_partial_day: item.day >= today,
180+
$insert_id: `github_repo_traffic_daily:${repository}:${item.day}`,
181+
},
182+
})
183+
);
184+
}
185+
186+
function availableWindow({ views, clones }) {
187+
const days = [
188+
...(views.views ?? []).map((item) => dayFromTimestamp(item.timestamp)),
189+
...(clones.clones ?? []).map((item) => dayFromTimestamp(item.timestamp)),
190+
].sort();
191+
192+
return {
193+
window_start_date: days[0] ?? null,
194+
window_end_date: days[days.length - 1] ?? null,
195+
available_day_count: new Set(days).size,
196+
};
197+
}
198+
199+
function buildSnapshotEvents({
200+
repository,
201+
owner,
202+
repo,
203+
distinctId,
204+
capturedAt,
205+
apiVersion,
206+
views,
207+
clones,
208+
paths,
209+
referrers,
210+
dailyEvents,
211+
}) {
212+
const base = baseProperties({ repository, owner, repo, capturedAt, distinctId, apiVersion });
213+
const runKey = runIdentity();
214+
const window = availableWindow({ views, clones });
215+
const today = dayFromTimestamp(new Date().toISOString());
216+
const includesPartialDay = window.window_end_date === today;
217+
218+
const snapshot = postHogEvent({
219+
event: 'github_repo_traffic_window_snapshot',
220+
distinctId,
221+
timestamp: capturedAt,
222+
properties: {
223+
...base,
224+
traffic_period: 'last_14_days',
225+
...window,
226+
includes_partial_day: includesPartialDay,
227+
daily_events_sent: dailyEvents.length,
228+
view_count: views.count ?? 0,
229+
view_uniques: views.uniques ?? 0,
230+
clone_count: clones.count ?? 0,
231+
clone_uniques: clones.uniques ?? 0,
232+
top_path_count: paths.length,
233+
top_referrer_count: referrers.length,
234+
raw_views_daily: views.views ?? [],
235+
raw_clones_daily: clones.clones ?? [],
236+
$insert_id: `github_repo_traffic_window_snapshot:${repository}:${runKey}`,
237+
},
238+
});
239+
240+
const pathEvents = paths.map((item, index) =>
241+
postHogEvent({
242+
event: 'github_repo_traffic_path_snapshot',
243+
distinctId,
244+
timestamp: capturedAt,
245+
properties: {
246+
...base,
247+
traffic_period: 'last_14_days',
248+
rank: index + 1,
249+
path: item.path,
250+
title: item.title,
251+
view_count: item.count ?? 0,
252+
view_uniques: item.uniques ?? 0,
253+
$insert_id: `github_repo_traffic_path_snapshot:${repository}:${runKey}:${index + 1}:${item.path}`,
254+
},
255+
})
256+
);
257+
258+
const referrerEvents = referrers.map((item, index) =>
259+
postHogEvent({
260+
event: 'github_repo_traffic_referrer_snapshot',
261+
distinctId,
262+
timestamp: capturedAt,
263+
properties: {
264+
...base,
265+
traffic_period: 'last_14_days',
266+
rank: index + 1,
267+
referrer: item.referrer,
268+
view_count: item.count ?? 0,
269+
view_uniques: item.uniques ?? 0,
270+
$insert_id: `github_repo_traffic_referrer_snapshot:${repository}:${runKey}:${index + 1}:${item.referrer}`,
271+
},
272+
})
273+
);
274+
275+
return [snapshot, ...pathEvents, ...referrerEvents];
276+
}
277+
278+
async function sendPostHogBatch({ host, apiKey, events }) {
279+
const response = await fetch(`${normalizeHost(host)}/batch/`, {
280+
method: 'POST',
281+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
282+
headers: {
283+
'Content-Type': 'application/json',
284+
},
285+
body: JSON.stringify({
286+
api_key: apiKey,
287+
historical_migration: true,
288+
batch: events,
289+
}),
290+
});
291+
292+
if (!response.ok) {
293+
const body = await response.text();
294+
throw new Error(`PostHog batch API ${response.status}: ${body}`);
295+
}
296+
}
297+
298+
async function main() {
299+
const repository = readEnv('GITHUB_REPOSITORY_NAME', readEnv('GITHUB_REPOSITORY'));
300+
if (!repository) {
301+
throw new Error('GITHUB_REPOSITORY_NAME or GITHUB_REPOSITORY is required');
302+
}
303+
304+
const { owner, repo } = parseRepository(repository);
305+
const githubToken = requireEnv('GITHUB_TOKEN');
306+
const apiVersion = readEnv('GITHUB_API_VERSION', DEFAULT_GITHUB_API_VERSION);
307+
const posthogHost = readEnv('POSTHOG_HOST', DEFAULT_POSTHOG_HOST);
308+
const dryRun = readEnv('POSTHOG_DRY_RUN', 'false') === 'true';
309+
const posthogApiKey = dryRun
310+
? readEnv('POSTHOG_PROJECT_API_KEY', 'dry-run')
311+
: requireEnv('POSTHOG_PROJECT_API_KEY');
312+
const includePartialDay = readEnv('GITHUB_TRAFFIC_INCLUDE_PARTIAL_DAY', 'false') === 'true';
313+
const dailyLagHours = parseNonNegativeNumber(
314+
readEnv('GITHUB_TRAFFIC_DAILY_LAG_HOURS', String(DEFAULT_DAILY_LAG_HOURS)),
315+
'GITHUB_TRAFFIC_DAILY_LAG_HOURS'
316+
);
317+
const capturedAt = new Date().toISOString();
318+
const distinctId = `github_repo:${repository}`;
319+
320+
const request = { owner, repo, githubToken, apiVersion };
321+
const [views, clones, paths, referrers] = await Promise.all([
322+
githubGetJson({ ...request, path: '/traffic/views?per=day' }),
323+
githubGetJson({ ...request, path: '/traffic/clones?per=day' }),
324+
githubGetJson({ ...request, path: '/traffic/popular/paths' }),
325+
githubGetJson({ ...request, path: '/traffic/popular/referrers' }),
326+
]);
327+
328+
const common = { repository, owner, repo, distinctId, capturedAt, apiVersion };
329+
const dailyEvents = buildDailyTrafficEvents({ ...common, views, clones, includePartialDay, dailyLagHours });
330+
const snapshotEvents = buildSnapshotEvents({ ...common, views, clones, paths, referrers, dailyEvents });
331+
const events = [...dailyEvents, ...snapshotEvents];
332+
333+
if (events.length === 0) {
334+
console.log(`No GitHub traffic events to send for ${repository}`);
335+
return;
336+
}
337+
338+
if (dryRun) {
339+
console.log(`Dry run: built ${events.length} GitHub traffic events for ${repository}`);
340+
console.log(JSON.stringify({ event_count: events.length, events }, null, 2));
341+
return;
342+
}
343+
344+
await sendPostHogBatch({ host: posthogHost, apiKey: posthogApiKey, events });
345+
console.log(
346+
`Sent ${events.length} GitHub traffic events for ${repository} to PostHog (${dailyEvents.length} daily, ${snapshotEvents.length} snapshots)`
347+
);
348+
}
349+
350+
main().catch((error) => {
351+
console.error(error instanceof Error ? error.message : error);
352+
process.exit(1);
353+
});

0 commit comments

Comments
 (0)