Skip to content

Commit a5d065f

Browse files
authored
Merge pull request #4222 from ava-labs/feat/builder-insights-improved-utm
feat(insights): top traffic sources per event in Builder Insights
2 parents a8072b0 + 4c363df commit a5d065f

5 files changed

Lines changed: 216 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ web_modules/
8686
.env.production.local
8787
.env.local
8888

89+
# Local working notes / planning docs (never commit)
90+
tasks/
91+
8992
# parcel-bundler cache (https://parceljs.org/)
9093
# .cache handled above
9194
.parcel-cache

components/profile/shell/InsightsCard.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -672,12 +672,13 @@ function EventHistorySection({ data }: { data: BuilderInsightsData }) {
672672
<th>Event</th>
673673
<th className="pr-num">Inscriptions</th>
674674
<th className="pr-num">Projects submitted</th>
675+
<th className="pr-num">Top traffic sources (90d)</th>
675676
</tr>
676677
</thead>
677678
<tbody>
678679
{sorted.length === 0 ? (
679680
<tr>
680-
<td colSpan={3} className="pr-leaderboard__empty">
681+
<td colSpan={4} className="pr-leaderboard__empty">
681682
No events recorded yet.
682683
</td>
683684
</tr>
@@ -694,6 +695,22 @@ function EventHistorySection({ data }: { data: BuilderInsightsData }) {
694695
</td>
695696
<td className="pr-num">{formatNumber(e.registrations)}</td>
696697
<td className="pr-num">{formatNumber(e.projects)}</td>
698+
<td>
699+
{e.topTrafficSources.length === 0 ? (
700+
<div className="pr-traffic-sources__empty">No data</div>
701+
) : (
702+
<ul className="pr-traffic-sources">
703+
{e.topTrafficSources.map((src) => (
704+
<li key={src.source}>
705+
<span className="pr-traffic-sources__name">{src.source}</span>
706+
<span className="pr-traffic-sources__count">
707+
{formatNumber(src.visitors)}
708+
</span>
709+
</li>
710+
))}
711+
</ul>
712+
)}
713+
</td>
697714
</tr>
698715
))
699716
)}

components/profile/shell/styles.css

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2159,6 +2159,42 @@
21592159
color: var(--pr-g-650);
21602160
font-size: 12.5px;
21612161
}
2162+
.profile .pr-traffic-sources {
2163+
list-style: none;
2164+
margin: 0 0 0 auto;
2165+
padding: 0;
2166+
display: flex;
2167+
flex-direction: column;
2168+
gap: 2px;
2169+
width: 240px;
2170+
max-width: 100%;
2171+
}
2172+
.profile .pr-traffic-sources li {
2173+
display: flex;
2174+
align-items: baseline;
2175+
justify-content: space-between;
2176+
gap: 12px;
2177+
font-size: 11.5px;
2178+
}
2179+
.profile .pr-traffic-sources__name {
2180+
color: var(--pr-g-800);
2181+
overflow: hidden;
2182+
text-overflow: ellipsis;
2183+
white-space: nowrap;
2184+
max-width: 200px;
2185+
}
2186+
.profile .pr-traffic-sources__count {
2187+
font-family: var(--pr-mono);
2188+
font-size: 10.5px;
2189+
color: var(--pr-g-650);
2190+
flex-shrink: 0;
2191+
}
2192+
.profile .pr-traffic-sources__empty {
2193+
font-family: var(--pr-mono);
2194+
font-size: 10px;
2195+
color: var(--pr-g-650);
2196+
text-align: right;
2197+
}
21622198

21632199
/* Hackathon history — 4-up grid with one wide featured tile. Each card
21642200
shows title + date + status pill + a 2-cell stat row at the bottom. */

server/services/builderInsights.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
} from "@/lib/referrals/targets";
88
import { runHogQL } from "@/lib/posthog-query";
99
import { REFERRAL_TEAM_LABELS } from "@/lib/referrals/team-labels";
10+
import {
11+
getTopHackathonTrafficSourcesBatch,
12+
type HackathonTrafficSource,
13+
} from "./hackathonTrafficSources";
1014

1115
export interface MonthlySignupPoint {
1216
month: string;
@@ -33,6 +37,7 @@ export interface EventParticipantPoint {
3337
registrations: number;
3438
startDate: string | null;
3539
endDate: string | null;
40+
topTrafficSources: HackathonTrafficSource[];
3641
}
3742

3843
export interface TopReferrerRow {
@@ -446,6 +451,10 @@ export async function getBuilderInsightsData(currentUserId: string): Promise<Bui
446451
};
447452
});
448453

454+
const trafficSourcesByEvent = await getTopHackathonTrafficSourcesBatch(
455+
eventParticipantRows.map((row) => row.eventId),
456+
);
457+
449458
const eventParticipants: EventParticipantPoint[] = eventParticipantRows.map((row) => ({
450459
eventId: row.eventId,
451460
event: row.event,
@@ -454,6 +463,7 @@ export async function getBuilderInsightsData(currentUserId: string): Promise<Bui
454463
registrations: toNumber(row.registrations),
455464
startDate: row.startDate ? row.startDate.toISOString() : null,
456465
endDate: row.endDate ? row.endDate.toISOString() : null,
466+
topTrafficSources: trafficSourcesByEvent.get(row.eventId) ?? [],
457467
}));
458468

459469
const totalHackathonsHosted = eventParticipants.length;
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { runHogQL } from "@/lib/posthog-query";
2+
3+
const POSTHOG_BUILDER_HUB_PROJECT_ID = process.env.POSTHOG_PROJECT_ID;
4+
5+
const HOGQL_HOST_FILTER =
6+
"properties.$host IN ('build.avax.network', 'www.build.avax.network')";
7+
8+
/**
9+
* HogQL expression that buckets every pageview into a single `source` string.
10+
*
11+
* Channel-mix only — no handle/page extraction. X (t.co) and LinkedIn strip
12+
* the originating tweet/post from the Referer header, so organic social can
13+
* only be attributed at the channel level. UTM-tagged links keep full
14+
* granularity via the first branch (use utm_content for the poster handle).
15+
*
16+
* Priority: explicit UTM (excluding PostHog's '$direct' sentinel) →
17+
* sign-in/OAuth redirects → broad channel → bare domain → "Direct".
18+
*/
19+
const SOURCE_BUCKET_EXPR = `
20+
multiIf(
21+
notEmpty(properties.utm_source) AND properties.utm_source != '$direct',
22+
concat(properties.utm_source, ' / ', coalesce(properties.utm_campaign, '(no campaign)')),
23+
properties.$referring_domain IN (
24+
'accounts.google.com', 'login.microsoftonline.com', 'github.com'
25+
),
26+
'Sign-in redirect',
27+
properties.$referring_domain IN ('x.com', 'twitter.com', 't.co'),
28+
'X (untagged)',
29+
properties.$referring_domain = 'linkedin.com'
30+
OR endsWith(properties.$referring_domain, '.linkedin.com'),
31+
'LinkedIn (untagged)',
32+
properties.$referring_domain IN ('youtube.com', 'www.youtube.com', 'youtu.be'),
33+
'YouTube',
34+
properties.$referring_domain IN ('discord.com', 'discord.gg'),
35+
'Discord',
36+
endsWith(properties.$referring_domain, 't.me'),
37+
'Telegram',
38+
properties.$referring_domain IN ('build.avax.network', 'www.build.avax.network'),
39+
'BuildersHub (internal)',
40+
notEmpty(properties.$referring_domain) AND properties.$referring_domain != '$direct',
41+
properties.$referring_domain,
42+
'Direct'
43+
)
44+
`.trim();
45+
46+
export interface HackathonTrafficSource {
47+
source: string;
48+
visitors: number;
49+
reachedRegister: number;
50+
}
51+
52+
interface RawRow {
53+
source: string;
54+
visitors: number | string | null;
55+
reachedRegister: number | string | null;
56+
}
57+
58+
function toNumber(value: number | string | null | undefined): number {
59+
if (value === null || value === undefined) return 0;
60+
return typeof value === "number" ? value : Number(value) || 0;
61+
}
62+
63+
/**
64+
* UUIDs only. Hackathon ids in this codebase are uuid v4 (see prisma/schema.prisma),
65+
* so we restrict to that shape rather than escape-quoting. Anything else returns
66+
* an empty result rather than risk a query injection through PostHog.
67+
*/
68+
function isSafeHackathonId(id: string): boolean {
69+
return /^[a-zA-Z0-9_-]{1,64}$/.test(id);
70+
}
71+
72+
export interface TopTrafficSourcesOptions {
73+
/** Lookback window in days. Default 90. */
74+
days?: number;
75+
/** Number of source buckets to return. Default 3. */
76+
limit?: number;
77+
}
78+
79+
interface BatchRow extends RawRow {
80+
hackathon_id: string | null;
81+
}
82+
83+
/**
84+
* Batched variant — top-N traffic sources for a list of hackathons in a single
85+
* HogQL query. Used by the Builder Insights event-history view where we'd
86+
* otherwise make one HTTP roundtrip per row. Returns a map keyed by
87+
* hackathonId; events missing from the result are returned as empty arrays.
88+
*/
89+
export async function getTopHackathonTrafficSourcesBatch(
90+
hackathonIds: string[],
91+
{ days = 90, limit = 3 }: TopTrafficSourcesOptions = {},
92+
): Promise<Map<string, HackathonTrafficSource[]>> {
93+
const safeIds = Array.from(new Set(hackathonIds.filter(isSafeHackathonId)));
94+
const result = new Map<string, HackathonTrafficSource[]>();
95+
for (const id of safeIds) result.set(id, []);
96+
if (safeIds.length === 0) return result;
97+
98+
const safeDays = Math.max(1, Math.min(365, Math.floor(days)));
99+
const safeLimit = Math.max(1, Math.min(20, Math.floor(limit)));
100+
const idList = safeIds.map((id) => `'${id}'`).join(", ");
101+
102+
// Hackathon attribution: extract the UUID from /events/<id> or /hackathons/<id>
103+
// URLs. Both routes serve the same hackathon today.
104+
//
105+
// `LIMIT N BY column` is ClickHouse syntax: keep the first N rows per group
106+
// after ORDER BY — gives top-N per hackathon in one query. We pre-filter
107+
// pageviews that would bucket as "Direct" (no referrer + no real UTM) so
108+
// they never compete for a top-N slot.
109+
const HACKATHON_ID_FROM_PATH =
110+
"extract(properties.$pathname, '^/(?:hackathons|events)/([a-fA-F0-9-]{36})')";
111+
112+
const query = `
113+
SELECT
114+
${HACKATHON_ID_FROM_PATH} AS hackathon_id,
115+
${SOURCE_BUCKET_EXPR} AS source,
116+
count(DISTINCT distinct_id) AS visitors,
117+
countIf(properties.$pathname LIKE '%/registration-form%') AS reachedRegister
118+
FROM events
119+
WHERE event = '$pageview'
120+
AND ${HOGQL_HOST_FILTER}
121+
AND timestamp >= now() - INTERVAL ${safeDays} DAY
122+
AND ${HACKATHON_ID_FROM_PATH} IN (${idList})
123+
AND (
124+
(notEmpty(properties.$referring_domain) AND properties.$referring_domain != '$direct')
125+
OR (notEmpty(properties.utm_source) AND properties.utm_source != '$direct')
126+
)
127+
GROUP BY hackathon_id, source
128+
ORDER BY hackathon_id, visitors DESC
129+
LIMIT ${safeLimit} BY hackathon_id
130+
`.trim();
131+
132+
const rows = await runHogQL<BatchRow>({
133+
projectId: POSTHOG_BUILDER_HUB_PROJECT_ID,
134+
query,
135+
});
136+
137+
for (const row of rows) {
138+
if (!row.hackathon_id) continue;
139+
const bucket = result.get(row.hackathon_id);
140+
if (!bucket) continue;
141+
bucket.push({
142+
source: row.source ?? "Direct",
143+
visitors: toNumber(row.visitors),
144+
reachedRegister: toNumber(row.reachedRegister),
145+
});
146+
}
147+
148+
return result;
149+
}

0 commit comments

Comments
 (0)