Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tiny-static-script-loader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': minor
---

Add a single-tag browser SDK loader with URL or attribute tokens and optional deferred initialization.
2 changes: 1 addition & 1 deletion packages/browser/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Regenerate only to record intentional, reviewed SDK behavior or fixture/tooling
pnpm --filter posthog-js compatibility:update
```

Review the resulting six-file diff with the code change, then run `compatibility:test` again. Do not regenerate to accept unexplained failures, nondeterminism or lost behavior. Updates require the complete matrix and equal repetitions; focused checks cannot update the expected set. See the suite README for prerequisites, selectors, coverage limits and evidence paths.
Review the resulting seven-file diff with the code change, then run `compatibility:test` again. Do not regenerate to accept unexplained failures, nondeterminism or lost behavior. Updates require the complete matrix and equal repetitions; focused checks cannot update the expected set. See the suite README for prerequisites, selectors, coverage limits and evidence paths.

### Comparing `array.js` bundle size

Expand Down
52 changes: 52 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,58 @@ SDK usage examples and code snippets live in the official documentation so they

- [JavaScript library docs](https://posthog.com/docs/libraries/js)

## Single script tag

```html
<script
src="https://us-assets.i.posthog.com/static/loader.js?project_token=phc_YOUR_PROJECT_TOKEN"
crossorigin="anonymous"
Comment thread
marandaneto marked this conversation as resolved.
Resolved
></script>
```

`crossorigin="anonymous"` is recommended but optional on the loader tag. Without it, the loader still works, but uses the browser's default script request mode and has limited cross-origin error reporting. The loader cannot change the mode of its own request after download. The full SDK script it injects always uses `crossOrigin = 'anonymous'`, regardless of the outer tag.

Use `eu-assets.i.posthog.com` for EU projects. Every project loads the same static file: the loader reads `project_token` from its own URL, falling back to `data-project-token`. Putting the token in the URL path instead would require additional routing, as with Sentry's loader.

The small loader exposes queued public SDK methods, including `people.set` and `people.set_once`, and asynchronously loads an immutable SDK bundle without forwarding the query string. Its method list is generated from the SDK class during the build, so it stays in sync without bundling the SDK itself. Existing snippet and npm installations are unchanged; use only one installation method per page.

Include the tag before application scripts that call `posthog`. Do not add `async` or `defer` if those scripts need the queue immediately. Calls before the full SDK loads are queued; synchronous getters cannot return SDK values yet. The loader does not buffer browser interactions or errors that happen before the SDK installs its listeners.

| Attribute | Purpose |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-project-token` | Project token fallback when the URL has no `project_token` value. |
| `data-api-host` | Ingestion host, including relative proxy paths such as `/ingest`. Inferred for the standard US/EU asset hosts; required for custom asset hosts. |
| `data-defaults` | Defaults snapshot override. If neither this attribute nor `data-config.defaults` is set, the SDK uses `unset`. |
| `data-config` | JSON object with SDK options, applied before initialization. Explicit `data-api-host` and `data-defaults` attributes take precedence. |
| `data-defer-init` | Set to `true` to wait for an explicit `init()` call. Defaults to `false`. |

For consent-gated automatic installation, set `data-config='{"opt_out_capturing_by_default":true}'` on the tag before it executes.

### Deferred initialization

Set `data-defer-init="true"` to download the SDK without automatically initializing it. The default is `false`. Pass the project token and all configuration to an explicit `posthog.init()` call instead. The automatic-init token and configuration attributes are not used in this mode.

```html
<script src="https://us-assets.i.posthog.com/static/loader.js" data-defer-init="true" crossorigin="anonymous"></script>
<script>
posthog.init('phc_TOKEN', {
api_host: 'https://us.i.posthog.com',
defaults: '2026-08-30',
})
posthog.capture('example event')
</script>
```

Call `init()` before other SDK methods. If the SDK is still downloading, initialization and subsequent calls are queued. If it has already downloaded, `init()` uses the normal SDK API. Deferred initialization supports function-valued configuration, such as `before_send`, and named instances. It does not defer downloading the scripts and is separate from HTML's `defer` attribute.

The mutable `/static/loader.js` and `/static/<major>/loader.js` aliases embed their SDK build version and load `/static/<build-version>/array.js`. A cached loader therefore keeps using its matching SDK even when the aliases change. The loader does not fall back to the mutable `/static/array.js` alias.

To pin the SDK, use `/static/<version>/loader.js` for a release that includes the loader. An explicitly versioned loader URL keeps loading its versioned sibling `array.js`. Historical releases without a loader are not changed or backfilled. Reverse proxies and self-hosted deployments must serve the versioned SDK directories as well as the loader, along with the SDK's normal API/config/extension paths.

Fatal automatic-initialization configuration errors are reported even when debug mode is off. Invalid JSON configuration still prevents automatic initialization.

The loader forwards its CSP nonce to the SDK script. Your CSP must still allow the SDK, its extensions, and event ingestion. An existing legacy stub or live global instance takes precedence: the loader never replaces it or switches its project token.

## Surveys and capture

Built-in surveys stay hidden while event capture is disabled, including before consent when `opt_out_capturing_by_default` is enabled. This applies to automatic display, `displaySurvey()` (including `ignoreConditions`), and `renderSurvey()`. Delayed surveys recheck capture before appearing.
Expand Down
1 change: 1 addition & 0 deletions packages/browser/playwright.config.compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export default defineConfig({
'**/session-recording-fetch-sync-config.spec.ts',
'**/session-recording-fetch-rejection-stack.spec.ts',
'**/session-recording-fetch-compat.spec.ts',
'**/static-loader.spec.ts',
],
use: {
...devices['Desktop Chrome'],
Expand Down
16 changes: 10 additions & 6 deletions packages/browser/playwright/compatibility/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Browser deployment compatibility snapshots

This credential-free suite exercises production-packed `posthog-js` through its native snippet, regular npm and slim npm entrypoints. Historical deployments keep published `posthog-js@1.354.0` core bytes for snippet and regular npm, and `posthog-js@1.407.6` for slim with `AllExtensions`. Both load the current checkout's CDN extensions. SDK methods and extension setup are not replaced by the harness.
This credential-free suite exercises production-packed `posthog-js` through its native snippet, regular npm, slim npm and static-loader entrypoints. Historical deployments keep published `posthog-js@1.354.0` core bytes for snippet and regular npm, and `posthog-js@1.407.6` for slim with `AllExtensions`. Both load the current checkout's CDN extensions. SDK methods and extension setup are not replaced by the harness.

## Commands

Expand All @@ -14,7 +14,7 @@ pnpm --filter posthog-js compatibility:self-test
pnpm --filter posthog-js compatibility:controls
pnpm --filter posthog-js compatibility:test

# Explicitly regenerate all six files for code review:
# Explicitly regenerate all seven files for code review:
pnpm --filter posthog-js compatibility:update

# A focused, read-only check (reported as partial):
Expand All @@ -25,15 +25,19 @@ Both test and update build this checkout using its normal production build, pack

Selectors are comma-separated `--engines`, `--modes`, `--comparisons` (`current,historical`) and `--scenarios`. `--repeats` may increase the repeat count, never reduce it below two. `--output /path/to/new-directory` selects a new evidence directory; existing directories are rejected. The default is a unique directory under the ignored repository-root `test-results/compatibility/`. Controls likewise accept `--output`.

A full run comprises 450 cells / 900 executions: two core families × three entrypoints × three engines × 25 scenarios. Browser engines execute in parallel against one prepared input manifest; each engine's cells and repetitions remain sequential. All engines finish and close before the combined matrix is validated. `goldens/` contains exactly six readable JSON files, each with 75 cells. Updates reject partial/incomplete coverage, functional failures, unexpected errors, changed inputs and unequal repetitions before publishing a staged complete directory. Checks never write expected files. Semantic differences in `runs/report.json` identify deployment, browser, scenario, JSON path and expected/actual values. Ordered callbacks and nested payload batches remain ordered.
A full run comprises 525 cells / 1,050 executions: seven supported deployments × three engines × 25 scenarios. Current cores cover snippet, regular npm, slim npm and the static loader; historical cores retain the original three entrypoints because those releases did not ship a loader. `goldens/` contains exactly seven readable JSON files, each with 75 cells. Updates reject partial/incomplete coverage, functional failures, unexpected errors, changed inputs and unequal repetitions before publishing a staged complete directory. Checks never write expected files. Semantic differences in `runs/report.json` identify deployment, browser, scenario, JSON path and expected/actual values. Ordered callbacks and nested payload batches remain ordered.

The `loader` mode dynamically inserts the production-packed `/static/loader.js?project_token=phc_COMPAT` tag with JSON configuration and default automatic initialization. Its native SDK request is held while public calls are queued. A public queue callback observes readiness because auto-init JSON cannot carry a `loaded` function. Each scenario independently requires exactly one loader request and one matching immutable core request with no forwarded query parameters. Deferred initialization remains covered by the focused static-loader Playwright tests.

Browser engines execute in parallel against one prepared input manifest; each engine's cells and repetitions remain sequential. All engines finish and close before the combined matrix is validated.

Follow the [snapshot update criteria in the browser contributor guide](../../CONTRIBUTING.md#deployment-compatibility-snapshots) before regenerating expectations.

## Evidence and reproducibility

Each execution records settings, raw API/UI/replay/network observations, complete HTTP wire evidence (including exact body bytes in base64), normalized observations and backend inspection. Failures retain `failure.json` and a best-effort screenshot. Matrix evidence records actual versions, host, browser versions, source state, tarballs, consumer locks and expected/before/after SHA-256 inventories. Uncommitted source additions, changes and deletions are fingerprinted as the working tree, not attributed to an unchanged HEAD. No staging or stashing is required.

The normalization retains null/missing/explicit undefined/error distinctions, application values, coordinates, privacy markers, identity relationships, decoded replay content and compression versions. Replay network records normalize only finite native clock fields (`startTime`, `endTime`, `timeOrigin`, `timestamp`) and known navigation/resource timing measurements; independent assertions validate the fixture requests' ordering and clock relationship first. Request/response headers, bodies, methods, status, URLs and record order remain observable. Only recognized core/extension metadata is replaced with role tokens after asserting the actual value against its manifest. Raw evidence retains actual versions. Independent HTTP deliveries may reorder; arrays inside delivered batches and callbacks do not. Pending-barrier samples and known CPU/encoded-size diagnostics are preserved raw, with bounded normalization used for comparison. Web Vitals compares delivery and metric structure, not exact FCP values or ratings: finite nonnegative measurements, recognized ratings and SDK metric IDs normalize only inside `$web_vitals` events; raw measurements remain in the evidence.
The normalization retains null/missing/explicit undefined/error distinctions, application values, coordinates, privacy markers, identity relationships, decoded replay content and compression versions. Replay network records normalize only finite native clock fields (`startTime`, `endTime`, `timeOrigin`, `timestamp`) and known navigation/resource timing measurements; independent assertions validate the fixture requests' ordering and clock relationship first. Request/response headers, bodies, methods, status, URLs and record order remain observable. Only recognized core/extension metadata is replaced with role tokens after asserting the actual value against its manifest. The fixture loader tag's recorded `data-config` JSON normalizes local fixture origins while retaining its configuration values; unrelated DOM attributes remain untouched. Raw evidence retains actual versions. Independent HTTP deliveries may reorder; arrays inside delivered batches and callbacks do not. Pending-barrier samples and known CPU/encoded-size diagnostics are preserved raw, with bounded normalization used for comparison. Web Vitals compares delivery and metric structure, not exact FCP values or ratings: finite nonnegative measurements, recognized ratings and SDK metric IDs normalize only inside `$web_vitals` events; raw measurements remain in the evidence.

The execution profile fixes viewport (1024×768), locale, UTC timezone, light color scheme, reduced motion, blocked service workers and the pinned Playwright 1.52 Desktop Chrome/Firefox/Safari user agents. Chrome/Firefox report the profile's Windows desktop OS; Safari reports macOS. OS/device attributes remain observable, including in logs. This profile emulates those desktop user agents; it does not change the host OS or claim complete device emulation. Host-dependent rendering and browser implementation differences must still pass the same snapshots on each supported host. Browser binaries, Playwright code and the native bundler are fingerprinted. Firefox's installation-local `.parentlock` runtime file is excluded from the tooling inventory. Browser/tooling upgrades require reviewed snapshot changes. Interaction and replay-lifecycle scenarios use a paused Playwright clock and synchronize native browser/config execution before advancing timers. The replay network scenario freezes only Date, preserving native `performance.now()`, resource timings and timer scheduling; other scenarios use Playwright's fixed-time clock. Large replay idle intervals advance in bounded steps, draining native HTTP between steps so simulated time cannot race real responses. Only navigation-ending `unload` and `replay-bounce` enable request batching; the HTTPS egress guard remains active.

Expand All @@ -50,11 +54,11 @@ Additional product and replay cases cover:
- Cold start with legacy persisted recording config lacking `cache_timestamp`, held live config, a capture while waiting, preservation of the saved config and recording after release. Startup before live config is observed rather than forced: current cores wait for fresh config, while historical cores use their own policy.
- Recording idle/wake, analytics-session rotation, passive real-navigation unload and minimum-duration gating, with full snapshots, privacy checks and reconstruction of the changed DOM.

Self-tests protect small semantic API/payload changes, nested batch/callback order, exact coverage, repeat equality, historical source attribution and update publication/rollback faults. Live controls first alter an artifact without repinning and require rejection before browser execution. They then pin a throwing survey CDN script and require failures through both current and historical native loaders. A separate control removes `captureLog` from the prepared current consumer and requires log scenarios to fail instead of publishing new expectations. An empty Web Vitals CDN extension must also fail both current and historical delivery scenarios. Disabling Replay header/body capture must fail both core families even when the application requests succeed, preventing an update from accepting missing network records. Expected snapshots remain unchanged in all controls.
Self-tests protect small semantic API/payload changes, nested batch/callback order, exact coverage, repeat equality, historical source attribution and update publication/rollback faults. Live controls first alter an artifact without repinning and require rejection before browser execution. They then pin a throwing survey CDN script and require failures through both current and historical native loaders. A separate control removes `captureLog` from the prepared current consumer and requires log scenarios to fail instead of publishing new expectations. An empty Web Vitals CDN extension must also fail both current and historical delivery scenarios. Disabling Replay header/body capture must fail both core families even when the application requests succeed, preventing an update from accepting missing network records. A throwing static-loader bootstrap must also fail its deployment scenarios. Expected snapshots remain unchanged in all controls.

## Historical coverage and limits

The historical pins are independently verified against their published SHA-512 package integrities and frozen consumer locks. Slim uses `1.407.6`, which preserves private names across its core and extension bundles. All 450 cells must reach full functional readiness and pass the independent behavior gates.
The historical pins are independently verified against their published SHA-512 package integrities and frozen consumer locks. Slim uses `1.407.6`, which preserves private names across its core and extension bundles. All 525 cells must reach full functional readiness and pass the independent behavior gates.

Historical logs API coverage is unavailable. Historical version-fallback cases use `strict_script_versioning: false` and prove only the legacy loader path; current cases use `'fallback'` and require a versioned 404 followed by legacy success and rendered survey UI. Disabled products retain enabled flags. Survey coverage is one open-text popover. Scrolling uses explicit public pageview/pageleave captures around native wheel input, not automatic navigation pageleave. This is not exhaustive SDK, historical-version, quota/retry, survey-type, CJS, touch or production-ingestion coverage. Shared mock routes are synthetic backend responses, not ingestion-service conformance.

Expand Down
Loading
Loading