Skip to main content

Test Ad-Click Attribution

The simulate-ad-click.mjs script opens your site in Chrome as if a visitor clicked a Meta, Reddit or LinkedIn ad. It then checks that the Syft tag captured the ad click, so you can test attribution and conversions without paying for an ad.

Before you start​

  • Node.js 22 or later. Check with node --version. Get it from nodejs.org.
  • Google Chrome. The script opens a separate, temporary profile, so your own Chrome is not touched.
  • The script. Copy it from the end of this page and save it as simulate-ad-click.mjs, or download it:
curl -O https://docs.syftdata.com/scripts/simulate-ad-click.mjs

Run it​

In a terminal, in the folder with the script, run it with your site's address:

node simulate-ad-click.mjs https://www.yoursite.com/

If your site loads the Meta, Reddit and LinkedIn pixels, add --has-pixels:

node simulate-ad-click.mjs https://www.yoursite.com/ --has-pixels

Without --has-pixels, the script sets the cookies those pixels would set, with the same domain, lifetime and security settings, so you can test a site that does not load them.

Three Chrome windows open, one each for Meta, Reddit and LinkedIn. Each one is a new visitor who lands from an ad, with the platform's ad parameters and click id. The windows stay open, so you can continue the visit to test a conversion, such as a sign-up. Close the windows or press Ctrl+C when you are done.

Test visits are recorded like real ad visits, under the campaigns syft_test_meta, syft_test_reddit and syft_test_linkedin. Filter them out of your reports. If your site reports conversions to these ad platforms, a test sign-up can be reported too.

Read the results​

For each click, the terminal shows what the Syft tag sent and a PASS or FAIL line per check:

[linkedin] landing page sent by the Syft tag
url https://www.yoursite.com/?utm_source=linkedin&utm_medium=paid_social&...
referrer https://www.linkedin.com/
clickIds {"li_fat_id":"58dba089-..."}
adCookies {"li_fat_id":"58dba089-..."}
PASS click id li_fat_id captured
PASS referrer https://www.linkedin.com/ captured
PASS cookie li_fat_id captured

All checks passed.

The cookie checks run only without --has-pixels.

FAILWhat to check
click id capturedA redirect on the landing page that drops the part of the address after ?.
referrer capturedA redirect that resets where the visit came from, or a Referrer-Policy: no-referrer header.
cookie capturedA consent tool or script that deletes cookies before the Syft tag runs.
the Syft tag sent nothingThat the page loads the Syft tag. If a cookie banner appears, accept it in the window within 20 seconds.
the page did not loadThat the address is right and the site is up.

Check what the ad platforms receive​

The script checks what the Syft tag captures. To see what Meta, Reddit and LinkedIn receive from your site, use their own tools:

PlatformTool
MetaMeta Pixel Helper (Chrome extension), and the Test Events tab in Events Manager
RedditReddit Pixel Helper (Chrome extension), and Events Manager
LinkedInThe Conversion Tracking page in Campaign Manager

Extensions do not load in the script's windows. To use one, copy a test link from the terminal into your own Chrome. The test click ids are made up, so the platforms will not credit these visits to one of your ads.

If Chrome is not found​

Set CHROME_PATH to the Chrome program:

# macOS
CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" node simulate-ad-click.mjs https://www.yoursite.com/

# Linux
CHROME_PATH=/usr/bin/google-chrome node simulate-ad-click.mjs https://www.yoursite.com/
# Windows (PowerShell)
$env:CHROME_PATH = "C:\Program Files\Google\Chrome\Application\chrome.exe"
node simulate-ad-click.mjs https://www.yoursite.com/

The script​

simulate-ad-click.mjs
#!/usr/bin/env node
/**
* Simulates a visitor clicking a Meta, Reddit and LinkedIn ad and landing on
* a site that runs the Syft tag, then checks that the tag captured each click.
* Use it to test ad attribution and conversions without paying for an ad.
*
* node simulate-ad-click.mjs https://www.example.com/
* node simulate-ad-click.mjs https://www.example.com/ --has-pixels
*
* It opens your installed Google Chrome, one window per platform. Each window
* uses a new temporary profile, so every click is a new visitor and your own
* Chrome profile is not touched. Each visit lands with the platform's ad
* parameters and click id, and comes from the platform's site:
*
* meta ?utm_source=meta&utm_medium=paid_social&...&fbclid=IwY2xj...
* from https://l.facebook.com/
* reddit ?rdt_cid=<19 digits>&utm_medium=paid_social&utm_source=reddit&...
* from https://www.reddit.com/
* linkedin ?utm_source=linkedin&utm_medium=paid_social&...&li_fat_id=<uuid>
* from https://www.linkedin.com/
*
* Without --has-pixels, the script also sets the cookies the Meta, Reddit and
* LinkedIn pixels would write (_fbp and _fbc, _rdt_uuid and _rdt_cid,
* li_fat_id), with the same domain, lifetime, SameSite and Secure settings
* the pixels use (see pixelCookieScript). Pass --has-pixels when your site loads
* those pixels, so they set the cookies themselves.
*
* Test visits are recorded like real ad visits, with the campaign
* syft_test_meta, syft_test_reddit or syft_test_linkedin.
*
* The windows stay open so you can continue the visit, for example sign up or
* submit a form. Close them or press Ctrl+C to finish. The temporary profiles
* are deleted when the script ends.
*
* Needs Node 22 or later and Google Chrome. No npm packages. If Chrome is not
* in its usual place, set CHROME_PATH to the Chrome (or Chromium) program.
*/

import { spawn } from "node:child_process";
import { randomBytes, randomInt, randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";

const PLATFORMS = ["meta", "reddit", "linkedin"];
const LANDING_TIMEOUT_MS = 20_000;
const CHROME_START_TIMEOUT_MS = 15_000;

const USAGE =
"Usage: node simulate-ad-click.mjs <your site's URL> [--has-pixels]";

function parseArgs(argv) {
const args = argv.filter((a) => a !== "--");
if (args.includes("--help") || args.includes("-h")) {
console.log(USAGE);
process.exit(0);
}
const flags = args.filter((a) => a.startsWith("-"));
const unknown = flags.filter((f) => f !== "--has-pixels");
if (unknown.length) {
throw new Error(`Unknown option ${unknown.join(" ")}.\n${USAGE}`);
}
const urls = args.filter((a) => !a.startsWith("-"));
if (urls.length !== 1) throw new Error(USAGE);

// Accept "example.com" or "localhost:3000" without the scheme.
let raw = urls[0];
if (!/^https?:\/\//i.test(raw)) {
const local = /^(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(raw);
raw = `${local ? "http" : "https"}://${raw}`;
}
let url;
try {
url = new URL(raw);
} catch {
throw new Error(`Not a valid URL: ${urls[0]}`);
}
return { url: url.toString(), hasPixels: flags.includes("--has-pixels") };
}

function base64url(bytes) {
return randomBytes(bytes).toString("base64url");
}

function digits(n) {
let s = String(randomInt(1, 10));
while (s.length < n) s += String(randomInt(0, 10));
return s;
}

/**
* Builds one ad click: the landing URL, the referrer, the click id and the
* cookies the platform's pixel would set.
*/
function buildAdClick(platform, landingUrl, now = Date.now()) {
const url = new URL(landingUrl);
const set = (params) => {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
};
const campaign = `syft_test_${platform}`;

if (platform === "meta") {
// Meta click ids start with "IwY2xj" and end with an "_aem_" segment.
const fbclid = `IwY2xjaw${base64url(96)}_aem_${base64url(16)}`;
set({
utm_source: "meta",
utm_medium: "paid_social",
utm_campaign: campaign,
utm_content: "syft_test_ad",
utm_term: "broad",
utm_id: digits(14),
fbclid,
});
return {
platform,
url: url.toString(),
referrer: "https://l.facebook.com/",
clickId: ["fbclid", fbclid],
// https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc
cookies: {
_fbp: `fb.1.${now}.${digits(18)}`,
_fbc: `fb.1.${now}.${fbclid}`,
},
};
}

if (platform === "reddit") {
const rdtCid = digits(19);
// Reddit puts rdt_cid first.
url.searchParams.set("rdt_cid", rdtCid);
set({
utm_campaign: campaign,
utm_content: "syft_test_ad",
utm_medium: "paid_social",
utm_source: "reddit",
});
return {
platform,
url: url.toString(),
referrer: "https://www.reddit.com/",
clickId: ["rdt_cid", rdtCid],
cookies: { _rdt_uuid: `${now}.${randomUUID()}`, _rdt_cid: rdtCid },
};
}

const liFatId = randomUUID();
set({
utm_source: "linkedin",
utm_medium: "paid_social",
utm_campaign: campaign,
utm_content: "syft_test_ad",
utm_term: "titles",
li_fat_id: liFatId,
});
return {
platform,
url: url.toString(),
referrer: "https://www.linkedin.com/",
clickId: ["li_fat_id", liFatId],
cookies: { li_fat_id: liFatId },
};
}

// --- Chrome ------------------------------------------------------------------

function findChrome() {
if (process.env.CHROME_PATH) {
if (existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH;
throw new Error(`CHROME_PATH does not exist: ${process.env.CHROME_PATH}`);
}
const candidates = [];
if (process.platform === "darwin") {
for (const app of ["Google Chrome", "Chromium"]) {
candidates.push(`/Applications/${app}.app/Contents/MacOS/${app}`);
candidates.push(
join(
process.env.HOME ?? "",
`Applications/${app}.app/Contents/MacOS/${app}`
)
);
}
} else if (process.platform === "win32") {
for (const base of [
process.env.PROGRAMFILES,
process.env["PROGRAMFILES(X86)"],
process.env.LOCALAPPDATA,
]) {
if (base)
candidates.push(join(base, "Google/Chrome/Application/chrome.exe"));
}
} else {
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
for (const name of [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
]) {
candidates.push(join(dir, name));
}
}
}
const found = candidates.find((p) => p && existsSync(p));
if (!found) {
throw new Error(
"Google Chrome was not found. Install it, or set CHROME_PATH to the Chrome program."
);
}
return found;
}

/**
* Starts Chrome with a new temporary profile and its DevTools port open.
* Resolves to { child, exited, profileDir, port } once the port is ready.
*/
async function launchChrome(chromePath) {
const profileDir = mkdtempSync(join(tmpdir(), "syft-ad-click-"));
const args = [
`--user-data-dir=${profileDir}`,
"--remote-debugging-port=0",
"--no-first-run",
"--no-default-browser-check",
"--disable-search-engine-choice-screen",
// Undocumented, for automated runs of this script.
...(process.env.SYFT_AD_CLICK_HEADLESS ? ["--headless=new"] : []),
"about:blank",
];
const child = spawn(chromePath, args, { stdio: "ignore" });
const exited = new Promise((resolve) => child.once("exit", resolve));

// Chrome writes the port it picked to DevToolsActivePort in the profile.
const portFile = join(profileDir, "DevToolsActivePort");
const deadline = Date.now() + CHROME_START_TIMEOUT_MS;
while (Date.now() < deadline) {
if (child.exitCode != null) break;
if (existsSync(portFile)) {
const port = Number(readFileSync(portFile, "utf8").split("\n")[0]);
if (port > 0) return { child, exited, profileDir, port };
}
await new Promise((r) => setTimeout(r, 100));
}
child.kill();
throw new Error(`Chrome did not start (${chromePath}).`);
}

/** A minimal DevTools protocol client over Node's built-in WebSocket. */
class CdpSession {
static async connect(port) {
let target;
for (let i = 0; i < 50 && !target; i++) {
const res = await fetch(`http://127.0.0.1:${port}/json/list`);
target = (await res.json()).find((t) => t.type === "page");
if (!target) await new Promise((r) => setTimeout(r, 100));
}
if (!target) throw new Error("Chrome has no open tab to control.");
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
ws.addEventListener("open", resolve, { once: true });
ws.addEventListener("error", reject, { once: true });
});
return new CdpSession(ws);
}

constructor(ws) {
this.ws = ws;
this.nextId = 1;
this.pending = new Map();
this.listeners = new Map();
// Resolves when the tab closes. On macOS, Chrome keeps running after its
// last window closes, so this is how we know the visitor is done.
this.closed = new Promise((resolve) =>
ws.addEventListener("close", resolve, { once: true })
);
ws.addEventListener("message", (msg) => {
const data = JSON.parse(msg.data);
if (data.id != null) {
const p = this.pending.get(data.id);
this.pending.delete(data.id);
if (data.error) p?.reject(new Error(data.error.message));
else p?.resolve(data.result);
} else {
for (const fn of this.listeners.get(data.method) ?? []) fn(data.params);
}
});
ws.addEventListener("close", () => {
for (const p of this.pending.values())
p.reject(new Error("Chrome closed."));
this.pending.clear();
});
}

send(method, params = {}) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
});
}

on(method, fn) {
if (!this.listeners.has(method)) this.listeners.set(method, []);
this.listeners.get(method).push(fn);
}

close() {
this.ws.close();
}
}

// --- Tag uploads -------------------------------------------------------------

/** Returns the Syft tag events in an upload body, or null if it is not one. */
function tagEvents(method, body) {
if (method !== "POST" || !body || !/"ann?onymousId"/.test(body)) return null;
try {
const events = JSON.parse(body)?.events;
return Array.isArray(events) ? events : null;
} catch {
return null;
}
}

/** Reads a request body, which Chrome leaves out of the event when it is large. */
async function requestBody(cdp, request, requestId) {
if (request.postData) return request.postData;
if (!request.hasPostData) return null;
try {
return (await cdp.send("Network.getRequestPostData", { requestId }))
.postData;
} catch {
return null;
}
}

function printLanding(click, event, opts) {
const ctx = event.context ?? {};
const page = event.page ?? ctx.page ?? {};
const referrer = ctx.referrer || page.referrer || null;

console.log(`\n[${click.platform}] landing page sent by the Syft tag`);
console.log(` url ${page.url}`);
console.log(` referrer ${referrer ?? "(none)"}`);
console.log(` campaign ${JSON.stringify(ctx.campaign ?? null)}`);
console.log(` clickIds ${JSON.stringify(ctx.clickIds ?? null)}`);
console.log(` adCookies ${JSON.stringify(ctx.adCookies ?? null)}`);

const [idName, idValue] = click.clickId;
const checks = [
[`click id ${idName} captured`, ctx.clickIds?.[idName] === idValue],
[`referrer ${click.referrer} captured`, referrer === click.referrer],
];
// With real pixels, the cookies often arrive after the first page view.
if (!opts.hasPixels) {
for (const [name, value] of Object.entries(click.cookies)) {
// Meta's value starts with the domain level the pixel picked (fb.1.).
const sent = ctx.adCookies?.[name];
const same = name.startsWith("_fb")
? sent?.replace(/^fb\.\d+\./, "") === value.replace(/^fb\.\d+\./, "")
: sent === value;
checks.push([`cookie ${name} captured`, same]);
}
}
for (const [label, ok] of checks) {
console.log(` ${ok ? "PASS" : "FAIL"} ${label}`);
}
return checks.every(([, ok]) => ok);
}

/** Opens one ad click in its own Chrome. Resolves to true when every check passes. */
async function openClick(chromePath, click, opts, browsers) {
const browser = await launchChrome(chromePath);
browsers.push(browser);
const cdp = await CdpSession.connect(browser.port);
browser.cdp = cdp;

let onLanding;
const landed = new Promise((resolve) => (onLanding = resolve));
let seen = false;
await cdp.send("Network.enable");
await cdp.send("Page.enable");
cdp.on("Network.requestWillBeSent", async ({ requestId, request }) => {
const body = await requestBody(cdp, request, requestId);
const event = tagEvents(request.method, body)?.find(
(e) => e?.type === "page"
);
if (!event || seen) return;
seen = true;
onLanding(printLanding(click, event, opts));
});

if (!opts.hasPixels) {
await cdp.send("Page.addScriptToEvaluateOnNewDocument", {
source: pixelCookieScript(click.cookies, new URL(opts.url).hostname),
});
}

console.log(`\n[${click.platform}] opening ${click.url}`);
const nav = await cdp.send("Page.navigate", {
url: click.url,
referrer: click.referrer,
// Chrome drops an https referrer on an http page (most local test sites).
// Real ad clicks land on https, so send it anyway. It is only an origin.
referrerPolicy: "unsafeUrl",
});
if (nav.errorText) {
console.log(
`\n[${click.platform}] FAIL: the page did not load (${nav.errorText}). Check the URL.`
);
return false;
}

const result = await Promise.race([
landed,
new Promise((r) => setTimeout(() => r(null), LANDING_TIMEOUT_MS)),
]);
if (result === null) {
console.log(
`\n[${click.platform}] FAIL: the Syft tag sent nothing within ${LANDING_TIMEOUT_MS / 1000}s. Check that the page loads the tag. If a cookie banner appears, accept it in the window.`
);
return false;
}
return result;
}

/**
* Returns a script that writes the pixel cookies inside the page, before the
* site's own scripts run, the way each pixel writes them: through
* document.cookie, with the pixel's own cookie string and its own loop for
* picking the domain, so Chrome applies its usual rules (for example, it
* refuses a public suffix such as "co.uk"). Read from fbevents.js, Reddit
* pixel.js and LinkedIn insight.min.js, and checked in Chrome:
*
* _fbp, _fbc 90 days, SameSite=Lax, never Secure. Tries "com", then
* "example.com", ...; the value records the level it used.
* _rdt_uuid, 90 days, SameSite=Strict, Secure on https pages. Tries
* _rdt_cid "example.com", then "www.example.com", ...
* li_fat_id 30 days, no SameSite, never Secure. Tries "example.com",
* then "www.example.com", ..., then a host-only cookie.
*
* A cookie that already exists is left alone, as the pixels do.
*/
function pixelCookieScript(cookies, hostname) {
return `(() => {
if (location.hostname !== ${JSON.stringify(hostname)}) return;
const cookies = ${JSON.stringify(cookies)};
const has = (name) =>
document.cookie.split(";").some((c) => c.trim().startsWith(name + "="));
const expires = (days) => new Date(Date.now() + days * 864e5).toUTCString();
const host = location.hostname.replace(/\\.$/, "");
const labels = host.split(".");

// Meta, fbevents.js writeNewCookie.
for (const name of ["_fbp", "_fbc"]) {
if (!(name in cookies) || has(name)) continue;
for (let i = 0; i < labels.length; i++) {
const domain = labels.slice(labels.length - 1 - i).join(".");
const value = cookies[name].replace(/^fb\\.\\d+\\./, "fb." + i + ".");
document.cookie = name + "=" + encodeURIComponent(value) +
";expires=" + expires(90) + ";domain=." + domain +
";SameSite=Lax;path=/";
if (has(name)) break;
}
}

// Reddit, pixel.js en() and tn().
for (const name of ["_rdt_uuid", "_rdt_cid"]) {
if (!(name in cookies) || has(name)) continue;
const write = (domain) => {
document.cookie = name + "=" + cookies[name] + ";domain=." + domain +
";expires=" + expires(90) + ";path=/;samesite=strict" +
(location.protocol === "https:" ? ";secure" : "");
return document.cookie.indexOf(name + "=" + cookies[name]) >= 0;
};
const last = labels[labels.length - 1];
if (labels.length === 1 || String(Number(last)) === last) {
write(host);
} else {
const rest = labels.slice(0, -2);
let domain = labels.slice(-2).join(".");
while (!write(domain) && rest.length) domain = rest.pop() + "." + domain;
}
}

// LinkedIn, insight.min.js on() with Y() and q().
if ("li_fat_id" in cookies && !has("li_fat_id")) {
const write = (domain) => {
document.cookie = "li_fat_id=" + encodeURIComponent(cookies.li_fat_id) +
";expires=" + expires(30) + (domain ? ";domain=" + domain : "") +
";path=/";
};
const domains = [];
for (let i = labels.length - 2; i >= 0; i--) domains.push(labels.slice(i).join("."));
for (const domain of domains) {
write(domain);
if (has("li_fat_id")) break;
}
if (!has("li_fat_id")) write(null);
}
})();`;
}

async function closeBrowsers(browsers) {
for (const b of browsers) {
try {
b.cdp?.close();
} catch {
// already closed
}
if (b.child.exitCode == null) b.child.kill();
}
await Promise.all(browsers.map((b) => b.exited));
for (const b of browsers) {
rmSync(b.profileDir, { recursive: true, force: true, maxRetries: 5 });
}
}

async function main() {
const opts = parseArgs(process.argv.slice(2));
if (typeof WebSocket === "undefined") {
throw new Error(
`This script needs Node 22 or later. You have ${process.version}.`
);
}
const chromePath = findChrome();
const clicks = PLATFORMS.map((p) => buildAdClick(p, opts.url));

const browsers = [];
let stopping = false;
const stop = async () => {
if (stopping) return;
stopping = true;
await closeBrowsers(browsers);
process.exit(130);
};
process.on("SIGINT", stop);
process.on("SIGTERM", stop);

let allPassed = true;
try {
for (const click of clicks) {
const passed = await openClick(chromePath, click, opts, browsers);
allPassed = allPassed && passed;
}
} catch (e) {
await closeBrowsers(browsers);
throw e;
}

console.log(
allPassed
? "\nAll checks passed."
: "\nSome checks failed. See the FAIL lines above."
);
if (!process.env.SYFT_AD_CLICK_HEADLESS) {
console.log(
"The windows stay open so you can continue the visit. Close them or press Ctrl+C to finish."
);
await Promise.all(
browsers.map((b) => Promise.race([b.exited, b.cdp?.closed]))
);
}
await closeBrowsers(browsers);
return allPassed ? 0 : 1;
}

main()
.then((code) => process.exit(code))
.catch((e) => {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
});