Browse documentation

Client & deploy

Analytics (experimental)

Add privacy-minimizing pageview analytics and an admin dashboard on Cloudflare Analytics Engine - one verified recipe for a client project.

Experimental. Forge Analytics is public but outside the stable 1.x compatibility promise: its interfaces may change in any release, it only has a Cloudflare implementation, and it is deliberately small. It is an opt-in site capability, never part of the runtime, the Local API or the REST envelope.

A client who opens the admin can see pageviews, the change against the previous period, a timeline, top pages, referrers and countries for the last 24 hours, 7, 30 or 90 days.

The server files below are embedded from apps/demo-aesthetics, where they are covered by tests (pnpm test:demo): the collector's privacy, the missing-binding no-op, configured: false, an invalid range, and an empty dataset. Copy them as they are; only the dataset name and the auth check are yours.

What is and is not collected

Collected (per pageview) Never collected
path, with the query string and hash removed IP address, email, Forge user id
referrer host only (same-site referrers dropped) cookies or any persistent visitor identifier
country from Cloudflare's CF-IPCountry header the raw referrer URL, the raw query string
a site id from server configuration any other field of the request body

There are no unique visitors: counting them would need an identifier, which Forge deliberately does not create. Retention is Analytics Engine's own 90 days.

1. Bind the dataset

# wrangler.toml
[[analytics_engine_datasets]]
binding = "ANALYTICS"
dataset = "my_site_analytics"

The dataset is created on the first write. Without the binding the collector does nothing (it never fails the request).

2. The collector (public)

import {
  AnalyticsEngineWriter,
  NoopAnalyticsWriter,
  sanitizeCountry,
  sanitizePathname,
  sanitizeReferrerHost,
  sanitizeSiteId,
  type AnalyticsEngineDataset
} from '@forge-cms/cloudflare';

/** The bindings and variables the collector reads. `ANALYTICS` is optional on purpose: no binding, no-op. */
export interface AnalyticsCollectEnv {
  ANALYTICS?: AnalyticsEngineDataset;
  FORGE_ANALYTICS_SITE_ID?: string;
}

export interface CollectPageviewInput {
  /** The parsed JSON body, whatever it is. Only `pathname` and `referrer` are ever read from it. */
  body: unknown;
  env: AnalyticsCollectEnv | undefined;
  /** Host of the request itself, so same-site referrers are dropped. */
  requestHost: string;
  /** The `CF-IPCountry` request header. */
  countryHeader: string | undefined;
}

/**
 * Records one pageview. Everything but `pathname`/`referrer` is derived on the server: the country from
 * Cloudflare's header, the site id from configuration. No IP, email, user id, cookie, visitor id,
 * query string or hash is read or stored.
 */
export function collectPageview(input: CollectPageviewInput): void {
  const body = (typeof input.body === 'object' && input.body !== null ? input.body : {}) as {
    pathname?: unknown;
    referrer?: unknown;
  };
  const env = input.env;
  const writer = env?.ANALYTICS
    ? new AnalyticsEngineWriter().init({ ANALYTICS: env.ANALYTICS })
    : new NoopAnalyticsWriter();

  writer.recordPageview({
    siteId: sanitizeSiteId(env?.FORGE_ANALYTICS_SITE_ID),
    pathname: sanitizePathname(body.pathname),
    referrerHost: sanitizeReferrerHost(body.referrer, input.requestHost),
    country: sanitizeCountry(input.countryHeader)
  });
}

Expose it as a public route (POST /api/analytics/collect, answering 204). The h3 wrapper reads the JSON body, getRequestURL(event).hostname and the cf-ipcountry header and calls collectPageview; see apps/demo-aesthetics/src/server/routes/api/analytics/collect.post.ts.

3. The admin summary (admin only)

Querying uses Cloudflare's Analytics Engine SQL API, an HTTPS call that needs two server-only secrets. Set them with wrangler pages secret put and never commit them:

CLOUDFLARE_ACCOUNT_ID=...
CLOUDFLARE_ANALYTICS_TOKEN=...   # API token scoped to Account > Account Analytics > Read
import {
  ANALYTICS_DATE_RANGES,
  AnalyticsEngineQueryClient,
  sanitizeSiteId,
  type AnalyticsDateRange,
  type AnalyticsSummaryResult
} from '@forge-cms/cloudflare';

/** Must match the `dataset` declared for the `ANALYTICS` binding in wrangler.toml. */
export const ANALYTICS_DATASET = 'forge_analytics_demo';

export interface AnalyticsSummaryEnv {
  FORGE_ANALYTICS_SITE_ID?: string;
  /** Server-only secrets for the Analytics Engine SQL API (query side only; writes need none). */
  CLOUDFLARE_ACCOUNT_ID?: string;
  CLOUDFLARE_ANALYTICS_TOKEN?: string;
}

export type AnalyticsSummaryResponse =
  | { status: 400; message: string }
  | { status: 200; data: { configured: false } }
  | { status: 200; data: { configured: true } & Omit<AnalyticsSummaryResult, never> };

const EMPTY_SUMMARY: Omit<AnalyticsSummaryResult, 'range'> = {
  totals: { pageviews: 0, previousPageviews: 0, changePct: null },
  timeline: [],
  topPages: [],
  referrers: [],
  countries: []
};

interface QueryClientLike {
  getSummary(siteId: string, range: AnalyticsDateRange): Promise<AnalyticsSummaryResult>;
}

export interface GetAnalyticsSummaryInput {
  /** The raw `range` query value. */
  range: unknown;
  env: AnalyticsSummaryEnv | undefined;
  /** Test seam; defaults to the real Analytics Engine SQL API client. */
  createClient?: (options: {
    accountId: string;
    apiToken: string;
    dataset: string;
  }) => QueryClientLike;
}

function isValidRange(value: unknown): value is AnalyticsDateRange {
  return typeof value === 'string' && (ANALYTICS_DATE_RANGES as string[]).includes(value);
}

/**
 * The admin summary. The CALLER must have verified the user is an admin before calling this; the SQL API
 * must never be reachable for an unauthenticated request.
 */
export async function getAnalyticsSummary(
  input: GetAnalyticsSummaryInput
): Promise<AnalyticsSummaryResponse> {
  if (!isValidRange(input.range)) return { status: 400, message: 'Invalid or missing range' };

  const accountId = input.env?.CLOUDFLARE_ACCOUNT_ID;
  const apiToken = input.env?.CLOUDFLARE_ANALYTICS_TOKEN;
  if (!accountId || !apiToken) return { status: 200, data: { configured: false } };

  const create = input.createClient ?? ((options) => new AnalyticsEngineQueryClient(options));
  const client = create({ accountId, apiToken, dataset: ANALYTICS_DATASET });
  try {
    const summary = await client.getSummary(
      sanitizeSiteId(input.env?.FORGE_ANALYTICS_SITE_ID),
      input.range
    );
    return { status: 200, data: { configured: true, ...summary } };
  } catch {
    // Valid credentials but the query failed - most likely the dataset has never been written to yet
    // (Analytics Engine creates it on the first write). That is "no traffic yet", not a server error.
    return { status: 200, data: { configured: true, ...EMPTY_SUMMARY, range: input.range } };
  }
}

Expose it as GET /api/analytics/summary?range=24h|7d|30d|90d, after your admin check (the demo calls requireAdminAuth(event) first and answers 400 for an invalid range). An unauthenticated or non-admin caller must never reach the SQL API. Without the two secrets the response is { data: { configured: false } } and the dashboard shows a "not configured" state.

4. The tracker (browser)

import { provideForgeAnalytics, provideForgeCms } from '@forge-cms/angular';

providers: [
  provideForgeCms({ baseUrl: '/api/v1', authBaseUrl: '/api/auth', credentials: 'include' }),
  provideForgeAnalytics({ enabled: true }) // posts the pathname and referrer to /api/analytics/collect on navigation
];

endpoint can point at a different same-origin collector. Only the page path and the browser's referrer are sent.

5. The admin dashboard

import { forgeAdminAnalyticsRoutes } from '@forge-cms/admin';

// inside the layout route's children, next to forgeAdminContentRoutes():
...forgeAdminAnalyticsRoutes(),

Add a navigation item to the layout config: { label: 'Analytics', routerLink: '/admin/analytics', icon: 'analytics', exact: true } (use your mount, for example /studio/analytics). The route is lazy, so the dashboard code is not part of the public initial bundle.

The dashboard handles loading, an error with retry, "not configured" (configured: false), "no traffic yet", and range changes; a positive or negative change against the previous period is shown with its sign.

Limits

  • Experimental; Cloudflare Analytics Engine only; 90 days retention; no unique visitors, funnels, events, sessions, revenue or heatmaps.
  • Writes are fire-and-forget; counts are what Analytics Engine reports (sampled at very high volume).
  • This is a site capability. The ForgeCMS developer tooling (@forge-cms/agent) collects no telemetry of any kind.