Skip to main content

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:

IdentifierLifetimeStored inShared between tabs
device_idUntil storage and cookies are cleared, or the server replaces itlocalStorage paa_did, with cookie fallbacksyes
session_idUntil 30 minutes of inactivity, a traffic-source change, or consent acceptance; may be adopted from another Pulse platformlocalStorage paa_sid and paa_la (by default)yes
tab_idThe life of one browser tabsessionStorage paa_tab_idno

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:

  1. localStorage paa_did
  2. the unprefixed cookie paa-did
  3. field 0 of the pulse-state cookie
  4. 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_id is 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-did header that differs from the local id, HttpService.replaceDeviceId pushes an analytics_stats event (metric_name: 'device_id_replaced', with current_id and cookie_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_did is watched through storage change streams, so a replacement in one tab reaches the others. The current value is exposed as getDeviceId$().
caution

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:

ConditionRenewal reason
There is no stored session idfirst visit
More than sessionTimeoutMS (default 1,800,000 ms, 30 min) since last activitysession_reset
The traffic source changed: a new UTM set, a click id, or an external referrersession_reset
Consent changed on an opt-in check, or DataGrail tracking switched from off to onconsent_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.

note

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 sessionStorageType is memory (anonymous and page-session modes), or when the psid is stale, too far ahead or malformed.
  • Adopting writes paa_sid and sets paa_la to now, and primes the traffic-source hash (and paa_tsp), so the OAuth landing's referrer, UTM or click id does not rotate the session it just adopted.
  • session_start fires, with no source, only when the handoff changed the stored paa_sid. The session carries a handoff verdict (started or resumed, never sent on the wire) that overrides the usual session_start deduplication. An equal id resumes silently.
  • Back-forward cache. A restored page (pageshow with persisted) does not reboot, so it re-checks the psid with the same rules against the current stored paa_sid, which another tab may have rotated. That session_start has no preceding navigation.

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_ss and 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_ss from 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​

WriterFields
StatePluginapp, market and platform ('web'), version, v (SDK version), debug, infected, and data with language_code, cpus, touch, browser_platform and attr_* client attributes
SessionServicedevice_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
Settingsdata.attr_* settings attributes
ResponseTrackerServicesegments 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.

note

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 by AttributeSpreadPipe into one $overwrite operation per key. $inc and $dec operations also exist.
  • Attributes travel in the same chunk as events, in attributes, under the same header.
  • ChangedAttributesPipe suppresses a value that has not changed within attributeTimeoutMS (7 days), so repeated calls on every page load cost nothing.
  • attribute_hints (currently user_agent) let event-receiver derive device attributes on the server instead of parsing them in the browser. DeviceAttributesPlugin sends 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.

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​

ProviderReads
OneTrustThe OptanonConsent and OptanonAlertBoxClosed cookies
DataGrailDG_BANNER_API, dgEvent and paa_data_grail_consent
Web-to-mobileperformance_cookie_accepted in w2m_meta_info; wins when it is a boolean
CustomA handler that returns a value, a Promise or an Observable

How the decision is computed​

trackingEnabled() is a BehaviorSubject:

  • The initial value is true only if every check is opt-out. Before any provider answers, an opt-in site sends nothing.
  • After that, it is the AND of all check results.
  • A pending result counts as true for opt-out and false for opt-in.
  • the message queue: everything except Force priority is dropped while it is false;
  • serverCookies in the batch config, which is false while 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.

standardanonymouspage-session
Storage for session, attributes, tabs, traffic sourcepersistent / tabmemorymemory
device_id in headeryesnono
session_id in headeryesnoyes, new on every page load
tab_id, referrer, legacy_source enrichmentyesnono
attributeHintsyesnono
serverCookiesyesnono

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.

info

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.

KeyEffect
debuggerSets 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
receiverSends 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-leuidLive event debug badge and device-id handoff to the portal, enabled by the ?debug-leuid query parameter
allEnables every key
caution

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​

ConcernPath
Device idpackages/browser/shared/src/services/device/device-id.service.ts
Id generationpackages/browser/shared/src/services/uuid.service.ts
Server id replacementpackages/browser/shared/src/services/http/http.service.ts
Sessionpackages/browser/tracker/src/services/session/session.service.ts
Session handoff parsingpackages/browser/shared/src/services/session-handoff.service.ts
Traffic source and paa_tsppackages/browser/shared/src/services/traffic.source/traffic-source.service.ts, packages/browser/shared/src/services/page-info.service.ts
Tab idpackages/browser/shared/src/services/tab/tab-manager.ts
State headerpackages/browser/shared/src/services/state.service.ts
Default header fieldspackages/browser/tracker/src/plugins/state.plugin.ts
Segments and experiments into statepackages/browser/settings/src/services/response-tracker.service.ts
dataLayer consumerpackages/browser/tracker/src/plugins/data-layer.plugin.ts
set(), stateData(), getState()packages/browser/tracker/src/tracker.ts
Attribute pipespackages/browser/tracker/src/pipes/
Consentpackages/browser/tracker/src/services/consent/
consent_changepackages/browser/tracker/src/plugins/builtin-events/consent-plugin
Modespackages/browser/tracker/src/tracker.module.ts
Debug keyspackages/browser/tracker/src/services/debug.service.ts
Web-to-mobile meta infopackages/browser/shared/src/services/w2m.service.ts