14 Commits

Author SHA1 Message Date
Elijah Duffy
a9a9f5ed30 0.0.6 2025-12-18 22:08:31 -08:00
Elijah Duffy
0e1a449cb6 conversion: more robust fbp, fbc with header-based attempt 2025-12-18 22:06:23 -08:00
Elijah Duffy
764da5db2e conversion: make event ID optional 2025-12-18 22:04:32 -08:00
Elijah Duffy
6692338b83 pixel: allow disabling conversion API forwarding 2025-12-18 21:27:44 -08:00
Elijah Duffy
3561012fb9 pixel: fix $state usage, fix load ordering 2025-12-18 21:22:07 -08:00
Elijah Duffy
b26f6160f8 set log level to warn in production 2025-12-18 21:21:46 -08:00
Elijah Duffy
1bb202ffa5 pixel: rename pixel-control to svelte file 2025-12-18 19:24:28 -08:00
Elijah Duffy
0cd3f10da6 pixel: add ensure fbc fallback 2025-12-18 19:22:54 -08:00
Elijah Duffy
f2d389ee64 improve logging consistency 2025-12-18 19:03:27 -08:00
Elijah Duffy
f5ec7b3812 conversion: explicit event params, removes any in PixelControl 2025-12-18 18:44:33 -08:00
Elijah Duffy
fc07bb057c pixel: refactor to separate files 2025-12-18 17:20:05 -08:00
Elijah Duffy
9400e81aaa pixel: integrate conversion API 2025-12-18 17:12:57 -08:00
Elijah Duffy
824fd262ed conversion: fix exports and strengthen IP extraction 2025-12-18 17:12:47 -08:00
Elijah Duffy
82cce84a4e capi: proper dev mode handling requiring test event code 2025-12-18 13:07:39 -08:00
13 changed files with 668 additions and 363 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.5", "version": "0.0.6",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

View File

@@ -1,285 +0,0 @@
<!-- @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>
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 {
private _pixelID: string;
private _testEventCode?: string = undefined;
private _trackingManager: MaybeGetter<TrackingManager | undefined>;
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 {
return this._baseLoaded;
}
/**
* Ensures that the Meta Pixel base has been loaded before
* allowing further operations.
* @throws Error if the Meta Pixel API is not loaded.
*/
static loadGuard(): void {
if (!this._baseLoaded || !window.fbq) {
throw new Error('Meta Pixel API has not been loaded. Call PixelControl.load() first.');
}
}
private constructor(
trackingManager: MaybeGetter<TrackingManager | undefined>,
pixelID: string,
options?: PixelControlOptions
) {
this._trackingManager = trackingManager;
this._pixelID = pixelID;
this._testEventCode = options?.testEventCode;
}
/** Loads the Meta Pixel base script. */
static async load() {
if (this._baseLoaded && !!window.fbq) return;
if (!window.fbq) {
try {
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;
log.debug('Meta Pixel base script loaded.');
}
/** Tells the Meta pixel that the user has given consent for tracking. */
static grantConsent() {
this.loadGuard();
window.fbq?.('consent', 'grant');
log.debug('Meta Pixel consent granted.');
}
/** Tells the Meta pixel that the user has revoked consent for tracking. */
static revokeConsent() {
this.loadGuard();
window.fbq?.('consent', 'revoke');
log.debug('Meta Pixel consent revoked.');
}
/**
* Registers a PixelControl instance for the given Meta Pixel ID. If
* the base Meta Pixel script has not been loaded yet, it will be
* loaded automatically. Optionally sets a test event code for the Pixel.
* Should only be called once for each Pixel ID, use PixelControl.get()
* to retrieve existing instances.
* @param trackingManager Tracking manager to handle user consent for tracking
* @param pixelID Meta Pixel ID
* @param options Optional settings
* @returns PixelControl instance
*/
static initialize(
trackingManager: MaybeGetter<TrackingManager | undefined>,
pixelID: string,
options?: PixelControlOptions
): PixelControl {
// Load the base script if not already loaded
PixelControl.load();
// 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;
}
/**
* Returns an existing PixelControl instance for the given Meta Pixel ID.
* @param pixelID Meta Pixel ID
* @returns PixelControl instance
* @throws Error if no PixelControl instance is found for the given ID.
*/
static get(pixelID: string): PixelControl {
const pixel = this._registeredPixels[pixelID];
if (!pixel) {
throw new Error(`No PixelControl instance found for Meta Pixel ID: ${pixelID}`);
}
return pixel;
}
/**
* Checks if the Meta Pixel has consent to track user data
* and if the Pixel has been loaded.
* @returns true if tracking is allowed, false otherwise.
* @throws Error if the Meta Pixel is not loaded.
*/
consentGuard(): boolean {
PixelControl.loadGuard();
const trackingManager = resolveGetter(this._trackingManager);
return trackingManager?.haveUserConsent() ?? false;
}
/**
* Sends a PageView event
* @throws Error if the Meta Pixel is not initialized.
*/
pageView() {
if (!this.consentGuard()) return;
// Send the PageView event
if (!dev || 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.`
);
}
}
/**
* Tracks a standard event for this pixel (uses `trackSingle` under the hood)
* @throws Error if the Meta Pixel is not initialized.
*/
track<K extends StandardEventName>(event: K, params?: EventParamsByName[K], eventID?: string) {
if (!this.consentGuard()) return;
if (!dev || this._testEventCode) {
window.fbq('trackSingle', this._pixelID, event, params, {
eventID,
test_event_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.`
);
}
}
/**
* Tracks a custom event for this pixel (uses `trackSingleCustom` under the hood)
* @throws Error if the Meta Pixel is not initialized.
*/
trackCustom(event: string, params?: CommonParams & CustomParams, eventID?: string) {
if (!this.consentGuard()) return;
if (!dev || this._testEventCode) {
window.fbq('trackSingleCustom', this._pixelID, event, params, {
eventID,
test_event_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 lang="ts">
import { onMount } from 'svelte';
import type { TrackingManager } from './tracking.svelte.ts';
import type {
EventParamsByName,
StandardEventName,
CommonParams,
CustomParams,
AdvancedMatching,
InitOptions
} from './types/fbq.js';
import { loadMetaPixel } from './util/meta-pixel-loader.ts';
import { onNavigate } from '$app/navigation';
import { resolveGetter, type MaybeGetter } from './util/getter.ts';
import log from 'loglevel';
import { dev } from '$app/environment';
interface Props {
/**
* Tracking manager to handle user consent for tracking. If omitted
* tracking is disabled by default until consent is granted via
* PixelControl.grantConsent().
*/
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, pixelOptions, autoPageView = true, trackingManager }: Props = $props();
let pixel = $state<PixelControl | null>(null);
onMount(() => {
if (!trackingManager) {
throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
}
PixelControl.load();
pixel = PixelControl.initialize(trackingManager, pixelID, pixelOptions);
trackingManager.runWithConsent(() => {
if (autoPageView && pixel) {
pixel.pageView();
}
});
});
onNavigate(() => {
trackingManager?.runWithConsent(() => {
if (autoPageView && pixel) {
pixel.pageView();
}
});
});
export const getPixelControl = (): PixelControl => {
if (!pixel) {
throw new Error('MetaPixel component has not been initialized yet, wait for onMount.');
}
return pixel;
};
</script>

View File

@@ -25,7 +25,9 @@
const consentGranted = $derived(trackingManager ? trackingManager.consent === true : true); const consentGranted = $derived(trackingManager ? trackingManager.consent === true : true);
// Development overrides to prevent dirty analytics // Development overrides to prevent dirty analytics
const devConsoleTag = $derived(`[dev][consent: ${consentGranted ? 'granted' : 'revoked'}]`); const devConsoleTag = $derived(
`[Umami] [dev][consent: ${consentGranted ? 'granted' : 'revoked'}]`
);
const devOverride = { const devOverride = {
track: (...args: unknown[]): Promise<string> | undefined => { track: (...args: unknown[]): Promise<string> | undefined => {
log.debug(`${devConsoleTag}: Track called with:`, ...args); log.debug(`${devConsoleTag}: Track called with:`, ...args);
@@ -62,7 +64,7 @@
} }
}); });
if (dev) log.debug('[dev]: Umami tracking disabled'); if (dev) log.info('[Umami] [dev]: tracking disabled');
onMount(() => { onMount(() => {
if (dev) { if (dev) {

View File

@@ -1,6 +1,8 @@
import { getFbpFbc } from '../metapixel/fbc.ts';
import type { TrackingManager } from '$lib/tracking.svelte'; import type { TrackingManager } from '$lib/tracking.svelte';
import type { import type {
ConversionErrorResponseBody, ConversionErrorResponseBody,
ConversionEventParams,
ConversionRequestBody, ConversionRequestBody,
ConversionResponseBody, ConversionResponseBody,
ConversionUserData ConversionUserData
@@ -37,17 +39,16 @@ export class ConversionClient {
options: { options: {
eventID: string; eventID: string;
user?: Omit<ConversionUserData, 'ip' | 'fbp' | 'fbc' | 'ua'>; user?: Omit<ConversionUserData, 'ip' | 'fbp' | 'fbc' | 'ua'>;
customData?: Record<string, string>; customData?: ConversionEventParams;
} }
): Promise<ConversionResponseBody | ConversionErrorResponseBody> { ): Promise<ConversionResponseBody | ConversionErrorResponseBody> {
// Extract user data // Extract user data
const fbp = await cookieStore.get('_fbp'); const { fbp, fbc } = getFbpFbc();
const fbc = await cookieStore.get('_fbc');
const user: ConversionUserData = { const user: ConversionUserData = {
...options.user, ...options.user,
fbp: fbp?.value, fbp,
fbc: fbc?.value fbc
}; };
// Get event source URL & extract UTM params if present // Get event source URL & extract UTM params if present

View File

@@ -1,12 +1,19 @@
import { import {
Content,
CustomData, CustomData,
EventRequest, EventRequest,
EventResponse,
ServerEvent, ServerEvent,
UserData UserData
} from 'facebook-nodejs-business-sdk'; } from 'facebook-nodejs-business-sdk';
import type { StandardEventName } from '../types/fbq.js'; import type { StandardEventName } from '../types/fbq.js';
import type { ConversionUserData } from '$lib/types/conversion.js'; import type {
ConversionEventDetails,
ConversionEventParams,
ConversionResponseBody,
ConversionUserData
} from '$lib/types/conversion.js';
import { dev } from '$app/environment';
import log from 'loglevel';
/** /**
* Builds UserData for conversion events. * Builds UserData for conversion events.
@@ -14,7 +21,7 @@ import type { ConversionUserData } from '$lib/types/conversion.js';
* @param data - The user data to include. * @param data - The user data to include.
* @returns The constructed UserData object. * @returns The constructed UserData object.
*/ */
export const buildConversionUserData = (data: ConversionUserData): UserData => { const buildUserData = (data: ConversionUserData): UserData => {
const userData = new UserData(); const userData = new UserData();
if (data.email) userData.setEmail(data.email); if (data.email) userData.setEmail(data.email);
if (data.phone) userData.setPhone(data.phone); if (data.phone) userData.setPhone(data.phone);
@@ -34,38 +41,39 @@ export const buildConversionUserData = (data: ConversionUserData): UserData => {
* @param params - The custom data parameters. * @param params - The custom data parameters.
* @returns The constructed CustomData object. * @returns The constructed CustomData object.
*/ */
export const buildCustomData = (params: Record<string, string>): CustomData => { const buildCustomData = (params: ConversionEventParams): CustomData => {
const c = new CustomData(); const c = new CustomData();
// Map known fields safely if (params.value) c.setValue(params.value);
if (params.net_revenue) c.setNetRevenue(params.net_revenue);
if (params.currency) c.setCurrency(params.currency); if (params.currency) c.setCurrency(params.currency);
if (typeof params.value === 'number') c.setValue(params.value); if (params.content_name) c.setContentName(params.content_name);
if (Array.isArray(params.contents)) c.setContents(params.contents); if (params.content_category) c.setContentCategory(params.content_category);
if (params.content_type) c.setContentType(params.content_type); if (params.content_ids) c.setContentIds(params.content_ids);
if (Array.isArray(params.content_ids)) c.setContentIds(params.content_ids); if (params.contents) {
if (typeof params.num_items === 'number') c.setNumItems(params.num_items); const contents: Content[] = params.contents.map((content) => {
if (params.search_string) c.setSearchString(params.search_string); const c = new Content();
// Attach anything else as custom_properties if (content.id) c.setId(content.id.toString());
const extras = { ...params }; if (content.quantity) c.setQuantity(content.quantity);
delete extras.currency; if (content.item_price) c.setItemPrice(content.item_price);
delete extras.value; if (content.title) c.setTitle(content.title);
delete extras.contents; if (content.description) c.setDescription(content.description);
delete extras.content_type; if (content.category) c.setCategory(content.category);
delete extras.content_ids; if (content.brand) c.setBrand(content.brand);
delete extras.num_items; if (content.delivery_category) c.setDeliveryCategory(content.delivery_category);
delete extras.search_string; return c;
if (Object.keys(extras).length) c.setCustomProperties(extras); });
c.setContents(contents);
}
if (params.content_type) c.setContentType(params.content_type);
if (params.order_id) c.setOrderId(params.order_id);
if (params.predicted_ltv) c.setPredictedLtv(params.predicted_ltv);
if (params.num_items) c.setNumItems(params.num_items);
if (params.search_string) c.setSearchString(params.search_string);
if (params.status) c.setStatus(params.status);
if (params.item_number) c.setItemNumber(params.item_number);
if (params.delivery_category) c.setDeliveryCategory(params.delivery_category);
if (params.custom_properties) c.setCustomProperties(params.custom_properties);
return c; return c;
};
/**
* Parameters for sending a conversion event to Meta Pixel.
*/
export type ConversionEventOptions = {
eventID: string;
eventSourceURL?: string;
actionSource: 'website' | 'app' | 'offline' | 'other';
userData: UserData;
customData?: CustomData;
}; };
/** /**
@@ -90,7 +98,8 @@ export class ConversionControl {
} }
/** /**
* Sends a conversion event to Meta Pixel. * Sends a conversion event to Meta Pixel. Requires a test event code for
* events to be sent to Meta when in development mode.
* *
* @param eventName - The name of the standard event to send. * @param eventName - The name of the standard event to send.
* @param options - Additional options for the event. * @param options - Additional options for the event.
@@ -98,20 +107,51 @@ export class ConversionControl {
*/ */
async trackEvent( async trackEvent(
eventName: StandardEventName, eventName: StandardEventName,
options: ConversionEventOptions details: ConversionEventDetails,
): Promise<EventResponse> { params?: ConversionEventParams
): Promise<ConversionResponseBody> {
const event = new ServerEvent() const event = new ServerEvent()
.setEventName(eventName) .setEventName(eventName)
.setEventTime(Math.floor(Date.now() / 1000)) .setEventTime(Math.floor(Date.now() / 1000))
.setEventId(options.eventID) .setUserData(buildUserData(details.userData))
.setUserData(options.userData) .setActionSource(details.actionSource);
.setActionSource(options.actionSource);
if (options?.eventSourceURL) event.setEventSourceUrl(options.eventSourceURL); if (details.eventID) event.setEventId(details.eventID);
if (options?.customData) event.setCustomData(options.customData); if (details.eventSourceURL) event.setEventSourceUrl(details.eventSourceURL);
if (params) {
const customData = buildCustomData(params);
event.setCustomData(customData);
}
// If we're in dev mode and missing a test event code, log and exit
if (dev && !this._testEventCode) {
log.debug(
`[ConversionControl] ${eventName} event not sent - missing test event code in dev mode.`
);
return Promise.resolve({
pixelID: this._pixelID,
fbtrace_id: 'dev-mode-no-test-event-code',
receivedEvents: 1,
processedEvents: 0,
messages: ['Event not sent - missing test event code in dev mode.']
});
}
const req = new EventRequest(this._accessToken, this._pixelID).setEvents([event]); const req = new EventRequest(this._accessToken, this._pixelID).setEvents([event]);
if (this._testEventCode) req.setTestEventCode(this._testEventCode); if (this._testEventCode) req.setTestEventCode(this._testEventCode);
return req.execute(); const response = await req.execute();
const structuredResponse: ConversionResponseBody = {
pixelID: response.id,
fbtrace_id: response.fbtrace_id,
receivedEvents: response.events_received,
processedEvents: response.num_processed_entries,
messages: response.messages
};
log.debug(
`[ConversionControl] Sent ${eventName}, Event response: ${JSON.stringify(structuredResponse)}`
);
return structuredResponse;
} }
} }

View File

@@ -1,61 +1,72 @@
import { json, type RequestHandler } from '@sveltejs/kit'; import { json, type RequestHandler } from '@sveltejs/kit';
import { buildConversionUserData, buildCustomData, ConversionControl } from './control.ts'; import { ConversionControl } from './control.ts';
import type { import type {
ConversionErrorResponseBody, ConversionErrorResponseBody,
ConversionEventParams,
ConversionRequestBody, ConversionRequestBody,
ConversionResponseBody ConversionUserData
} from '$lib/types/conversion.js'; } from '$lib/types/conversion.js';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { getFbpFbcFromCookies } from '../metapixel/fbc.ts';
export const getRequestIP = (request: Request, getClientAddress: () => string) => {
return (
request.headers.get('x-forwarded-for') ||
request.headers.get('cf-connecting-ip') ||
request.headers.get('x-real-ip') ||
request.headers.get('x-client-ip') ||
request.headers.get('x-cluster-client-ip') ||
request.headers.get('x-original-forwarded-for') ||
request.headers.get('forwarded-for') ||
request.headers.get('forwarded') ||
getClientAddress()
);
};
export const createConversionRequestHandler: (control: ConversionControl) => RequestHandler = ( export const createConversionRequestHandler: (control: ConversionControl) => RequestHandler = (
control control
) => { ) => {
const handle: RequestHandler = async ({ request, getClientAddress }) => { const handle: RequestHandler = async ({ request, getClientAddress, cookies }) => {
try { try {
const body = (await request.json()) as ConversionRequestBody; const body = (await request.json()) as ConversionRequestBody;
// Build user data with IP and user agent // Build user data with IP and user agent
const ip = getClientAddress(); const ip = getRequestIP(request, getClientAddress);
const ua = request.headers.get('user-agent'); const ua = request.headers.get('user-agent');
const { fbp, fbc } = getFbpFbcFromCookies(cookies);
const userData = buildConversionUserData({ const userData: ConversionUserData = {
...body.user, ...body.user,
ip, ip,
ua: ua ?? body.user?.ua ?? undefined ua: ua ?? body.user?.ua ?? undefined,
}); fbc: body.user?.fbc ?? fbc,
fbp: body.user?.fbp ?? fbp
};
// Build custom data with UTM params if applicable // Build custom data with UTM params if applicable
let rawCustomData = body.customData ?? {}; let customData: ConversionEventParams = body.customData ?? {};
if (body.eventName === 'PageView' && body.utms) { if (body.eventName === 'PageView' && body.utms) {
// For PageView events, automatically include UTM params if provided // For PageView events, automatically include UTM params if provided
rawCustomData = { customData = {
...rawCustomData, ...customData,
...body.utms ...body.utms
}; };
} }
const customData = buildCustomData(rawCustomData);
// Send the event via the control // Send the event via the control
const response = await control.trackEvent(body.eventName, { const response = await control.trackEvent(
body.eventName,
{
eventID: body.eventID, eventID: body.eventID,
eventSourceURL: body.eventSourceURL, eventSourceURL: body.eventSourceURL,
actionSource: 'website', actionSource: 'website',
userData, userData
},
customData customData
}); );
// Structure the response return json(response, { status: StatusCodes.OK });
const structuredResponse: ConversionResponseBody = {
pixelID: response.id,
fbtrace_id: response.fbtrace_id,
receivedEvents: response.events_received,
processedEvents: response.num_processed_entries,
messages: response.messages
};
return json(structuredResponse, { status: StatusCodes.OK });
} catch (e) { } catch (e) {
return json( return json(
{ error: e instanceof Error ? e.message : String(e) } as ConversionErrorResponseBody, { error: e instanceof Error ? e.message : String(e) } as ConversionErrorResponseBody,

View File

@@ -3,12 +3,16 @@
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import log from 'loglevel'; import log from 'loglevel';
export type * from './types/conversion.d.ts';
export type * as fbq from './types/fbq.d.ts'; export type * as fbq from './types/fbq.d.ts';
export { default as MetaPixel, PixelControl } from './MetaPixel.svelte'; export * from './metapixel/index.ts';
export * from './tracking.svelte.ts'; export * from './tracking.svelte.ts';
export { default as Umami } from './Umami.svelte'; export { default as Umami } from './Umami.svelte';
export * from './conversion/index.ts';
// set log level to debug if we're in dev mode // set log level to debug if we're in dev mode
if (dev) { if (dev) {
log.setLevel('debug'); log.setLevel('debug');
} else {
log.setLevel('warn');
} }

View File

@@ -0,0 +1,74 @@
<!-- @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.
-->
<script lang="ts">
import { onMount } from 'svelte';
import type { TrackingManager } from '../tracking.svelte.ts';
import { onNavigate } from '$app/navigation';
import { PixelControl, type PixelControlOptions } from './pixel-control.ts';
import { ensureFbc, type EnsureFbcOptions } from './fbc.ts';
interface Props {
/**
* Tracking manager to handle user consent for tracking. If omitted
* tracking is disabled by default until consent is granted via
* PixelControl.grantConsent().
*/
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, pixelOptions, autoPageView = true, trackingManager }: Props = $props();
let pixel = $state<PixelControl | null>(null);
const fbcOptions: EnsureFbcOptions = {
sameSite: 'Lax'
};
onMount(() => {
if (!trackingManager) {
throw new Error('MetaPixel component requires a TrackingManager to manage consent.');
}
pixel = PixelControl.initialize(trackingManager, pixelID, pixelOptions);
trackingManager.runWithConsent(() => {
if (autoPageView && pixel) {
pixel.pageView();
ensureFbc(fbcOptions);
}
});
});
onNavigate(() => {
trackingManager?.runWithConsent(() => {
if (autoPageView && pixel) {
pixel.pageView();
ensureFbc(fbcOptions);
}
});
});
export const getPixelControl = (): PixelControl => {
if (!pixel) {
throw new Error('MetaPixel component has not been initialized yet, wait for onMount.');
}
return pixel;
};
</script>

98
src/lib/metapixel/fbc.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { Cookies } from '@sveltejs/kit';
import log from 'loglevel';
export type EnsureFbcOptions = {
days?: number; // cookie lifetime, default 180
domain?: string; // optional cookie domain
sameSite?: 'Lax' | 'Strict' | 'None'; // default Lax
secure?: boolean; // default inferred from location.protocol === 'https:'
pixelLoaded?: boolean; // if true, skip manual set and let Pixel handle it
};
function getParam(name: string): string | undefined {
const params = new URLSearchParams(location.search);
const v = params.get(name);
return v || undefined;
}
function getCookie(name: string): string | undefined {
return document.cookie
.split('; ')
.find((c) => c.startsWith(name + '='))
?.split('=')[1];
}
function setCookie(
name: string,
value: string,
{
days = 180,
domain,
sameSite = 'Lax',
secure
}: Pick<EnsureFbcOptions, 'days' | 'domain' | 'sameSite' | 'secure'> = {}
) {
const d = new Date();
d.setTime(d.getTime() + days * 864e5);
const parts = [
`${name}=${encodeURIComponent(value)}`,
`expires=${d.toUTCString()}`,
'path=/',
`SameSite=${sameSite}`
];
if (domain) parts.push(`domain=${domain}`);
const isSecure = secure ?? location.protocol === 'https:';
if (isSecure) parts.push('Secure');
document.cookie = parts.join('; ');
}
function isValidFbc(value: string | undefined): boolean {
if (!value) return false;
// Expect "fb.1.<unix>.<fbclid>"
if (!value.startsWith('fb.1.')) return false;
const parts = value.split('.');
return parts.length >= 4 && /^\d+$/.test(parts[2]) && parts[3].length > 0;
}
/**
* Ensure _fbc cookie exists when landing URL contains fbclid.
* Call after consent. If pixelLoaded is true, it skips manual setting.
*/
export function ensureFbc(options: EnsureFbcOptions = {}) {
try {
const { pixelLoaded = false, ...cookieOpts } = options;
if (pixelLoaded) throw new Error('Pixel loaded, skipping manual _fbc set'); // Let the Pixel set _fbc if its active
const fbclid = getParam('fbclid');
if (!fbclid) throw new Error('No fbclid param present');
const existing = getCookie('_fbc');
if (isValidFbc(existing)) throw new Error('_fbc cookie already present and valid');
const ts = Math.floor(Date.now() / 1000);
const fbc = `fb.1.${ts}.${fbclid}`;
setCookie('_fbc', fbc, cookieOpts);
log.debug('[ensureFbc] Set _fbc cookie:', fbc);
} catch (e) {
log.debug('[ensureFbc]', (e as Error).message);
}
}
/**
* Helper to read both _fbp and _fbc for your CAPI payload from browser cookies.
*/
export function getFbpFbc(): { fbp?: string; fbc?: string } {
const fbp = getCookie('_fbp');
const fbc = getCookie('_fbc');
return { fbp, fbc };
}
/**
* Helper to read both _fbp and _fbc for your CAPI payload from cookies object.
*/
export function getFbpFbcFromCookies(cookies: Cookies): { fbp?: string; fbc?: string } {
const fbp = cookies.get('_fbp') || undefined;
const fbc = cookies.get('_fbc') || undefined;
return { fbp, fbc };
}

View File

@@ -0,0 +1,4 @@
export { default as MetaPixel } from './MetaPixel.svelte';
export { PixelControl, type PixelControlOptions } from './pixel-control.ts';
export * from './fbc.ts';
export * from './pixel-control.ts';

View File

@@ -0,0 +1,315 @@
import type { TrackingManager } from '../tracking.svelte.ts';
import type {
EventParamsByName,
StandardEventName,
CommonParams,
CustomParams,
AdvancedMatching,
InitOptions
} from '../types/fbq.js';
import { loadMetaPixel } from '../util/meta-pixel-loader.ts';
import { resolveGetter, type MaybeGetter } from '../util/getter.ts';
import log from 'loglevel';
import { dev } from '$app/environment';
import { ConversionClient } from '../conversion/client.ts';
import type { ConversionEventParams } from '$lib/types/conversion.js';
const pixelParamsToCustomData = (params: CommonParams & CustomParams): ConversionEventParams => {
const customData: ConversionEventParams = {};
if (params.value) customData.value = params.value;
if (params.currency) customData.currency = params.currency;
if (params.content_name) customData.content_name = params.content_name;
if (params.content_category) customData.content_category = params.content_category;
if (params.content_ids) customData.content_ids = params.content_ids;
if (params.contents) {
const acc: ConversionEventParams['contents'] = [];
customData.contents = params.contents.reduce((acc, content) => {
acc.push({
id: content.id.toString(),
quantity: content.quantity
});
return acc;
}, acc);
}
if (params.num_items) customData.num_items = params.num_items;
if (params.search_string) customData.search_string = params.search_string;
if (params.predicted_ltv) customData.predicted_ltv = params.predicted_ltv;
return customData;
};
/**
* Options for configuring a PixelControl instance.
*/
export type PixelControlOptions = {
/**
* if provided, events fired will always have this code attached
* to prevent them from polluting real analytics data.
*/
testEventCode?: string;
/**
* if provided, all events fired will be passed to the server endpoint
* at this URL to be sent via the Conversion API. Any events sent
* without a event ID will be assigned a random one.
*/
conversionHref?: string;
/** Advanced matching data */
advancedMatching?: AdvancedMatching;
/** Initialization options */
initOptions?: InitOptions;
};
/**
* Manages multiple Meta Pixel instances and provides methods to
* interact with them, including consent management and event tracking.
*/
export class PixelControl {
private _pixelID: string;
private _testEventCode?: string = undefined;
private _trackingManager: MaybeGetter<TrackingManager | undefined>;
private _conversionClient?: ConversionClient = undefined;
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 {
return this._baseLoaded;
}
/**
* Ensures that the Meta Pixel base has been loaded before
* allowing further operations.
* @throws Error if the Meta Pixel API is not loaded.
*/
static loadGuard(): void {
if (!this._baseLoaded || !window.fbq) {
throw new Error('Meta Pixel API has not been loaded. Call PixelControl.load() first.');
}
}
private constructor(
trackingManager: MaybeGetter<TrackingManager | undefined>,
pixelID: string,
options?: PixelControlOptions
) {
this._trackingManager = trackingManager;
this._pixelID = pixelID;
this._testEventCode = options?.testEventCode;
const resolvedTrackingManager = resolveGetter(trackingManager);
if (options?.conversionHref && resolvedTrackingManager) {
this._conversionClient = new ConversionClient(
options.conversionHref,
resolvedTrackingManager
);
} else if (options?.conversionHref) {
log.warn(
`[PixelControl] Conversion Client ${options.conversionHref} for Meta Pixel [${this._pixelID}] not initialized, TrackingManager is required for user consent.`
);
}
}
/** Loads the Meta Pixel base script. */
static load() {
if (this._baseLoaded && !!window.fbq) return;
loadMetaPixel(); // Load the Meta Pixel script
this._baseLoaded = true;
log.debug('[PixelControl] Meta Pixel base script loaded.', this._baseLoaded);
}
/** Tells the Meta pixel that the user has given consent for tracking. */
static grantConsent() {
this.loadGuard();
window.fbq?.('consent', 'grant');
log.debug('[PixelControl] Pixel consent granted.');
}
/** Tells the Meta pixel that the user has revoked consent for tracking. */
static revokeConsent() {
this.loadGuard();
window.fbq?.('consent', 'revoke');
log.debug('[PixelControl] Pixel consent revoked.');
}
/**
* Registers a PixelControl instance for the given Meta Pixel ID. If
* the base Meta Pixel script has not been loaded yet, it will be
* loaded automatically. Optionally sets a test event code for the Pixel.
* Should only be called once for each Pixel ID, use PixelControl.get()
* to retrieve existing instances.
* @param trackingManager Tracking manager to handle user consent for tracking
* @param pixelID Meta Pixel ID
* @param options Optional settings
* @returns PixelControl instance
*/
static initialize(
trackingManager: MaybeGetter<TrackingManager | undefined>,
pixelID: string,
options?: PixelControlOptions
): PixelControl {
// Load the base script if not already loaded
PixelControl.load();
// 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(`[PixelControl] [${pixel._pixelID}] initialized.`);
return pixel;
}
/**
* Returns an existing PixelControl instance for the given Meta Pixel ID.
* @param pixelID Meta Pixel ID
* @returns PixelControl instance
* @throws Error if no PixelControl instance is found for the given ID.
*/
static get(pixelID: string): PixelControl {
const pixel = this._registeredPixels[pixelID];
if (!pixel) {
throw new Error(`No PixelControl instance found for Meta Pixel ID: ${pixelID}`);
}
return pixel;
}
/**
* Checks if the Meta Pixel has consent to track user data
* and if the Pixel has been loaded.
* @returns true if tracking is allowed, false otherwise.
* @throws Error if the Meta Pixel is not loaded.
*/
consentGuard(): boolean {
PixelControl.loadGuard();
const trackingManager = resolveGetter(this._trackingManager);
return trackingManager?.haveUserConsent() ?? false;
}
/**
* Sends a PageView event
* @param disableCAPI If true, disables sending this event to the Conversion API
* @throws Error if the Meta Pixel is not initialized.
*/
pageView(disableCAPI: boolean = false) {
if (!this.consentGuard()) return;
let eventID: string | undefined = undefined;
// Optionally, send to conversion API endpoint if configured
if (this._conversionClient && !disableCAPI) {
eventID = crypto.randomUUID();
this._conversionClient
.trackEvent('PageView', { eventID })
.then((response) => {
log.debug(
`[PixelControl] [${this._pixelID}] PageView event sent to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify(
response
)}`
);
})
.catch((error) => {
log.error(
`[PixelControl] [${this._pixelID}] Failed to send PageView event to Conversion API with Event ID: ${eventID}`,
error
);
});
}
// Send the PageView event to Meta
if (!dev || this._testEventCode) {
window.fbq('track', 'PageView', undefined, {
test_event_code: this._testEventCode,
eventID
});
log.debug(
`[PixelControl] [${this._pixelID}] PageView event sent${dev && ` (test code: ${this._testEventCode})`}.`
);
} else {
log.info(
`[PixelControl] [${this._pixelID}] PageView event not sent in development mode without a test event code.`
);
}
}
/**
* Tracks a standard event for this pixel (uses `trackSingle` under the hood)
* @param event Standard event name
* @param params Event parameters
* @param eventID Optional event ID for deduplication with Conversion API
* @param disableCAPI If true, disables sending this event to the Conversion API
* @throws Error if the Meta Pixel is not initialized.
*/
track<K extends StandardEventName>(
event: K,
params?: EventParamsByName[K],
eventID?: string,
disableCAPI: boolean = false
) {
if (!this.consentGuard()) return;
// Optionally, send to conversion API endpoint if configured
if (this._conversionClient && !disableCAPI) {
eventID = eventID ?? crypto.randomUUID();
this._conversionClient
.trackEvent(event, { eventID: eventID, customData: pixelParamsToCustomData(params ?? {}) })
.then((response) => {
log.debug(
`[PixelControl] [${this._pixelID}] ${event} event sent to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify(
response
)}`
);
})
.catch((error) => {
log.error(
`[PixelControl] [${this._pixelID}] Failed to send ${event} event to Conversion API with Event ID: ${eventID}`,
error
);
});
}
// Send the PageView event to Meta
if (!dev || this._testEventCode) {
window.fbq('trackSingle', this._pixelID, event, params, {
eventID,
test_event_code: this._testEventCode
});
log.debug(
`[PixelControl] [${this._pixelID}] ${event} event sent${dev && ` (test code: ${this._testEventCode})`}.`
);
} else {
log.info(
`[PixelControl] [${this._pixelID}] ${event} event not sent in development mode without a test event code.`
);
}
}
/**
* Tracks a custom event for this pixel (uses `trackSingleCustom` under the hood)
* @throws Error if the Meta Pixel is not initialized.
*/
trackCustom(event: string, params?: CommonParams & CustomParams, eventID?: string) {
if (!this.consentGuard()) return;
if (!dev || this._testEventCode) {
window.fbq('trackSingleCustom', this._pixelID, event, params, {
eventID,
test_event_code: this._testEventCode
});
log.debug(
`[PixelControl] [${this._pixelID}] ${event} custom event sent (test code: ${this._testEventCode}).`
);
} else {
log.info(
`[PixelControl] [${this._pixelID}] ${event} custom event not sent in development mode without a test event code.`
);
}
}
}

View File

@@ -16,6 +16,47 @@ export type ConversionUserData = {
externalId?: string; externalId?: string;
}; };
/**
* Supported custom data fields for conversion events.
*/
export type ConversionEventParams = {
value?: number;
net_revenue?: number;
currency?: string;
content_name?: string;
content_category?: string;
content_ids?: string[];
contents?: {
id?: string;
quantity?: number;
item_price?: number;
title?: string;
description?: string;
category?: string;
brand?: string;
delivery_category?: string;
}[];
content_type?: string;
order_id?: string;
predicted_ltv?: number;
num_items?: number;
search_string?: string;
status?: string;
item_number?: string;
delivery_category?: string;
custom_properties?: Record<string, unknown>;
};
/**
* Parameters for sending a conversion event to Meta Pixel.
*/
export type ConversionEventDetails = {
eventID?: string;
actionSource: 'website' | 'app' | 'offline' | 'other';
userData: ConversionUserData;
eventSourceURL?: string;
};
/** /**
* Request body for conversion event tracking. * Request body for conversion event tracking.
*/ */
@@ -28,7 +69,7 @@ export type ConversionRequestBody = {
Record<'utm_source' | 'utm_medium' | 'utm_campaign' | 'utm_content' | 'utm_term', string> Record<'utm_source' | 'utm_medium' | 'utm_campaign' | 'utm_content' | 'utm_term', string>
>; >;
user?: Omit<ConversionUserData, 'ip'>; user?: Omit<ConversionUserData, 'ip'>;
customData?: Record<string, string>; customData?: ConversionEventParams;
}; };
/** /**

View File

@@ -39,7 +39,7 @@ export type CommonParams = Partial<{
/** /**
* Product IDs associated with the event, such as SKUs (e.g. ["ABC123", "XYZ456"]). * Product IDs associated with the event, such as SKUs (e.g. ["ABC123", "XYZ456"]).
*/ */
content_ids: (string | number)[]; content_ids: string[];
/** /**
* Name of the page/product. * Name of the page/product.