9 Commits

Author SHA1 Message Date
Elijah Duffy
99c1f003c6 0.0.5 2025-12-18 10:33:25 -08:00
Elijah Duffy
97497db8e4 pixel: don't send events in dev mode without a test code 2025-12-18 10:33:17 -08:00
Elijah Duffy
674663b027 0.0.4 2025-12-18 10:23:17 -08:00
Elijah Duffy
e7b12f50b1 pixel: improve fetching existing PixelControls 2025-12-18 10:23:12 -08:00
Elijah Duffy
927b02d30e 0.0.3 2025-12-17 22:19:25 -08:00
Elijah Duffy
45a6bda53e bump required svelte version 2025-12-17 22:19:11 -08:00
Elijah Duffy
9a72280737 meta pixel: default to disablePushState = true
Breaks SvelteKit SPA which doesn't allow use of history API, requiring
its own wrapper to be used instead.
2025-12-17 22:19:05 -08:00
Elijah Duffy
095462c80d tracking manager: add localStorage persistence 2025-12-17 22:18:29 -08:00
Elijah Duffy
bb92e25485 meta pixel: more robust loading & graceful failure with adblockers 2025-12-16 21:04:32 -08:00
5 changed files with 291 additions and 94 deletions

View File

@@ -4,7 +4,7 @@
"type": "git", "type": "git",
"url": "https://gitea.auvem.com/svelte-toolkit/spectator.git" "url": "https://gitea.auvem.com/svelte-toolkit/spectator.git"
}, },
"version": "0.0.2", "version": "0.0.5",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -36,7 +36,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"svelte": "^5.0.0" "svelte": "^5.40.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.4.0", "@eslint/compat": "^1.4.0",

View File

@@ -1,11 +1,36 @@
<!-- @component
MetaPixel integrates the Meta (Facebook) Pixel into your Svelte application,
allowing you to track page views and custom events while respecting user consent
for tracking. The component manages the lifecycle of the Meta Pixel script and
PixelControl interface.
The PixelControl class also allows you to directly manage multiple Pixel
instances and handle event tracking with optional test event codes without
using the MetaPixel component.
-->
<script lang="ts" module> <script lang="ts" module>
export type PixelControlOptions = {
/**
* if provided, events fired will always have this code attached
* to prevent them from polluting real analytics data.
*/
testEventCode?: string;
/** Advanced matching data */
advancedMatching?: AdvancedMatching;
/** Initialization options */
initOptions?: InitOptions;
};
export class PixelControl { export class PixelControl {
private _pixelID: string; private _pixelID: string;
private _testEventCode?: string = undefined; private _testEventCode?: string = undefined;
private _trackingManager: MaybeGetter<TrackingManager | undefined>; private _trackingManager: MaybeGetter<TrackingManager | undefined>;
private static _baseLoaded: boolean = false; private static _baseLoaded: boolean = false;
private static _registeredPixels: Record<string, PixelControl> = {};
/** Indicates whether the Meta Pixel base script has been loaded. */
static get baseLoaded(): boolean { static get baseLoaded(): boolean {
return this._baseLoaded; return this._baseLoaded;
} }
@@ -16,7 +41,7 @@
* @throws Error if the Meta Pixel API is not loaded. * @throws Error if the Meta Pixel API is not loaded.
*/ */
static loadGuard(): void { static loadGuard(): void {
if (!this._baseLoaded || !window._fbq) { if (!this._baseLoaded || !window.fbq) {
throw new Error('Meta Pixel API has not been loaded. Call PixelControl.load() first.'); throw new Error('Meta Pixel API has not been loaded. Call PixelControl.load() first.');
} }
} }
@@ -24,19 +49,22 @@
private constructor( private constructor(
trackingManager: MaybeGetter<TrackingManager | undefined>, trackingManager: MaybeGetter<TrackingManager | undefined>,
pixelID: string, pixelID: string,
testEventCode?: string options?: PixelControlOptions
) { ) {
this._trackingManager = trackingManager; this._trackingManager = trackingManager;
this._pixelID = pixelID; this._pixelID = pixelID;
this._testEventCode = testEventCode; this._testEventCode = options?.testEventCode;
} }
/** Loads the Meta Pixel base script. */ /** Loads the Meta Pixel base script. */
static load() { static async load() {
if (this._baseLoaded && window._fbq) return; if (this._baseLoaded && !!window.fbq) return;
if (!window._fbq) { if (!window.fbq) {
PixelControl.revokeConsent(); // Initialize without consent try {
loadMetaPixel(); // Load the Meta Pixel script await loadMetaPixel(); // Load the Meta Pixel script
} catch (e) {
log.warn('Failed to load Meta Pixel script, all events will be queued.', e);
}
} }
this._baseLoaded = true; this._baseLoaded = true;
log.debug('Meta Pixel base script loaded.'); log.debug('Meta Pixel base script loaded.');
@@ -57,43 +85,55 @@
} }
/** /**
* Returns a PixelControl instance for the given Meta Pixel ID. If * Registers a PixelControl instance for the given Meta Pixel ID. If
* the base Meta Pixel script has not been loaded yet, it will be * the base Meta Pixel script has not been loaded yet, it will be
* loaded automatically. Optionally sets a test event code for the Pixel. * loaded automatically. Optionally sets a test event code for the Pixel.
* Does NOT initialize the Pixel; call `fireInit()` on the returned instance * Should only be called once for each Pixel ID, use PixelControl.get()
* before tracking events. * to retrieve existing instances.
* @param trackingManager Tracking manager to handle user consent for tracking * @param trackingManager Tracking manager to handle user consent for tracking
* @param pixelID Meta Pixel ID * @param pixelID Meta Pixel ID
* @param options Optional settings * @param options Optional settings
* @returns PixelControl instance * @returns PixelControl instance
*/ */
static for( static initialize(
trackingManager: MaybeGetter<TrackingManager | undefined>, trackingManager: MaybeGetter<TrackingManager | undefined>,
pixelID: string, pixelID: string,
options?: { options?: PixelControlOptions
/**
* if provided, events fired will always have this code attached
* to prevent them from polluting real analytics data.
*/
testEventCode?: string;
}
): PixelControl { ): PixelControl {
// Load the base script if not already loaded
PixelControl.load(); PixelControl.load();
return new PixelControl(trackingManager, pixelID, options?.testEventCode);
// Check for existing PixelControl instance
if (this._registeredPixels[pixelID]) {
log.warn(
`PixelControl instance for Meta Pixel ID: ${pixelID} already exists. Returning existing instance.`
);
return this._registeredPixels[pixelID];
}
// Create and register the PixelControl instance
const pixel = new PixelControl(trackingManager, pixelID, options);
this._registeredPixels[pixelID] = pixel;
// Fire initialization
window.fbq('init', pixel._pixelID, options?.advancedMatching, options?.initOptions);
log.debug(`Meta Pixel [${pixel._pixelID}] initialized.`);
return pixel;
} }
/** /**
* Initializes this pixel with the Meta Pixel API including any advanced * Returns an existing PixelControl instance for the given Meta Pixel ID.
* matching data and options. * @param pixelID Meta Pixel ID
* @param advancedMatching Advanced matching data * @returns PixelControl instance
* @param initOptions Initialization options * @throws Error if no PixelControl instance is found for the given ID.
* @returns this PixelControl instance
*/ */
fireInit(advancedMatching?: AdvancedMatching, initOptions?: InitOptions): PixelControl { static get(pixelID: string): PixelControl {
PixelControl.loadGuard(); const pixel = this._registeredPixels[pixelID];
window.fbq('init', this._pixelID, advancedMatching, initOptions); if (!pixel) {
log.debug(`Meta Pixel [${this._pixelID}] initialized.`); throw new Error(`No PixelControl instance found for Meta Pixel ID: ${pixelID}`);
return this; }
return pixel;
} }
/** /**
@@ -114,10 +154,17 @@
*/ */
pageView() { pageView() {
if (!this.consentGuard()) return; if (!this.consentGuard()) return;
window.fbq('track', 'PageView', undefined, { test_event_code: this._testEventCode }); // Send the PageView event
log.debug( if (!dev || this._testEventCode) {
`Meta Pixel [${this._pixelID}] PageView event sent (test code: ${this._testEventCode}).` window.fbq('track', 'PageView', undefined, { test_event_code: this._testEventCode });
); log.debug(
`Meta Pixel [${this._pixelID}] PageView event sent${dev && ` (test code: ${this._testEventCode})`}.`
);
} else {
log.info(
`Meta Pixel [${this._pixelID}] PageView event not sent in development mode without a test event code.`
);
}
} }
/** /**
@@ -126,13 +173,19 @@
*/ */
track<K extends StandardEventName>(event: K, params?: EventParamsByName[K], eventID?: string) { track<K extends StandardEventName>(event: K, params?: EventParamsByName[K], eventID?: string) {
if (!this.consentGuard()) return; if (!this.consentGuard()) return;
window.fbq('trackSingle', this._pixelID, event, params, { if (!dev || this._testEventCode) {
eventID, window.fbq('trackSingle', this._pixelID, event, params, {
test_event_code: this._testEventCode eventID,
}); test_event_code: this._testEventCode
log.debug( });
`Meta Pixel [${this._pixelID}] ${event} event sent (test code: ${this._testEventCode}).` log.debug(
); `Meta Pixel [${this._pixelID}] ${event} event sent${dev && ` (test code: ${this._testEventCode})`}.`
);
} else {
log.info(
`Meta Pixel [${this._pixelID}] ${event} event not sent in development mode without a test event code.`
);
}
} }
/** /**
@@ -141,13 +194,19 @@
*/ */
trackCustom(event: string, params?: CommonParams & CustomParams, eventID?: string) { trackCustom(event: string, params?: CommonParams & CustomParams, eventID?: string) {
if (!this.consentGuard()) return; if (!this.consentGuard()) return;
window.fbq('trackSingleCustom', this._pixelID, event, params, { if (!dev || this._testEventCode) {
eventID, window.fbq('trackSingleCustom', this._pixelID, event, params, {
test_event_code: this._testEventCode eventID,
}); test_event_code: this._testEventCode
log.debug( });
`Meta Pixel [${this._pixelID}] ${event} custom event sent (test code: ${this._testEventCode}).` log.debug(
); `Meta Pixel [${this._pixelID}] ${event} custom event sent (test code: ${this._testEventCode}).`
);
} else {
log.info(
`Meta Pixel [${this._pixelID}] ${event} custom event not sent in development mode without a test event code.`
);
}
} }
} }
</script> </script>
@@ -168,32 +227,30 @@
import { onNavigate } from '$app/navigation'; import { onNavigate } from '$app/navigation';
import { resolveGetter, type MaybeGetter } from './util/getter.ts'; import { resolveGetter, type MaybeGetter } from './util/getter.ts';
import log from 'loglevel'; import log from 'loglevel';
import { dev } from '$app/environment';
interface Props { interface Props {
/** Meta Pixel ID */
pixelID: string;
/**
* If a test event code is available, events fired will always have this
* code attached to prevent them from polluting real analytics data.
*/
testEventCode?: string;
/**
* Controls whether page views are automatically tracked by this
* component (default: true).
*/
autoPageView?: boolean;
/** /**
* Tracking manager to handle user consent for tracking. If omitted * Tracking manager to handle user consent for tracking. If omitted
* tracking is disabled by default until consent is granted via * tracking is disabled by default until consent is granted via
* PixelControl.grantConsent(). * PixelControl.grantConsent().
*/ */
trackingManager?: TrackingManager; trackingManager?: TrackingManager;
/** Meta Pixel ID */
pixelID: string;
/** Meta Pixel Options */
pixelOptions?: PixelControlOptions;
/**
* Controls whether page views are automatically tracked by this
* component (default: true).
*/
autoPageView?: boolean;
} }
let { pixelID, testEventCode, autoPageView = true, trackingManager }: Props = $props(); let { pixelID, pixelOptions, autoPageView = true, trackingManager }: Props = $props();
let pixel = $state<PixelControl | null>(null); let pixel = $state<PixelControl | null>(null);
@@ -202,7 +259,7 @@
throw new Error('MetaPixel component requires a TrackingManager to manage consent.'); throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
} }
PixelControl.load(); PixelControl.load();
pixel = PixelControl.for(trackingManager, pixelID, { testEventCode }).fireInit(); pixel = PixelControl.initialize(trackingManager, pixelID, pixelOptions);
trackingManager.runWithConsent(() => { trackingManager.runWithConsent(() => {
if (autoPageView && pixel) { if (autoPageView && pixel) {
@@ -218,4 +275,11 @@
} }
}); });
}); });
export const getPixelControl = (): PixelControl => {
if (!pixel) {
throw new Error('MetaPixel component has not been initialized yet, wait for onMount.');
}
return pixel;
};
</script> </script>

View File

@@ -1,4 +1,6 @@
import { setContext, getContext, onDestroy } from 'svelte'; import { browser } from '$app/environment';
import log from 'loglevel';
import { onDestroy, onMount, createContext } from 'svelte';
/** /**
* Options for initializing the TrackingManager. * Options for initializing the TrackingManager.
@@ -36,17 +38,69 @@ type InternalService<T> = Service<T> & {
* Manages user tracking preferences and services that require consent. * Manages user tracking preferences and services that require consent.
*/ */
export class TrackingManager { export class TrackingManager {
/** tracking consent, persisted to localStorage by saveOpts */
private _consent: boolean | null = $state(null); private _consent: boolean | null = $state(null);
private _services: Record<string, InternalService<unknown>> = {}; private _services: Record<string, InternalService<unknown>> = {};
private _changeCallbacks: Array<(consent: boolean | null) => void> = []; private _changeCallbacks: Array<(consent: boolean | null) => void> = [];
private _consentQueue: Array<() => void> = []; private _consentQueue: Array<() => void> = [];
/**
* Saves consent state to localStorage if browser storage is available.
* Automatically called after updating consent.
* @throws Error if storage is not available.
*/
saveOpts(): TrackingManager {
if (!browser || !window?.localStorage) {
throw new Error('Cannot access localStorage to save tracking state');
}
window.localStorage.setItem(
'trackingOpts',
JSON.stringify({ consent: this._consent } as TrackingManagerOpts)
);
return this;
}
/**
* Loads tracking options from localStorage if available. Suitable to call
* after initialization, e.g. in onMount after a TrackingManager is created.
* @throws Error if storage is not available.
*/
loadOpts(): TrackingManager {
if (!browser || !window?.localStorage) {
throw new Error('Cannot access localStorage to load tracking state');
}
const raw = window.localStorage.getItem('trackingOpts');
if (raw) {
const opts = JSON.parse(raw) as TrackingManagerOpts;
if (opts.consent !== undefined && opts.consent !== null) {
this.setConsent(opts.consent);
}
log.debug('[TrackingManager] Loaded tracking options from storage:', opts);
}
return this;
}
/**
* Creates a TrackingManager instance.
* @param opts Optional initial options.
*/
constructor(opts?: TrackingManagerOpts) { constructor(opts?: TrackingManagerOpts) {
if (opts) { if (opts) {
if (opts.consent !== undefined) this._consent = opts.consent; if (opts.consent !== undefined) this._consent = opts.consent;
} }
} }
/**
* Creates a TrackingManager instance from localStorage data.
* @throws Error if storage is not available.
*/
static fromLocalStorage(): TrackingManager {
return new TrackingManager().loadOpts();
}
/** Indicates whether tracking is currently allowed. */ /** Indicates whether tracking is currently allowed. */
get consent() { get consent() {
return this._consent; return this._consent;
@@ -78,7 +132,7 @@ export class TrackingManager {
/** /**
* Sets whether tracking is consented. If set to true, all queued callbacks * Sets whether tracking is consented. If set to true, all queued callbacks
* will be executed. * will be executed. Automatically persists to localStorage if available.
*/ */
setConsent(value: boolean) { setConsent(value: boolean) {
if (this._consent === value) return; if (this._consent === value) return;
@@ -95,6 +149,7 @@ export class TrackingManager {
this._changeCallbacks.forEach((cb) => { this._changeCallbacks.forEach((cb) => {
cb(this._consent); cb(this._consent);
}); });
this.saveOpts();
} }
/** /**
@@ -113,16 +168,23 @@ export class TrackingManager {
/** /**
* Runs callback immediately if we have consent already or queues it for later. * Runs callback immediately if we have consent already or queues it for later.
* Removes callback from queue onDestroy.
* @param callback The function to run when consent is granted. * @param callback The function to run when consent is granted.
*/ */
runWithConsent(callback: () => void) { runWithConsent(callback: () => void) {
if (this._consent) { if (this._consent) callback();
callback(); else this._consentQueue.push(callback);
} else { }
this._consentQueue.push(callback);
}
/**
* Runs callback onMount if we have consent already or queues it for later.
* Removes the callback from the queue onDestroy.
* @param callback The function to run when consent is granted.
*/
lifecycleWithConsent(callback: () => void) {
onMount(() => {
if (this._consent) callback();
else this._consentQueue.push(callback);
});
onDestroy(() => { onDestroy(() => {
this._consentQueue = this._consentQueue.filter((cb) => cb !== callback); this._consentQueue = this._consentQueue.filter((cb) => cb !== callback);
}); });
@@ -192,19 +254,31 @@ export class TrackingManager {
} }
} }
const trackingManagerKey = Symbol(); const [getTrackingContext, setTrackingContext] = createContext<TrackingManager>();
/** /**
* Gets the TrackingManager from context, or creates one if it doesn't exist. * Gets the TrackingManager from context, or creates one if it doesn't exist.
* @param initializer Optional initializer function to customize the TrackingManager. * If called from the browser, attempts to load saved state from localStorage.
* @returns The TrackingManager instance. * @returns The TrackingManager instance.
*/ */
export const getTrackingManager = (): TrackingManager => { export const getTrackingManager = (): TrackingManager => {
const saved = getContext<TrackingManager>(trackingManagerKey); try {
if (saved) return saved; const saved = getTrackingContext();
if (saved) {
log.debug('[TrackingManager] Using existing instance from context');
return saved;
}
} catch {
// ignore missing context, we'll create a new one
}
log.debug('[TrackingManager] Creating new instance');
const manager = $state(new TrackingManager());
setTrackingContext(manager);
if (browser) {
manager.loadOpts();
}
console.debug('initializing a new TrackingManager');
const manager = new TrackingManager();
setContext(trackingManagerKey, manager);
return manager; return manager;
}; };

View File

@@ -340,6 +340,11 @@ export interface FBQ {
// consent and LDU // consent and LDU
(cmd: 'consent', state: 'grant' | 'revoke'): void; (cmd: 'consent', state: 'grant' | 'revoke'): void;
(cmd: 'dataProcessingOptions', options: string[], countryCode?: number, stateCode?: number): void; (cmd: 'dataProcessingOptions', options: string[], countryCode?: number, stateCode?: number): void;
/** Prevent automatic listening to history.pushState/popstate */
disablePushState?: boolean;
/** Allow duplicate page view events (legacy / undocumented behavior) */
allowDuplicatePageViews?: boolean;
} }
declare global { declare global {

View File

@@ -1,4 +1,5 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import log from 'loglevel';
const SCRIPT_SRC = 'https://connect.facebook.net/en_US/fbevents.js'; const SCRIPT_SRC = 'https://connect.facebook.net/en_US/fbevents.js';
@@ -8,22 +9,60 @@ type QueuedFBQ = ((...args: unknown[]) => void) & {
loaded?: boolean; loaded?: boolean;
version?: string; version?: string;
push?: unknown; push?: unknown;
disablePushState?: boolean;
allowDuplicatePageViews?: boolean;
}; };
/** /**
* Loads the Meta Pixel script and configures the `fbq` function to queue * Loads the Meta Pixel script and configures the `fbq` function to queue
* commands until the script is fully loaded. You may optionally await the * commands until the script is fully loaded. You may optionally await the
* returned Promise to ensure the script has loaded before proceeding. * returned Promise to ensure the script has loaded before proceeding.
*
* Options:
* - `disablePushState` (default: true) — when true, sets
* `window.fbq.disablePushState = true` before the pixel script loads so the
* pixel does not auto-listen to `history.pushState`/`popstate` (recommended
* for SPA frameworks like Svelte).
* - `allowDuplicatePageViews` (default: false) — when true, sets
* `window.fbq.allowDuplicatePageViews = true` on the stub.
*/ */
export const loadMetaPixel = (): Promise<void> => { export const loadMetaPixel = (opts?: {
disablePushState?: boolean;
allowDuplicatePageViews?: boolean;
}): Promise<void> => {
// Make sure we're using the browser // Make sure we're using the browser
if (!browser || !window) { if (!browser || !window) {
return Promise.reject(new Error('Window is undefined')); return Promise.reject(new Error(`Not in browser, can't access window`));
} }
// Default behavior: disable pushState handling since Svelte apps manage
// navigation themselves and Meta's auto-patching of history APIs can
// cause duplicate/incorrect pageview events. Consumers can pass
// `opts.disablePushState = false` to opt out.
const disablePushState = opts?.disablePushState ?? true;
const allowDuplicatePageViews = opts?.allowDuplicatePageViews ?? false;
// If fbq is already defined, resolve immediately // If fbq is already defined, resolve immediately
const existing = window.fbq as QueuedFBQ | undefined; const existing = window.fbq as QueuedFBQ | undefined;
if (existing && existing.loaded) { if (existing) {
// If the existing stub is present but hasn't set these flags yet, set
// them now so the loaded library (if it inspects them) sees intended
// behavior. Setting these is a no-op if initialization already
// completed.
if (disablePushState) existing.disablePushState = true;
if (allowDuplicatePageViews) existing.allowDuplicatePageViews = true;
const existingScript = getExistingScript();
if (existingScript) {
return new Promise((resolve, reject) => {
attachToScript(existingScript, resolve, reject);
});
}
log.debug(
'Meta Pixel fbq already present, skipping injection',
existing.version,
existing.queue
);
return Promise.resolve(); return Promise.resolve();
} }
@@ -40,18 +79,18 @@ export const loadMetaPixel = (): Promise<void> => {
q.push = q; q.push = q;
q.loaded = true; q.loaded = true;
q.version = '2.0'; q.version = '2.0';
// set control flags on the stub before the meta script runs
if (disablePushState) q.disablePushState = true;
if (allowDuplicatePageViews) q.allowDuplicatePageViews = true;
window.fbq = q; window.fbq = q;
window._fbq = q;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Avoid adding the same script twice // Avoid adding the same script twice
const existingScript = document.querySelector( const existingScript = getExistingScript();
`script[src="${SCRIPT_SRC}"]`
) as HTMLScriptElement | null;
if (existingScript) { if (existingScript) {
existingScript.addEventListener('load', () => resolve()); attachToScript(existingScript, resolve, reject);
existingScript.addEventListener('error', () => log.debug('Meta Pixel script already present, waiting for load');
reject(new Error('Failed to load Meta Pixel script'))
);
return; return;
} }
@@ -59,8 +98,23 @@ export const loadMetaPixel = (): Promise<void> => {
const script = document.createElement('script'); const script = document.createElement('script');
script.src = SCRIPT_SRC; script.src = SCRIPT_SRC;
script.async = true; script.async = true;
script.addEventListener('load', () => resolve()); attachToScript(script, resolve, reject);
script.addEventListener('error', () => reject(new Error('Failed to load Meta Pixel script')));
document.head.appendChild(script); document.head.appendChild(script);
log.debug('Meta Pixel script added to document');
}); });
}; };
const getExistingScript = (): HTMLScriptElement | null => {
return document.querySelector(
`script[src*="connect.facebook.net"][src*="fbevents.js"]`
) as HTMLScriptElement | null;
};
const attachToScript = (
el: HTMLScriptElement,
resolve: () => void,
reject: (err: Error) => void
) => {
el.addEventListener('load', () => resolve());
el.addEventListener('error', () => reject(new Error('Failed to load Meta Pixel script')));
};