Relevant source files
The following files were used as context for generating this wiki page:
- apps/web/app/api/analytics/dashboard/route.ts
- apps/web/app/api/analytics/route.ts
- apps/web/app/api/analytics/export/route.ts
- apps/web/app/ee/api/events/export/route.ts
- apps/web/app/api/og/analytics/route.tsx
- apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx
- apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/program-analytics-shell.tsx
- apps/web/app/ee/admin.dub.co/dashboard/analytics/page.tsx
- apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/usage-chart.tsx
- apps/web/app/ee/api/partner-profile/programs/programId/analytics/export/route.ts
- apps/web/ui/analytics/location-section.tsx
- apps/web/app/ee/api/partner-profile/programs/programId/analytics/route.ts
- apps/web/app/ee/admin.dub.co/dashboard/payouts/page.tsx
- apps/web/app/ee/api/program-applications/analytics/route.ts
- apps/web/app/ee/api/partners/analytics/route.ts
- apps/web/app/app.dub.co/dashboard/slug/links/analytics/page.tsx
- apps/web/app/ee/admin.dub.co/dashboard/commissions/page.tsx
- apps/web/app/ee/admin.dub.co/dashboard/revenue/page.tsx
- apps/web/ui/analytics/index.tsx
- apps/web/ui/analytics/device-section.tsx
- apps/web/ui/analytics/chart-section.tsx
- apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/page.tsx
- apps/web/ui/analytics/analytics-export-button.tsx
- apps/web/app/app.dub.co/share/share/dashboardId/page.tsx
- apps/web/app/ee/api/partner-profile/referrals/timeseries/route.ts
- apps/web/ui/analytics/toggle.tsx
- apps/web/app/ee/api/embed/referrals/analytics/route.ts
- apps/web/app/ee/admin.dub.co/dashboard/events/page.tsx
- apps/web/ui/analytics/referrers-utms.tsx
- apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-query.ts
Overview
Dub provides a comprehensive analytics dashboard system that tracks and aggregates link clicks, lead submissions, and sale conversions across workspaces, partner programs, and shared public views. The platform combines server-side query routing, Redis caching, and plan-aware validations with rich client-side visualizations—including timeseries charts, conversion funnels, and dimension breakdowns for geographic, device, and referrer data. Users can also configure filters, examine program-specific application pipelines, and export filtered datasets or raw events as CSV and ZIP archives.
Sources: apps/web/app/api/analytics/dashboard/route.ts:1-202, apps/web/app/api/analytics/route.ts:1-137, apps/web/app/api/analytics/export/route.ts:1-132, apps/web/app/ee/api/events/export/route.ts:37-181, apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx:13-120, apps/web/ui/analytics/location-section.tsx:19-175, apps/web/app/app.dub.co/share/share/dashboardId/page.tsx:31-122, apps/web/ui/analytics/index.tsx:23-55, apps/web/ui/analytics/device-section.tsx:14-150, apps/web/ui/analytics/chart-section.tsx:26-147, apps/web/ui/analytics/toggle.tsx:40-307, apps/web/ui/analytics/referrers-utms.tsx:42-237
Analytics API query routing
Parameter parsing and path normalization
Server-side analytics endpoints process incoming requests by parsing search parameters, validating workspace permissions and plan limits, and normalizing filter arguments before querying telemetry backends. The core analytics API (/api/analytics) is wrapped with workspace authentication requiring the analytics.read permission and checks click usage limits via throwIfClicksUsageExceeded.
export const GET = withWorkspace(
async ({ params, searchParams, workspace, session }) => {
throwIfClicksUsageExceeded(workspace);Sources: apps/web/app/api/analytics/route.ts:20-22
Query parameters are parsed using parseAnalyticsQuery(searchParams). Path parameters are evaluated against analyticsPathParamsSchema to extract event types and grouping endpoints, supporting backward compatibility where /analytics/[endpoint] patterns map directly to grouping configurations.
let { eventType: oldEvent, endpoint: oldType } =
analyticsPathParamsSchema.parse(params);
// for backwards compatibility (we used to support /analytics/[endpoint] as well)
if (!oldType && oldEvent && VALID_ANALYTICS_ENDPOINTS.includes(oldEvent)) {
oldType = oldEvent;
oldEvent = undefined;
}
const parsedParams = parseAnalyticsQuery(searchParams);Sources: apps/web/app/api/analytics/route.ts:24-33
Note
Backward compatibility logic checks if the endpoint parameter is omitted while an event matches VALID_ANALYTICS_ENDPOINTS, promoting it to the grouping parameter.
Warning
Requests referencing deprecated endpoints (/api/analytics/clicks, /api/analytics/count) set isDeprecatedClicksEndpoint to true so the underlying query builder handles legacy aggregation structures correctly.
Sources: apps/web/app/api/analytics/route.ts:28-31, apps/web/app/api/analytics/route.ts:116-118
Filter normalization and link resolution
When requests specify domain and key or external identifiers without an explicit linkId, the router resolves the link via getLinkOrThrow. The filter parameters are then normalized by replacing the domain/key combination with an explicit SQL operator structure on linkId and clearing the standalone domain filter.
if (!linkId && (externalId || (domain && key))) {
const link = await getLinkOrThrow({
workspaceId: workspace.id,
linkId,
externalId,
domain: getFirstFilterValue(domain),
key,
});
parsedParams.linkId = {
operator: "IS",
sqlOperator: "IN",
values: [link.id],
};
// since we're filtering for a specific link, exclude domain from filters
parsedParams.domain = undefined;Sources: apps/web/app/api/analytics/route.ts:71-88
Workspace folders associated with links or direct folder filters undergo access verification via verifyFolderAccess requiring folders.read permissions. Additionally, program identifiers are verified against the workspace default program, throwing a DubApiError with a forbidden code if a mismatch occurs.
if (programId) {
const workspaceProgramId = getDefaultProgramIdOrThrow(workspace);
if (programId !== workspaceProgramId) {
throw new DubApiError({
code: "forbidden",
message: `Program ${programId} does not belong to workspace ${prefixWorkspaceId(workspace.id)}.`,
});
}
if (groupBy === "timeseries") {Sources: apps/web/app/api/analytics/route.ts:53-61
Dashboard visualization layout and controls
Client-side analytics container and conditional stats grid
The client-side analytics view is initialized through the Analytics component within workspace routes, dashboard previews, and partner portal pages. It wraps child layouts in an AnalyticsProvider context, rendering a sticky top navigation bar via AnalyticsToggle alongside a responsive grid that houses the main chart section and statistical breakdown cards.
export default function Analytics({
adminPage,
dashboardProps,
}: {
adminPage?: boolean;
dashboardProps?: AnalyticsDashboardProps;
}) {
return (
<AnalyticsProvider {...{ adminPage, dashboardProps }}>
<AnalyticsContext.Consumer>
{({ dashboardProps }) => {
return (
<div
className={cn("pb-10", dashboardProps && "bg-neutral-50 pt-10")}
>
<AnalyticsToggle />
<div
className={cn(
"mx-auto grid max-w-screen-xl gap-5 px-3 lg:px-10",
// TODO: [PageContent] Remove once all pages are migrated to the new PageContent
!dashboardProps && !adminPage && "lg:px-6",
)}
>
<ChartSection />
<StatsGrid />
</div>
</div>
);
}}
</AnalyticsContext.Consumer>
</AnalyticsProvider>
);
}Sources: apps/web/ui/analytics/index.tsx:23-55
The StatsGrid component conditionally hides conversion tracking metrics based on the selected tab and workspace plan tier. Specifically, when the selected tab is set to leads, sales, or the view mode is set to funnel under a free or pro plan, the stats grid returns null to restrict advanced conversion insights to higher tiers.
function StatsGrid() {
const { dashboardProps, selectedTab, view } = useContext(AnalyticsContext);
const { plan } = useWorkspace();
const hide =
(selectedTab === "leads" || selectedTab === "sales" || view === "funnel") &&
(plan === "free" || plan === "pro");
return hide ? null : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
{!dashboardProps?.key && <TopLinks />}
<ReferrersUTMs />
<LocationSection />
<DeviceSection />
</div>
);
}Sources: apps/web/ui/analytics/index.tsx:57-72
Warning
Attempting to view leads, sales, or funnel views on free and pro workspaces completely unmounts the underlying dimension cards (TopLinks, ReferrersUTMs, LocationSection, and DeviceSection), preventing unauthorized metric exposure.
Sources: apps/web/ui/analytics/index.tsx:61-64
Date filtering, interval selection, and plan validation
The AnalyticsToggle component manages date range parameters, filtering state, and interval selections. It integrates a DateRangePicker utilizing interval presets defined by INTERVAL_DISPLAYS, allowing users to switch between standard pre-configured time ranges or custom date bounds.
const dateRangePicker = (
<DateRangePicker
className="w-full md:w-fit"
align="start"
value={
start && end
? {
from: start,
to: end,
}
: undefined
}
presetId={
start && end ? undefined : interval ?? DUB_LINKS_ANALYTICS_INTERVAL
}
onChange={(range, preset) => {
if (preset) {
queryParams({
del: ["start", "end"],
set: {
interval: preset.id,
},
});
return;
}
// Regular range
if (!range || !range.from || !range.to) return;
queryParams({
del: "interval",
set: {
start: range.from.toISOString(),
end: range.to.toISOString(),
},
});
}}
presets={INTERVAL_DISPLAYS.map(({ display, value, shortcut }) => {
const requiresUpgrade =
partnerPage ||
DUB_DEMO_LINKS.find((l) => l.domain === domain && l.key === key)
? false
: !validDateRangeForPlan({
plan: plan || dashboardProps?.workspacePlan,
dataAvailableFrom: createdAt,
interval: value,
start,
end,
}).valid;
const { startDate, endDate } = getStartEndDates({
interval: value,
dataAvailableFrom: createdAt,
});
return {
id: value,
label: display,
dateRange: {
from: startDate,
to: endDate,
},
requiresUpgrade,
tooltipContent: requiresUpgrade ? (
<UpgradeTooltip rangeLabel={display} plan={plan} />
) : undefined,
shortcut,
};
})}
/>Sources: apps/web/ui/analytics/toggle.tsx:94-164
When a user selects an interval preset, AnalyticsToggle updates the URL search parameters by deleting explicit start and end bounds while setting the interval query parameter to the preset ID. Conversely, selecting a custom date range removes the interval parameter and encodes ISO strings for start and end.
onChange={(range, preset) => {
if (preset) {
queryParams({
del: ["start", "end"],
set: {
interval: preset.id,
},
});
return;
}
// Regular range
if (!range || !range.from || !range.to) return;
queryParams({
del: "interval",
set: {
start: range.from.toISOString(),
end: range.to.toISOString(),
},
});
}}Sources: apps/web/ui/analytics/toggle.tsx:109-131
Tip
Preset validation checks plan capabilities using validDateRangeForPlan against workspace creation dates. If an interval exceeds the plan limit, an UpgradeTooltip is attached to prompt users for a plan upgrade.
Sources: apps/web/ui/analytics/toggle.tsx:132-163
Timeseries and funnel metric charts
Chart section container and tab management
The ChartSection component coordinates the visualization rendering inside the dashboard. It inspects workspace tier parameters alongside showConversions flags to build the available event tabs: clicks, leads, and sales.
export function ChartSection() {
const {
totalEvents,
requiresUpgrade,
showConversions,
selectedTab,
saleUnit,
view,
} = useContext(AnalyticsContext);
const { plan } = useWorkspace();
const { queryParams } = useRouterStuff();
const tabs = useMemo(
() =>
[
{
id: "clicks",
label: "Clicks",
colorClassName: "text-blue-500/50",
conversions: false,
},
...(showConversions
? [
{
id: "leads",
label: "Leads",
colorClassName: "text-violet-600/50",
conversions: true,
},
{
id: "sales",
label: "Sales",
colorClassName: "text-teal-400/50",
conversions: true,
},
]
: []),
] as Tab[],
[showConversions],
);Sources: apps/web/ui/analytics/chart-section.tsx:26-65
Each tab holds an identifier, display label, color styling class, and a boolean indicating whether it represents a conversion metric.
| Tab ID | Label | Color Class Name | Conversions |
|---|---|---|---|
clicks | Clicks | text-blue-500/50 | false |
leads | Leads | text-violet-600/50 | true |
sales | Sales | text-teal-400/50 | true |
Sources: apps/web/ui/analytics/chart-section.tsx:41-61
Warning
When showPaywall evaluates to true—triggered when accessing conversion tabs or funnel views on free or pro workspace plans—the chart container applies pointer-events disabling alongside linear gradient masking and renders the ConversionTrackingPaywall overlay.
Sources: apps/web/ui/analytics/chart-section.tsx:69-72, apps/web/ui/analytics/chart-section.tsx:99-105, apps/web/ui/analytics/chart-section.tsx:143-143
Program analytics chart data and view modes
Program-specific analytics charts fetch timeseries aggregation datasets from /api/analytics parameterized by groupBy=timeseries and the currently selected event tab. The returned records map start dates and metric counts or sale amounts into chart values.
const { data, error } = useSWR<
{
start: Date;
clicks: number;
leads: number;
sales: number;
saleAmount: number;
}[]
>(
`/api/analytics?${editQueryString(queryString ?? "", {
event: selectedTab,
groupBy: "timeseries",
})}`,
fetcher,
);
const chartData = useMemo(
() =>
data?.map((d) => ({
date: new Date(d.start),
values: {
amount:
selectedTab === "sales" && saleUnit === "saleAmount"
? d.saleAmount
: d[selectedTab],
},
})),
[data, selectedTab, saleUnit],
);Sources: apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx:19-47
The view switcher toggles between timeseries and funnel visualization modes. When loading data, a loading spinner is shown, while fetch errors present a fallback message.
<div className="relative h-72 md:h-96">
{dataLoading ? (
<div className="flex size-full items-center justify-center">
<LoadingSpinner />
</div>
) : error ? (
<div className="flex size-full items-center justify-center text-sm text-neutral-500">
Failed to load data
</div>
) : (
<>
{view === "timeseries" ? (
<div className="relative size-full p-6 pt-10">
<AnalyticsTimeseriesChart data={chartData} />
</div>
) : (
<AnalyticsFunnelChart />
)}Sources: apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx:74-91
Geographic, device, and referrer breakdown
Location metrics and country/city bar lists
The LocationSection component manages geographic distributions by rendering an AnalyticsCard with four tabs: countries, cities, regions, and continents. It retrieves data using useAnalyticsFilterOption(tab) alongside an unfiltered dataset (omitGroupByFilterKey: true) to render proportion values inside a BarList.
export function LocationSection() {
const { queryParams, searchParams } = useRouterStuff();
const { selectedTab, saleUnit } = useContext(AnalyticsContext);
const dataKey = selectedTab === "sales" ? saleUnit : "count";
const [tab, setTab] = useState<
"countries" | "cities" | "regions" | "continents"
>("countries");
const { data } = useAnalyticsFilterOption(tab);
const { data: allData } = useAnalyticsFilterOption(tab, {
omitGroupByFilterKey: true,
});Sources: apps/web/ui/analytics/location-section.tsx:19-32
Each item formats its display title and icon based on the active tab context. Continent tabs resolve names from CONTINENTS, countries map through COUNTRIES with CountryFlag elements, and regions or cities construct composite location strings with regional fallbacks.
| Tab ID | Icon Component | Title Resolution Source | Endpoint Mapping |
|---|---|---|---|
countries | CountryFlag | COUNTRIES[d.country] | country |
cities | CountryFlag | `$d.city, $REGIONS[d.region] | |
regions | CountryFlag | `REGIONS[d.region] | |
continents | ContinentIcon | CONTINENTS[d.continent] | continent |
Sources: apps/web/ui/analytics/location-section.tsx:74-79, apps/web/ui/analytics/location-section.tsx:94-117
Note
When location filter values are toggled, onApplyFilterValues updates URL query parameters using the singular endpoint name from SINGULAR_ANALYTICS_ENDPOINTS[tab]. Empty selections delete the parameter, whereas multiple selections join values with commas.
Sources: apps/web/ui/analytics/location-section.tsx:47-59
Device, browser, and OS distributions
The DeviceSection component displays technical breakdown statistics across four device categories: devices, browsers, os, and triggers. It utilizes DeviceIcon and TRIGGER_DISPLAY maps to render respective icons and labels for each metric entry.
export function DeviceSection() {
const { queryParams, searchParams } = useRouterStuff();
const { selectedTab, saleUnit } = useContext(AnalyticsContext);
const dataKey = selectedTab === "sales" ? saleUnit : "count";
const [tab, setTab] = useState<DeviceTabs>("devices");
const { data } = useAnalyticsFilterOption(tab);
const { data: allData } = useAnalyticsFilterOption(tab, {
omitGroupByFilterKey: true,
});
const singularTabName = SINGULAR_ANALYTICS_ENDPOINTS[tab];Sources: apps/web/ui/analytics/device-section.tsx:14-25
The card styles its elements using green color variables (bg-green-100, hover:bg-green-50, bg-green-600) to visually distinguish device telemetry from other analytics sections.
| Tab ID | Label | Icon Component | Singular Endpoint |
|---|---|---|---|
devices | Devices | MobilePhone | device |
browsers | Browsers | Window | browser |
os | OS | Cube | os |
triggers | Triggers | CursorRays | trigger |
Sources: apps/web/ui/analytics/device-section.tsx:66-71, apps/web/lib/analytics/constants.ts
Referrers and UTM parameter breakdowns
The ReferrersUTMs component handles traffic source grouping by splitting metrics between referers and utms top-level tabs. Referrers contain subtabs for domain referers and full referer_urls, while UTM tabs iterate over UTM_TAGS_PLURAL_LIST.
const TAB_CONFIG: Record<
TabId,
{
subtabs: Subtab[];
defaultSubtab: Subtab;
getSubtabLabel: (subtab: Subtab) => string;
}
> = {
referers: {
subtabs: ["referers", "referer_urls"],
defaultSubtab: "referers",
getSubtabLabel: (subtab) => (subtab === "referers" ? "Domain" : "URL"),
},
utms: {
subtabs: [...UTM_TAGS_PLURAL_LIST] as Subtab[],
defaultSubtab: "utm_sources" as Subtab,
getSubtabLabel: (subtab) =>
SINGULAR_ANALYTICS_ENDPOINTS[subtab as UTM_TAGS_PLURAL].replace(
"utm_",
"",
),
},
};Sources: apps/web/ui/analytics/referrers-utms.tsx:18-40
For URL subtabs or standard referrers, item icons fetch favicons using GOOGLE_FAVICON_URL alongside getApexDomain. Direct traffic entries render a Link2 icon, and UTM rows load their specific parameter icon from UTM_PARAMETERS.
icon:
isUtmTab && UTMTagIcon ? (
<UTMTagIcon />
) : isDirect ? (
<Link2 className="h-4 w-4" />
) : (
<BlurImage
src={`${GOOGLE_FAVICON_URL}${
isRefererUrl
? getApexDomain(d[singularTabName])
: d[singularTabName]
}`}
alt={d[singularTabName]}
width={20}
height={20}
className="h-4 w-4 rounded-full"
/>
),Sources: apps/web/ui/analytics/referrers-utms.tsx:152-169
Warning
Switching between the referers and utms primary tabs automatically resets the active subtab to the corresponding default defined in TAB_CONFIG and clears any selected filter items.
Sources: apps/web/ui/analytics/referrers-utms.tsx:53-56, apps/web/ui/analytics/referrers-utms.tsx:68-70
Partner program analytics pipelines
Program application metrics and event aggregations
Program applications and referral events are processed through specific workspace analytics routes and Prisma aggregations. The endpoint /api/program-applications/analytics handles queries for application metrics, supporting distinct group-by configurations including absolute counts, groupings by referralSource or country, partner-specific breakdowns, and timeseries intervals.
const aggregations = {
_count: {
visitedAt: true,
startedAt: true,
submittedAt: true,
approvedAt: true,
rejectedAt: true,
},
} as const;Sources: apps/web/app/ee/api/program-applications/analytics/route.ts:27-35
Application event queries evaluate specific event stages through dedicated query hooks and parse stage identifiers (visited, started, submitted, approved) alongside view options like timeseries and funnel.
| Stage Identifier | Matching Event Field | Description |
|---|---|---|
visited | visitedAt | Initial visit tracking on the partner program page |
started | startedAt | Application form interaction initiation |
submitted | submittedAt | Completed application submission |
approved | approvedAt | Program manager approval of partner application |
Sources: apps/web/app/ee/api/program-applications/analytics/route.ts:27-35, apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-query.ts:9-14
Note
When executing partner-specific application aggregations via byPartnerId, the system resolves partner records by filtering IDs that maintain active program enrollments in the target programId.
Sources: apps/web/app/ee/api/program-applications/analytics/route.ts:174-187
Commissions and partner analytics routing
Partner performance and commission metrics are handled by /api/partners/analytics and partner profile analytics routes, aligning tracking data with earnings aggregations. Depending on the groupBy parameter (count, timeseries, or top_links), the route queries the Commission table using Prisma raw queries or aggregate functions with status filters (pending, processed, paid) and types such as sale or referral.
if (groupBy === "count") {
const earnings = await prisma.commission.aggregate({
_sum: {
earnings: true,
},
where: {
type: "sale",
amount: {
gt: 0,
},
programId: programEnrollment.programId,
partnerId: programEnrollment.partnerId,
status: {
in: ["pending", "processed", "paid"],
},
createdAt: {
gte: startDate,
lt: endDate,
},
},
});
return NextResponse.json({
...analytics,
earnings: earnings._sum.earnings || 0,
});
}Sources: apps/web/app/ee/api/partners/analytics/route.ts:92-118
Partner profile program analytics endpoints evaluate large program thresholds and link counts before querying underlying analytics providers, returning fallback errors if Tinybird execution exceeds timeout thresholds.
| Parameter / Constant | Purpose | Context / Condition |
|---|---|---|
LARGE_PROGRAM_IDS | Identifies high-volume programs | Checks program eligibility for optimized analytics aggregation |
LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS | Commission threshold | Minimum commission cents required for detailed analytics queries |
MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING | Link optimization threshold | Determines whether to query by partnerId or specific linkId array |
Sources: apps/web/app/ee/api/partner-profile/programs/programId/analytics/route.ts:7-10, apps/web/app/ee/api/partner-profile/programs/programId/analytics/route.ts:86-96
Warning
If Tinybird times out after 30 seconds during heavy partner analytics queries, the API throws a bad_request DubApiError instructing the UI to prompt a page refresh, preventing unhandled 500 server errors.
Sources: apps/web/app/ee/api/partner-profile/programs/programId/analytics/route.ts:102-109
Network referrals timeseries routing
Network referral timeseries data is aggregated via /api/partner-profile/referrals/timeseries, which requires partners to maintain an approved or trusted network status. The pipeline executes concurrent raw SQL queries against Partner and Commission tables using timezone conversion and date formatting maps.
const [partnersResult, earningsResult] = await Promise.all([
prisma.$queryRaw<{ start: string; partners: number }[]>(
Prisma.sql`
SELECT
DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone || "UTC"}), ${dateFormat}) AS start,
COUNT(*) AS partners
FROM Partner
WHERE
referredByPartnerId = ${partner.id}
AND createdAt >= ${startDate}
AND createdAt < ${endDate}
GROUP BY start
ORDER BY start ASC
`,
),
prisma.$queryRaw<{ start: string; earnings: number }[]>(
Prisma.sql`
SELECT
DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone || "UTC"}), ${dateFormat}) AS start,
SUM(earnings) AS earnings
FROM Commission
WHERE
partnerId = ${partner.id}
AND programId = ${NETWORK_PROGRAM_ID}
AND type = ${CommissionType.referral}
AND status in ('pending', 'processed', 'paid')
AND createdAt >= ${startDate}
AND createdAt < ${endDate}
GROUP BY start
ORDER BY start ASC
`,
),
]);Sources: apps/web/app/ee/api/partner-profile/referrals/timeseries/route.ts:40-73
Public and shared dashboard delivery
Password verification and dashboard page rendering
Shared analytics views are accessed via dynamic dashboard identifiers at /share/[dashboardId]. The page renderer fetches dashboard metadata using getDashboard, inspecting whether the targeted resource is tied to a specific link or folder. If no valid link or folder association exists, the server triggers a 404 response.
export default async function DashboardPage(props: {
params: Promise<{ dashboardId: string }>;
}) {
const params = await props.params;
const data = await getDashboard({ id: params.dashboardId });
// if the dashboard, link, or folder doesn't exist
if (!data?.link && !data?.folder) {
notFound();
}Sources: apps/web/app/app.dub.co/share/share/dashboardId/page.tsx:31-40
When a dashboard enforces password protection, the server compares the cookie value stored under dub_password_[dashboardId] against the password hash configured on the dashboard record. If the cookie does not match, the page renders an authentication view featuring a decorative grid, an aurora gradient background, and a DashboardPasswordForm component.
if (
data.password &&
(await cookies()).get(`dub_password_${params.dashboardId}`)?.value !==
data.password
) {
return (
<main className="relative min-h-[100dvh]">
<div className="absolute inset-0 isolate overflow-hidden bg-neutral-50">
{/* Grid */}
<div
className={cn(
"absolute inset-y-0 left-1/2 w-[1200px] -translate-x-1/2",
"[mask-composite:intersect] [mask-image:linear-gradient(black,transparent_320px),linear-gradient(90deg,transparent,black_5%,black_95%,transparent)]",
)}
>
<Grid
cellSize={60}
patternOffset={[0.75, 0]}
className="text-neutral-200"
/>
</div>
<AuroraGradient />
</div>
<div className="relative flex min-h-[100dvh] w-full items-center justify-center px-4 pb-12 pt-20">
<a
href="https://dub.co"
target="_blank"
rel="noopener noreferrer"
className="absolute left-1/2 top-4 -translate-x-1/2"
>
<Wordmark className="h-8" />
</a>
<div className="animate-slide-up-fade flex w-full max-w-[320px] flex-col items-center [--offset:10px] [animation-duration:1s] [animation-fill-mode:both]">
<div className="flex size-8 items-center justify-center rounded-md bg-neutral-200/60">
<ShieldKeyhole className="size-[18px] text-neutral-800" />
</div>
<h1 className="mt-4 text-center text-xl font-semibold text-neutral-900">
Enter Password
</h1>
<p className="mt-1 text-center text-base font-medium text-neutral-500">
This dashboard is password protected.
</p>
<div className="mt-10 w-full">
<DashboardPasswordForm />
</div>
</div>
</div>
</main>
);
}Sources: apps/web/app/app.dub.co/share/share/dashboardId/page.tsx:42-94
Warning
If a shared dashboard requires a password and the matching cookie dub_password_[dashboardId] is absent or incorrect, the server bypasses the analytics component entirely and returns an unauthorized password entry view.
Sources: apps/web/app/app.dub.co/share/share/dashboardId/page.tsx:42-46
REST analytics endpoint authentication
The backend route /api/analytics/dashboard validates incoming query parameters via parseAnalyticsQuery, checking for domain, key, or folderId filters. Folders and links are looked up in Prisma to verify whether they expose a public dashboard configuration and whether a password is required.
export const GET = async (req: Request) => {
try {
const searchParams = getSearchParams(req.url);
const parsedParams = parseAnalyticsQuery(searchParams);
const {
domain: domainFilter,
key,
folderId: folderIdFilter,
interval,
start,
end,
} = parsedParams;
// Extract string values for specific link/folder lookup
const domain = getFirstFilterValue(domainFilter);
const folderId = getFirstFilterValue(folderIdFilter);
if ((!domain || !key) && !folderId) {
throw new DubApiError({
code: "bad_request",
message: "Missing domain/key or folderId query parameters",
});
}Sources: apps/web/app/api/analytics/dashboard/route.ts:17-40
| Entity Type | Lookup Strategy | Password Enforcement | Error Code on Missing Dashboard |
|---|---|---|---|
folderId | prisma.folder.findUnique | Checked via assertDashboardPassword | forbidden |
demoLink | Matched against DUB_DEMO_LINKS | None (public demo) | N/A |
link | prisma.link.findUnique via domain_key | Checked via assertDashboardPassword | forbidden |
Sources: apps/web/app/api/analytics/dashboard/route.ts:44-142
The helper function assertDashboardPassword evaluates the dashboard.password property against the cookie dub_password_$dashboard.id. If validation fails, a DubApiError with code unauthorized is thrown.
async function assertDashboardPassword(dashboard: {
id: string;
password: string | null;
}) {
if (!dashboard.password) {
return;
}
const cookiePassword = (await cookies()).get(
`dub_password_${dashboard.id}`,
)?.value;
if (cookiePassword !== dashboard.password) {
throw new DubApiError({
code: "unauthorized",
message: "This dashboard is password protected",
});
}
}Sources: apps/web/app/api/analytics/dashboard/route.ts:204-222
Dataset and event CSV exports
Analytics ZIP archive generation
Exporting comprehensive analytics datasets is handled by the server route /api/analytics/export, which returns a compressed ZIP archive containing multiple CSV breakdowns. The route enforces request rate limits via RATELIMIT_POLICIES.analyticsExport keyed to the workspace ID, and checks short link usage constraints using throwIfClicksUsageExceeded.
await assertRateLimit({
policy: RATELIMIT_POLICIES.analyticsExport,
identifier: workspace.id,
});
throwIfClicksUsageExceeded(workspace);Sources: apps/web/app/api/analytics/export/route.ts:26-31
Query parameters are parsed using parseAnalyticsQuery. If an external ID, domain, or key filter is provided without an explicit link ID, the backend resolves the target link using getLinkOrThrow to scope the parameters accurately. Date ranges are validated against plan capabilities via assertValidDateRangeForPlan, and workspace-level permission requirements enforce analytics.read.
const parsedParams = parseAnalyticsQuery(searchParams);
let {
interval,
start,
end,
folderId,
domain,
key,
linkId,
externalId,
programId,
} = parsedParams;
let programStartedAt: Date | null | undefined = null;
if (programId) {
const workspaceProgramId = getDefaultProgramIdOrThrow(workspace);
if (programId !== workspaceProgramId) {
throw new DubApiError({
code: "forbidden",
message: `Program ${programId} does not belong to workspace ${prefixWorkspaceId(workspace.id)}.`,
});
}
const program = await getProgramOrThrow({
workspaceId: workspace.id,
programId,
});
programStartedAt = program.startedAt;
}
let folderIdToVerify = getFirstFilterValue(folderId);
if (!linkId && (externalId || (domain && key))) {
const link = await getLinkOrThrow({
workspaceId: workspace.id,
linkId,
externalId,
domain: getFirstFilterValue(domain),
key,
});
parsedParams.linkId = {
operator: "IS",
sqlOperator: "IN",
values: [link.id],
};
parsedParams.domain = undefined;
if (link.folderId && !folderIdToVerify) {
folderIdToVerify = link.folderId;
}
}
if (folderIdToVerify) {
await verifyFolderAccess({
workspace,
userId: session.user.id,
folderId: folderIdToVerify,
requiredPermission: "folders.read",
});
}
assertValidDateRangeForPlan({
plan: workspace.plan,
dataAvailableFrom: workspace.createdAt,
interval,
start,
end,
});Sources: apps/web/app/api/analytics/export/route.ts:33-101
The underlying archive compilation is delegated to exportAnalyticsToZip, incorporating conversion tracking capabilities where permitted by the workspace plan. The resulting data is streamed back with a Content-Type header of application/zip and a default file attachment name.
const zipData = await exportAnalyticsToZip({
params: analyticsParams,
workspaceId: workspace.id,
useComposite: canTrackConversions,
skipTopLinksForSingleLink: hasExactlyOneLinkIdFilter(parsedParams.linkId),
getDataAvailableFrom: (endpoint) =>
endpoint === "timeseries"
? programStartedAt ?? workspace.createdAt
: undefined,
...(programId && {
formatRows: formatProgramAnalyticsForExport,
}),
});
return new Response(zipData as unknown as BodyInit, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": "attachment; filename=analytics_export.zip",
},
});Sources: apps/web/app/api/analytics/export/route.ts:109-128
Partner profile program analytics follow a parallel export path at /api/partner-profile/programs/[programId]/analytics/export, utilizing partner authentication rules and applying distinct rate-limit policies.
Sources: apps/web/app/ee/api/partner-profile/programs/programId/analytics/export/route.ts:26-31
Raw event CSV exports and threshold queuing
Exporting individual telemetry events to CSV is managed by the EE events export route at /api/events/export. This endpoint requires business, advanced, or enterprise plans along with analytics.read permissions, and enforces a strict threshold where requests exceeding MAX_EVENTS_TO_EXPORT (set to 1000) are offloaded to background execution via QStash.
const MAX_EVENTS_TO_EXPORT = 1000;
const exportQuerySchema = z
.object({
columns: z
.string()
.transform((c) => c.split(","))
.pipe(z.string().array()),
})
.passthrough();
// GET /api/events/export – export events to CSV (with async support if >1000 events)
export const GET = withWorkspace(Sources: apps/web/app/ee/api/events/export/route.ts:25-37
| Condition / Metric | Threshold Value | Handling Mechanism | Response Status |
|---|---|---|---|
MAX_EVENTS_TO_EXPORT | 1000 events | Synchronous CSV generation for counts within limit | 200 OK |
| Volume > threshold | > 1000 events | Publish background job to /api/cron/export/events/workspace via QStash | 202 Accepted |
Sources: apps/web/app/ee/api/events/export/route.ts:25-149
When the event count is at or below the threshold, raw events are retrieved via getEvents, mapped against specified column accessors and names, converted into comma-separated values using convertToCSV, and returned as an attachment.
const response = await getEvents({
...parsedParams,
event,
workspaceId: workspace.id,
limit: MAX_EVENTS_TO_EXPORT,
dataAvailableFrom,
});
const data = response.map((row) =>
Object.fromEntries(
columns.map((c) => [
eventsExportColumnNames?.[c] ?? capitalize(c),
eventsExportColumnAccessors[c]?.(row) ?? row?.[c],
]),
),
);
const csvData = convertToCSV(data);
return new Response(csvData, {
headers: {
"Content-Type": "application/csv",
"Content-Disposition": `attachment; filename=${event}_export.csv`,
},
});Sources: apps/web/app/ee/api/events/export/route.ts:151-175
Caution
Requesting an event export that exceeds 1,000 matching items causes the server to immediately short-circuit synchronous generation, dispatch a background worker payload via QStash, and return an empty 202 Accepted JSON response instead of a downloadable file.
Sources: apps/web/app/ee/api/events/export/route.ts:137-149
Client-side export initiation
The client-side trigger for downloading analytics packages is encapsulated within AnalyticsExportButton. The component reads workspace context parameters via AnalyticsContext to determine whether to call the standard workspace export route or a partner-specific export path.
export function AnalyticsExportButton({
setOpenPopover,
}: {
setOpenPopover: Dispatch<SetStateAction<boolean>>;
}) {
const [loading, setLoading] = useState(false);
const { queryString, baseApiPath, partnerPage } =
useContext(AnalyticsContext);
async function exportData() {
setLoading(true);
try {
const exportPath = partnerPage
? `${baseApiPath}/export`
: "/api/analytics/export";Sources: apps/web/ui/analytics/analytics-export-button.tsx:7-22
Upon user interaction, the component executes a GET request against the resolved export route with active query parameters, handles error payloads by extracting nested API error messages, and programmatically generates an object URL blob download named with an ISO timestamp.
const response = await fetch(`${exportPath}?${queryString}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
let message = response.statusText;
try {
const body = await response.json();
if (body?.error?.message) {
message = body.error.message;
}
} catch {
// ignore JSON parse errors
}
throw new Error(message);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Dub Analytics Export - ${new Date().toISOString()}.zip`;
a.click();
window.URL.revokeObjectURL(url);
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
} finally {
setLoading(false);
}
}Sources: apps/web/ui/analytics/analytics-export-button.tsx:23-57
Sources: apps/web/app/api/analytics/export/route.ts:26-128, apps/web/app/ee/api/events/export/route.ts:37-149, apps/web/ui/analytics/analytics-export-button.tsx:23-51
Related
On This Page
OverviewAnalytics API query routingParameter parsing and path normalizationFilter normalization and link resolutionDashboard visualization layout and controlsClient-side analytics container and conditional stats gridDate filtering, interval selection, and plan validationTimeseries and funnel metric chartsChart section container and tab managementProgram analytics chart data and view modesGeographic, device, and referrer breakdownLocation metrics and country/city bar listsDevice, browser, and OS distributionsReferrers and UTM parameter breakdownsPartner program analytics pipelinesProgram application metrics and event aggregationsCommissions and partner analytics routingNetwork referrals timeseries routingPublic and shared dashboard deliveryPassword verification and dashboard page renderingREST analytics endpoint authenticationDataset and event CSV exportsAnalytics ZIP archive generationRaw event CSV exports and threshold queuingClient-side export initiationRelated