Skip to main content

Cross-Repository Contracts

Definition​

This document explains the contracts between pulse-web and the systems that consume its output: event-receiver, the optifyr settings service, tools-portal-front, autopilot, and older SDK versions that share storage on the same device. Each contract listed here must change on both sides together, or not at all.

Why contracts fail silently​

Pulse is a producer. Almost everything it emits is parsed by another system, and none of those systems complain loudly when the shape drifts:

  • event-receiver answers HTTP 200 and strips unknown fields. It validates events against definitions stored in tools-portal with Ajv removeAdditional: 'all', so a field the portal does not know about simply disappears.
  • tools-portal-front ignores postMessage messages it does not recognise.
  • autopilot reads a missing state field as an empty string and keeps recording.
  • replay-receiver drops invalid metadata and still answers HTTP 200.

Unit and e2e tests in pulse-web only prove that pulse-web sends what pulse-web expects. They cannot prove that the other side still understands it. That is why every contract change needs a counterpart check, and why an HTTP 200 proves nothing.

caution

Browsers keep running cached bundles, and integrators keep running pinned npm versions, for a long time after a release. Old and new SDK versions share cookies, localStorage and IndexedDB on the same device. A rename that looks atomic in a merge request is never atomic in production.

Naming conventions on the wire​

Wire field names are snake_case (session_id, country_code, frame_id). The one exception is the batch envelope, which is camelCase (config.deviceId, attributeHints). Keep both as they are. Do not "fix" either convention, because the receiving side depends on it.

A change of type (string to number, string to boolean) is a contract change even when the name stays the same.

Events to event-receiver​

Events are sent as POST requests to {serverBaseURL}/{app}/{transport}, where transport is fetch, xhr or beacon and serverBaseURL is https://t.picsart.com/events/v2/web. The body is an array of state-header chunks; see Event Pipeline for how they are built.

Contractpulse-web sideOther side (event-receiver)
URL path and the transport suffix (fetch, xhr or beacon)tracker/src/tracker.configuration.ts, shared/src/services/http/http.service.tsapps/instance/src/controllers/web.v2.controller.ts; CORS is configured only on the three-segment form
Body: array of PulseBatchChunk with config, header, events, attributes, attributeHintscommon/src/sdks/tracker/models/batch.ts, common/src/sdks/tracker/transport/group-by-context.tsdata/web-v2-request-schema.ts, models/web/web-session.ts
config.deviceId, config.sessionId, config.tabId storage types (persistent, tab, memory, none) and config.serverCookies (boolean or 'same-origin')tracker/src/transport/batch-helper.ts (getTrackerBatchConfig)Controls server cookie setting and server-side session override
Header fields device_id, session_id, app, market, platform, v, version, debug, user_id, tier_id, infected[], segments, experiments, datatracker/src/plugins/state.plugin.ts, tracker/src/services/session/session.service.ts, shared/src/services/state.service.ts; segments and trackable experiments via settings/src/services/response-tracker.service.ts and a dataLayer pulse: 'state' pushlibs/sessions/src/models/session-header.ts
Required per-event fields v, version, session_id, country_code, device_id, platform, market, event_type, timestamp (integer milliseconds)StatePlugin, SessionService, EventGlobalsPipe; the SDK does not set country_code itselflibs/definitions/src/schemas/event-schema-base.ts; the receiver falls back to cf-ipcountry
Built-in event names and their data fields (navigation, session_start, click, popup, consent_change, experiment_*, analytics_stats, rum, and others)tracker/src/plugins/*Must exist as event definitions in tools-portal, or the fields are stripped
click_source.navigate_label (string) on click and navigation: the pulse-navigate attribute value of a declared click, on the click itself and on the navigation it causes, in the same tab or in the new tab it openstracker/src/services/activity.tracker/declared-navigation.utils.ts, tracker/src/plugins/builtin-events/click-event.plugin.ts, tracker/src/services/navigation-event.service.tsThe tools-portal click and navigation definitions need click_source.navigate_label (STRING) before or with the SDK release that sends it; until then the receiver drops it and still answers 200. See Declared Navigation Source
Legacy renames _experiment_participate, _app_load, tier_idThe SDK sends experiment_participate; tier_id comes from statelibs/events/src/normalizer/provider.ts
Request header receiver-debugtracker/src/services/transport/transport.tsThe receiver echoes its ProcessResult
header.debug === true routes to debug-only processingStatePlugin (debug key debugger)event-processor.service.ts
Unprefixed cookie and response header paa-didDeviceIdService, HttpService.replaceDeviceIdThe receiver sets both; CORS exposes paa-did
Device id format a.{c,m,s}.{base36 ts}.{uuid}shared/src/services/uuid.service.ts (browser uses c and m), public/server/src/services/attribute-parser.service.ts (s)The receiver decodes first_visit_date from segment 3 (attribute-hints.service.ts); the portal validates with ^[\w.-]{4,128}$
Session id ${ms}_${deviceId}tracker/src/services/session/session.service.tsPortal session-route-id.util.ts; replay data. After a session handoff (below) the session_id embeds another platform's device id, so never derive device_id from session_id

Settings to optifyr​

The settings SDK requests GET {settingsBaseURL}/{app}/{tag}. See Settings Internals for caching and deadlines.

Contractpulse-web side
Path /{app}/{tag} and the query keys did, uid, tid, dltid, ur, is-test, versioncode, exp, debug-exp, ref, cidp, usrc, umed, ucmp, ucnt, attr-*settings/src/services/settings/settings-fetch.helper.ts; the server SDK builds the same request in public/server/src/services/transport/request-builder.ts
Response shape {settings, experiments, segments}settings/src/services/settings/
debug-exp={id}:{variant}settings/src/services/experiment-debug.service.ts

tools-portal-front​

The portal drives two debugging flows through query parameters and postMessage. See Ecosystem for how the debugger works end to end.

Contractpulse-web sidetools-portal-front side
Query parameter ?debug-leuid (its value is ignored)tracker.configuration.ts (debugLiveEventKey), plugins/live-event-debug.plugin.tsmain/debugger/debugger.component.ts
postMessage of {type: 'pulse/live-debug-device-id', deviceId} to https://picsart.tools through window.openertracker/src/services/live-event-debug.service.tsmain/debugger/device-id-handoff.ts, which checks that the origin equals the app.webBaseUrl origin
Query parameter ?pulse-debug-exp= carrying a JSON array of app, id, name, variant and return URL; the app must equal the SDK appsettings/src/services/experiment-debug.service.tsmain/experiments/experiment-utils.ts (buildDebugURL)
Debugger stream fields commonEvent.device_id, debug, experiments, segments, vStatePlugin, SessionService; experiments and segments from ResponseTrackerService through the dataLayer pulse: 'state' pushmain/debugger/debugger.interfaces.ts

autopilot​

The loader injects the autopilot session-recording script and shares Pulse state with it. Several types here are hand-copied between the repositories, so check them against the autopilot source, not against memory.

Contractpulse-web sideautopilot side
window.pulse.getState() and getState$(), with the fields session_id, device_id, user_id, app, country_code, tab_id, versioncommon/src/pulse/proxy.ts, shared/src/services/state.service.tspackages/browser/src/main/services/pulse-provider.service.ts, session-orchestrator.service.ts
Globals window.autopilot, window.autopilotOptions, the internal object window.autopilot.__ (fields q, c, idu, t, v, b, m, stopRequested) and autopilot.stop()loader/src/bundles/autopilot.loader.tspackages/browser/src/main/entrypoint.ts, main.module.ts, globals.d.ts
Config in __.c: app, mode (standard, iframe or mobile), initialFrameStateAutopilotMode in the loaderBrowserMode in main/configuration.ts
postMessage of type autopilot/set-frame-id with a frameState of frame_id, session_id, device_id, tab_id, app, version, country_code, user_id, session_start_timeAUTOPILOT_SET_FRAME_ID_TYPE, AutopilotFrameState (hand-copied)main/models/frame-messaging.ts (FrameStatePayload)
URL layout /ap/web/v1/sdk/{app}/ap.{variant}.js (variant is modern or compat) and worker apw.{variant}.js; the duplicate-script check looks for ap-worker and ap/webLoaderautopilot worker
Modern or compat detection must match autopilot's worker selection, including the type of __.mbrowserModernity in the loadermain/globals.d.ts, worker.service.ts
Remote settings pulse_loading_config and autopilot_config.tracking_enabledall-in-one.bundle.tsAuthored in the portal
note

The type of __.m currently differs between the two sides (the loader sends a string, autopilot expects a boolean). It is a tracked open issue. Fix both sides together.

Session handoff from other Pulse platforms​

Another Pulse platform hands its session to the web through the OAuth state query parameter. See Session Handoff for the integrator and producer view, and Identity and State for the adoption rules. The landing URL carries:

state=<nonce>.<base64url(returnPath)>.<base64url("psid=<lastActivityMs>|<sessionId>")>

The producer builds the psid value as `${Date.now()}|${(await pulse.getState()).session_id}`.

Contractpulse-web side (reader)Producer side
Landing query state: 3 or more segments, the psid in the 3rd, later segments ignored, padding up to two =. psid is read through URLSearchParams (a raw pipe or %7C). lastActivityMs has 13 digits; sessionId is <13-digit startMs>_<device part> with startMs ≤ lastActivityMs and a device part of 4–128 [A-Za-z0-9._-]shared/src/services/session-handoff.service.ts (parse), tracker/src/services/session/session.service.ts (adopt)The producer's OAuth flow builds the psid from Date.now() and its own session_id. Anything invalid is ignored
Freshness: adopted on every boot while now − lastActivityMs < sessionTimeoutMS and at most 60 s in the futureSessionServicelastActivityMs comes from the producer's clock and is compared with this device's clock
  • An adoption that changes the stored session id fires session_start with no source and no renewal navigation. Re-adopting the same id fires nothing.
  • A back-forward-cache restore re-checks the psid; a session_start fired there has no preceding navigation, so consumers must not require one.
  • Autopilot reads pulse.getState() after the tracker boots, so it records the adopted session_id from the start.

These names are read by other SDK versions on the same device, by @pulse/server during SSR, and by legacy integrations. See Identity and State for how each value is resolved.

NameWhere it is definedNotes
paa_ prefixstoragePrefixThe Worker sets it too. Don't change it
pulse-state cookie, 16 pipe-separated fieldscommon/src/context/state.ts, settings/src/services/cookie-sync.service.tsRead by @pulse/server. The field order is a contract
paa-did (unprefixed), w2m_meta_info, OptanonConsent, _fbpUnPrefixedCookieStorageTokenLook these up with unprefixed storage, or you read keys like paa_paa-did
paa_did, paa_sid, paa_la, paa_ss, paa_tab_id, paa_debug, paa_debug-exp, paa_experiments, paa_config_overrideVarious servicesPersisted across SDK upgrades. A rename orphans existing users' data
paa_tsp in sessionStorageTrafficSourceServiceA per-tab presence marker ('1'). It lives only as long as the tab, so it is safe to rename or drop; older bundles ignore it
paa_tab_activity_{tabId} (each tab's recent clicks and context menus), paa_tab_declared_navigation_{tabId}, paa_tab_new_tab_taken_{tabId}; sessionStorage paa_tab_opener, paa_first_navigationtracker/src/services/activity.synchronizer/activity-synchronizer.ts, tracker/src/services/tabs/tab-opener-detector.tsEvery SDK version on the site reads every tab's paa_tab_activity_* entries to guess a navigation's source and sends an entry's type as click_source.type, so a new entry type reaches the wire through older SDKs. A declared middle-click is stored as type: 'click' with auxClick: true for that reason. See Declared Navigation Source
IndexedDB pulseDBN version 2 with eventsStore, batchesStore, settingsStore, attributesStoreshared/src/services/indexedDBA schema change needs an upgrade path
window.pulse, θ, window.pulseInit, window.pulseReady, window.dataLayer, window.pulse.tracker, window.pulse.settingsproxy.ts, legacy-on-ready.tsLegacy integrations use these

For the storage keys as seen by integrators, see the Storage Usage section of the Web SDK docs and Web To Mobile for w2m_meta_info.

Checklist for a contract change​

Follow these steps for any change that touches a row on this page.

  1. Find every consumer. Grep the sibling repositories (event-receiver, tools-portal-front, autopilot, replay-receiver) for the literal name, and note the consuming file.
  2. Stay backward compatible. Keep the old name or shape working until the new one is deployed everywhere. Keep reading the old storage key, and keep emitting the old field, until every consumer has moved. Cached bundles and old npm versions keep running for a long time.
  3. Plan the order. Decide whether the counterpart must change first, together, or after, and link its merge request from yours.
  4. Define new event fields first. A new data field on a built-in event must be added as an event definition in tools-portal for that app before release, or event-receiver strips it.
  5. Verify end to end. Check the event in the portal debugger. An HTTP 200 proves nothing.
  6. Update this page in the same change.
tip

A breaking change to a public npm API (@pulse/browser, @pulse/react, @pulse/core, @pulse/server, @pulse/express, @pulse/nest, @picsart/pulse-common, @picsart/pulse-mini) is also a contract change. It needs a BREAKING CHANGE footer; see Contributing.

Where to look in the code​

Areapulse-web path
Batch envelope and groupingpackages/common/src/sdks/tracker/models/batch.ts, packages/common/src/sdks/tracker/transport/group-by-context.ts
Batch config (storage types, server cookies)packages/browser/tracker/src/transport/batch-helper.ts
Header and state fieldspackages/browser/tracker/src/plugins/state.plugin.ts, packages/browser/shared/src/services/state.service.ts
Transport and paa-did handlingpackages/browser/shared/src/services/http/http.service.ts
Id formatspackages/browser/shared/src/services/uuid.service.ts, packages/browser/tracker/src/services/session/session.service.ts
Session handoffpackages/browser/shared/src/services/session-handoff.service.ts, packages/browser/tracker/src/services/session/session.service.ts
Settings requestpackages/browser/settings/src/services/settings/settings-fetch.helper.ts
Portal debug flowspackages/browser/tracker/src/services/live-event-debug.service.ts, packages/browser/settings/src/services/experiment-debug.service.ts
Autopilot integrationpackages/browser/loader/src/bundles/autopilot.loader.ts
pulse-state cookiepackages/common/src/context/state.ts, packages/browser/settings/src/services/cookie-sync.service.ts
Global proxypackages/common/src/pulse/proxy.ts