Visitor Identity¶
The storefront knows every visitor from their first request, logged in or not, so their browsing (page views, recently viewed, likes, the basket) can be tied together and carried over when they register or log in. This page covers how that identity is issued and stored, and what it costs a request.
The cookies¶
UserResolutionFilter resolves the visitor on every storefront request from two
signed cookies, both written by BadgerUserCookieSigner:
BDGR_USR(<userId>$<hmac>) is the visitor's identity.BDGR_VIS(<lastActiveEpochSeconds>$<hmac>) is a hint that the visitor is browse-only, so the request can skip reading their user document. The signature covers theBDGR_USRuser id, so the hint stops being valid as soon as anything switchesBDGR_USRto another user (a login, for example).
| Request | What happens |
|---|---|
| No cookie (first visit) | A transient User is built in memory with a fresh ObjectId and BDGR_USR is set. Nothing is written in the request. |
Valid BDGR_USR and a valid BDGR_VIS |
The visitor is rebuilt from the cookies. No database read. |
Valid BDGR_USR, document found |
The stored User is loaded (one _id read). |
Valid BDGR_USR, no document, id issued in the last day |
The worker hasn't created it yet, or the visitor has only browsed listing pages (see Visitors who aren't saved). The visitor keeps the id; the cookie is not reissued. |
Valid BDGR_USR, no document, older id |
The document was purged or erased. The visitor gets a new identity, as for a first visit. |
| Invalid signature | Treated as a first visit. |
BDGR_USR lasts 30 days (CookieUtils.SITE_COOKIE_EXPIRY). It is written again
whenever the visitor's activity is recorded (at most every 5 minutes), so it runs
out after 30 days idle, not 30 days after the first visit.
The one-day window is UserResolutionFilter.PENDING_CREATION_WINDOW. The issue
time is read from the ObjectId, so the BDGR_USR format is unchanged. A visitor
kept under their id this way is marked unloaded, so saving them never overwrites
a document of the same id (another site's user, say: the lookup returns nothing
for those too).
Browse-only visitors¶
A browse-only visitor has nothing on their User but tracking data: id, site
group, registration time and IP, last-active date, location, and the bot flag
(BrowseOnlyVisitors). There's no basket, no login and no captured details, so
the only field a request acts on is last-active, and the hint carries it.
BrowseOnlyVisitors is an allow-list of those tracking fields. A field added to
User counts as state by default, which is the safe direction: wrongly treating
a visitor with state as browse-only would hide their basket from them.
The hint is reconciled just before each response commits (OnCommittedResponseWrapper):
- A visitor who is still browse-only gets the hint, or has its last-active stamp refreshed if it changed.
- The hint is withdrawn (
Max-Age=0) the moment a request gives the visitor state. The filter checks the request'sUser. As a safety net,StatefulUserSaveTrackeralso records every user saved with state on the request thread, in case some code saved a separate copy. - No hint is given on a first visit or while the document is still being created. It appears on a later request, once the document exists.
A user rebuilt from cookies is marked unloaded. If a request gives them state and
saves them, UserServiceImpl first fills every field the request didn't set from
the stored document. So the whole-document save never wipes their tracked
history.
Old pods ignore BDGR_VIS and read the user as before, so a rolling deploy is
safe in both directions.
Visitors who aren't saved¶
A visitor with no document only gets one from a request worth keeping
(UserResolutionFilter.shouldSaveNewVisitor). These requests don't save one:
- a
GETorHEADfrom a bot (BotDetectionService.isBot), on any page; - a
GETorHEADof a listing page:/c/**,/collection/**,/searchand/search/**.
Crawlers that keep no cookies walk listing pages and their facet combinations by the thousand, and each hit used to write a user. On 2026-10-23 one did that to Halden at about 27 requests a second.
The visitor still gets BDGR_USR, but no BDGR_VIS hint (the hint would skip the
read that lets a later request save them). Their next request that is worth
keeping, within the one-day window, saves them under the same id: a product page,
the home page, any POST (adding to the basket, signing in), or a handler calling
UserResolutionFilter.persistDeferredUser. A bot's POST is still saved, marked
for deletion. Page views of unsaved visitors are still recorded against their id.
Writes happen on the worker¶
The request thread never writes the user document. VisitorActivityService
publishes a VisitorActivity message to visitor-activity-queue, off the request
thread, and the worker's VisitorActivityListener applies it:
NEW_VISITORis sent after a first-visit request returns 2xx (as before, 404s, redirects and/errordon't create users, nor do the requests above). The worker inserts the document only if it's absent (UserService.createAnonymousUserIfAbsent), and counts the visitor (badger.shop.events,event=visitor) only when its insert creates it, since the message is re-sent until the document exists.ACTIVITYis sent when a returning visitor's last-active date is more than 5 minutes old, or their location changes. It carries only the visit (time and location). The worker reads the stored user, adds the location to their history and applies a targeted$set(UserService.recordVisit). A browse-only request never sees the history, so it can't compute it.
If the broker can't take the message, the publisher applies it directly, so no visitor is lost when RabbitMQ is down.
Anything stateful a visitor does before the worker catches up (adding to the
basket, registering, capturing an email) saves the request's User itself, which
creates the document straight away. The worker's later insert finds it and does
nothing.
Code that loads the current user from the database by id must allow for the
document not existing yet, and fall back to the request's User (see
SetUserWithCapturedDetailsIfNullProcessor).
The basket¶
OrderService.retrieveCurrentOrderOrCreate only looks for a basket when the user
has a currentOrderId. If that basket can no longer be used (typically because
it has been placed) and there's no newer one, currentOrderId is cleared with a
conditional $unset (UserService.clearCurrentOrder). Otherwise every later
request would repeat both lookups until the customer started a new basket.
Logging in¶
On password and returning-OIDC login, VisitorActivityService.linkToAccount moves
what the visitor did anonymously onto their account, off the request thread:
- page views (
PageViewHistoryService.moveHistory, merged in date order), - likes (
LikeService.transferLikes, dropping any the account already has), - recently viewed products (
SiteUsageStatisticsService.mergeRecentlyViewedProducts).
The basket was already merged at login, and a first OIDC login upgrades the anonymous user in place, so neither needs linking.
Page views are only ever written with atomic updates ($push), both by the
worker's PageViewedEventReceiver and by the move, so the two can't overwrite
each other.
Bots¶
BotDetectionService flags bots when their user is created. A bot reading pages
gets no user document at all (see above). One that posts (adding to a basket, a
form) is still created, already marked for deletion, and
MarkedForDeletionOrderAndUserCleaner removes it on its hourly run. See also
Anonymous Visitors and Bots.
Measuring it¶
These appear in performance statistics:
VisitorActivityServiceImplon the web nodes.UserServiceImpl.createAnonymousUserIfAbsentandrecordVisiton the worker.UserServiceImpl.findUserByIdAndSiteContexton the web nodes. It should drop as browse-only visitors start carrying the hint.
Before this work, UserServiceImpl.persistTransientUser ran in the request for
every first-time visitor: 26k times a week in production at about 18 ms each,
most of them one-hit crawlers. Returning visitors each paid a
findUserByIdAndSiteContext read (38k a week at about 14 ms).