diff --git a/packages/audience/pixel/README.md b/packages/audience/pixel/README.md new file mode 100644 index 0000000000..9f6b52a653 --- /dev/null +++ b/packages/audience/pixel/README.md @@ -0,0 +1,152 @@ +# @imtbl/pixel — Immutable Tracking Pixel + +A drop-in JavaScript snippet that captures device signals, page views, and attribution data for Immutable's events pipeline. Use it to measure campaign performance and attribute player acquisition across your marketing sites, landing pages, and web shops. Zero configuration beyond a publishable key. + +## Quick Start + +Paste this snippet into your site's `` tag: + +```html + +``` + +Replace `YOUR_PUBLISHABLE_KEY` with your project's publishable key from [Immutable Hub](https://hub.immutable.com/). + +The script loads asynchronously and does not block page rendering. The default consent level is `none` — the pixel loads but does not collect until consent is explicitly set (see [Consent Modes](#consent-modes)). To start collecting anonymous device signals immediately, add `"consent":"anonymous"` to the init options: + +```diff +- w[i].push(["init",{"key":"YOUR_PUBLISHABLE_KEY"}]); ++ w[i].push(["init",{"key":"YOUR_PUBLISHABLE_KEY","consent":"anonymous"}]); +``` + +## Consent Modes + +The `consent` option controls what the pixel collects. **Default is `none`** (no events fire until consent is set). + +| Level | What's collected | Cookies set | Use case | +|-------|-----------------|-------------|----------| +| `none` | Nothing — pixel loads but is inert | None | Before consent banner interaction | +| `anonymous` | Device signals, attribution, page views, form submissions, link clicks (no PII) | `imtbl_anon_id`, `_imtbl_sid` | Anonymous analytics without PII | +| `full` | Everything in `anonymous` + hashed email capture from form submissions (for identity matching) | `imtbl_anon_id`, `_imtbl_sid` | After explicit user consent for marketing/ads | + +### Automatic consent detection + +If your site uses a Consent Management Platform (CMP), the pixel can auto-detect consent state. Set `consentMode` to `'auto'` instead of setting `consent` directly: + +```diff +- w[i].push(["init",{"key":"YOUR_KEY","consent":"anonymous"}]); ++ w[i].push(["init",{"key":"YOUR_KEY","consentMode":"auto"}]); +``` + +> **Note:** `consentMode` and `consent` are mutually exclusive — do not set both. + +The pixel starts in `none` and checks for these CMP standards (in priority order): + +1. [**Google Consent Mode v2**](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced) — reads `analytics_storage` and `ad_storage` from `window.dataLayer` +2. [**IAB TCF v2**](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md) — reads purpose consents via `window.__tcfapi` + +Once a CMP is detected, the pixel upgrades consent automatically and continues listening for changes (e.g. when a user updates their cookie preferences). If no CMP is detected after ~2.5 seconds, the pixel remains in `none` silently (there is no failure callback). If your CMP may not be present on every page, push a manual fallback on your own timeout: + +```javascript +setTimeout(function() { + window.__imtbl.push(['consent', 'anonymous']); +}, 3000); +``` + +### Updating consent at runtime + +If you are not using `consentMode: 'auto'`, you can set consent manually at any time: + +```javascript +// After cookie banner interaction — upgrade to full +window.__imtbl.push(['consent', 'full']); + +// Or downgrade (purges PII from queue) +window.__imtbl.push(['consent', 'none']); +``` + +## Auto-Tracked Events + +All events fire automatically with no instrumentation required. + +| Event | When it fires | Key properties | +|-------|--------------|----------------| +| `page` | Every page load | UTMs, click IDs (`gclid`, `fbclid`, `ttclid`, `msclkid`, `dclid`, `li_fat_id`), `referral_code`, `landing_page` | +| `session_start` | New session (no active `_imtbl_sid` cookie) | `sessionId` | +| `session_end` | Page unload (`visibilitychange` / `pagehide`) | `sessionId`, `duration` (seconds) | +| `form_submitted` | HTML form submission | `formAction`, `formId`, `formName`, `fieldNames`. `emailHash` at `full` consent only. | +| `link_clicked` | Outbound link click (external domains only) | `linkUrl`, `linkText`, `elementId`, `outbound: true` | + +### Disabling specific auto-capture + +```html + +``` + +## Cookies + +| Cookie | Lifetime | Purpose | +|--------|----------|---------| +| `imtbl_anon_id` | 2 years | Anonymous device ID (shared with web SDK) | +| `_imtbl_sid` | 30 minutes (rolling) | Session ID — resets on inactivity | + +Both cookies are first-party (`SameSite=Lax`, `Secure` on HTTPS). + +## Content Security Policy (CSP) + +If your site uses a Content-Security-Policy header, add these origins to the relevant directives: + +``` +script-src ... https://cdn.immutable.com; +connect-src ... https://api.immutable.com; +``` + +These must be added alongside your existing policy values, not replace them. + +For nonce-based CSP, add the nonce to the inline ` +``` + +Note: the nonce covers the inline snippet only. The CDN-loaded script (`imtbl.js`) is covered by the `script-src https://cdn.immutable.com` directive. + +## Browser Support + +| Browser | Minimum Version | +|---------|----------------| +| Chrome | 80+ | +| Firefox | 78+ | +| Safari | 14+ | +| Edge | 80+ | diff --git a/packages/audience/pixel/scripts/validate-cdn.sh b/packages/audience/pixel/scripts/validate-cdn.sh new file mode 100755 index 0000000000..e3a45f5439 --- /dev/null +++ b/packages/audience/pixel/scripts/validate-cdn.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# Validate the pixel CDN bundle is deployed and within budget. +# +# Usage: +# ./scripts/validate-cdn.sh [URL] +# +# Defaults to the production CDN URL if no argument is provided. + +set -euo pipefail + +CDN_URL="${1:-https://cdn.immutable.com/pixel/v1/imtbl.js}" +MAX_GZIP_BYTES=10240 # 10 KB — must match bundlebudget.json +WARN_GZIP_BYTES=8192 # 8 KB + +PASS=0 +FAIL=0 + +TMPFILE=$(mktemp) +TMPHEADERS=$(mktemp) +trap 'rm -f "$TMPFILE" "$TMPHEADERS"' EXIT + +pass() { echo " ✓ $1"; PASS=$((PASS + 1)); } +fail() { echo " ✗ $1"; FAIL=$((FAIL + 1)); } + +echo "Validating pixel bundle: $CDN_URL" +echo "" + +# --- Fetch the bundle (single request for body + headers) --- +HTTP_CODE=$(curl -s --connect-timeout 10 --max-time 30 -D "$TMPHEADERS" -o "$TMPFILE" -w '%{http_code}' "$CDN_URL") +CONTENT_TYPE=$(grep -i '^content-type:' "$TMPHEADERS" | tr -d '\r' | awk '{print $2}') + +# --- HTTP status --- +echo "HTTP Response:" +if [ "$HTTP_CODE" = "200" ]; then + pass "Status: $HTTP_CODE" +else + fail "Status: $HTTP_CODE (expected 200)" +fi + +# --- Content-Type --- +if echo "$CONTENT_TYPE" | grep -qi 'javascript'; then + pass "Content-Type: $CONTENT_TYPE" +else + fail "Content-Type: $CONTENT_TYPE (expected application/javascript)" +fi + +# --- Bundle size --- +RAW_BYTES=$(wc -c < "$TMPFILE" | tr -d ' ') +GZIP_BYTES=$(gzip -c "$TMPFILE" | wc -c | tr -d ' ') + +echo "" +echo "Bundle Size:" +echo " Raw: $RAW_BYTES bytes ($(awk -v b="$RAW_BYTES" 'BEGIN{printf "%.1f", b/1024}') KB)" +echo " Gzip: $GZIP_BYTES bytes ($(awk -v b="$GZIP_BYTES" 'BEGIN{printf "%.1f", b/1024}') KB)" + +if [ "$GZIP_BYTES" -le "$MAX_GZIP_BYTES" ]; then + if [ "$GZIP_BYTES" -le "$WARN_GZIP_BYTES" ]; then + pass "Under budget ($GZIP_BYTES / $MAX_GZIP_BYTES bytes gzipped)" + else + pass "Under max budget but above warning threshold ($GZIP_BYTES / $WARN_GZIP_BYTES warn, $MAX_GZIP_BYTES max)" + fi +else + fail "Over budget! $GZIP_BYTES bytes gzipped exceeds $MAX_GZIP_BYTES limit" +fi + +# --- Content markers --- +# These patterns are chosen to avoid false positives in minified code. +echo "" +echo "Content Checks:" +if grep -q '__imtbl' "$TMPFILE"; then + pass "Contains __imtbl global" +else + fail "Missing __imtbl global" +fi + +if grep -q '"pixel"' "$TMPFILE" || grep -q "'pixel'" "$TMPFILE"; then + pass "Contains 'pixel' surface string literal" +else + fail "Missing 'pixel' surface string literal" +fi + +if grep -q 'session_start' "$TMPFILE"; then + pass "Contains session_start event" +else + fail "Missing session_start event" +fi + +if grep -q 'form_submitted' "$TMPFILE"; then + pass "Contains form_submitted event" +else + fail "Missing form_submitted event" +fi + +# --- Summary --- +echo "" +echo "Results: $PASS passed, $FAIL failed" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi