Blog Product analytics and replay

Move off Mixpanel without rewriting tracking

You have mixpanel.track() calls all over a codebase and you want those events somewhere else. Rewriting every call site is the part nobody wants to do. ZipLogger accepts Mixpanel-shaped requests, so for the common calls the change is a host setting and a key. This post shows the exact settings, what carries over, and what does not.

The three routes

ZipLogger has three routes that read Mixpanel's request format:

Mixpanel endpointZipLogger routeWhat happens
/track/ingest/v1/mp/trackEvents. Name and custom properties are kept.
/import/ingest/v1/mp/importSame pipeline as track, meant for backfills.
/engage/ingest/v1/mp/engage$set and $set_once are stored as a $profile_set event. See the gaps below.

Each route accepts a JSON object, a JSON array, or Mixpanel's data= field with base64 JSON in a form body or query string, which is what the Mixpanel libraries send.

For authentication, ZipLogger reads an X-Api-Key header or, if there is none, the token property inside the event. An unmodified Mixpanel SDK puts your project token there. So the switch is: create an ingest key under Settings → API keys, and pass it where you passed the Mixpanel project token. After that, these routes go through the same pipeline as native events: the same plan quota, the same redaction of credential-looking values, the same workspace isolation.

Browser: mixpanel-browser

Before:

import mixpanel from 'mixpanel-browser'

mixpanel.init('YOUR_PROJECT_TOKEN')

After:

import mixpanel from 'mixpanel-browser'

// YOUR_API_KEY is a ZipLogger ingest key, not a Mixpanel project token
mixpanel.init('YOUR_API_KEY', {
  api_host: 'https://app.ziplogger.ai/ingest/v1/mp',
})

// The call sites stay as they are
mixpanel.track('Checkout Started', { total: 49, currency: 'USD' })
mixpanel.identify('user_42')
mixpanel.track('Order Placed', { total: 49 })

api_host must include the /ingest/v1/mp path. mixpanel-browser appends its own route to it, so track calls end up at /ingest/v1/mp/track/.

Here is how the Mixpanel fields map:

  • distinct_id of the form $device:... becomes the anonymous id. Any other distinct_id becomes the user id.
  • $device_id and $user_id (Mixpanel's simplified ID merge) become the anonymous id and the user id.
  • The $identify event that mixpanel.identify() sends becomes an identity link, so the visitor's events from before sign-in join the same user.
  • time can be epoch seconds or milliseconds.
  • $insert_id becomes the event's id, so a retried batch overwrites instead of counting twice.
  • $current_url becomes the event URL, with the query string removed.
  • token, ip, mp_lib and $lib_version are dropped. Everything else, including mixpanel-browser's own $browser and $os, is kept as a custom property under the name you sent.

Two changes you will notice in the Events page. Event names are normalized, so Checkout Started is stored as checkout_started. And an event keeps at most 60 properties and strings are cut at 1,000 characters.

Server: the Mixpanel Node library

The Node library takes a host and a path, and builds each request as path plus route:

const Mixpanel = require('mixpanel')

const mixpanel = Mixpanel.init('YOUR_API_KEY', {
  host: 'app.ziplogger.ai',
  path: '/ingest/v1/mp',
})

mixpanel.track('Invoice Paid', { distinct_id: 'user_42', amount: 120 })

Location is looked up from the address that sent the request, and the ip property is ignored. Events sent from your server therefore get your server's location, not the user's.

Three gaps you will hit today

The track routes store the events, but they do not answer the way Mixpanel's libraries expect, and profile updates do not get through at all. Know this before you rely on callbacks, console output or people.set().

  • Response format. ZipLogger answers with HTTP 202 and its own JSON (accepted, rejected and quota counts), not Mixpanel's 1 or {"status": 1}. The Node library passes a "Mixpanel Server Error" to your callback even when the event was stored. The Python library (mixpanel) reads the status field and raises an error on every call, so do not point it at ZipLogger yet. From Python, use ZipLogger's HTTP API or its own SDK.
  • Browser console. mixpanel-browser sends its requests with credentials, and ZipLogger's ingest routes allow any origin but not credentials. The request still reaches ZipLogger and the events are stored, but the browser blocks the SDK from reading the response and logs a CORS error for each request.
  • Profile updates from the SDKs. ZipLogger looks for the key in properties.token, which is where track events carry it. Mixpanel's libraries send profile updates with the key in a top-level $token field instead, so mixpanel.people.set() from an unmodified SDK gets a 401 and is lost. A request to /ingest/v1/mp/engage that you build yourself with an X-Api-Key header works.

So check that events arrived in the app, not through the SDK's own success signals.

What does not carry over

  • Groups. There is no /groups route, so set_group and group profiles do nothing.
  • Other profile operations. /engage reads only $set and $set_once. people.increment, append, union, unset and remove are ignored, and a request that contains only those gets a 400.
  • $set_once semantics. It is stored the same way as $set. Nothing checks whether the value was already set.
  • A profile store. ZipLogger builds user profiles from the event stream. There is no profile document to overwrite in place.
  • alias(). The $create_alias event is stored as an ordinary event. It does not link ids. Use identify().
  • Old history. Timestamps more than 30 days in the past are replaced with the arrival time, and imported events count against your plan's event quota. Backfill the window you actually query.
  • Anything else Mixpanel serves. Session recording, feature flags, and the query and export APIs have no ZipLogger routes.
  • Events with no identity. An event with no distinct_id, $device_id or $user_id is rejected.
  • Sessions and traces. Events that come in through these routes carry no ZipLogger session id or request id. They will not show on a session replay timeline or link to a trace.

Or switch to the native calls

The Mixpanel routes are there so you can move without touching call sites. Once you are over, the native SDK is a small change per call and it fixes the last point on that list.

MixpanelZipLogger browser SDK
mixpanel.init(token, { api_host })new ZipLoggerBrowser({ endpoint, apiKey })
mixpanel.track(name, props)ziplogger.track(name, props)
mixpanel.identify(id)ziplogger.identify(id)
mixpanel.people.set(props)No profile store. ziplogger.identify(id, props) keeps the properties on the identify event
mixpanel.reset()ziplogger.reset()
import { ZipLoggerBrowser } from '@ziplogger/browser'

export const ziplogger = new ZipLoggerBrowser({
  endpoint: 'https://app.ziplogger.ai',
  apiKey: 'YOUR_API_KEY',
})

ziplogger.instrumentFetch()   // links each event to the request it happened in

ziplogger.track('checkout_started', { total: 49, currency: 'USD' })
ziplogger.identify('user_42', { plan: 'team' })   // after sign-in
ziplogger.reset()                                 // on sign-out

What you get over the Mixpanel route: every event carries a session id, so it shows on that session's replay timeline. The anonymous id is kept in localStorage (zl_anon) and linked by identify(). Every event carries an insertId, so a retry cannot count it twice. With instrumentFetch() on, an event tracked right after a request carries that request's trace id, and the event page shows that request's logs and traces.

On a .NET backend, ZipLogger.Extensions.Logging registers an IEventTracker next to the logger. The Serilog post covers logging setup; for events it looks like this:

using ZipLogger.Client;

builder.Logging.AddZipLogger(options =>
{
    options.Endpoint = "https://app.ziplogger.ai";
    options.ApiKey   = "YOUR_API_KEY";
});

var app = builder.Build();

app.MapPost("/invoices/{id}/pay", (string id, IEventTracker analytics) =>
{
    analytics.Track("invoice_paid", new { userId = "user_42", invoiceId = id, amount = 120 });
    return Results.Ok();
});

Track never blocks and never throws. userId, anonymousId and sessionId in the object are lifted into the event's identity fields, and the rest become properties. To link a pre-login id to an account, call analytics.Identify(userId, anonymousId).

Check that it arrived

Open Events in the app. The list shows each event with its user, session, location and device, and you can search by event name and user id. Explore is where you filter on your own properties, such as the total you were already sending to Mixpanel.

The ZipLogger Events page with a checkout_started event expanded to show its fields, on demo data

If nothing shows up, the status code of the request tells you why. 401 means the key is wrong or revoked. 429 means the plan's event quota is used up, and the response carries Retry-After. 400 with "No events in payload" means nothing in the request could be read as an event.

Next step

The Mixpanel alternative page compares the two products honestly, including when to stay on Mixpanel. For what you can do with the events once they land, see product analytics.

Release health: crash-free sessions per release

See crash-free sessions, crash-free users and adoption for each release, from Sentry SDKs or ZipLogger's own SDKs, and get alerted when a release crashes more.

Funnel drop-off: who left and what they did next

Build a saved checkout process in ZipLogger, open the exact people who dropped at a step, and see the next thing each of them did. Limits included.

Use the Sentry SDK with ZipLogger

Keep your Sentry SDK and send its errors, traces and logs to ZipLogger by changing the DSN. Node, browser and Python setups, and what is dropped.

Try it on your own logs

There is a free plan and no sales call. Install an SDK, send a few log lines, and search them.