Skip to content
Merged
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ web_modules/
.env.production.local
.env.local

# Local working notes / planning docs (never commit)
tasks/

# parcel-bundler cache (https://parceljs.org/)
# .cache handled above
.parcel-cache
Expand Down
19 changes: 18 additions & 1 deletion components/profile/shell/InsightsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -672,12 +672,13 @@ function EventHistorySection({ data }: { data: BuilderInsightsData }) {
<th>Event</th>
<th className="pr-num">Inscriptions</th>
<th className="pr-num">Projects submitted</th>
<th className="pr-num">Top traffic sources (90d)</th>
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={3} className="pr-leaderboard__empty">
<td colSpan={4} className="pr-leaderboard__empty">
No events recorded yet.
</td>
</tr>
Expand All @@ -694,6 +695,22 @@ function EventHistorySection({ data }: { data: BuilderInsightsData }) {
</td>
<td className="pr-num">{formatNumber(e.registrations)}</td>
<td className="pr-num">{formatNumber(e.projects)}</td>
<td>
{e.topTrafficSources.length === 0 ? (
<div className="pr-traffic-sources__empty">No data</div>
) : (
<ul className="pr-traffic-sources">
{e.topTrafficSources.map((src) => (
<li key={src.source}>
<span className="pr-traffic-sources__name">{src.source}</span>
<span className="pr-traffic-sources__count">
{formatNumber(src.visitors)}
</span>
</li>
))}
</ul>
)}
</td>
</tr>
))
)}
Expand Down
36 changes: 36 additions & 0 deletions components/profile/shell/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2159,6 +2159,42 @@
color: var(--pr-g-650);
font-size: 12.5px;
}
.profile .pr-traffic-sources {
list-style: none;
margin: 0 0 0 auto;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
width: 240px;
max-width: 100%;
}
.profile .pr-traffic-sources li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: 11.5px;
}
.profile .pr-traffic-sources__name {
color: var(--pr-g-800);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.profile .pr-traffic-sources__count {
font-family: var(--pr-mono);
font-size: 10.5px;
color: var(--pr-g-650);
flex-shrink: 0;
}
.profile .pr-traffic-sources__empty {
font-family: var(--pr-mono);
font-size: 10px;
color: var(--pr-g-650);
text-align: right;
}

/* Hackathon history — 4-up grid with one wide featured tile. Each card
shows title + date + status pill + a 2-cell stat row at the bottom. */
Expand Down
10 changes: 10 additions & 0 deletions server/services/builderInsights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
} from "@/lib/referrals/targets";
import { runHogQL } from "@/lib/posthog-query";
import { REFERRAL_TEAM_LABELS } from "@/lib/referrals/team-labels";
import {
getTopHackathonTrafficSourcesBatch,
type HackathonTrafficSource,
} from "./hackathonTrafficSources";

export interface MonthlySignupPoint {
month: string;
Expand All @@ -33,6 +37,7 @@ export interface EventParticipantPoint {
registrations: number;
startDate: string | null;
endDate: string | null;
topTrafficSources: HackathonTrafficSource[];
}

export interface TopReferrerRow {
Expand Down Expand Up @@ -446,6 +451,10 @@ export async function getBuilderInsightsData(currentUserId: string): Promise<Bui
};
});

const trafficSourcesByEvent = await getTopHackathonTrafficSourcesBatch(
eventParticipantRows.map((row) => row.eventId),
);

const eventParticipants: EventParticipantPoint[] = eventParticipantRows.map((row) => ({
eventId: row.eventId,
event: row.event,
Expand All @@ -454,6 +463,7 @@ export async function getBuilderInsightsData(currentUserId: string): Promise<Bui
registrations: toNumber(row.registrations),
startDate: row.startDate ? row.startDate.toISOString() : null,
endDate: row.endDate ? row.endDate.toISOString() : null,
topTrafficSources: trafficSourcesByEvent.get(row.eventId) ?? [],
}));

const totalHackathonsHosted = eventParticipants.length;
Expand Down
149 changes: 149 additions & 0 deletions server/services/hackathonTrafficSources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { runHogQL } from "@/lib/posthog-query";

const POSTHOG_BUILDER_HUB_PROJECT_ID = process.env.POSTHOG_PROJECT_ID;

const HOGQL_HOST_FILTER =
"properties.$host IN ('build.avax.network', 'www.build.avax.network')";

/**
* HogQL expression that buckets every pageview into a single `source` string.
*
* Channel-mix only — no handle/page extraction. X (t.co) and LinkedIn strip
* the originating tweet/post from the Referer header, so organic social can
* only be attributed at the channel level. UTM-tagged links keep full
* granularity via the first branch (use utm_content for the poster handle).
*
* Priority: explicit UTM (excluding PostHog's '$direct' sentinel) →
* sign-in/OAuth redirects → broad channel → bare domain → "Direct".
*/
const SOURCE_BUCKET_EXPR = `
multiIf(
notEmpty(properties.utm_source) AND properties.utm_source != '$direct',
concat(properties.utm_source, ' / ', coalesce(properties.utm_campaign, '(no campaign)')),
properties.$referring_domain IN (
'accounts.google.com', 'login.microsoftonline.com', 'github.com'
),
'Sign-in redirect',
properties.$referring_domain IN ('x.com', 'twitter.com', 't.co'),
'X (untagged)',
properties.$referring_domain = 'linkedin.com'
OR endsWith(properties.$referring_domain, '.linkedin.com'),
'LinkedIn (untagged)',
properties.$referring_domain IN ('youtube.com', 'www.youtube.com', 'youtu.be'),
'YouTube',
properties.$referring_domain IN ('discord.com', 'discord.gg'),
'Discord',
endsWith(properties.$referring_domain, 't.me'),
'Telegram',
properties.$referring_domain IN ('build.avax.network', 'www.build.avax.network'),
'BuildersHub (internal)',
notEmpty(properties.$referring_domain) AND properties.$referring_domain != '$direct',
properties.$referring_domain,
'Direct'
)
`.trim();

export interface HackathonTrafficSource {
source: string;
visitors: number;
reachedRegister: number;
}

interface RawRow {
source: string;
visitors: number | string | null;
reachedRegister: number | string | null;
}

function toNumber(value: number | string | null | undefined): number {
if (value === null || value === undefined) return 0;
return typeof value === "number" ? value : Number(value) || 0;
}

/**
* UUIDs only. Hackathon ids in this codebase are uuid v4 (see prisma/schema.prisma),
* so we restrict to that shape rather than escape-quoting. Anything else returns
* an empty result rather than risk a query injection through PostHog.
*/
function isSafeHackathonId(id: string): boolean {
return /^[a-zA-Z0-9_-]{1,64}$/.test(id);
}

export interface TopTrafficSourcesOptions {
/** Lookback window in days. Default 90. */
days?: number;
/** Number of source buckets to return. Default 3. */
limit?: number;
}

interface BatchRow extends RawRow {
hackathon_id: string | null;
}

/**
* Batched variant — top-N traffic sources for a list of hackathons in a single
* HogQL query. Used by the Builder Insights event-history view where we'd
* otherwise make one HTTP roundtrip per row. Returns a map keyed by
* hackathonId; events missing from the result are returned as empty arrays.
*/
export async function getTopHackathonTrafficSourcesBatch(
hackathonIds: string[],
{ days = 90, limit = 3 }: TopTrafficSourcesOptions = {},
): Promise<Map<string, HackathonTrafficSource[]>> {
const safeIds = Array.from(new Set(hackathonIds.filter(isSafeHackathonId)));
const result = new Map<string, HackathonTrafficSource[]>();
for (const id of safeIds) result.set(id, []);
if (safeIds.length === 0) return result;

const safeDays = Math.max(1, Math.min(365, Math.floor(days)));
const safeLimit = Math.max(1, Math.min(20, Math.floor(limit)));
const idList = safeIds.map((id) => `'${id}'`).join(", ");

// Hackathon attribution: extract the UUID from /events/<id> or /hackathons/<id>
// URLs. Both routes serve the same hackathon today.
//
// `LIMIT N BY column` is ClickHouse syntax: keep the first N rows per group
// after ORDER BY — gives top-N per hackathon in one query. We pre-filter
// pageviews that would bucket as "Direct" (no referrer + no real UTM) so
// they never compete for a top-N slot.
const HACKATHON_ID_FROM_PATH =
"extract(properties.$pathname, '^/(?:hackathons|events)/([a-fA-F0-9-]{36})')";

const query = `
SELECT
${HACKATHON_ID_FROM_PATH} AS hackathon_id,
${SOURCE_BUCKET_EXPR} AS source,
count(DISTINCT distinct_id) AS visitors,
countIf(properties.$pathname LIKE '%/registration-form%') AS reachedRegister
FROM events
WHERE event = '$pageview'
AND ${HOGQL_HOST_FILTER}
AND timestamp >= now() - INTERVAL ${safeDays} DAY
AND ${HACKATHON_ID_FROM_PATH} IN (${idList})
AND (
(notEmpty(properties.$referring_domain) AND properties.$referring_domain != '$direct')
OR (notEmpty(properties.utm_source) AND properties.utm_source != '$direct')
)
GROUP BY hackathon_id, source
ORDER BY hackathon_id, visitors DESC
LIMIT ${safeLimit} BY hackathon_id
`.trim();

const rows = await runHogQL<BatchRow>({
projectId: POSTHOG_BUILDER_HUB_PROJECT_ID,
query,
});

for (const row of rows) {
if (!row.hackathon_id) continue;
const bucket = result.get(row.hackathon_id);
if (!bucket) continue;
bucket.push({
source: row.source ?? "Direct",
visitors: toNumber(row.visitors),
reachedRegister: toNumber(row.reachedRegister),
});
}

return result;
}
Loading