---
title: "Analytics Dashboard"
description: "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. ..."
last_updated: "2026-10-12T07:00:10.087014+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/analytics-dashboard"
---

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [apps/web/app/api/analytics/dashboard/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/dashboard/route.ts)
- [apps/web/app/api/analytics/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/route.ts)
- [apps/web/app/api/analytics/export/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/export/route.ts)
- [apps/web/app/ee/api/events/export/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/events/export/route.ts)
- [apps/web/app/api/og/analytics/route.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-chart.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/program-analytics-shell.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/program-analytics-shell.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/analytics/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/billing/usage-chart.tsx)
- [apps/web/app/ee/api/partner-profile/programs/programId/analytics/export/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/analytics/export/route.ts)
- [apps/web/ui/analytics/location-section.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/location-section.tsx)
- [apps/web/app/ee/api/partner-profile/programs/programId/analytics/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/analytics/route.ts)
- [apps/web/app/ee/admin.dub.co/dashboard/payouts/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx)
- [apps/web/app/ee/api/program-applications/analytics/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/program-applications/analytics/route.ts)
- [apps/web/app/ee/api/partners/analytics/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partners/analytics/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/links/analytics/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/analytics/page.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/commissions/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/revenue/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx)
- [apps/web/ui/analytics/index.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/index.tsx)
- [apps/web/ui/analytics/device-section.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/device-section.tsx)
- [apps/web/ui/analytics/chart-section.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/chart-section.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/page.tsx)
- [apps/web/ui/analytics/analytics-export-button.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/analytics-export-button.tsx)
- [apps/web/app/app.dub.co/share/share/dashboardId/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(share)/share/%5BdashboardId%5D/page.tsx)
- [apps/web/app/ee/api/partner-profile/referrals/timeseries/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partner-profile/referrals/timeseries/route.ts)
- [apps/web/ui/analytics/toggle.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/toggle.tsx)
- [apps/web/app/ee/api/embed/referrals/analytics/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/embed/referrals/analytics/route.ts)
- [apps/web/app/ee/admin.dub.co/dashboard/events/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/admin.dub.co/(dashboard)/events/page.tsx)
- [apps/web/ui/analytics/referrers-utms.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/referrers-utms.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-query.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/use-applications-analytics-query.ts)
</details>

## Overview

Dub provides a comprehensive analytics dashboard system that tracks and aggregates link clicks, lead submissions, and sale conversions across workspaces, [partner programs](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/partner-programs/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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/dashboard/route.ts#L1-L202), [apps/web/app/api/analytics/route.ts:1-137](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/route.ts#L1-L137), [apps/web/app/api/analytics/export/route.ts:1-132](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/export/route.ts#L1-L132), [apps/web/app/ee/api/events/export/route.ts:37-181](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/events/export/route.ts#L37-L181), [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx:13-120](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-chart.tsx#L13-L120), [apps/web/ui/analytics/location-section.tsx:19-175](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/location-section.tsx#L19-L175), [apps/web/app/app.dub.co/share/share/dashboardId/page.tsx:31-122](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(share)/share/%5BdashboardId%5D/page.tsx#L31-L122), [apps/web/ui/analytics/index.tsx:23-55](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/index.tsx#L23-L55), [apps/web/ui/analytics/device-section.tsx:14-150](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/device-section.tsx#L14-L150), [apps/web/ui/analytics/chart-section.tsx:26-147](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/chart-section.tsx#L26-L147), [apps/web/ui/analytics/toggle.tsx:40-307](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/toggle.tsx#L40-L307), [apps/web/ui/analytics/referrers-utms.tsx:42-237](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/referrers-utms.tsx#L42-L237)

## 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`.

```typescript
export const GET = withWorkspace(
  async ({ params, searchParams, workspace, session }) => {
    throwIfClicksUsageExceeded(workspace);
```

Sources: [apps/web/app/api/analytics/route.ts:20-22](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/route.ts#L20-L22)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/route.ts#L24-L33)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/route.ts#L28-L31), [apps/web/app/api/analytics/route.ts:116-118](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/route.ts#L116-L118)

### 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/route.ts#L71-L88)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/route.ts#L53-L61)

## 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.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/index.tsx#L23-L55)

The `StatsGrid` component conditionally hides [conversion tracking](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/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.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/index.tsx#L57-L72)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/index.tsx#L61-L64)

### 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.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/toggle.tsx#L94-L164)

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`.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/toggle.tsx#L109-L131)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/toggle.tsx#L132-L163)

## 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`.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/chart-section.tsx#L26-L65)

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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/chart-section.tsx#L41-L61)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/chart-section.tsx#L69-L72), [apps/web/ui/analytics/chart-section.tsx:99-105](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/chart-section.tsx#L99-L105), [apps/web/ui/analytics/chart-section.tsx:143-143](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/chart-section.tsx#L143-L143)

### 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.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-chart.tsx#L19-L47)

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.

```tsx
<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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-chart.tsx#L74-L91)

## 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`.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/location-section.tsx#L19-L32)

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] || ...}` | `city` |
| `regions` | `CountryFlag` | `REGIONS[d.region] || fallback` | `region` |
| `continents` | `ContinentIcon` | `CONTINENTS[d.continent]` | `continent` |

Sources: [apps/web/ui/analytics/location-section.tsx:74-79](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/location-section.tsx#L74-L79), [apps/web/ui/analytics/location-section.tsx:94-117](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/location-section.tsx#L94-L117)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/location-section.tsx#L47-L59)

### 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.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/device-section.tsx#L14-L25)

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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/device-section.tsx#L66-L71), [apps/web/lib/analytics/constants.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/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`.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/referrers-utms.tsx#L18-L40)

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`.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/referrers-utms.tsx#L152-L169)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/referrers-utms.tsx#L53-L56), [apps/web/ui/analytics/referrers-utms.tsx:68-70](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/referrers-utms.tsx#L68-L70)

## 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. 

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/program-applications/analytics/route.ts#L27-L35)

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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/program-applications/analytics/route.ts#L27-L35), [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-query.ts:9-14](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/use-applications-analytics-query.ts#L9-L14)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/program-applications/analytics/route.ts#L174-L187)

### 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`.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partners/analytics/route.ts#L92-L118)

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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/analytics/route.ts#L7-L10), [apps/web/app/ee/api/partner-profile/programs/programId/analytics/route.ts:86-96](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/analytics/route.ts#L86-L96)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/analytics/route.ts#L102-L109)

### 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partner-profile/referrals/timeseries/route.ts#L40-L73)

## 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.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(share)/share/%5BdashboardId%5D/page.tsx#L31-L40)

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.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(share)/share/%5BdashboardId%5D/page.tsx#L42-L94)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(share)/share/%5BdashboardId%5D/page.tsx#L42-L46)

### 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/dashboard/route.ts#L17-L40)

| 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/dashboard/route.ts#L44-L142)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/dashboard/route.ts#L204-L222)

## 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`.

```typescript
await assertRateLimit({
  policy: RATELIMIT_POLICIES.analyticsExport,
  identifier: workspace.id,
});

throwIfClicksUsageExceeded(workspace);
```

Sources: [apps/web/app/api/analytics/export/route.ts:26-31](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/export/route.ts#L26-L31)

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`.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/export/route.ts#L33-L101)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/export/route.ts#L109-L128)

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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/analytics/export/route.ts#L26-L31)

### 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/events/export/route.ts#L25-L37)

| 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/events/export/route.ts#L25-L149)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/events/export/route.ts#L151-L175)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/events/export/route.ts#L137-L149)

### 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.

```tsx
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/analytics-export-button.tsx#L7-L22)

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.

```tsx
    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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/analytics-export-button.tsx#L23-L57)

```mermaid
sequenceDiagram
    participant User as AnalyticsExportButton
    participant API as /api/analytics/export
    participant QStash as QStash / Cron Worker

    User->>API: GET /api/analytics/export?queryString
    API->>API: Rate limit check & usage validation
    API->>API: Parse query & verify folder/link access
    alt Events export count > 1000
        API->>QStash: Publish JSON job to cron export endpoint
        API-->>User: Return 202 Accepted (empty JSON)
    else Standard ZIP export
        API->>API: Compile analytics breakdowns into ZIP archive
        API-->>User: Return application/zip blob (download triggered)
    end
```

Sources: [apps/web/app/api/analytics/export/route.ts:26-128](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/export/route.ts#L26-L128), [apps/web/app/ee/api/events/export/route.ts:37-149](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/events/export/route.ts#L37-L149), [apps/web/ui/analytics/analytics-export-button.tsx:23-51](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/analytics/analytics-export-button.tsx#L23-L51)

## Related

- [Tinybird Analytics](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/tinybird-analytics)
- [Conversion Tracking](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/conversion-tracking)


## Sitemap

See the full [sitemap](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/llms.txt) for all pages in this wiki.
