brixon-tracking
v0.8.0
Published
Brixon Website-Tracking v2 — typed event catalog, consent-gated PostHog boot, destination adapters, bx_uid identity utils and server-truth capture. See SYSTEM-tracking/docs/website-tracking-v2.md
Readme
brixon-tracking
Brixon Website-Tracking v2 als npm-Paket (M5): typisierter Event-Katalog,
track() mit Destination-Adaptern (PostHog als Backbone, GA4 gefiltert und
adressiert, Browser-Conversions je Plattform), consent-gated PostHog-Boot,
bx_uid-Identity-Utils und Server-Truth-Capture.
Konzept: SYSTEM-tracking/docs/website-tracking-v2.md (§4 Identity, §5 Consent,
§6 Taxonomie, §6.1 Instrumentierungs-Standard). Event-Katalog geseedet aus
SYSTEM-tracking/docs/events-foundation.md.
Changes in 0.8.0 (unreleased)
- Added the
csauerborn-websiteproperty contract, its purchase/newsletter conversions and the commerce lifecycle catalogue (checkout_started, order bump, upsell, purchase and payment status events). Event schemas now support booleans, string enums, string arrays and optional parameters. - Event authority (
capture) and source conversion eligibility (adEligible) are independent generated fields. Only the canonical serverconversionis destination-routable. PostHogBootnow has route-level pageview, pageleave, exception and replay controls, registerssource_app/page_variantbefore the initial pageview, uses personless SDK mode and retries the first-party UID request at most five times. Each attempt has a bounded timeout; revoke and lifecycle cleanup abort an in-flight UID request before PostHog can boot.brixon-tracking/serverincludes a Worker-native Fetch Capture API client;brixon-tracking/astroincludes an allowlisted, header-scrubbing PostHog ingest proxy.- The DW&P registry now covers every ads-eligible confirmed server-side form flow and explicitly excludes the corresponding browser echoes from conversion routing. Applications and feedback stay PostHog-only operational events.
PostHogBootaccepts an optionallocaleand registers it before the initial pageview.createConversionTracker()exposes the actualdeliveredresult so API routes can distinguish a successfully handled business request from a failed analytics delivery without throwing.- Browser configuration, declarative click listeners and reset state are shared across package entry points without creating state during server rendering.
- Attribution snapshots and hidden fields no longer expose retired proxy identifiers or proprietary backup click-ID query parameters.
TrackingHiddenFieldsincludesttp; its optional businesseventIdremains separate from the attribution snapshot and is not persisted by the package.
Breaking Changes in 0.6.0
Fuenf Aenderungen, die eine bestehende Integration still veraendern koennen.
1. Das Paket sendet keine Consent-Kommandos mehr. initGoogleAnalyticsTag feuerte bis 0.5.1
gtag('consent','update',{analytics_storage:…}). Der CMP besitzt den Consent Mode jetzt
vollstaendig — fuer gtag und fuer Microsoft UET. Wer sich dafuer auf das Paket verlassen hat,
braucht den CMP. Der Grund, warum das traegt und warum die halbe Variante schlechter war: gtag
wertet einen nicht gesetzten Consent-Typ als granted, nicht als denied. Der
Default-Denied-Block des CMP ist also der eigentliche Schutz, und das Paket setzte nur
analytics_storage, nie ad_storage, ad_user_data oder ad_personalization.
Die einzige Garantie des Pakets bleibt: es laedt nie vor Consent.
2. googleAnalyticsAdapter filtert die Properties. An GA4 gehen nur event_id, value,
currency und die im Event-Katalog deklarierten Parameter des jeweiligen Events; erweiterbar ueber
allowProperties. Vorher wurde alles durchgereicht — inklusive email und phone_e164, wenn ein
Aufrufer sie mitgab, denn die Event-Schemas sind bewusst looseObject. Praktische Folge:
conversion_outcome und utm_* erreichen GA4 nicht mehr.
3. Der skipEvents-Default ist gewachsen — GA4_ENHANCED_MEASUREMENT_EVENTS statt nur
page_view. GA4 Enhanced Measurement erfasst form_start, form_submit, video_start,
video_progress und video_complete selbst, per Default aktiv fuer neue Web-Streams; das Paket
feuerte sie ein zweites Mal obendrauf, und form_submit ist ueblicherweise Key Event und wird nach
Google Ads importiert. Wer die Schalter im GA4-Stream abdreht und die reicheren Paket-Events will,
uebergibt skipEvents: ["page_view"].
4. Ein gesetztes conversion_outcome faellt nicht mehr auf den Event-Namen zurueck. Wer
conversion_outcome mitgibt und LinkedIn oder Google Ads bisher nur ueber die Event-Namen-Map
konfiguriert hat, muss jetzt linkedinOutcomes bzw. googleOutcomes setzen — sonst feuern diese
beiden Browser-Conversions nicht mehr. Das Paket warnt in dem Fall auf der Konsole, unabhaengig vom
debug-Flag: still waere genau die Fehlerklasse, die diese Aenderung behebt. Grund fuer den
Fail-closed: ein Tippfehler im Outcome liess den Browser einen anderen Event-Namen melden als der
Server, und Meta wie TikTok deduplizieren auf (Event-Name, event_id) — beide Kopien blieben stehen.
5. dataLayerAdapter ist entfernt. Er existierte nur, um während einer GTM-Migration
dieselben Events zusätzlich in window.dataLayer zu schieben. Es gibt keine Dual-Run-Phase, also
war er toter Code — und zwar gefährlicher als der Rest: er reichte properties ungefiltert weiter,
eine email aus einem track()-Aufruf landete damit in einer globalen Variable, die jedes Script
auf der Seite lesen kann. Wer ihn registriert hatte, nimmt ihn aus configureTracking heraus.
window.dataLayer selbst bleibt: gtag.js braucht die Queue als Command-Bus, das ist kein
GTM-Datenmodell.
Entries
| Entry | Inhalt | Peers |
|---|---|---|
| brixon-tracking/posthog | Schlanker Browser-Einstieg mit track(), configureTracking(), posthogAdapter, startDataTrackListener, PostHog-Boot und Event-ID-Helpern; enthält keine Legacy-Destinations | posthog-js |
| brixon-tracking/react/posthog | Schlanker React-Einstieg ausschließlich für PostHogBoot | react >=18, posthog-js |
| brixon-tracking | eventCatalog/eventSchemas, track() + configureTracking(), Adapter posthogAdapter/googleAnalyticsAdapter/adConversionAdapter, startDataTrackListener, consent-gated Init fuer GA4, Meta, TikTok, LinkedIn, Google Ads und Microsoft UET, readTrackingParams(), Identity-/Click-ID-/Consent-/event_id-Helper | posthog-js |
brixon-tracking configures the shared Zod module with jitless: true before
creating its schemas. This is intentional: strict CSPs report Zod's otherwise
caught Function capability probe as a violation. Consumers sharing that Zod
instance therefore use the interpreter instead of generated validators.
| brixon-tracking/react | PostHogBoot, ScrollDepthTracker, useTrackingParams, TrackingHiddenFields, GoogleAnalyticsTag, MetaPixel, TikTokPixel, LinkedInInsight, GoogleAdsTag, MicrosoftUet | react >=18, posthog-js |
| brixon-tracking/server | createConversionTracker() (kanonischer conversion-Vertrag), Worker-native createPostHogFetchClient(), trackCanary(), trackServer() (Low-Level-Events), createUidRouteHandler() (Web-Standard Request/Response — Next, Astro, Workers), readBxUid(), createConversionEventId() | posthog-node >=5 optional; Fetch-Client ohne Peer |
| brixon-tracking/astro | initPostHogAstro(), gehärteter createPostHogIngestProxy(), Cloudflare-Experiment-Delivery, HTMLRewriter-/Island-Fallback, same-origin Exposure-Endpoint; Re-Exports der Ad-Pixel-Initialisierung und readTrackingParams() | posthog-js |
Astro Experiment Delivery auf Cloudflare
Der vollstaendige Integrations-, Bindings-, Diagnose- und Abnahmevertrag steht
in docs/astro-experiment-delivery.md.
brixon-tracking/astro liefert serverseitige Textvarianten ohne D1- oder
PostHog-Aufruf im Request-Hot-Path aus. Der Adapter liest ein signiertes,
kleiner als 8 KB gehaltenes Manifest ueber ein Cloudflare Service Binding,
cached dessen Revision maximal fuenf Sekunden und weist Varianten lokal per
Web-Crypto-Hash zu. Jeder Fehler, Bot-Traffic, Selector-Konflikt oder
Control-Text-Drift liefert die unveraenderte Control.
import {
createAstroExperimentDelivery,
handleIslandExperimentExposure,
installIslandExperimentFallback,
} from "brixon-tracking/astro";
const deliverExperiments = createAstroExperimentDelivery({
siteId: "website-dwp",
propertyId: "dwp",
controlPlane: env.EXPERIMENT_CONTROL_PLANE,
exposureQueue: env.EXPERIMENT_EXPOSURES,
exposureStateSecret: env.EXPERIMENT_STATE_SECRET,
operationalMetrics: env.EXPERIMENT_METRICS,
});
export default {
async fetch(request, env, context) {
const url = new URL(request.url);
if (url.pathname === "/__brixon/experiment-exposure") {
return handleIslandExperimentExposure({
request,
siteId: "website-dwp",
propertyId: "dwp",
queue: env.EXPERIMENT_EXPOSURES,
exposureStateSecret: env.EXPERIMENT_STATE_SECRET,
operationalMetrics: env.EXPERIMENT_METRICS,
});
}
const astroResponse = await env.ASTRO.fetch(request);
return deliverExperiments({ request, response: astroResponse, executionContext: context });
},
};Den Browser-Fallback einmal im Client-Bootstrap installieren. Er greift nur bei spaet gerenderten Islands, prueft Selector und Control-Hash erneut und meldet die Exposure same-origin; die HttpOnly-Identitaet bleibt im Worker.
installIslandExperimentFallback();Der eigentliche Astro-/Asset-Fetch bleibt gemeinsam cachebar; erst die danach
erzeugte personalisierte HTML-Response erhaelt private, no-store. Sichere
Textsetzung (html: false) ist absichtlich die einzige v2-Mutation.
Erfolgreiche Exposures werden in einem HMAC-signierten, HttpOnly
bx_exp_state-Cookie mit hoechstens fuenf aktiven Experimenten markiert. Eine
erneute Seite wendet dieselbe Variante weiterhin an, erzeugt aber kein zweites
Exposure. Island-Payloads sind ebenfalls signiert; der same-origin Endpoint
stellt bx_uid serverseitig wieder her und weist gefaelschte Varianten ab.
Manifest-Eintraege tragen einen expliziten deliveryMode. Der normale Modus
experiment erzeugt die deduplizierte Exposure. Der ausschliesslich von der
Control Plane signierte Modus promotion_override liefert einen zertifizierten
Gewinner zu 100 %, unterdrueckt jedoch Queue-Event, Island-Report und
bx_exp_state-Eintrag. So kann die permanente Source-of-Truth-Aenderung
verifiziert werden, ohne eine zweite Messphase zu erfinden.
Experimentteilnehmer erhalten bx_uid bewusst bereits vor dem allgemeinen
Analytics-Consent. Vor Consent wird ausschliesslich die pseudonyme Exposure und
die notwendige Server-Truth-Lead-Zuordnung erfasst; Autocapture, Replay und
Werbepixel bleiben consent-gated. Dokumentierte Rechtsgrundlage, Retention und
Datenschutzhinweis sind Go-live-Gates, keine Annahmen des Pakets.
Quickstart (Next.js App Router)
// layout.tsx (Client-Bereich)
import { PostHogBoot, ScrollDepthTracker } from "brixon-tracking/react";
<PostHogBoot
token={process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN}
propertyId="dwp"
environment={process.env.NODE_ENV === "production" ? "production" : "development"}
sourceApp="dwp-website"
capturePageview="history_change"
capturePageleave
captureExceptions
sessionReplay={{ enabled: true, maskAllInputs: true }}
debug={process.env.NODE_ENV === "development"}
isolateNetwork={process.env.NEXT_PUBLIC_E2E_MODE === "1"}
/>isolateNetwork keeps same-origin capture available for browser assertions,
but disables PostHog remote config, recording and lazy-loaded extensions. Use it
only in isolated browser suites, not as a production privacy setting.
Bei einem Hidden-/Prerender-Dokument bleibt bereits posthog.init() pending,
damit der SDK-eigene Visibility-Guard keinen spaeter verworfenen initialen
Pageview als erledigt markieren kann. Sobald das Dokument sichtbar ist und der
externe Analytics-Grant weiterhin gilt, registriert der loaded-Callback
property_id und environment, spiegelt den Grant mit
captureEventName: false in posthog-js und dispatcht danach
brixon:posthog-boot. Dadurch enthaelt der einmalige initiale $pageview beide
Properties, es entsteht kein $opt_in, und Listener des Boot-Signals koennen
sofort sicher tracken. Ein Widerruf waehrend des Pending-Boots verhindert den
Init; ein spaeterer sichtbarer Re-Grant finalisiert den Boot genau einmal.
Jeder UID-Versuch hat standardmaessig ein Timeout von fuenf Sekunden
(uidRequestTimeoutMs, maximal 15 Sekunden). Consent-Widerruf und Cleanup
brechen den aktiven Request ab; ein Re-Grant waehrend dieses Abbruchs startet
nach Freigabe des Boot-Guards mit einem frischen Consent-Epoch neu.
Nach einem Widerruf wird außerdem die alte In-memory-Identität sofort
verworfen. Ein Re-Grant holt eine neue anonyme bx_uid und beginnt damit eine
neue PostHog-Session; Browser- und Server-Events verwenden die vom
Consent-Endpunkt gelöschte Identität nicht weiter.
Der posthogAdapter puffert waehrend eines bereits consent-granted,
paketverwalteten Boots hoechstens 50 Events. Ein Revoke leert diese Queue;
Events ohne Consent werden nicht bei einem spaeteren Grant nachgesendet.
Routen mit sensitiven Inhalten schalten die nativen Funktionen explizit aus:
initPostHogAstro({
token: import.meta.env.PUBLIC_POSTHOG_KEY,
propertyId: "csauerborn",
sourceApp: "csauerborn-website",
capturePageview: false,
capturePageleave: false,
captureExceptions: false,
sessionReplay: { enabled: false },
});// Einmalig im Client-Bootstrap (z. B. eigene TrackingProvider-Komponente):
import {
configureTracking,
posthogAdapter,
googleAnalyticsAdapter,
adConversionAdapter,
startDataTrackListener,
} from "brixon-tracking";
configureTracking({
destinations: [
posthogAdapter(),
googleAnalyticsAdapter(),
adConversionAdapter({
// Prefer registry outcomes when one generic RESPOND event serves several forms.
// With the fallback off, only an explicit commercial outcome reaches Meta.
useEventNameFallback: false,
// metaEvents: {} override optional (Default-Map deckt die Standard-Events ab)
// LinkedIn needs the browser conversion-rule IDs:
// linkedinOutcomes: { sales_lead: 123456 },
}),
],
validate: process.env.NODE_ENV === "development",
});
startDataTrackListener();RESPOND-Vertrag
Ein RESPOND-Call sendet den beschreibenden Namen, zum Beispiel
assessment_complete, ueber den posthogAdapter an PostHog. Nach erfolgreicher
Transaktion sendet createConversionTracker() zusaetzlich genau einen
autoritativen serverseitigen Event-Record namens conversion; der beschreibende
Name steht dort in source_event. capture: "server" bezeichnet diese Autoritaet;
adEligible klassifiziert davon unabhaengig, ob der Source-Event eine kanonische Conversion
erzeugen darf. Keines von beiden ist eine Client-Skip-Regel. Ads-Destinationen duerfen
ausschliesslich conversion filtern, nie die beschreibenden Client-Events.
// src/app/api/uid/route.ts
import { createUidRouteHandler } from "brixon-tracking/server";
export const POST = createUidRouteHandler({
secure: process.env.NODE_ENV === "production",
});// Conversion-Route (Server-Truth):
import pkg from "brixon-tracking/package.json";
import {
createConversionTracker,
createConversionEventId,
readBxUid,
} from "brixon-tracking/server";
import { PostHog } from "posthog-node";
const client = new PostHog(process.env.POSTHOG_KEY!, {
host: "https://eu.i.posthog.com", flushAt: 1, flushInterval: 0,
});
const trackConversion = createConversionTracker({
sourceApp: "borggalea-website",
environment: process.env.NODE_ENV === "production" ? "production" : "development",
schemaVersion: pkg.version,
resolveConsent: (request) => ({
allowed: /(?:^|;\\s*)brixon_consent=accepted(?:;|$)/.test(
"headers" in request ? request.headers.get("cookie") ?? "" : request.cookieHeader ?? ""
),
source: "brixon_consent_cookie",
policyVersion: "2026-07",
observedAt: new Date().toISOString(),
}),
delivery: "immediate",
});
const eventId = createConversionEventId(); // gleiche ID → PostHog, n8n, Client-Echo
await trackConversion({
client,
sourceEvent: "contact_form_submitted",
request: req,
currentUrl: conversionUrl,
bxUid: readBxUid(req.headers.get("cookie")),
eventId,
sessionId: phSessionId, // Hidden Field
properties: { email, form_name: "contact" },
});external_id_sha256 ist ein optionales Meta-Match-Signal. Der Aufrufer darf es
nur aus einer echten bx_uid bilden, wenn Analytics-Consent vorliegt, und muss
den bereits normalisierten SHA-256-Wert übergeben. Bei Marketing-Consent ohne
Analytics-Consent bleibt das Feld weg; die event-spezifische
no-consent-<event_id>-Identität darf niemals als external_id gehasht werden.
Cloudflare Worker brauchen posthog-node nicht:
import {
createConversionTracker,
createPostHogFetchClient,
} from "brixon-tracking/server";
import { createPostHogIngestProxy } from "brixon-tracking/astro";
const proxyPostHog = createPostHogIngestProxy();
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
if (url.pathname.startsWith("/ingest/")) return proxyPostHog(request);
const client = createPostHogFetchClient({
token: env.PUBLIC_POSTHOG_KEY,
timeoutMs: 10_000,
});
// Pass `client` to createConversionTracker(...)(...). Stable eventId and
// timestamp still come from the site's durable outbox. Its saved request
// context can be passed as { cookieHeader, userAgent }.
},
};Der Proxy akzeptiert nur die vom gepinnten posthog-js verwendeten Capture-, Flags-, Replay- und
Static-Pfade sowie exakt /array/<project-token>/config.js für die Remote-Konfiguration. Er
entfernt Cookies, Authorization und eingehende Forwarding-/Cloudflare-Header,
setzt X-Forwarded-For ausschliesslich aus CF-Connecting-IP und verwirft Upstream-Set-Cookie.
POST-Bodies sind auf 5 MiB begrenzt: deklarierte Uebergroesse wird vor dem Lesen abgewiesen,
Streams ohne Content-Length werden waehrend des Lesens begrenzt. Das laesst deutlichen
Spielraum fuer komprimierte Replay-Batches, verhindert aber unbeschraenkten Isolate-Speicher.
Explizit fremde Origin-/Sec-Fetch-Site: cross-site-Requests werden blockiert; normale
same-origin sendBeacon-Requests und Clients ohne diese Browser-Header bleiben kompatibel.
Deklaratives Klick-Tracking (GTM-Trigger-Ersatz)
<!-- CTA-Shortcut (Pilot-Muster) -->
<a href="/kontakt" data-track-cta="hero">Jetzt anfragen</a>
<!-- Beliebiges Katalog-Event -->
<button data-track="assessment_start"
data-track-assessment-name="quickcheck"
data-track-assessment-type="ai">Start</button>Ad-Pixel-Basis-Tags (M7-Cutover)
Consent-gated Basis-Snippets fuer GA4, Meta Pixel, TikTok Pixel, LinkedIn
Insight Tag, Google Ads und Microsoft UET. Meta/TikTok/LinkedIn/Ads/UET
verwenden das Marketing-Consent-Flag (window.__brixonConsentMarketing ===
true), GA4 das Analytics-Consent-Flag (window.__brixonConsentAnalytics ===
true). Die Listener-Mechanik ist jeweils per
consentGranted/consentEventNames konfigurierbar.
consentGranted muss Live-State lesen, keine eingefrorene Closure. Die
React-Wrapper leiten den aktuellen Consent-Wert im Render ab und nutzen ihn als
Effect-Dependency, und sie reichen dem Core eine stabile Bruecke statt der
Closure vom Mount. Das ist noetig, weil React 18 setState innerhalb des
CMP-Dispatches batcht: im selben Tick sieht das Gate sonst noch den alten Wert,
danach rendert React neu — aber nichts prueft den Consent erneut, und das Pixel
bliebe trotz Zustimmung aus.
// layout.tsx (Client-Bereich) — fehlende Env-Var = Kill-Switch (No-op):
import {
GoogleAnalyticsTag,
MetaPixel,
TikTokPixel,
LinkedInInsight,
GoogleAdsTag,
MicrosoftUet,
} from "brixon-tracking/react";
<GoogleAnalyticsTag measurementId={process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID} />
<MetaPixel pixelId={process.env.NEXT_PUBLIC_META_PIXEL_ID} />
<TikTokPixel pixelId={process.env.NEXT_PUBLIC_TIKTOK_PIXEL_ID} />
<LinkedInInsight partnerId={process.env.NEXT_PUBLIC_LINKEDIN_PARTNER_ID} />
<GoogleAdsTag conversionId={process.env.NEXT_PUBLIC_GOOGLE_ADS_ID} />
<MicrosoftUet tagId={process.env.NEXT_PUBLIC_MICROSOFT_UET_TAG_ID} />// Astro (Client-Script) — Re-Exports aus brixon-tracking/astro:
import {
initGoogleAnalyticsTag,
initMetaPixel,
initTikTokPixel,
initLinkedInInsight,
initMicrosoftUet,
} from "brixon-tracking/astro";
initGoogleAnalyticsTag({
measurementId: import.meta.env.PUBLIC_GA4_MEASUREMENT_ID,
consentEventNames: ["brixon:consent"],
});
initMetaPixel({
pixelId: import.meta.env.PUBLIC_META_PIXEL_ID,
consentEventNames: ["brixon:consent"],
consentGranted: () => window.__brixonConsentMarketing === true,
});
initTikTokPixel({
pixelId: import.meta.env.PUBLIC_TIKTOK_PIXEL_ID,
consentEventNames: ["brixon:consent"],
consentGranted: () => window.__brixonConsentMarketing === true,
});
initLinkedInInsight({
partnerId: import.meta.env.PUBLIC_LINKEDIN_PARTNER_ID,
consentEventNames: ["brixon:consent"],
consentGranted: () => window.__brixonConsentMarketing === true,
});
initMicrosoftUet({
tagId: import.meta.env.PUBLIC_MICROSOFT_UET_TAG_ID,
consentEventNames: ["brixon:consent"],
consentGranted: () => window.__brixonConsentMarketing === true,
});Env-Var-Konvention (Next.js; fuer Astro dieselben Namen mit PUBLIC_ statt
NEXT_PUBLIC_):
| Var | Plattform | Consent | Format |
|---|---|---|---|
| NEXT_PUBLIC_GA4_MEASUREMENT_ID | GA4 | Analytics | G-XXXXXXXXXX |
| NEXT_PUBLIC_META_PIXEL_ID | Meta Pixel | Marketing | numerisch |
| NEXT_PUBLIC_TIKTOK_PIXEL_ID | TikTok Pixel | Marketing | Pixel-Code |
| NEXT_PUBLIC_LINKEDIN_PARTNER_ID | LinkedIn Insight | Marketing | numerisch |
| NEXT_PUBLIC_GOOGLE_ADS_ID | Google Ads | Marketing | AW-XXXXXXXXX |
| NEXT_PUBLIC_MICROSOFT_UET_TAG_ID | Microsoft UET | Marketing | numerische Tag-ID |
Eine fehlende oder leere Var ist ein bewusster No-op: kein Script, kein Global, kein Netzwerk-Request. Das ist der Kill-Switch ohne Deploy.
Regeln:
- Es laedt nur die offiziellen Basis-Snippets, jeweils genau einmal:
GA4/Google Ads
gtag.js, Metafbevents.js, TikTokevents.jsund LinkedIninsight.min.js. Conversion-Events feuert der separateadConversionAdapter. - GA4 nutzt den normalen Tracking-Schnipsel ohne Gateway oder GTM.
gtag.jsbraucht intern weiterhinwindow.dataLayerals Command-Queue; das Paket legt sie selbst an. Es gibt keinen fachlichen GTM-Data-Layer. trackInitialPageView: falseschaltet bei GA4 den initialen Config-Pageview aus; bei Meta und TikTok zusaetzlich den paketverwalteten History-Hook.GoogleAnalyticsTagundGoogleAdsTagteilen sich vorhandenesgtag/dataLayerund injizieren keinen zweiten Loader.- SPA — hier lag bis 0.5.1 eine Doppelzaehlung. Meta und TikTok tracken
History-Changes selbst, per Default: Meta feuert bei jedem
pushStateeinen PageView, TikTok "will measure an additional pageview representing a view on the new page URL". Das Paket hat obendrauf eigene Hooks installiert, also gab es zwei PageViews pro Routenwechsel auf beiden Plattformen. Ab 0.6.0 werden die Hersteller-Listener abgeschaltet (fbq.disablePushState = truevor dem Init,historyObserver: falseinttq.load) und der eigene Hook bleibt — er ist der einzige, der vor jedem Fire den aktuellen Consent liest; der native feuert nach einem Widerruf ungebremst weiter. GA4 nutzt die History-Erkennung von Enhanced Measurement, Microsoft UET seinenableAutoSpaTracking; fuer beide installiert das Paket bewusst keinen eigenen Hook. LinkedIn hat keinen SPA-Pfad, das ist Absicht (der Insight Tag ist ein Retargeting-Cookie, kein Pageview-Analytics). - Consent-Widerruf: Geladene Pixel lassen sich nicht vollstaendig entladen; die
erste Garantie ist deshalb "nie vor Consent laden". Ein bereits gebootetes
Meta Pixel erhaelt bei jeder echten Zustandsaenderung zusaetzlich das
dokumentierte
fbq('consent','revoke'|'grant'). Die paketverwalteten Meta- PageViews und alle Browser-Conversions bleiben waehrend Denial hart gesperrt. TikToks Queue stelltrevokeConsent/grantConsentbereit. LinkedIn bietet hier keinen belastbaren Runtime-Revoke-Vertrag: Das Paket stoppt seine eigenenlintrk-Conversions sofort; die vollstaendige Wirkung fuer das bereits geladene Basis-Tag gilt erst ab dem naechsten MPA-Dokument-Load und muss vor Release als Consent-Gate getestet werden. - CSP fuer Microsoft UET, falls gesetzt:
script-src https://bat.bing.com https://bat.bing.netund dieselben Hosts inimg-src(UET sendet seine Events als Image-Beacon).
Meta Advanced Matching
initMetaPixel({ pixelId, advancedMatching }) reicht Kundendaten als drittes
Argument an fbq('init', …) — Meta: "Be sure to place advanced matching
parameters in the pixel base code or the values will not be treated as manual
advanced matching values." Belegt sind elf Felder: em, fn, ln, ph,
external_id, ge, db, ct, st, zp, country.
Damit kehrt sich fuer Meta die bisherige Regel "keine PII an den
Client-Pixel" um. Fuer TikTok und alle anderen gilt sie weiter: dort gehen nur
value, currency und content_name mit. Gehashte Kundendaten aus dem Browser
sind eine neue Verarbeitung — Vermerk in Datenschutzerklaerung und VVZ, analog
zur maskAllInputs: false-Entscheidung. Meta verlangt vertraglich "robust and
sufficiently prominent notice" und im EWR nachweisbaren Consent.
Drei Dinge, die man leicht verwechselt:
Klartext an den Pixel, nicht vorhashen. "Values will be hashed automatically by the pixel using SHA-256." Die CAPI verlangt genau umgekehrt SHA-256 vom Aufrufer. Zwei gegenlaeufige Regeln fuer dieselben Felder — wer hier selbst hasht, macht die Werte unbrauchbar.
Die Telefonnummer braucht die Laendervorwahl vom Aufrufer. Metas
Normalisierung lautet nur "remove symbols, letters, and any leading zeros". Aus
0170 1234567 wird damit 1701234567, und das matcht nichts. Uebergib
+49 170 1234567 oder 0049 170 1234567 — eine Vorwahl kann das Paket nicht
erraten, und es tut auch nicht so.
reinitWithAdvancedMatching ist Default aus. Aktiviert ruft es bei neuen
AM-Daten ein zweites fbq('init'). Das ist in Metas Doku weder dokumentiert
noch verboten noch in seinem Verhalten beschrieben — die einzige Stelle, an
der es ueberhaupt diskutiert wird, ist ein unbeantworteter Community-Thread. Wer
es einschaltet, verifiziert es je Property im Events Manager gegen die EMQ,
bevor er sich darauf verlaesst. Der dokumentierte Weg ist stattdessen: AM zum
Init-Zeitpunkt, wenn die Identitaet schon feststeht (serverseitig gerenderte
Danke-Seite, eingeloggter Bereich) — auf dem Astro/MPA-Stack ist das der
Normalfall. Ergaenzend deckt Automatic Advanced Matching (ein Schalter im
Events Manager, kein Code) den gewoehnlichen Formularfall ab; es kennt aber
weder external_id noch db und sieht nichts, was nur in SPA-State oder einem
fetch-Body steht.
Eine AM-Payload, deren einzige Felder fn + ge oder ln + ge sind, wird
verworfen und der Pixel ohne AM initialisiert — Meta zaehlt solche
Kombinationen als invalides Event.
Unser Consent-Modell umgeht dabei eine dokumentierte Falle: Advanced Matching
muss im init stehen. Das Paket erzeugt deshalb vor dem ersten Grant weder
Stub noch Loader noch init. Erst spaetere Zustandswechsel des gebooteten
Pixels werden mit fbq('consent','revoke'|'grant') gespiegelt.
Client-Conversions und Plattformpfade
Meta, TikTok und LinkedIn unterstuetzen das redundante Setup: dieselbe
Conversion feuert clientseitig und serverseitig und wird ueber eine geteilte
event_id dedupliziert. Der adConversionAdapter uebernimmt den Client-Teil;
er feuert nur, wenn das jeweilige Basis-Pixel bereits gebootet ist und der
aktuelle Marketing-Consent weiterhin true ist. Google Ads nutzt dieselbe ID
als transaction_id; die Serverkopie muss dieselbe Conversion Action und
dieselbe Order-/Transaction-ID verwenden. GA4 besitzt keine allgemeine
Browser/Server-Deduplizierung und bleibt fuer diese Events clientseitiger Owner.
Bei einem eigenen CMP-Status wird derselbe aktuelle Getter an Pixel-Boot und
Conversion-Adapter gegeben. AdConversionConfig.consentGranted wird vor jedem
Conversion-Call neu ausgewertet; ohne Getter bleibt
window.__brixonConsentMarketing === true der Default.
import {
adConversionAdapter,
configureTracking,
googleAnalyticsAdapter,
initGoogleAnalyticsTag,
initLinkedInInsight,
initMetaPixel,
initTikTokPixel,
posthogAdapter,
} from "brixon-tracking";
const marketingConsentGranted = () => customConsent.marketing === true;
const analyticsConsentGranted = () => customConsent.analytics === true;
initGoogleAnalyticsTag({
measurementId: ga4MeasurementId,
consentGranted: analyticsConsentGranted,
});
initMetaPixel({ pixelId: metaPixelId, consentGranted: marketingConsentGranted });
initTikTokPixel({ pixelId: tiktokPixelId, consentGranted: marketingConsentGranted });
initLinkedInInsight({ partnerId: linkedinPartnerId, consentGranted: marketingConsentGranted });
configureTracking({
destinations: [
posthogAdapter(),
googleAnalyticsAdapter({ consentGranted: analyticsConsentGranted }),
adConversionAdapter({
consentGranted: marketingConsentGranted,
linkedinOutcomes: { sales_lead: linkedinBrowserConversionId },
}),
],
});Die eine Regel: EINE event_id pro realer Conversion — am Call-Site erzeugen
und an track() UND an die Server-Route geben. Sie darf bei Retries nicht neu
erzeugt werden. Im PostHog-Destination-Mapping wird sie fuer Meta und TikTok als
event_id, fuer LinkedIn als eventId und fuer Google Ads als
Order-/Transaction-ID verwendet.
Der Adapter akzeptiert nur RESPOND-Events mit Server-Truth und verwirft den
Browserpfad ohne gueltige UUIDv7-event_id.
Dieses Fail-closed-Verhalten gilt ab 0.5.0; Consumer muessen die ID vor dem
Request erzeugen und im bestaetigten Client-Echo wiederverwenden.
Serverseitig wird dieselbe ID auch als PostHog-uuid verwendet. Fuer spaeter
bestaetigte Transaktionen muss createConversionTracker() zusaetzlich einen
stabilen, serverseitig gespeicherten timestamp erhalten; so bleiben uuid,
Eventname, Zeitpunkt und distinct_id bei einem Retry identisch.
import { generateEventId } from "brixon-tracking";
import { track } from "brixon-tracking";
// Im Submit-Handler:
const eventId = generateEventId();
// 1) Server-Request mit der gemeinsamen id
const response = await fetch("/api/lead-magnet/request", {
method: "POST",
body: JSON.stringify({ ...formData, event_id: eventId, phSessionId }),
});
if (!response.ok) throw new Error("delivery failed");
// 2) Erst nach bestaetigtem 2xx: Client-Echo + Meta-Browser-Conversion
track("lead_magnet_lead", {
lead_magnet_name: "ki-readiness",
lead_magnet_type: "pdf",
event_id: eventId,
conversion_outcome: "lead_magnet",
});Mapping kanonisch -> Plattform-Event:
- Meta:
DEFAULT_META_EVENT_MAP(z. B.lead_magnet_lead->Lead,meeting_booking->Schedule). UebermetaEventsueberschreibbar;nullschaltet Meta fuer ein Event ab.application_submitfehlt bewusst (kein Marketing-Lead). Fuer generische RESPOND-Events mapptDEFAULT_META_OUTCOME_MAPdas Propertyconversion_outcome; mituseEventNameFallback: falseerreicht nur ein ausdrueckliches kommerzielles Outcome den Meta-Pixel. - TikTok:
DEFAULT_TIKTOK_EVENT_MAPundDEFAULT_TIKTOK_OUTCOME_MAPverwenden aktuelle Standardevents wieLead,Download,Schedule,CompleteRegistrationundPurchase. Browser- und Serverevent brauchen denselben Eventnamen und dieselbeevent_id. - LinkedIn:
linkedinConversions(kanonisch -> numerische conversion_id). Fuer generische Events kannlinkedinOutcomesstattdessen nachconversion_outcomemappen. Der Browser-Call enthaeltevent_id; die CAPI muss sie alseventIderhalten. LinkedIn verlangt je eine Conversion Rule fuer Browser und Server. - Google Ads:
googleConversionIdplusgoogleLabels(nach Event-Namen) odergoogleOutcomes(nachconversion_outcome) sendet die Browserconversion mittransaction_id = event_id.googleOutcomesist ab 0.6.0 da, weil Google vorher als einzige Plattform nicht nach Outcome routen konnte — im empfohlenen Setup mituseEventNameFallback: falselandeten damit Newsletter, Lead Magnet und Sales Lead auf derselben Conversion Action. Eine redundante Serverkopie muss dieselbe Conversion Action und ID verwenden;order_iddedupliziert nur innerhalb einer Conversion Action. - Microsoft UET:
microsoftEvents(nach Event-Namen) odermicrosoftOutcomes(nach Outcome, Default aus der Registry:sales_lead -> generate_sales_lead,meeting -> book_appointment, …). Der Browser-Call sendetevent_id,revenue_valueundcurrency. Vorsicht mitevent_id: es ist nur in Microsofts CAPI-Doku belegt, fehlt in der offiziellen UET-Parametertabelle und hat keinen publizierten Wire-Namen. Vor dem Verlassen darauf im UET Tag Helper pruefen, dass es den Beacon erreicht.transaction_idexistiert bei UET auch, ist aber ein Ecommerce-Attribut und kein Dedup-Key. - GA4:
googleAnalyticsAdaptersendet Katalogevents direkt clientseitig, gefiltert — nurevent_id,value,currencyund die im Katalog deklarierten Parameter des Events, erweiterbar perallowProperties.conversion_outcomeundutm_*erreichen GA4 also nicht. Das PostHog-Setup darf dieselbe GA4-Conversion nicht erneut per Measurement Protocol senden, weil GA4 allgemeine Events nicht ueberevent_iddedupliziert.
Attribution zum Submit-Zeitpunkt
readTrackingParams() liest Click-IDs, UTMs, $session_id und die
Conversion-URL in dem Moment, in dem eine Geschaeftsaktion abgeschickt wird.
Ab 0.6.0 liegt die Logik im Core statt im /react-Entry und ist damit auch aus
brixon-tracking und brixon-tracking/astro erreichbar — vorher zog sie React
mit, und der Astro-Stack hatte keinen Weg, Click-IDs mitzugeben. Ohne die
fehlen der CAPI ihre Match-Keys.
// Astro / vanilla
import { readTrackingParams, setSessionIdResolver } from "brixon-tracking/astro";
const tracking = readTrackingParams();
// tracking.gclid, .gbraid, .wbraid, .fbclid, .fbc, .fbp, .ttclid, .ttp,
// .msclkid, .liFatId, .gaClientId, .utm_*, .conversionUrl, .phSessionId// React — unveraenderte Signatur
import { useTrackingParams } from "brixon-tracking/react";
const tracking = useTrackingParams();Neu gegenueber 0.5.1:
gbraidundwbraid— Googles iOS-Click-IDs.gbraidbei einem Klick auf eine Web-Anzeige, der in die iOS-App fuehrt,wbraidumgekehrt. Beide sind eigenstaendigeClickConversion-Identifier nebengclidund case sensitive; nichts davon wird normalisiert.ttclidauch aus dem First-Party-Cookie — das TikTok-Pixel legt es unter demselben Namen ab, der URL-Parameter ueberlebt keinen Seitenwechsel. Werte koennen 1000 Zeichen lang sein.ttpaus dem_ttp-Cookie, offizieller TikTok-Match-Key. Setzt voraus, dass First-Party-Cookies in den Events-Manager-Pixel-Einstellungen aktiv sind.fbcwird ausfbclidabgeleitet, wenn das_fbc-Cookie fehlt — Meta erlaubt das ausdruecklich fuer die CAPI und schreibtsubdomainIndex = 1vor, wenn der Wert ohne Cookie erzeugt wird. Der abgeleitete Wert wird profbclidgecacht, damit der Snapshot referenziell stabil bleibt.msclkidwird unveraendert durchgereicht. Microsofts eigene Seiten widersprechen sich beim Format (Parametertabelle: 32-stellige GUID mit Status-Suffix; CAPI-Doku: hyphenierte UUID), also normalisiert das Paket nicht.
setSessionIdResolver() injiziert den Session-ID-Getter. Ohne Aufruf liest der
Snapshot window.__brixonPosthog — die Instanz, die startConsentGatedPostHog
ablegt. window.posthog setzt posthog-js nur im Snippet- und CJS-Build, nicht
im ESM-Build, den jeder Bundler nimmt; es bleibt nur als zweiter Versuch stehen.
Der /react-Entry registriert den Resolver selbst mit seiner importierten
Instanz.
TrackingHiddenFields rendert dieselben Werte einschliesslich ttp als Hidden
Fields. Der Dedup-Key bleibt davon getrennt: Das optionale eventId-Prop rendert
ihn fuer den klassischen Form-Post, waehrend der Attributions-Snapshot ihn weder
liest noch persistiert.
Merge-Regeln (nicht verhandelbar, v2 §4.3)
- Der Client ruft niemals
identify()auf. - Website-Server-Events setzen keine
$set-Person-Properties. - Nur der n8n Lead-Processor merged (
$identifymitdistinct_id = person_uid).
Entwicklung
npm install
npm test # vitest
npm run test:astro-fixture # echter Astro-Build fuer Control + Late-Island
npm run build # tsup: ESM + CJS + dts
npm run validate # typecheck + unit + package build + echte Astro-FixtureVor einem Release ist npm run validate verpflichtend. Ein Publish, eine
Cloudflare-Bindings-Aenderung oder ein Preview-/Produktionsdeploy ist nicht Teil
dieses Scripts und benoetigt eine separate Freigabe.
