Blog Product analytics and replay
Identify anonymous users and follow their journey
A visitor reads your pricing page, starts a signup, creates an account and hits an error on the first screen. To see that as one story you need two things. The events from before sign-in have to be tied to the account, and the events have to be tied to the requests and errors around them.
This post covers how ZipLogger does both: the ids the browser SDK keeps, what identify() sends and changes, and what the user profile and session journey pages show.
Three ids on every event
The browser SDK (@ziplogger/browser) puts up to three ids on each event:
anonymousId: made once per browser and kept inlocalStorageunderzl_anon. It starts withanon_.sessionId: made once per tab and kept insessionStorageunderzl_sess. It starts withsess_.userId: your own id for the signed-in user. It is empty until you callidentify()or passuserIdto the constructor.
If storage is blocked, the ids live in memory for that page. An event with neither a user id nor an anonymous id is rejected by the server, which is why the anonymous id exists.
What identify() sends
Call identify() once, right after sign-in, and reset() on sign-out:
import { ZipLoggerBrowser } from '@ziplogger/browser'
const ziplogger = new ZipLoggerBrowser({ endpoint: 'https://app.ziplogger.ai', apiKey: 'YOUR_API_KEY' })
ziplogger.track('pricing_viewed', { plan: 'team' })
ziplogger.track('signup_started')
// after the account exists
ziplogger.identify('user_42', { plan: 'team' })
ziplogger.track('signup_completed', { plan: 'team' })
// on sign-out
ziplogger.reset()
We ran exactly this against a small local HTTP server and logged what arrived at /ingest/v1/events (some fields trimmed):
identity before: {"userId":null,"anonymousId":"anon_9ccaa9d026e116090001","sessionId":"sess_fc9f96790ed480714cb3"}
{"type":"track","name":"pricing_viewed","anonymousId":"anon_9ccaa9d026e116090001","sessionId":"sess_fc9f96790ed480714cb3","properties":{"plan":"team"}}
{"type":"track","name":"signup_started","anonymousId":"anon_9ccaa9d026e116090001","sessionId":"sess_fc9f96790ed480714cb3"}
{"type":"identify","userId":"user_42","anonymousId":"anon_9ccaa9d026e116090001","sessionId":"sess_fc9f96790ed480714cb3","properties":{"plan":"team"}}
{"type":"track","name":"signup_completed","userId":"user_42","anonymousId":"anon_9ccaa9d026e116090001","sessionId":"sess_fc9f96790ed480714cb3","properties":{"plan":"team"}}
identity after reset: {"userId":null,"anonymousId":"anon_baa4b97d4438351f1939","sessionId":"sess_344b69f17cc2fabb48ce"}
Three things to see here:
- The events before sign-in carry only the anonymous id.
identify()does not rewrite them. identify()sends a separateidentifyrecord with both ids, straight away, without waiting for a full batch.reset()makes a new anonymous id and a new session, so the next person on the same computer is not merged into the last one.
What the server does with it
The identify record does two things on the server:
- It saves a link from the anonymous id to the user id. Sending the same pair again changes nothing. If the same anonymous id is later identified as a different user, the old link is revoked and the new one wins, because the newest sign-in on a device says who is using it.
- It stores a
$identifyevent, so the merge shows up in the user's activity.
Stored events are never changed. The link is applied when you ask about one person. The profile page, that user's activity list, their sessions and a User filter on a dashboard all look up the user id plus every linked anonymous id (up to 50). So pricing_viewed and signup_started above show up on user_42's profile.
The link is not used in most aggregate counts. Retention counts the anonymous id and the user id as two people, and sequential funnel widgets skip events without a user id. Process reports that count People are the exception: they apply the link as it stood when the report ran. The funnel analysis post shows what that means for a signup funnel.
From a .NET backend, IEventTracker.Identify(userId, anonymousId) creates the same link. The server has to be told the anonymous id, for example by your sign-in form sending ziplogger.identity.anonymousId along with the credentials:
using ZipLogger.Client;
app.MapPost("/login", (LoginRequest req, IEventTracker analytics) =>
{
var userId = SignIn(req); // your own sign-in
if (!string.IsNullOrEmpty(req.AnonymousId))
analytics.Identify(userId, req.AnonymousId);
return Results.Ok();
});
record LoginRequest(string Email, string Password, string? AnonymousId);
Calling identify() in the browser is simpler and does the same thing. Do one or the other.
The user profile
Every signed-in user has a page at /users/<userId>. You get there from the User id column in Events → Explore, the User profile link on an expanded event, the user on a session journey, or the user on a replay.
There is no stored profile. The page is worked out from events plus identity links:
- Events, Sessions, First seen, Last seen.
- Where: the city and country of the latest event, from an approximate IP lookup, if the server has a GeoIP database. The IP address itself is never stored.
- Client: device type, browser and OS of the latest event.
- Recent sessions: the latest 20, each linking to its journey.
- Top events: this user's ten most frequent event names.
- Identity links: the anonymous ids linked to this user. Users with the Editor role can revoke a link with ×. That un-merges those pre-login events from this user. The events themselves are kept.
- Recent activity: events newest first, 50 at a time with Load more. Each row links to its journey and, when the event has a request id, its trace.
The page looks up events by user id. For a visitor who never signed in, open one of their sessions instead.
The session journey
/sessions/<sessionId> shows one visit in order. The header shows User, Started, Duration, Events, Errors and Client. Below it is a timeline that mixes two things:
- the events the session tracked, each linking to its event page;
- error logs from the same requests, matched on trace id: an event's
requestIdagainst a log'straceIdfield.
Any row with a request id links to that request's trace. If a session replay was recorded for the session, the header also shows Watch the replay. Replay is off until a workspace admin turns it on in the Session replay card under Settings → Data and your app attaches the replay recorder. The session replay privacy post covers the masking side.
An error only appears on the journey if both halves carry the same trace id:
- Browser events get a
requestIdwheninstrumentFetch()is on and the event is tracked within 5 seconds of an instrumented request. A request that fails with HTTP 500 or higher, or with a network error, is logged at error severity with its trace id, so it appears on the journey. A 4xx is logged as a warning and does not. - Backend logs at error severity get a
traceIdfield from the currentActivitywhen they go throughZipLogger.Extensions.Loggingor the Serilog sink. - A browser exception reported with
captureError()has no trace id, so it does not appear on the journey. You will find it in Search.
Server-side Track() calls get their requestId from the current trace, but not the browser's session id. To put a server event on the journey, pass sessionId to Track, or wrap the work in AmbientSession.Begin(sessionId) so every Track call in that async flow carries it.
Going wider than one session
The journey is one visit. Two other views join more:
- Investigate. Expand any event in Events → Explore and click Investigate. It collects what is connected to that event: the request's trace and spans, its logs, and the session around it. The investigation post walks through it.
- What were users doing? In Search, open an error pattern and click this button. It works backwards from the error to the events on the failing requests, and shows the most common path into it, how many users were affected, and example sessions.
Limits
- A journey loads 500 events at a time. A longer session still shows its real start, end and event count, with Load earlier events and Load later events. Errors are looked up for its first 100 distinct requests, at error severity, from 5 minutes before the first event to 10 minutes after the last.
- No profile store. You cannot set a property on a user. Properties passed to
identify()are kept on the$identifyevent. - No aliasing or merging of two user ids. Links go from anonymous ids to one user id.
- Links are per anonymous id. A person on three browsers is linked once they sign in on each one.
The SDK calls are in the browser SDK docs and the product analytics docs. Or create a free workspace, add identify() to your sign-in, and open Events → Explore to find your first user.