Identity, State, Consent and Modes
Definition
This document explains how the browser tracker identifies a device, a session and a tab, how the state header that travels with every chunk is built and kept consistent across tabs, and how consent, tracker modes and debug keys change what is collected and sent.
For how messages are queued and delivered, see Event Pipeline. For the header's field list as the receiver sees it, see Batch State and Contracts.
Why identity is split three ways
Pulse tracks at three levels, and each level needs a different lifetime:
| Identifier | Lifetime | Stored in | Shared between tabs |
|---|---|---|---|
device_id | Until storage and cookies are cleared, or the server replaces it | localStorage paa_did, with cookie fallbacks | yes |
session_id | Until 30 minutes of inactivity, a traffic-source change, or consent acceptance; may be adopted from another Pulse platform | localStorage paa_sid and paa_la (by default) | yes |
tab_id | The life of one browser tab | sessionStorage paa_tab_id | no |
All storage keys carry the storagePrefix (paa_ by default). They are persisted across SDK upgrades, so renaming one orphans every existing user's data.
Device id
DeviceIdService (packages/browser/shared/src/services/device/device-id.service.ts) owns the device id.
Format
New ids are generated by UUIDService as:
a.{c|m}.{base36 millisecond timestamp}.{uuidv4}
│ │
│ └─ entropy source: c = crypto.getRandomValues, m = Math.random
└──── format version
The backend derives the device's first-visit date from the third segment. Ids that were adopted from a cookie, the server or a web-to-mobile host rather than generated here may not have this shape, so code must not assume it.
Lookup order
On start the service takes the first non-empty value from:
- localStorage
paa_did - the unprefixed cookie
paa-did - field 0 of the
pulse-statecookie - a newly generated id
The cookie fallbacks exist because localStorage is sometimes wiped while cookies survive; without them, the same browser would appear as a new device. Whatever is chosen is written back to paa_did.
Overrides and replacement
- Web-to-mobile. If
w2m_meta_info.device_idis present (cookie first, then unprefixed localStorage), it always wins over any other value. See Web To Mobile. - Server replacement. If any response carries a
paa-didheader that differs from the local id,HttpService.replaceDeviceIdpushes ananalytics_statsevent (metric_name: 'device_id_replaced', withcurrent_idandcookie_id) and then adopts the server id. This supports cross-site identity: the receiver can recognise the same browser across Picsart domains and hand back one id. - Cross-tab.
paa_didis watched through storage change streams, so a replacement in one tab reaches the others. The current value is exposed asgetDeviceId$().
The server-replacement design has changed direction more than once. Discuss any change to it with the team before touching replaceDeviceId; it affects identity for every user.
Session id
SessionService (packages/browser/tracker/src/services/session/session.service.ts) owns the session.
Format and storage
session_id = `${Date.now()}_${deviceId}`;
tools-portal-front and session replay parse this shape, so it is a contract. The id is stored under paa_sid, and the last-activity time under paa_la, written at most once per second to keep storage writes cheap. Both live in sessionStorageType storage, which is persistent (localStorage) by default. That is why all tabs of one site share a session.
When a session rotates
A new session starts when any of these is true at the moment the session is read:
| Condition | Renewal reason |
|---|---|
| There is no stored session id | first visit |
More than sessionTimeoutMS (default 1,800,000 ms, 30 min) since last activity | session_reset |
| The traffic source changed: a new UTM set, a click id, or an external referrer | session_reset |
| Consent changed on an opt-in check, or DataGrail tracking switched from off to on | consent_change |
The traffic-source check compares the last traffic source against a hash kept in HashStore under session_traffic_source. A tab's own landing source counts once per tab: TrafficSourceService sets the sessionStorage presence marker paa_tsp ('1') on its first evaluation in each document, even for a direct landing. In a tab that has the marker, a reload or back_forward load is never a new arrival, even after the app rewrote the landing URL with pushState or replaceState; the navigation type comes from PageInfoService.navigationType(), and an unknown type counts as reload. Only a navigate load is a new arrival and may rotate. Without the marker, two tabs that landed from different referrers would keep taking traffic_source back from each other and rotate the shared session on every event. ConsentEventPlugin calls startNewSession('consent_change') when an opt-in check (other than DataGrail) reports a consent change, or when DataGrail tracking switches from off to on.
On renewal, session_start is fired first and then navigation, with source set to the renewal reason. session_start is deduplicated through HashStore and an in-memory session-id guard, because several tabs may notice the same expiry. See session_start for the event itself.
A w2m_meta_info.session_id pins the session: it is used as the session id and the session never expires. SessionStartEventPlugin also skips session_start whenever it is set, so session_start never fires on Web To Mobile pages.
Session handoff
Another Pulse platform can hand its session to a web page through the OAuth state query parameter; see Session Handoff for the format and the producer rules. SessionHandoffService (packages/browser/shared/src/services/session-handoff.service.ts) parses the psid from the raw landing URL, and the SessionService constructor adopts it.
- Timestamp only. Every boot with a fresh psid adopts it (
now − lastActivity < sessionTimeoutMS, and at most 60 s in the future), including a reload, Back and a pasted URL. There is no apply-once marker. - Rejected with no side effects when a Web To Mobile session is present, when
sessionStorageTypeismemory(anonymousandpage-sessionmodes), or when the psid is stale, too far ahead or malformed. - Adopting writes
paa_sidand setspaa_lato now, and primes the traffic-source hash (andpaa_tsp), so the OAuth landing's referrer, UTM or click id does not rotate the session it just adopted. session_startfires, with nosource, only when the handoff changed the storedpaa_sid. The session carries ahandoffverdict (startedorresumed, never sent on the wire) that overrides the usualsession_startdeduplication. An equal id resumes silently.- Back-forward cache. A restored page (
pageshowwithpersisted) does not reboot, so it re-checks the psid with the same rules against the current storedpaa_sid, which another tab may have rotated. Thatsession_starthas no precedingnavigation.
After the handoff the session expires, renews, rotates and syncs as usual. The adopted id embeds the producer's device id while device_id stays local, so never derive one from the other.
Tab id
TabManagerService (packages/browser/shared/src/services/tab/tab-manager.ts) keeps the tab id in sessionStorage paa_tab_id and holds a lock named tab_id_<id> for it. sessionStorage is copied when a user duplicates a tab, so both tabs would start with the same id. The lock breaks the tie: the duplicate cannot acquire the lock and generates its own id.
TabLinkingPlugin and TabInfoService use the tab id to track sibling tabs and the opener tab (opener_tab_id), and the event pipeline uses it to avoid resending batches owned by another open tab. Code that reads a sibling or opener tab's storage must first check that the other tab is same-origin; cross-origin access has broken this more than once.
The state header
StateService (packages/browser/shared/src/services/state.service.ts) holds the object that becomes each chunk's header.
How it behaves
- It is stored in
paa_ssand restored from there on start. setState(partial)shallow-merges into the current object. If the merged result is deep-equal to the current one, nothing happens; otherwise the service creates a new object, persists it, and publishes it on a ReplaySubject.- Storage change streams keep it in sync across tabs. A change event makes the service reload
paa_ssfrom storage rather than trust the event payload, because a page restored from the back-forward cache can receive queued events after it already wrote a newer state. A corrupt or non-object stored value is ignored and the cached state kept.
The new-object-on-change rule is what makes chunking work. Each message captures the state object reference when it is created, and groupByContext groups by that reference, so every chunk carries exactly the header that was in effect for its events.
setState({ user_id: 'u1' }) → state A event1, event2 ┐
setState({ user_id: 'u1' }) → no-op (deep-equal) ├─ chunk { header: A, events: [1,2] }
setState({ user_id: 'u2' }) → state B event3 └─ chunk { header: B, events: [3] }
Who writes it
| Writer | Fields |
|---|---|
StatePlugin | app, market and platform ('web'), version, v (SDK version), debug, infected, and data with language_code, cpus, touch, browser_platform and attr_* client attributes |
SessionService | device_id, session_id (each only when enabled by stateEnrichment) |
pulse.set() / pulse.state() | Any top-level field, for example user_id; language_code is stripped |
pulse.stateData() | Fields merged into data |
| Settings | data.attr_* settings attributes |
ResponseTrackerService | segments and trackable experiments from every settings response |
The last row takes an indirect path. ResponseTrackerService (packages/browser/settings/src/services/response-tracker.service.ts) lives in the settings package, not the tracker, so it does not call the tracker. It pushes to the dataLayer instead:
settings response { settings, experiments, segments }
▼
ResponseTrackerService.trackResponse
│ keep experiments where trackable is set
▼
window.dataLayer.push({ pulse: 'state', segments, experiments })
▼
DataLayerPlugin → tracker.set(...) → StateService.setState
Every chunk therefore carries the segments and experiments the page was actually served. The portal debugger displays both fields from the header.
Batch State lists language_code beside the top-level header fields. In the current tracker StatePlugin writes it as data.language_code, and pulse.set() drops a top-level language_code.
getState() versus getState$()
pulse.getState() returns a copy of the header with tab_id, data.is_w2m and data.referrer added. The raw getState$() stream publishes the stored object, which does not include tab_id. Autopilot reads both and merges them, so a change to either shape affects session recording.
Attributes
Attributes describe the device or user rather than a moment in time.
pulse.attribute({ k: v })is expanded byAttributeSpreadPipeinto one$overwriteoperation per key.$incand$decoperations also exist.- Attributes travel in the same chunk as events, in
attributes, under the same header. ChangedAttributesPipesuppresses a value that has not changed withinattributeTimeoutMS(7 days), so repeated calls on every page load cost nothing.attribute_hints(currentlyuser_agent) let event-receiver derive device attributes on the server instead of parsing them in the browser.DeviceAttributesPluginsends device attributes and hints at most once per 24 hours, and again on every new session.
See Attributes and Attribute Hints for the public API.
Consent
TrackingConsentService (packages/browser/tracker/src/services/consent/consent.service.ts) decides whether the tracker may send data.
Configuration
consentChecks: [
{
type: 'tracking',
mode: 'opt-in', // or 'opt-out'
consent: 'OneTrust', // or 'DataGrail', or { name, handler }
report: true,
},
];
The Worker sets consentChecks per segment; GDPR countries get opt-in.
Providers
| Provider | Reads |
|---|---|
| OneTrust | The OptanonConsent and OptanonAlertBoxClosed cookies |
| DataGrail | DG_BANNER_API, dgEvent and paa_data_grail_consent |
| Web-to-mobile | performance_cookie_accepted in w2m_meta_info; wins when it is a boolean |
| Custom | A handler that returns a value, a Promise or an Observable |
How the decision is computed
trackingEnabled() is a BehaviorSubject:
- The initial value is
trueonly if every check isopt-out. Before any provider answers, an opt-in site sends nothing. - After that, it is the AND of all check results.
- A
pendingresult counts astrueforopt-outandfalseforopt-in.
What consent gates
- the message queue: everything except
Forcepriority is dropped while it isfalse; serverCookiesin the batch config, which isfalsewhile tracking is off;- loading and stopping autopilot.
When consent changes, ConsentEventPlugin sends consent_change with Force priority, so the decision itself is delivered even when the answer is "no". On opt-in it also renews the session and resends attributes. consent_change is not fired on Web To Mobile pages; see Web To Mobile.
Modes
config.mode changes storage types, enrichment and a few flags. It does not switch to different code paths. The overrides are applied by buildConfig in packages/browser/tracker/src/tracker.module.ts, the beforeInit of TrackerModule.
standard | anonymous | page-session | |
|---|---|---|---|
| Storage for session, attributes, tabs, traffic source | persistent / tab | memory | memory |
device_id in header | yes | no | no |
session_id in header | yes | no | yes, new on every page load |
tab_id, referrer, legacy_source enrichment | yes | no | no |
attributeHints | yes | no | no |
serverCookies | yes | no | no |
In the non-standard modes the other StatePlugin fields (app, market, platform, version, v and data.language_code) stay in the header. The Worker forces page-session for editor SDK origins.
In page-session mode the session lives in memory, so every page load gets a new session id. That is the intended behaviour, not a bug.
Debug keys
DebugService (packages/browser/tracker/src/services/debug.service.ts) keeps a set of debug keys in paa_debug. The set is seeded from config.debug; the Worker sets ['debugger'] outside production. config.debugClear empties it.
| Key | Effect |
|---|---|
debugger | Sets header.debug = true, which routes events to the portal debugger and away from production topics. Also forces device_id into the header even when stateEnrichment.device_id is off, because the live debugger filters on device id |
receiver | Sends the request header receiver-debug: true, so event-receiver returns its validation result in the response body. It does not route events to the debugger |
debug-leuid | Live event debug badge and device-id handoff to the portal, enabled by the ?debug-leuid query parameter |
all | Enables every key |
receiver-debug is a request header, and sendBeacon cannot set headers. Batches that fall back to sendBeacon are sent without it.
Where to look in the code
| Concern | Path |
|---|---|
| Device id | packages/browser/shared/src/services/device/device-id.service.ts |
| Id generation | packages/browser/shared/src/services/uuid.service.ts |
| Server id replacement | packages/browser/shared/src/services/http/http.service.ts |
| Session | packages/browser/tracker/src/services/session/session.service.ts |
| Session handoff parsing | packages/browser/shared/src/services/session-handoff.service.ts |
Traffic source and paa_tsp | packages/browser/shared/src/services/traffic.source/traffic-source.service.ts, packages/browser/shared/src/services/page-info.service.ts |
| Tab id | packages/browser/shared/src/services/tab/tab-manager.ts |
| State header | packages/browser/shared/src/services/state.service.ts |
| Default header fields | packages/browser/tracker/src/plugins/state.plugin.ts |
| Segments and experiments into state | packages/browser/settings/src/services/response-tracker.service.ts |
| dataLayer consumer | packages/browser/tracker/src/plugins/data-layer.plugin.ts |
set(), stateData(), getState() | packages/browser/tracker/src/tracker.ts |
| Attribute pipes | packages/browser/tracker/src/pipes/ |
| Consent | packages/browser/tracker/src/services/consent/ |
consent_change | packages/browser/tracker/src/plugins/builtin-events/consent-plugin |
| Modes | packages/browser/tracker/src/tracker.module.ts |
| Debug keys | packages/browser/tracker/src/services/debug.service.ts |
| Web-to-mobile meta info | packages/browser/shared/src/services/w2m.service.ts |