Skip to main content

Architecture Overview

Definition​

This document is meant for engineers who wish to contribute to the web tracking SDK or just want to find out how it works. It explains how the Pulse web SDK (the pulse-web repository) is put together: what the SDK is responsible for, which systems it talks to, and the core ideas you need before changing it. Every other page in this section goes deeper into one part of the picture.

Who this is for

This section is written for engineers contributing to pulse-web. If you only want to integrate Pulse into an application, start with the Integration Guide.

What the web SDK does​

Pulse is Picsart's first-party analytics platform. In the browser, the SDK has three jobs:

  1. Tracking. It collects events and user attributes, and sends them in batches to event-receiver, the ingestion service. Events come from your code (pulse.event(...)), from the dataLayer, or from the SDK's built-in plugins.
  2. Remote settings. It fetches feature flags, experiments and segments from optifyr and caches them. The values are authored in the Tools portal.
  3. Self-delivery. A Cloudflare Worker serves the SDK itself, and personalises the bundle for every request. The SDK can also load Autopilot, the session recorder.

The same code base also ships wrappers for other environments. @pulse/react runs in the browser, and @pulse/core works both in the browser and on the server. @pulse/server, @pulse/express and @pulse/nest run on the server, and @pulse/mini runs in miniapps.

System map​

                          Tools portal (picsart.tools)
authors ───────────────┬───────────────────────┬──────────── views
event definitions │ settings, experiments │ live debugger · session replay
│ │ segments │ ▲ ▲
▼ ▼ │ │ │
event-definition optifyr ◄── GET /web/v2/{app}/{tag} websocket session-replay
│ ▲ (settings) │ gateway API
│ │ │ ▲ ▲
▼ │ │ debug stream Kafka
event-receiver ◄───────┼── POST t.picsart.com/events/v2/web/{app}/fetch │
validate · enrich │ (events + attributes) replay-receiver
→ Kafka topics │ ▲
│ │ replay metadata
page ─ <script module | nomodule src=optifyr.com/pulse/{app}/…/pulse.js> │
│ Cloudflare Worker (apps/server): segments → config → bundle │
▼ │
window.pulse (tracker + settings) ── getState() / getState$() ──► Autopilot
gzip batches → CDN

Each arrow is a contract: a URL, payload shape, message type or storage key that another system depends on. The Cross-Repository Contracts page lists them all.

A page load, step by step​

  1. The page adds two script tags. It includes the module and nomodule tags for https://optifyr.com/pulse/{app}/…/pulse.js, either written by hand or injected by @pulse/browser's initialize(). Modern browsers load the esnext bundle and older browsers load the es5 one.
  2. The Worker personalises the bundle. It works out which segments the request belongs to, based on origin, country and query parameters. From those segments it builds the configuration: consent provider, settings URL, debug keys, batch sizes and so on. It then returns the full bundle with that configuration embedded.
  3. Calls wait for the SDK. Any calls the page made on window.pulse before the bundle arrived have been waiting in a queue.
  4. The bundle boots. It starts its modules with Pulse's small dependency-injection container, merges the Worker config with the page's own config, builds the pulse instance, and replays the queued calls in order.
  5. The SDK starts working. The tracker restores identity (device_id, session_id, tab_id), checks consent and starts its plugins. That produces session_start and navigation events, clicks and so on. The settings module serves pulse.get(...) and pulse.observe(...) from its cache, and refreshes values from optifyr.
  6. Events are sent in batches. Events are persisted to IndexedDB, grouped into batches of 60 KB or less, and sent to event-receiver. If the page is closed mid-flight, the next page load resends them.
  7. Autopilot may load. If Autopilot is enabled for the page and consent allows it, Pulse loads it. Autopilot reads the Pulse session to label its recording.

Core concepts​

These ten ideas explain most of the design. Each links to the page that covers it in depth.

#ConceptIn shortRead more
1One global, many entry pointswindow.pulse is a callable proxy. Calls made before the SDK loads are queued and replayed in order. Every integration goes through it.SDK Delivery
2The Worker serves the SDK itselfThere is no separate loader. The Worker returns the complete bundle with per-request config embedded. Client config overrides server config.SDK Delivery
3A small custom DI containerPulseModule composes modules one level deep. All modules share one merged configuration object.Packages and DI
4State header plus eventsEvents are never sent on their own. They are grouped under the state (header) they were created with, and event-receiver merges the header into every event.Event Pipeline
5Durable deliveryIndexedDB, then batches of 60 KB or less, then a byte-budgeted queue, then fetch keepalive, sendBeacon or XHR. Retries use backoff, and unsent batches survive a reload.Event Pipeline
6Stable identity formatsdevice_id looks like a.<c or m>.<base36 time>.<uuid>, and session_id is <start ms>_<device_id>. Other systems parse both.Identity and State
7Cross-tab by defaultEvery tab runs its own copy of every service over shared storage. Storage change streams, locks and BroadcastChannel keep the tabs consistent.Identity and State
8Consent gates collectionNothing is queued without tracking consent. The only exception is consent_change itself. Autopilot also follows consent.Identity and State
9Modes change configuration, not codestandard, anonymous and page-session only switch storage types and enrichment flags.Identity and State
10Other systems depend on the SDK silentlyevent-receiver strips undeclared fields and still answers 200. The debugger and Autopilot depend on exact field names.Ecosystem, Contracts

The monorepo at a glance​

apps/server                  Cloudflare Worker: serves the personalised bundles from KV
packages/
common DI container, configuration models, global proxy (no browser code)
browser/
shared storage, cookies, locks, HTTP, device/tab id, state, IndexedDB
tracker-shared plugin & pipe hosts, RUM collectors
tracker the full tracker: session, queue, batching, built-in events
settings remote settings, experiments, segments, cookie sync
mini lightweight tracker for miniapps
loader bundle entry points (all-in-one, mini) and the Autopilot loader
public/
browser, core, react browser wrappers
server, express, nest server-side SDK and middleware
mini npm wrapper for the mini tracker
testing/ JSDOM and Playwright test harnesses

The layering is strict. Lower packages never import higher ones. tracker, settings and mini never import each other; the loader package is the only place where they are combined. See Packages and DI.

Before you change something​

Ask yourself these questions. Most past regressions came from one of them.

  • Does another repository read this? Examples are a header or event field, a query parameter, a storage or cookie key, a postMessage type, a window global or a URL. If so, follow Cross-Repository Contracts.
  • Does it run during bootstrap? Slow work in module init delays the tracker. That regenerates sessions and breaks the e2e tests.
  • What happens with two tabs open? Also consider no localStorage, a hanging IndexedDB, and consent turned off.
  • Can it fire an event twice? Duplicate session_start, navigation and consent_change events are the most common bug class.
  • Does it add weight to every page? Every byte of browser code ships on every page load, and it must also work in the ES5 build.

The Design Decisions and Common Pitfalls page explains the history behind each of these rules.

Pages in this section​

PageWhat it covers
Core ConceptsMessages, state, sessions, collectors, pipes, plugins, services, chunks and batches, visibility, tabs, location history, storage
Monorepo Packages and Dependency InjectionPackage layers, the DI container, how configuration is merged
SDK DeliveryThe Worker, bundles and KV, ES5/ESNext, the global proxy, public wrappers
Event PipelineFrom pulse.event() to the server: pipes, plugins, queues, batching, transport
Identity, State, Consent and ModesDevice, session and tab ids, the state header, attributes, consent, modes, debug keys
Settings InternalsFetching, caching, experiments, segments, preview mode, the pulse-state cookie
The Pulse EcosystemWhat event-receiver, the Tools portal, Autopilot and replay-receiver do with Pulse data
Cross-Repository ContractsThe things that must change in several repositories together
Contributing to pulse-webBuild, tests, CI, releases, conventions and the review checklist
Design Decisions and Common PitfallsSettled decisions, design history, recurring bug classes