Skip to content
trakoo
Esc
navigateopen⌘Jpreview
On this page

Proxy

Send browser events through your own endpoint, then forward them to server providers.

The Proxy sends browser events through an endpoint you control, then forwards them to your server providers. Because the request goes to your own domain, it bypasses ad-blockers, and your server can enrich or filter events before they reach a vendor.

The Proxy is not an analytics service. It is the transport between client analytics and server analytics: the browser batches version 2 events to your API, and your API validates and forwards them through whatever server providers you configure.

When to use it

  • You want first-party delivery so ad-blockers and tracking-prevention features don’t drop browser events.
  • You need to add server-side context (authenticated user, geography, feature flags) before events reach a vendor.
  • You use a server provider that needs the incoming request, such as Pirsch, which reads headers to derive page views.
  • You want provider API keys to stay on the server instead of shipping in your client bundle.

If none of these apply, send events straight to a client provider — the Proxy adds an extra hop you don’t need.

Installation

The Proxy ships with trakoo, so it has no extra package of its own. Install the SDK for each server provider you forward to—for example, posthog-node for PostHog or @emitkit/js for EmitKit. Pirsch needs no additional package.

Client-side usage

Add ProxyProvider to your client analytics and point it at the endpoint that will receive events. Events are buffered and flushed in batches.

import { createClientAnalytics } from "trakoo/client";
import { ProxyProvider } from "trakoo/providers/client";
import { appEvents } from "@/lib/events";

export const analytics = createClientAnalytics({
  events: appEvents,
  providers: [
    new ProxyProvider({
      endpoint: "/api/events",
      batch: { size: 10, interval: 5000 },
    }),
  ],
});

await analytics.track("button_clicked", { buttonId: "signup-cta" });

Delivery behavior

The client keeps queued events until the transport accepts their batch. A failed manual flush() rejects and leaves the events queued, so a later flush can try the same events again. Automatic size- and timer-based failures call onDeliveryError when you provide it; otherwise Trakoo logs only the error class and no event, payload, endpoint, header, or full error object.

const proxy = new ProxyProvider({
  endpoint: "/api/events",
  batch: { size: 10, interval: 5000 },
  onDeliveryError: (error) => {
    reportAnalyticsDeliveryFailure(error);
  },
});

This is in-memory, at-least-once retry behavior rather than exactly-once or durable delivery. A batch can reach your endpoint more than once when the server accepts a request but its response is lost. Queued events can still be lost if the page or process terminates before the browser accepts a request. Make downstream event handling tolerant of duplicates when that matters.

batch.interval starts when the first event enters an empty queue. Later events do not postpone that batch, so a steady stream cannot reset the timer indefinitely. Only one request is sent at a time; events added during that request stay in order for the next batch.

On page lifecycle delivery, Trakoo first asks sendBeacon to accept the batch. If the browser has no sendBeacon implementation or refuses the batch, Trakoo falls back to fetch with keepalive: true, including your configured custom headers and retry policy.

Call await proxy.shutdown() when your application owns an explicit teardown. Shutdown first awaits any request already in flight and then uses unload-safe delivery for the remaining queue. It resolves only after the queue is empty. If delivery fails, shutdown rejects and retains the events; once shutdown starts, identify, track, page-view, and reset calls reject new events.

Server-side usage

Your endpoint receives the batch and sends each event through normal server analytics. Import the same appEvents registry on both sides. A version 2 track event carries the original registry input, so the server runs its Standard Schema validator on untrusted raw input before any provider receives transformed properties. The wire format also preserves whether input was omitted, which keeps propertyless events distinct from an explicitly supplied undefined.

Let trakoo own the whole route with createProxyHandler. The resolver below reads an authenticated session from server state rather than trusting the event’s claimed user ID or traits. Authorization and the application-owned rate limiter run before the request body is parsed:

import { createServerAnalytics } from "trakoo/server";
import {
  EmitKitServerProvider,
  createProxyHandler,
} from "trakoo/providers/server";
import { appEvents } from "@/lib/events";

const serverAnalytics = createServerAnalytics({
  events: appEvents,
  providers: [
    new EmitKitServerProvider({
      apiKey: process.env.EMITKIT_API_KEY!,
      channelName: "product-events",
    }),
  ],
});

const analyticsRateLimiter = createApplicationRateLimiter({
  limit: 60,
  window: "1m",
});

async function resolveAuthenticatedIdentity(request: Request) {
  const session = await getAuthenticatedSession(request);
  if (!session) return undefined;

  return {
    userId: session.user.id,
    user: {
      email: session.user.email,
      traits: { plan: session.user.plan },
    },
  };
}

export const POST = createProxyHandler(serverAnalytics, {
  authorize: async (request) => {
    const session = await getAuthenticatedSession(request);
    return Boolean(session);
  },
  admit: (request) =>
    analyticsRateLimiter.allow(getAnalyticsRateLimitKey(request)),
  resolveIdentity: ({ request }) => resolveAuthenticatedIdentity(request),
});

authorize always runs before admit, and both hooks run before body parsing. Keep them cheap: inspect already-available authentication state and use a low-latency limiter rather than doing expensive analytics work. These hooks protect application policy, but they do not replace platform-level limits at your CDN, WAF, reverse proxy, or API gateway. Use platform limits to reject abusive traffic before it consumes application bandwidth and concurrency.

Or keep your own handler and call ingestProxyEvents when you need to run authentication, enrichment, or logging around ingestion:

import { createServerAnalytics } from "trakoo/server";
import {
  PirschServerProvider,
  ingestProxyEvents,
} from "trakoo/providers/server";
import { appEvents } from "@/lib/events";

const serverAnalytics = createServerAnalytics({
  events: appEvents,
  providers: [
    new PirschServerProvider({
      hostname: "example.com",
      clientSecret: process.env.PIRSCH_SECRET!,
    }),
  ],
});

export async function POST(req: Request) {
  await ingestProxyEvents(req, serverAnalytics, {
    resolveIdentity: async ({ request }) => {
      const session = await getAuthenticatedSession(request);
      return session
        ? {
            userId: session.user.id,
            user: {
              email: session.user.email,
              traits: { plan: session.user.plan },
            },
          }
        : undefined;
    },
  });
  return new Response("OK");
}

Browser identify() calls are still included as claims so a resolver can inspect the requested action, but their user ID, email, and traits never go directly to a provider. Track and page-view events stay anonymous when resolveIdentity is absent or returns undefined; identify events fail closed without a trusted user ID.

How events flow

Browser → ProxyProvider (batches) → your /api/events → server providers

The browser collects raw event input and flushes a V2 batch once batch.size is reached or batch.interval elapses, whichever comes first. Your endpoint validates the raw input through the server registry, adds server-owned request context, resolves trusted identity, and then fans the transformed event out to configured providers. The same routing rules from Providers apply on the server side.

Configuration

ProxyProvider takes the endpoint and an optional batching policy.

PropType
endpointstring

URL that receives batched events, usually a route on your own domain.

Typestring
batch?object

Controls how events are buffered before they are sent.

Typeobject
retry?object

Controls retry attempts, backoff strategy, and initial delay.

Typeobject
headers?Record<string, string>

Custom headers included in fetch requests, including keepalive fallback.

TypeRecord<string, string>
onDeliveryError?(error: unknown) => void

Observes failures from automatic batch and page-lifecycle delivery.

Type(error: unknown) => void

The batch object accepts:

PropType
size?number

Number of events to buffer before flushing a batch.

Typenumber
interval?number

Maximum milliseconds from the first queued event before flushing a partial batch.

Typenumber

createProxyHandler and ingestProxyEvents accept the same server-ingestion options:

PropType
maxBodyBytes?number

Maximum encoded request-body size in bytes (256 KiB). Must be a finite positive integer.

Typenumber
Default262144
maxBatchSize?number

Maximum number of V2 events accepted in one request. Must be a finite positive integer.

Typenumber
Default100
authorize?(request: Request) => boolean | Promise<boolean>

Application authorization hook. Runs before admission and body parsing.

Type(request: Request) => boolean | Promise<boolean>
admit?(request: Request) => boolean | Promise<boolean>

Application-owned admission or rate-limit hook. Runs before body parsing.

Type(request: Request) => boolean | Promise<boolean>
resolveIdentity?({ request, event }) => ProxyTrustedIdentity | undefined | Promise<...>

Derives trusted identity from authenticated server state for each event.

Type({ request, event }) => ProxyTrustedIdentity | undefined | Promise<...>
enrichContext?(request: Request) => Partial<EventContext>

Adds server-owned event context.

Type(request: Request) => Partial<EventContext>
extractIp?(request: Request) => string | undefined

Overrides extraction from standard forwarding headers.

Type(request: Request) => string | undefined
onError?(error: unknown) => void

Observes ingestion and event-processing errors without changing safe HTTP responses.

Type(error: unknown) => void

The default request limit is 256 KiB and the default batch limit is 100 events. The body limit uses encoded UTF-8 bytes, not JavaScript character count. Requests must use POST with an application/json media type; parameters such as charset=utf-8 are accepted.

Handler responses

The handler returns stable, payload-free JSON for rejected requests:

Status Code Meaning
400 invalid_payload Malformed JSON, unsupported protocol version, or invalid V2 envelope
401 unauthorized authorize rejected the request
405 method_not_allowed The request did not use POST; the response includes Allow: POST
413 body_too_large The encoded body or event batch exceeded its configured limit
415 unsupported_media_type The media type was not application/json
429 rate_limited admit rejected the request
500 internal_error An unexpected hook or ingestion failure occurred

Expected rejection bodies have the shape { "ok": false, "code": "<code>" } and do not include request data or validation details. Event-level failures such as an identify event without resolver-owned identity are sent to onError while the rest of the valid batch continues; they do not become an HTTP failure.

Resources

Was this page helpful?