@brandkarma/tracker
v0.1.0
Published
Cookieless web analytics and attribution SDK for BrandKarma. Image-beacon transport, no cookies, no device storage. Framework-free core plus React and Next.js App Router bindings.
Maintainers
Readme
@brandkarma/tracker
Cookieless web analytics and attribution SDK for BrandKarma. Image-beacon transport, no cookies, no device storage of any kind. Framework-free core with React and Next.js App Router bindings.
@brandkarma/tracker— core, framework-free, valid Node ESM (usable anywhere)@brandkarma/tracker/react— generic React client components (optional peer:react >= 18)@brandkarma/tracker/next— Next.js App Router bindings, everything from/reactplusAnalytics(optional peers:react,next >= 14)
ESM only.
Install
pnpm add @brandkarma/tracker
# or: npm install @brandkarma/trackerQuickstart (Next.js App Router)
1. Define your site's event taxonomy (see Event taxonomy below):
// lib/events.ts
export const EVENTS = {
PAGEVIEW: "pageview", // REQUIRED literal — the server's pageview metrics match it exactly
CTA_CLICK: "cta_click",
CONTACT_CLICK: "contact_click",
} as const;
export type EventName = (typeof EVENTS)[keyof typeof EVENTS];2. Configure the tracker ONCE in a single "use client" module:
// lib/tracker.ts
"use client";
import { createBrandKarma } from "@brandkarma/tracker/next";
import type { EventName } from "./events";
export const { track, withAttribution, getAttribution, Analytics, TrackedOutboundLink, ContactLink, TrackView } =
createBrandKarma<EventName>({
projectId: "<your BrandKarma Brand id>",
canonicalOrigin: "https://www.example.com",
});3. Mount Analytics once in your root (or locale) layout, inside your providers, after
{children}:
// app/[locale]/layout.tsx (server component)
import { Analytics } from "@/lib/tracker";
// ... inside the provider tree, after {children}:
<Analytics />Analytics fires a pageview on mount and on every client-side route change. It reads the
route via usePathname() and deliberately never calls useSearchParams() — that would opt
every page out of static rendering. It also guards against React StrictMode's double effect
invocation, so no duplicate pageviews land in your data.
4. Track from anywhere:
// In a SERVER component: render the components (they are client references).
<TrackView event="pricing_view" />
<TrackedOutboundLink href="https://app.example.com/auth/register" event="cta_click" location="hero">
Start free
</TrackedOutboundLink>
<ContactLink href="mailto:[email protected]" event="contact_click" location="footer">
[email protected]
</ContactLink>
// In a CLIENT component: call the functions.
track("cta_click", { location: "navbar" });Server components vs client components
The component exports (Analytics, TrackView, TrackedOutboundLink, ContactLink)
are client references — render them directly from server components; they need no wrapper.
The function exports (track, withAttribution, captureAttribution, getAttribution)
are import-legal everywhere but callable only inside client components. Calling track()
from a server component fails the build with "It's not possible to invoke a client function
from the server." If a server page needs to record a view, render <TrackView /> instead of
calling track().
Plain React (no Next.js)
Use @brandkarma/tracker/react — the same factory without Analytics (which depends on
Next's router). Fire pageviews yourself from your router of choice, with your own
"did the path actually change" guard.
No framework
Use the core directly:
import { createTracker } from "@brandkarma/tracker";
const tracker = createTracker({ projectId: "...", canonicalOrigin: "https://www.example.com" });
tracker.captureAttribution(); // on landing
tracker.track("pageview");Getting a projectId (it's a Brand id)
projectId is your BrandKarma Brand id — the id of the Brand document, shown in the
BrandKarma app under Brand Details → Tracking Pixel. It is public by design (it ships in
every beacon URL); the read side of the analytics is what's protected.
Warning: unknown or mistyped ids fail SILENTLY. The tracking endpoint accepts any projectId without validation — events for a wrong id are stored but unreadable by anyone. After setup, always verify that events appear in your BrandKarma dashboard before trusting the integration.
Event taxonomy
The SDK is generic over your event names (createBrandKarma<EventName>(...)); the taxonomy
itself stays in your site's code, not in the SDK. Conventions that work well:
- A single
lib/events.tswith a const map + derived union type (see Quickstart). pageviewmust be the literal string"pageview"— server-side pageview metrics compare it exactly.- Name events by intent (
cta_click,contact_click,tool_use,signup), and pass the placement as alocationprop (hero,navbar,pricing:not-sure) so one event can be compared across placements. - If you assign monetary values to conversions, keep a
CONVERSION_VALUESmap next to the taxonomy in the same file.
Privacy posture (cookieless, no device storage)
This SDK never uses cookies, localStorage, sessionStorage, or any other terminal-device storage — reading or writing. All three count as terminal-device storage under §25 TDDDG (and equivalent EU consent regimes) and would require an opt-in banner. An in-memory variable does not. This is a load-bearing product promise, not an implementation detail.
How it works instead:
- Attribution is captured once per JS context from the landing URL and held in memory.
It survives client-side navigation (SPA routers keep one JS context) but is lost on a hard
reload — an accepted trade-off, since the realistic ad journey (land with
?gclid→ browse → click the CTA) stays within one context. - Sessions and unique visitors are stitched server-side by BrandKarma from a daily-salted hash of user agent + IP, held in memory for a 30-minute sliding window. The salt is random per server process and never persisted; IP addresses are never stored. Visitors cannot be joined across days or server restarts.
- The SDK sends no session IDs, no visitor IDs, no timestamps — such parameters don't exist on the server. Do not batch or delay events either: the server timestamps at receipt, so deferral skews sessionization.
Attribution capture and click-ID semantics
Captured parameters (exactly the server's binding list, exported as ATTRIBUTION_PARAMS):
| Group | Params | Meaning |
|---|---|---|
| UTM | utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id | Campaign labeling. A paid-style utm_medium (cpc, ppc, paid…) classifies traffic as paid. |
| Paid click IDs | gclid, gbraid, wbraid, msclkid | Google/Microsoft Ads. Their presence classifies the session as paid and they are the keys for ad-platform conversion import. |
| Organic-capable click IDs | li_fat_id, fbclid, ttclid | LinkedIn/Meta/TikTok. Captured and stored, but deliberately not treated as paid evidence (they appear on organic shares too). |
Semantics:
- Capture-once, first-non-empty-wins. The landing-page capture is kept for the life of the JS context and re-sent with every event, so attribution survives navigation to URLs that no longer carry the params. Explicit beacon params outrank the server's fallback parsing of the page URL. An empty capture does not lock; a later navigation carrying params can still be captured.
?ref=shorthand (common on inbound links) is mapped client-side toutm_sourcewhen noutm_sourceis present;refitself is never sent.- Values are trimmed and truncated to 512 chars (matching the server cap).
withAttribution(url)appends the captured params to an outbound URL — use it (orTrackedOutboundLink, which does it for you) to carry click IDs across a domain boundary to your app, where they can be stored server-side against the account and conversions imported back to the ad platform without any ad cookie. Params already on the target URL are never overwritten. During SSR it returns the URL unchanged (TrackedOutboundLinkserver-renders the clean href and upgrades after hydration).referreris sent as an explicit query param (fromdocument.referrer, only when non-empty) because the HTTP Referer header of an image beacon identifies the page the pixel is on, not where the visitor came from. Traffic-source classification (paid/search/llm/social/referral/direct) is derived server-side, first-touch per session.
Custom props: contract and limits
The optional second argument to track(event, props):
track("tool_use", { tool: "gtin-validator" });
track("cta_click", { location: "hero", destination: "/auth/register" });- Type:
Record<string, string | number | boolean | undefined>. - On the wire each prop becomes a
p_-prefixed query param (p_tool=gtin-validator), which is what the server recognizes as a custom prop. The prefix makes collisions with reserved params (projectId,event,url,referrer, attribution params, …) impossible. - Keys must match
^[A-Za-z0-9_-]{1,64}$. Non-conforming keys (dots,$, spaces, empty, over-long) are dropped silently —tracknever throws. - Values are stringified and truncated to 512 chars.
undefined,nulland""values are skipped.falseand0are sent. - Max 20 props per event; extras are dropped.
- Props are the first thing shed if the beacon URL approaches the server's 8 KB request-line limit (see below).
Transport and limits
- Transport is a 1×1 image GET: immune to CORS, stays in flight across page unload (matters
for outbound clicks), can't read a response. Fire-and-forget — there is no delivery
confirmation and no retry, and
track()is a silent no-op during SSR. - A
_tcache-buster is appended (the server sets no Cache-Control on the pixel), unique even for identical events fired in the same millisecond. - The server silently drops request lines over 8 KB, so the SDK measures the final encoded
URL and sheds in order until it fits: custom props → page-URL query string →
utm_term/utm_content→ referrer path → remaining non-paid attribution params. The paid click IDs (gclid/gbraid/wbraid/msclkid) are last to go and in practice never dropped.
API notes
- Single tracker per page. Attribution capture state is module-level and shared; configure exactly one tracker per site (the factory-module pattern above enforces this naturally).
resetAttribution()(core export) clears the captured attribution. Test-only — it exists because capture-once semantics are otherwise untestable. Never call it in production code.createTracker(core) returns plain closures — destructuring is safe, nothing depends onthis.
License
MIT
