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.
endpointstring
URL that receives batched events, usually a route on your own domain.
stringbatch?object
Controls how events are buffered before they are sent.
objectretry?object
Controls retry attempts, backoff strategy, and initial delay.
objectheaders?Record<string, string>
Custom headers included in fetch requests, including keepalive fallback.
Record<string, string>onDeliveryError?(error: unknown) => void
Observes failures from automatic batch and page-lifecycle delivery.
(error: unknown) => voidThe batch object accepts:
size?number
Number of events to buffer before flushing a batch.
numberinterval?number
Maximum milliseconds from the first queued event before flushing a partial batch.
numbercreateProxyHandler and ingestProxyEvents accept the same server-ingestion options:
maxBodyBytes?number
Maximum encoded request-body size in bytes (256 KiB). Must be a finite positive integer.
number262144maxBatchSize?number
Maximum number of V2 events accepted in one request. Must be a finite positive integer.
number100authorize?(request: Request) => boolean | Promise<boolean>
Application authorization hook. Runs before admission and body parsing.
(request: Request) => boolean | Promise<boolean>admit?(request: Request) => boolean | Promise<boolean>
Application-owned admission or rate-limit hook. Runs before body parsing.
(request: Request) => boolean | Promise<boolean>resolveIdentity?({ request, event }) => ProxyTrustedIdentity | undefined | Promise<...>
Derives trusted identity from authenticated server state for each event.
({ request, event }) => ProxyTrustedIdentity | undefined | Promise<...>enrichContext?(request: Request) => Partial<EventContext>
Adds server-owned event context.
(request: Request) => Partial<EventContext>extractIp?(request: Request) => string | undefined
Overrides extraction from standard forwarding headers.
(request: Request) => string | undefinedonError?(error: unknown) => void
Observes ingestion and event-processing errors without changing safe HTTP responses.
(error: unknown) => voidThe 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.
