6 Commits

Author SHA1 Message Date
Elijah Duffy
354a2ac739 0.1.1 2025-12-24 12:02:01 -08:00
Elijah Duffy
ac3aabc1ed improve logs & capi event source URL handling 2025-12-24 12:01:50 -08:00
Elijah Duffy
2daf2eabfb pixel: include event source url when sending to CAPI 2025-12-24 10:55:53 -08:00
Elijah Duffy
e3c880c17b 0.1.0 2025-12-22 16:57:46 -08:00
Elijah Duffy
01fd81f911 tracking: add onceLoaded callback 2025-12-22 16:57:20 -08:00
Elijah Duffy
af1b66649b fix capi response types, improve log clarity 2025-12-22 16:57:06 -08:00
9 changed files with 193 additions and 93 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.6", "version": "0.1.1",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

View File

@@ -64,7 +64,7 @@
} }
}); });
if (dev) log.info('[Umami] [dev]: tracking disabled'); if (dev) log.info('[Umami] [dev]: reporting disabled');
onMount(() => { onMount(() => {
if (dev) { if (dev) {

View File

@@ -31,7 +31,8 @@ export class CAPIClient {
} }
/** /**
* Sends CAPIEvents to the server endpoint. * Sends CAPIEvents to the server endpoint. If no consent is given, no events
* are sent and a dummy response is returned.
* @param events - The array of CAPIEvents to send. * @param events - The array of CAPIEvents to send.
* @returns A promise that resolves to the server response. * @returns A promise that resolves to the server response.
* @throws Will throw an error if input or response shape validation fails. * @throws Will throw an error if input or response shape validation fails.
@@ -43,10 +44,8 @@ export class CAPIClient {
console.warn(`[CAPIClient] Consent not given. Skipping sending ${events.length} event(s).`); console.warn(`[CAPIClient] Consent not given. Skipping sending ${events.length} event(s).`);
} }
return { return {
pixelID: '', fbtrace_id: '',
fbTraceID: '', events_received: 0,
receivedEvents: 0,
processedEvents: 0,
messages: [] messages: []
}; };
} }
@@ -62,7 +61,11 @@ export class CAPIClient {
return e.toObject(); return e.toObject();
}) })
}; };
v.parse(v.object({ events: v.array(v.any()) }), body); // Validate body shape try {
v.parse(v.object({ events: v.array(v.any()) }), body); // Validate body shape
} catch (err) {
throw new Error(`[CAPIClient] Invalid request body shape: ${(err as Error).message}`);
}
const response = await fetch(this._href, { const response = await fetch(this._href, {
method: 'POST', method: 'POST',
@@ -73,12 +76,16 @@ export class CAPIClient {
}); });
const json = await response.json(); const json = await response.json();
if (response.ok) { try {
const parsed = v.parse(capiResponseBodySchema, json); if (response.ok) {
return parsed as CAPIResponseBody; const parsed = v.parse(capiResponseBodySchema, json);
} else { return parsed as CAPIResponseBody;
const parsed = v.parse(capiErrorBodySchema, json); } else {
return parsed as CAPIErrorBody; const parsed = v.parse(capiErrorBodySchema, json);
return parsed as CAPIErrorBody;
}
} catch (err) {
throw new Error(`[CAPIClient] Invalid response shape: ${(err as Error).message}`);
} }
} }

View File

@@ -1,25 +1,11 @@
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import log from 'loglevel'; import log from 'loglevel';
import type { CAPIEvent } from './event.ts'; import type { CAPIEvent } from './event.ts';
import * as v from 'valibot';
import { capiResponseBodySchema, type CAPIResponseBody } from './handle.ts';
const GRAPH_VERSION = 'v24.0'; const GRAPH_VERSION = 'v24.0';
/**
* Response body from Meta Conversion API after sending events.
*/
export type CAPIRequestResponseBody = {
/** Dataset or Pixel ID to which the event successfully posted. */
pixelID: string;
/** fbtrace_id for debugging purposes. */
fbtrace_id: string;
/** Number of events received that were sent by the request. */
receivedEvents: number;
/** Number of events successfully posted by the request. */
processedEvents: number;
/** Messages returned by the server. */
messages: string[];
};
/** /**
* Connector class for Meta Conversion API (CAPI). Abstraction over direct HTTP * Connector class for Meta Conversion API (CAPI). Abstraction over direct HTTP
* requests to Meta's CAPI endpoint. * requests to Meta's CAPI endpoint.
@@ -52,7 +38,7 @@ export class CAPIConnector {
* @returns The response from the Meta CAPI. * @returns The response from the Meta CAPI.
* @throws Will throw an error if the request fails or the API returns an error. * @throws Will throw an error if the request fails or the API returns an error.
*/ */
async sendEvents(events: CAPIEvent[]): Promise<CAPIRequestResponseBody> { async sendEvents(events: CAPIEvent[]): Promise<CAPIResponseBody> {
if (dev && !this._testEventCode) { if (dev && !this._testEventCode) {
log.warn( log.warn(
`[CAPIConnector] Sending ${events.length} event(s) in dev mode without a test event code. ` + `[CAPIConnector] Sending ${events.length} event(s) in dev mode without a test event code. ` +
@@ -60,14 +46,17 @@ export class CAPIConnector {
); );
} }
const url = `https://graph.facebook.com/${GRAPH_VERSION}/${this._pixelID}/events?access_token=${this._accessToken}`; const url = `https://graph.facebook.com/${GRAPH_VERSION}/${this._pixelID}/events`;
const body = { const body = {
data: events.map((e) => e.toObject()), data: events.map((e) => e.toObject()),
test_event_code: this._testEventCode test_event_code: this._testEventCode
}; };
log.debug(
`[CAPIConnector] [${this._pixelID}] Sending ${events.length} event(s) to Meta CAPI at ${url} with body: ${JSON.stringify(body, null, 2)}`
);
const resp = await fetch(url, { const resp = await fetch(`${url}?access_token=${this._accessToken}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -75,13 +64,20 @@ export class CAPIConnector {
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
const json = await resp.json(); const json = await resp.json();
console.log('CAPI response:', json);
if (!resp.ok) { if (!resp.ok) {
throw new Error(`Meta CAPI error ${resp.status}: ${JSON.stringify(json, null, 2)}`); throw new Error(`Meta CAPI error ${resp.status}: ${JSON.stringify(json, null, 2)}`);
} }
return {} as CAPIRequestResponseBody; try {
const parsed = v.parse(capiResponseBodySchema, json);
log.info(
`[CAPIConnector] [${this._pixelID}] Successfully sent ${events.length} event(s) to Meta CAPI.`
);
return parsed as CAPIResponseBody;
} catch (err) {
throw new Error(`[CAPIConnector] Invalid response shape: ${(err as Error).message}`);
}
} }
/** /**
@@ -91,7 +87,7 @@ export class CAPIConnector {
* @returns The response from the Meta CAPI. * @returns The response from the Meta CAPI.
* @throws Will throw an error if the request fails or the API returns an error. * @throws Will throw an error if the request fails or the API returns an error.
*/ */
async trackEvent(event: CAPIEvent): Promise<CAPIRequestResponseBody> { async trackEvent(event: CAPIEvent): Promise<CAPIResponseBody> {
return this.sendEvents([event]); return this.sendEvents([event]);
} }
} }

View File

@@ -1,6 +1,7 @@
import type { StandardEventName } from '../types/fbq.js'; import type { StandardEventName } from '../types/fbq.js';
import * as v from 'valibot'; import * as v from 'valibot';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import log from 'loglevel';
const sha256 = (value: string): string => { const sha256 = (value: string): string => {
return crypto.createHash('sha256').update(value).digest('hex'); return crypto.createHash('sha256').update(value).digest('hex');
@@ -202,7 +203,11 @@ export type CAPIEventOptions = {
eventTime?: Date; eventTime?: Date;
userData: CAPICustomerInfoParams; userData: CAPICustomerInfoParams;
customData?: CAPIStandardParams; customData?: CAPIStandardParams;
/** Required if actionSource is set to 'website' */ /**
* Required by Meta if actionSource is set to 'website'. If not provided and
* actionSource is 'website', a warning will be emitted and the field may be
* filled by the server receiving the event, however, accuracy is not guaranteed.
*/
eventSourceURL?: string; eventSourceURL?: string;
optOut?: boolean; optOut?: boolean;
eventID?: string; eventID?: string;
@@ -220,6 +225,12 @@ export class CAPIEvent {
return this._params; return this._params;
} }
/** Returns human-readable event name, optionally including event ID. */
get readableName() {
if (!this._params) return 'Unknown Event';
return `${this._params.event_name}${this._params.event_id ? ` (${this._params.event_id})` : ''}`;
}
private constructor() {} private constructor() {}
/** /**
@@ -249,9 +260,28 @@ export class CAPIEvent {
}; };
event._params = v.parse(MetaServerEventParamsSchema, event._params); event._params = v.parse(MetaServerEventParamsSchema, event._params);
event.paramIntegrationCheck();
return event; return event;
} }
/**
* Performs integration checks on the event parameters and logs warnings
* if potential issues are detected.
*/
private paramIntegrationCheck() {
if (
this._params &&
this._params.action_source === ActionSource.Website &&
!this._params.event_source_url
) {
console.warn(
`[CAPIEvent] Warning: ${this.readableName} ` +
`with actionSource 'website' is missing eventSourceURL. Provide eventSourceURL to improve ` +
`data quality and avoid blocks by Meta.`
);
}
}
/** /**
* Unmashals a JSON string or object into a CAPIEvent instance and parses * Unmashals a JSON string or object into a CAPIEvent instance and parses
* its parameters to match Meta's expected shape. * its parameters to match Meta's expected shape.
@@ -265,6 +295,7 @@ export class CAPIEvent {
const parsed = v.parse(MetaServerEventParamsSchema, obj); const parsed = v.parse(MetaServerEventParamsSchema, obj);
const event = new CAPIEvent(); const event = new CAPIEvent();
event._params = parsed; event._params = parsed;
event.paramIntegrationCheck();
return event; return event;
} }
@@ -309,7 +340,8 @@ export class CAPIEvent {
} }
/** /**
* Enriches the CAPIEvent with additional user data. * Enriches the CAPIEvent with additional user data. Will not override
* existing user data fields if already set.
* @param additionalData - Additional user data to merge. * @param additionalData - Additional user data to merge.
* @throws Will throw an error if the enriched data fails validation. * @throws Will throw an error if the enriched data fails validation.
*/ */
@@ -326,4 +358,25 @@ export class CAPIEvent {
// Re-validate after enrichment // Re-validate after enrichment
this._params = v.parse(MetaServerEventParamsSchema, this._params); this._params = v.parse(MetaServerEventParamsSchema, this._params);
} }
/**
* Enriches the CAPIEvent with an event source URL. Will not override
* existing event source URL if already set AND will only apply if the
* action source is 'website'. A message will be logged to warn the user
* that an implicit event source URL is being set.
* @param url - The event source URL to set.
*/
unsafeEventSourceURL(url?: string) {
if (!this._params) return;
if (!url) {
log.warn(
`[CAPIEvent] ${this.readableName} could not implicitly set eventSourceURL, event will be submitted without it.`
);
return;
}
if (!this._params.event_source_url && this._params.action_source === ActionSource.Website) {
this._params.event_source_url = url;
log.warn(`[CAPIEvent] ${this.readableName} implicitly setting eventSourceURL to ${url}`);
}
}
} }

View File

@@ -21,10 +21,8 @@ export const capiErrorBodySchema = v.object({
export type CAPIErrorBody = v.InferOutput<typeof capiErrorBodySchema>; export type CAPIErrorBody = v.InferOutput<typeof capiErrorBodySchema>;
export const capiResponseBodySchema = v.object({ export const capiResponseBodySchema = v.object({
pixelID: v.string(), fbtrace_id: v.string(),
fbTraceID: v.string(), events_received: v.number(),
receivedEvents: v.number(),
processedEvents: v.number(),
messages: v.array(v.string()) messages: v.array(v.string())
}); });
@@ -40,13 +38,14 @@ export type CAPIResponseBody = v.InferOutput<typeof capiResponseBodySchema>;
export const createCAPIHandler: (connector: CAPIConnector) => RequestHandler = (connector) => { export const createCAPIHandler: (connector: CAPIConnector) => RequestHandler = (connector) => {
const handle: RequestHandler = async ({ request, getClientAddress, cookies }) => { const handle: RequestHandler = async ({ request, getClientAddress, cookies }) => {
try { try {
const json = await request.json(); const jsonBody = await request.json();
const parsed = v.parse(capiRequestBodySchema, json); const parsed = v.parse(capiRequestBodySchema, jsonBody);
// Build enriched user data with IP, user agent, and fbp/fbc from cookies // Build enriched user data with IP, user agent, and fbp/fbc from cookies
const ip = getRequestIP(request, getClientAddress); const ip = getRequestIP(request, getClientAddress);
const ua = request.headers.get('user-agent') ?? undefined; const ua = request.headers.get('user-agent') ?? undefined;
const { fbp, fbc } = getFbpFbcFromCookies(cookies); const { fbp, fbc } = getFbpFbcFromCookies(cookies);
const eventSourceURL = request.headers.get('referer') ?? undefined;
const enrichedUserData: Partial<CAPICustomerInfoParams> = { const enrichedUserData: Partial<CAPICustomerInfoParams> = {
clientIP: ip, clientIP: ip,
@@ -55,10 +54,11 @@ export const createCAPIHandler: (connector: CAPIConnector) => RequestHandler = (
fbc fbc
}; };
// Enrich each event's user data // Enrich each event's user data & event source URL
const events: CAPIEvent[] = parsed.events.map((eventParams) => { const events: CAPIEvent[] = parsed.events.map((eventParams) => {
const event = CAPIEvent.fromObject(eventParams); const event = CAPIEvent.fromObject(eventParams);
event.enrichUserData(enrichedUserData); event.enrichUserData(enrichedUserData);
event.unsafeEventSourceURL(eventSourceURL);
return event; return event;
}); });

View File

@@ -13,6 +13,7 @@ export * from './util/ip.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');
log.debug('[spectator] Log level set to debug');
} else { } else {
log.setLevel('warn'); log.setLevel('warn');
} }

View File

@@ -12,7 +12,12 @@ 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 { CAPIClient } from '../capi/client.ts'; import { CAPIClient } from '../capi/client.ts';
import { ActionSource, CAPIEvent, type CAPIStandardParams } from '../capi/event.ts'; import {
ActionSource,
CAPIEvent,
type CAPIEventOptions,
type CAPIStandardParams
} from '../capi/event.ts';
const pixelParamsToCustomData = (params: CommonParams & CustomParams): CAPIStandardParams => { const pixelParamsToCustomData = (params: CommonParams & CustomParams): CAPIStandardParams => {
const customData: CAPIStandardParams = {}; const customData: CAPIStandardParams = {};
@@ -101,7 +106,7 @@ export class PixelControl {
this._conversionClient = new CAPIClient(options.conversionHref, resolvedTrackingManager); this._conversionClient = new CAPIClient(options.conversionHref, resolvedTrackingManager);
} else if (options?.conversionHref) { } else if (options?.conversionHref) {
log.warn( log.warn(
`[PixelControl] Conversion Client ${options.conversionHref} for Meta Pixel [${this._pixelID}] not initialized, TrackingManager is required for user consent.` `${this.logPrefix} CAPI Client ${options.conversionHref} available but not initialized, TrackingManager is required for user consent.`
); );
} }
} }
@@ -150,7 +155,7 @@ export class PixelControl {
// Check for existing PixelControl instance // Check for existing PixelControl instance
if (this._registeredPixels[pixelID]) { if (this._registeredPixels[pixelID]) {
log.warn( log.warn(
`[PixelControl] Instance for Meta Pixel ID: ${pixelID} already exists. Returning existing instance.` `${this._registeredPixels[pixelID].logPrefix} Instance already exists. Returning existing instance.`
); );
return this._registeredPixels[pixelID]; return this._registeredPixels[pixelID];
} }
@@ -161,7 +166,7 @@ export class PixelControl {
// Fire initialization // Fire initialization
window.fbq('init', pixel._pixelID, options?.advancedMatching, options?.initOptions); window.fbq('init', pixel._pixelID, options?.advancedMatching, options?.initOptions);
log.debug(`[PixelControl] [${pixel._pixelID}] initialized.`); log.debug(`${pixel.logPrefix} initialized.`);
return pixel; return pixel;
} }
@@ -192,6 +197,21 @@ export class PixelControl {
return trackingManager?.haveUserConsent() ?? false; return trackingManager?.haveUserConsent() ?? false;
} }
/** Warns if we're in dev mode and no test code is set */
private devModeWarn() {
if (dev && !this._testEventCode) {
log.warn(
`${this.logPrefix} Sending events in dev mode without a test event code. ` +
'Consider providing a test event code to avoid affecting real data.'
);
}
}
/** Returns the log prefix including the pixel ID */
private get logPrefix(): string {
return `[PixelControl] [${this._pixelID}]`;
}
/** /**
* Shorthand utility to send a PageView event * Shorthand utility to send a PageView event
* @param disableCAPI If true, disables sending this event to the Conversion API * @param disableCAPI If true, disables sending this event to the Conversion API
@@ -220,22 +240,28 @@ export class PixelControl {
eventID = crypto.randomUUID(); eventID = crypto.randomUUID();
} }
const opts: CAPIEventOptions = {
eventName: event,
eventID: eventID,
actionSource: ActionSource.Website,
eventSourceURL: window.location.href,
eventTime: new Date(),
userData: {
clientUserAgent: navigator.userAgent
},
customData: params ? pixelParamsToCustomData(params) : undefined
};
const ev = CAPIEvent.fromOpts(opts);
log.debug(
`${this.logPrefix} ${ev.readableName} forwarding to CAPI with body: ${JSON.stringify(ev.params, null, 2)}`
);
this._conversionClient this._conversionClient
.trackEvent( .trackEvent(ev)
CAPIEvent.fromOpts({
eventName: event,
eventID: eventID,
actionSource: ActionSource.Website,
eventTime: new Date(),
userData: {
clientUserAgent: navigator.userAgent
},
customData: params ? pixelParamsToCustomData(params) : undefined
})
)
.then((response) => { .then((response) => {
log.debug( log.debug(
`[PixelControl] [${this._pixelID}] ${event} event sent to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify( `${this.logPrefix} ${ev.readableName} forwarded to CAPI, response: ${JSON.stringify(
response, response,
null, null,
2 2
@@ -243,11 +269,20 @@ export class PixelControl {
); );
}) })
.catch((error) => { .catch((error) => {
log.error( log.error(`${this.logPrefix} ${ev.readableName} failed to forward to CAPI. Error: `, error);
`[PixelControl] [${this._pixelID}] Failed to send ${event} event to Conversion API with Event ID: ${eventID}`,
error
);
}); });
return eventID;
}
private getEventReadableName(
event: StandardEventName | string,
eventID?: string,
testCode?: string
): string {
return (
event + (eventID ? ` (${eventID})` : '') + (testCode ? ` [test_event_code: ${testCode}]` : '')
);
} }
/** /**
@@ -265,6 +300,7 @@ export class PixelControl {
disableCAPI: boolean = false disableCAPI: boolean = false
) { ) {
if (!this.consentGuard()) return; if (!this.consentGuard()) return;
this.devModeWarn();
// Optionally, send to conversion API endpoint // Optionally, send to conversion API endpoint
if (!disableCAPI) { if (!disableCAPI) {
@@ -272,19 +308,13 @@ export class PixelControl {
} }
// Send the event to Meta via the pixel // Send the event to Meta via the pixel
if (!dev || this._testEventCode) { window.fbq('trackSingle', this._pixelID, event, params, {
window.fbq('trackSingle', this._pixelID, event, params, { eventID,
eventID, test_event_code: this._testEventCode
test_event_code: this._testEventCode });
}); log.debug(
log.debug( `${this.logPrefix} ${this.getEventReadableName(event, eventID, this._testEventCode)} sent.`
`[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.`
);
}
} }
/** /**
@@ -302,25 +332,20 @@ export class PixelControl {
disableCAPI: boolean = false disableCAPI: boolean = false
) { ) {
if (!this.consentGuard()) return; if (!this.consentGuard()) return;
this.devModeWarn();
// Optionally, send to conversdion API endpoint // Optionally, send to conversion API endpoint
if (!disableCAPI) { if (!disableCAPI) {
eventID = this.forwardToCAPI(event, params, eventID); eventID = this.forwardToCAPI(event, params, eventID);
} }
// Send the event to Meta via the pixel // Send the event to Meta via the pixel
if (!dev || this._testEventCode) { window.fbq('trackSingleCustom', this._pixelID, event, params, {
window.fbq('trackSingleCustom', this._pixelID, event, params, { eventID,
eventID, test_event_code: this._testEventCode
test_event_code: this._testEventCode });
}); log.debug(
log.debug( `${this.logPrefix} ${this.getEventReadableName(event, eventID, this._testEventCode)} sent.`
`[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

@@ -42,6 +42,8 @@ export class TrackingManager {
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 _loadCallbacks: Array<(consent: boolean | null) => void> = [];
private _loaded: boolean = false;
private _consentQueue: Array<() => void> = []; private _consentQueue: Array<() => void> = [];
/** /**
@@ -80,6 +82,12 @@ export class TrackingManager {
log.debug('[TrackingManager] Loaded tracking options from storage:', opts); log.debug('[TrackingManager] Loaded tracking options from storage:', opts);
} }
// Run load callbacks
this._loadCallbacks.forEach((cb) => {
cb(this._consent);
});
this._loaded = true;
return this; return this;
} }
@@ -152,6 +160,16 @@ export class TrackingManager {
this.saveOpts(); this.saveOpts();
} }
/**
* Registers a callback to be called only once the tracking state has been
* loaded from localStorage. Will be called immediately if already loaded
* and with each subsequent load.
*/
onceLoaded(callback: (consent: boolean | null) => void) {
this._loadCallbacks.push(callback);
if (this._loaded) callback(this.consent);
}
/** /**
* Registers a callback to be notified when the tracking permission changes. * Registers a callback to be notified when the tracking permission changes.
*/ */