pex

Instrumenting Your App (Client vs Server)

Apex measures two ways, and using the right one for each event is the difference between a trustworthy funnel and a noisy, lossy one.

  • Client (the snippet / browser SDK) captures behavioral signal — what a user does in the browser. Page views, clicks, rage clicks, scroll depth, form interactions, experiment exposure.
  • Server (sendServerEvent / Server Events API) captures source-of-truth milestones — facts your backend knows for certain. Signup confirmed, activation reached, plan changed, payment succeeded.

Warning

The one rule that prevents double-counting: a given canonical event name should be emitted from exactly one side. If activated fires from both your client and your server, your activation rate is inflated and every belief built on it is wrong.

The decision matrix

MilestoneWhereWhy
Page / screen views, clicks, rage/dead clicks, scrollClientOnly the browser sees them. The snippet does this automatically.
Onboarding step completed (a UI step)Client (onboarding_step_completed)The step happens in the UI; fire it where it happens.
Signup confirmedServer (user_signed_up)Your auth backend is the authority on account creation.
Onboarding completedServer (onboarding_completed)A state flip your backend owns.
Activated ("aha" reached)Server (activated)Usually computed from backend state; must be unblockable + exact.
Plan change / subscriptionServer (subscription_event)Revenue truth — emit from your Stripe webhook, deduped on the event id.
Purchase / refundServer (in_app_purchase / purchase_refunded)Billing truth.

Why server for the money/outcome events: ad blockers and browser tracking prevention silently drop 10–30% of client events, and the moments that matter most (a webhook firing, a cron flipping a flag) never touch the browser at all.

Authenticated single-page apps

Most product UIs are SPAs behind a login. Three things make them measure correctly:

1. Install the snippet on the app shell. It runs in measurement-only mode on your authenticated routes (configured via your workspace's app-surface paths): it keeps measuring but does not run experiments, anti-flicker, or form auto-interception there. It also fires virtual page_views on client-side route changes, so your in-app funnel populates without full reloads.

2. Identify on login. Once the user authenticates, link them to their anonymous visitor so the marketing touch and the logged-in journey are one person:

window.apex.identify(user.email, { orgId: org.id, plan: org.plan });

From a backend SDK, pass the apex_vid cookie explicitly so the stitch lands:

identify(user.email, { visitorId: req.cookies["apex_vid"], plan: "pro" });

3. Fire outcomes server-side. When your backend flips a user to activated or a subscription changes, emit it with the Server Events API — idempotent on a stable key so webhook retries never double-count:

import { sendServerEvent } from "@apex-inc/sdk";

await sendServerEvent(
  { apiKey: process.env.APEX_API_KEY, workspaceKey: "YOUR_WORKSPACE_KEY" },
  { type: "activated", visitorId: apexVid, data: { criterion: "connected_first_integration" } },
  { idempotencyKey: `activated:${user.id}` },
);

Use canonical attribute names

When you identify, prefer Apex's canonical snake_case trait names (first_name, last_name, postal_code, created_at, plan, lifetime_value). They keep your Schema clean and make every personalization variable resolvable from day one.

window.apex.identify(user.email, {
  first_name: user.firstName,
  plan: org.plan,
  created_at: user.createdAt,
});

If your app already sends camelCase or other forms (firstName, zip), Apex maps the common aliases onto the canonical name automatically, so {{first_name}} still resolves. Sending canonical names just skips the mapping step. Anything that isn't canonical is kept as a custom attribute you can map or keep as-is.

How this keeps your data honest

  • Experiments only count the marketing (web) surface, so logged-in app traffic never biases an A/B test's denominator.
  • Billing meters marketing-surface measurement; high-volume in-app SPA navigation is recorded for analytics but not billed under the same meter.
  • Privacy: URLs are scrubbed of tokens/emails/ids before storage, on both the client and the server.

Next steps