conversion: explicit event params, removes any in PixelControl

This commit is contained in:
Elijah Duffy
2025-12-18 18:44:33 -08:00
parent fc07bb057c
commit f5ec7b3812
6 changed files with 146 additions and 55 deletions

View File

@@ -1,6 +1,7 @@
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,7 +38,7 @@ 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

View File

@@ -1,6 +1,17 @@
import { CustomData, EventRequest, ServerEvent, UserData } from 'facebook-nodejs-business-sdk'; import {
Content,
CustomData,
EventRequest,
ServerEvent,
UserData
} from 'facebook-nodejs-business-sdk';
import type { StandardEventName } from '../types/fbq.js'; import type { StandardEventName } from '../types/fbq.js';
import type { ConversionResponseBody, ConversionUserData } from '$lib/types/conversion.js'; import type {
ConversionEventDetails,
ConversionEventParams,
ConversionResponseBody,
ConversionUserData
} from '$lib/types/conversion.js';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import log from 'loglevel'; import log from 'loglevel';
@@ -10,7 +21,7 @@ import log from 'loglevel';
* @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);
@@ -30,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;
actionSource: 'website' | 'app' | 'offline' | 'other';
userData: UserData;
customData?: CustomData;
eventSourceURL?: string;
}; };
/** /**
@@ -95,17 +107,21 @@ export class ConversionControl {
*/ */
async trackEvent( async trackEvent(
eventName: StandardEventName, eventName: StandardEventName,
options: ConversionEventOptions details: ConversionEventDetails,
params?: ConversionEventParams
): Promise<ConversionResponseBody> { ): 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) .setEventId(details.eventID)
.setUserData(options.userData) .setUserData(buildUserData(details.userData))
.setActionSource(options.actionSource); .setActionSource(details.actionSource);
if (options?.eventSourceURL) event.setEventSourceUrl(options.eventSourceURL); if (details?.eventSourceURL) event.setEventSourceUrl(details.eventSourceURL);
if (options?.customData) event.setCustomData(options.customData); 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 we're in dev mode and missing a test event code, log and exit
if (dev && !this._testEventCode) { if (dev && !this._testEventCode) {

View File

@@ -1,6 +1,11 @@
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 { ConversionErrorResponseBody, ConversionRequestBody } from '$lib/types/conversion.js'; import type {
ConversionErrorResponseBody,
ConversionEventParams,
ConversionRequestBody,
ConversionUserData
} from '$lib/types/conversion.js';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
@@ -29,32 +34,33 @@ export const createConversionRequestHandler: (control: ConversionControl) => Req
const ip = getEventIP(request, getClientAddress); const ip = getEventIP(request, getClientAddress);
const ua = request.headers.get('user-agent'); const ua = request.headers.get('user-agent');
const userData = buildConversionUserData({ const userData: ConversionUserData = {
...body.user, ...body.user,
ip, ip,
ua: ua ?? body.user?.ua ?? undefined ua: ua ?? body.user?.ua ?? undefined
}); };
// 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
}); );
return json(response, { status: StatusCodes.OK }); return json(response, { status: StatusCodes.OK });
} catch (e) { } catch (e) {

View File

@@ -12,7 +12,34 @@ import { resolveGetter, type MaybeGetter } from '../util/getter.ts';
import log from 'loglevel'; import log from 'loglevel';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { ConversionClient } from '../conversion/client.ts'; 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 = { export type PixelControlOptions = {
/** /**
* if provided, events fired will always have this code attached * if provided, events fired will always have this code attached
@@ -229,7 +256,7 @@ export class PixelControl {
if (this._conversionClient) { if (this._conversionClient) {
eventID = eventID ?? crypto.randomUUID(); eventID = eventID ?? crypto.randomUUID();
this._conversionClient this._conversionClient
.trackEvent(event, { eventID: eventID, customData: params as any }) .trackEvent(event, { eventID: eventID, customData: pixelParamsToCustomData(params ?? {}) })
.then((response) => { .then((response) => {
log.debug( log.debug(
`Meta Pixel [${this._pixelID}] ${event} event sent to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify( `Meta Pixel [${this._pixelID}] ${event} event sent to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify(

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.