Compare commits
3 Commits
93e5c35d86
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3c880c17b | ||
|
|
01fd81f911 | ||
|
|
af1b66649b |
@@ -4,7 +4,7 @@
|
||||
"type": "git",
|
||||
"url": "https://gitea.auvem.com/svelte-toolkit/spectator.git"
|
||||
},
|
||||
"version": "0.0.6",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (dev) log.info('[Umami] [dev]: tracking disabled');
|
||||
if (dev) log.info('[Umami] [dev]: reporting disabled');
|
||||
|
||||
onMount(() => {
|
||||
if (dev) {
|
||||
|
||||
@@ -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.
|
||||
* @returns A promise that resolves to the server response.
|
||||
* @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).`);
|
||||
}
|
||||
return {
|
||||
pixelID: '',
|
||||
fbTraceID: '',
|
||||
receivedEvents: 0,
|
||||
processedEvents: 0,
|
||||
fbtrace_id: '',
|
||||
events_received: 0,
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
@@ -62,7 +61,11 @@ export class CAPIClient {
|
||||
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, {
|
||||
method: 'POST',
|
||||
@@ -73,12 +76,16 @@ export class CAPIClient {
|
||||
});
|
||||
const json = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
const parsed = v.parse(capiResponseBodySchema, json);
|
||||
return parsed as CAPIResponseBody;
|
||||
} else {
|
||||
const parsed = v.parse(capiErrorBodySchema, json);
|
||||
return parsed as CAPIErrorBody;
|
||||
try {
|
||||
if (response.ok) {
|
||||
const parsed = v.parse(capiResponseBodySchema, json);
|
||||
return parsed as CAPIResponseBody;
|
||||
} else {
|
||||
const parsed = v.parse(capiErrorBodySchema, json);
|
||||
return parsed as CAPIErrorBody;
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`[CAPIClient] Invalid response shape: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import { dev } from '$app/environment';
|
||||
import log from 'loglevel';
|
||||
import type { CAPIEvent } from './event.ts';
|
||||
import * as v from 'valibot';
|
||||
import { capiResponseBodySchema, type CAPIResponseBody } from './handle.ts';
|
||||
|
||||
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
|
||||
* requests to Meta's CAPI endpoint.
|
||||
@@ -52,7 +38,7 @@ export class CAPIConnector {
|
||||
* @returns The response from the Meta CAPI.
|
||||
* @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) {
|
||||
log.warn(
|
||||
`[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 = {
|
||||
data: events.map((e) => e.toObject()),
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -75,13 +64,20 @@ export class CAPIConnector {
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const json = await resp.json();
|
||||
console.log('CAPI response:', json);
|
||||
|
||||
if (!resp.ok) {
|
||||
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.
|
||||
* @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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,8 @@ export const capiErrorBodySchema = v.object({
|
||||
export type CAPIErrorBody = v.InferOutput<typeof capiErrorBodySchema>;
|
||||
|
||||
export const capiResponseBodySchema = v.object({
|
||||
pixelID: v.string(),
|
||||
fbTraceID: v.string(),
|
||||
receivedEvents: v.number(),
|
||||
processedEvents: v.number(),
|
||||
fbtrace_id: v.string(),
|
||||
events_received: v.number(),
|
||||
messages: v.array(v.string())
|
||||
});
|
||||
|
||||
@@ -40,8 +38,8 @@ export type CAPIResponseBody = v.InferOutput<typeof capiResponseBodySchema>;
|
||||
export const createCAPIHandler: (connector: CAPIConnector) => RequestHandler = (connector) => {
|
||||
const handle: RequestHandler = async ({ request, getClientAddress, cookies }) => {
|
||||
try {
|
||||
const json = await request.json();
|
||||
const parsed = v.parse(capiRequestBodySchema, json);
|
||||
const jsonBody = await request.json();
|
||||
const parsed = v.parse(capiRequestBodySchema, jsonBody);
|
||||
|
||||
// Build enriched user data with IP, user agent, and fbp/fbc from cookies
|
||||
const ip = getRequestIP(request, getClientAddress);
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from './util/ip.ts';
|
||||
// set log level to debug if we're in dev mode
|
||||
if (dev) {
|
||||
log.setLevel('debug');
|
||||
log.debug('[spectator] Log level set to debug');
|
||||
} else {
|
||||
log.setLevel('warn');
|
||||
}
|
||||
|
||||
@@ -192,6 +192,16 @@ export class PixelControl {
|
||||
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(
|
||||
`[PixelControl] [${this._pixelID}] Sending events in dev mode without a test event code. ` +
|
||||
'Consider providing a test event code to avoid affecting real data.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand utility to send a PageView event
|
||||
* @param disableCAPI If true, disables sending this event to the Conversion API
|
||||
@@ -235,7 +245,7 @@ export class PixelControl {
|
||||
)
|
||||
.then((response) => {
|
||||
log.debug(
|
||||
`[PixelControl] [${this._pixelID}] ${event} event sent to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify(
|
||||
`[PixelControl] [${this._pixelID}] ${event} event forwarded to Conversion API with Event ID: ${eventID}, Response: ${JSON.stringify(
|
||||
response,
|
||||
null,
|
||||
2
|
||||
@@ -244,10 +254,12 @@ export class PixelControl {
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error(
|
||||
`[PixelControl] [${this._pixelID}] Failed to send ${event} event to Conversion API with Event ID: ${eventID}`,
|
||||
`[PixelControl] [${this._pixelID}] Failed to forward ${event} event to Conversion API with Event ID: ${eventID}`,
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
return eventID;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,6 +277,7 @@ export class PixelControl {
|
||||
disableCAPI: boolean = false
|
||||
) {
|
||||
if (!this.consentGuard()) return;
|
||||
this.devModeWarn();
|
||||
|
||||
// Optionally, send to conversion API endpoint
|
||||
if (!disableCAPI) {
|
||||
@@ -272,19 +285,13 @@ export class PixelControl {
|
||||
}
|
||||
|
||||
// Send the event to Meta via the pixel
|
||||
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.`
|
||||
);
|
||||
}
|
||||
window.fbq('trackSingle', this._pixelID, event, params, {
|
||||
eventID,
|
||||
test_event_code: this._testEventCode
|
||||
});
|
||||
log.debug(
|
||||
`[PixelControl] [${this._pixelID}] ${event} event sent with event ID ${eventID} ${dev && ` (test code: ${this._testEventCode})`}.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,25 +309,20 @@ export class PixelControl {
|
||||
disableCAPI: boolean = false
|
||||
) {
|
||||
if (!this.consentGuard()) return;
|
||||
this.devModeWarn();
|
||||
|
||||
// Optionally, send to conversdion API endpoint
|
||||
// Optionally, send to conversion API endpoint
|
||||
if (!disableCAPI) {
|
||||
eventID = this.forwardToCAPI(event, params, eventID);
|
||||
}
|
||||
|
||||
// Send the event to Meta via the pixel
|
||||
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.`
|
||||
);
|
||||
}
|
||||
window.fbq('trackSingleCustom', this._pixelID, event, params, {
|
||||
eventID,
|
||||
test_event_code: this._testEventCode
|
||||
});
|
||||
log.debug(
|
||||
`[PixelControl] [${this._pixelID}] ${event} custom event sent with event ID ${eventID} ${dev && ` (test code: ${this._testEventCode})`}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export class TrackingManager {
|
||||
private _consent: boolean | null = $state(null);
|
||||
private _services: Record<string, InternalService<unknown>> = {};
|
||||
private _changeCallbacks: Array<(consent: boolean | null) => void> = [];
|
||||
private _loadCallbacks: Array<(consent: boolean | null) => void> = [];
|
||||
private _loaded: boolean = false;
|
||||
private _consentQueue: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
@@ -80,6 +82,12 @@ export class TrackingManager {
|
||||
log.debug('[TrackingManager] Loaded tracking options from storage:', opts);
|
||||
}
|
||||
|
||||
// Run load callbacks
|
||||
this._loadCallbacks.forEach((cb) => {
|
||||
cb(this._consent);
|
||||
});
|
||||
this._loaded = true;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -152,6 +160,16 @@ export class TrackingManager {
|
||||
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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user