@vulcantech/analytics
v0.1.2
Published
Lightweight first-party analytics tracker for Next.js — page views, custom events, form conversions. No cookies, no fingerprinting, <1KB gzipped.
Maintainers
Readme
@vulcantech/analytics
Lightweight first-party analytics for VulcanTech consumer sites — page views, custom events, and form/phone/email conversion tracking. No cookies, no fingerprinting, <1KB gzipped. Events are proxied through the site's own domain to vulcantech-cms.
Also provides CMS-driven third-party tracking tags (CMSTags): GA4 / Google Ads / Meta Pixel IDs are configured in the CMS and injected server-side, so consumer sites get "codeless" tag setup with no hand-rolled gtag/fbq wiring.
Exports:
AnalyticsTracker— client component; tracks page views on route change.trackEvent,trackFormView,trackFormSubmit,trackPhoneClick,trackEmailClick,track404,trackPageView— imperative trackers.@vulcantech/analytics/route— App Router proxy handler (POST,OPTIONS) for/api/a.@vulcantech/analytics/tags—CMSTagsserver component +fetchTrackingTags+ theTrackingTagstype.
Event shape (AnalyticsEvent)
Every tracker call builds one of these client-side and beacons it to /api/a, which forwards it to the CMS. You rarely construct one by hand, but the field limits below are enforced server-side on ingest — anything over them is silently truncated or dropped, not rejected.
interface AnalyticsEvent {
type: "pageview" | "event"; // the only two valid values
url: string; // pageview: pathname + search. event: pathname only
ref: string; // document.referrer on pageviews; "" on custom events
sid: string; // session id — see Session identity below
vw: number; // window.innerWidth — the CMS derives device from this
vh: number; // window.innerHeight
ts: number; // Date.now() on the client (clock is not trusted)
utm?: Record<string, string>; // pageviews only, and only when present in the URL
name?: string; // custom events only — the event name
props?: Record<string, string | number | boolean>; // custom events only
}Field notes — all verified against the CMS ingest path (/api/analytics → lib/analytics/ingest):
propsmust be flat primitives. Nested objects and arrays are dropped on ingest, so{ items: [...] }or{ user: { id } }silently vanish — flatten tostring | number | booleanyourself. At most 32 keys survive; keys are truncated to 64 chars, string values to 256, and HTML tags are stripped from both.- Length caps:
urlandref2048,name128,sid64. Over-length values are truncated, not rejected. vwdrives the device breakdown in the CMS dashboard:<768mobile,<1024tablet, else desktop. It is not merely diagnostic.ref+utmdrive traffic-source classification. Custom events sendref: "", so only pageviews carry attribution — don't expect a source ontrackEventdata.- Reserved names. Events with
name === "tracker_test"orurl === "/__analytics_test__"are deliberately discarded by the CMS (they exist for the connection self-test) and will never appear in stats. Don't use those values. type,url,sid,tsare required — an event missing any of them is rejected with a 400 at ingest.
Session identity
sid is a random ID in sessionStorage under __vt_sid — no cookies, so nothing survives a tab close and there is no cross-tab or cross-visit identity. When sessionStorage throws (private mode, blocked storage), the tracker falls back to a fresh random ID per event, which inflates session counts for those visitors. This is by design; the package never fingerprints.
Conversion tracking is automatic — do not double-wire
Importing this package (which AnalyticsTracker does) has two side effects at module load, on the client only:
- A delegated
documentclick listener (capture phase, installed once) firestrackPhoneClick/trackEmailClickfor everya[href^="tel:"]anda[href^="mailto:"]on the site. Adding your ownonClick={() => trackPhoneClick(...)}double-counts the conversion — the listener already caught it. Only call those functions manually for a non-anchor trigger (e.g. a button that dials via JS). window.__vtTrackis registered with the four conversion trackers.@vulcantech/formspicks it up, soform_view/form_submitconversions track automatically on any site runningAnalyticsTracker, with no per-form wiring. PassingonFormView/onFormSubmitprops toDynamicFormoverrides this rather than adding to it.
Dev-host skip
send() no-ops when the hostname is localhost, 127.0.0.1, 0.0.0.0, ends in .local, or contains "localhost". The tracker functions are still safe to call — they just do nothing — so there is no dev-only guard to write. Test conversions against a deployed URL.
AnalyticsTracker firing rules
Client component ("use client"), renders null, and wraps itself in Suspense (Next 16 requires useSearchParams inside one) — so it is safe to drop straight into a server layout. It fires exactly one trackPageView() on mount and one per pathname or searchParams change. Because it watches search params, /x?page=2 counts as a separate pageview from /x.
Tag config shape (TrackingTags)
fetchTrackingTags() resolves the project's third-party tag IDs; CMSTags renders them. The type carries only public IDs — the CMS strips its internal enabled / updatedAt fields before returning them to a consumer site.
interface TrackingTags {
ga4Id?: string; // GA4 measurement ID — CMS validates /^G-[A-Z0-9]{4,14}$/
googleAdsId?: string; // Google Ads conversion ID — CMS validates /^AW-\d{6,12}$/
metaPixelId?: string; // Meta Pixel ID — CMS validates /^\d{6,20}$/ (numeric string)
}fetchTrackingTags()returnsnull, not{}, when all three resolve empty.CMSTagsrendersnullin that case.- Format validation is CMS-side only. Those patterns are enforced when a staff user saves the config in the dashboard. Values coming from the
NEXT_PUBLIC_*env fallbacks are not validated by this package — a typo'd env ID is injected verbatim. CMSTagsis an async React Server Component. Do not add"use client"to a wrapper around it and do not callfetchTrackingTags()from client code — it readsVULCANTECH_API_KEY.- What it renders: one shared
next/scriptgtag.jsloader withstrategy="afterInteractive", keyed to the first present Google ID, plus an inline init thatgtag('config', …)s every present Google ID; and, for Meta, the standard inlinefbqbootstrap withfbq('track','PageView')and a<noscript>tracking-pixel<img>. Because it usesnext/script, mounting it in<body>is correct — it does not need to be in<head>. - No consent gating. Tags load as soon as the page hydrates. If the site needs a CMP / GDPR consent gate,
CMSTagsdoes not provide one and must not be mounted unconditionally on a site that requires consent.
The enabled toggle does not override env fallbacks
Turning tracking tags off in the CMS makes /api/analytics/config return config: null — which is the same response as "never configured". fetchTrackingTags then fills each field from NEXT_PUBLIC_GA_ID / NEXT_PUBLIC_GOOGLE_ADS_ID / NEXT_PUBLIC_META_PIXEL_ID. So a site with those env vars set keeps firing its tags after the CMS switch is off. To actually disable tags on such a site you must unset the env vars and redeploy. Sites that rely purely on the CMS (no NEXT_PUBLIC_* set) honor the toggle as expected.
Failure mode
Nothing in this package throws, and nothing surfaces an error to your code. There is no error state to render and no catch block worth writing — but that also means a fully broken pipeline looks identical to a working one from the site's side.
- Trackers (
trackEventand friends) swallow every send failure and returnvoid. They also no-op entirely on SSR and on dev hostnames. - The
/api/aproxy route returns503whenVULCANTECH_PROJECT_ID/VULCANTECH_API_KEYare unset and400on unparseable JSON — otherwise it always returns204, including when the CMS was unreachable or rejected the event. A 204 from/api/ais not evidence that anything was ingested. - The CMS drops events silently when the request is bot-flagged by user agent, or rate limited — 100 events/min per
sidand 600/min per IP. Neither is reported back to the browser. fetchTrackingTags/CMSTagsswallow CMS errors and fall through to the env fallbacks, then rendernullif those are empty too. A page build can never fail on the CMS being down.
Two different API-key scopes are required, and the common misconfiguration is having only one. Getting this wrong is invisible: events vanish while /api/a still answers 204.
| Surface | CMS endpoint | Required scope |
|---|---|---|
| AnalyticsTracker / trackers, via the /api/a proxy | POST /api/analytics | analytics:write |
| CMSTags / fetchTrackingTags | GET /api/analytics/config | analytics:read |
A site doing both needs both scopes on the one key. Verify ingestion in the CMS dashboard (Analytics → realtime), not by watching the browser's network tab for a 204.
Integration
Wire analytics into a consumer site (Next.js App Router):
1. Add the dependency:
npm install @vulcantech/analytics(Inside the VulcanTech monorepo, use the workspace wiring in MONOREPO.md instead.)
2. Set the env — see Required env below.
3. Mount AnalyticsTracker in the root layout (src/app/layout.tsx) — tracks page views on every route change:
import { AnalyticsTracker } from "@vulcantech/analytics";
// <body>
// <AnalyticsTracker />
// {children}
// </body>4. Re-export the proxy route src/app/api/a/route.ts — sends events to the CMS via the site's own domain (fails silently, never blocks the page):
export { POST, OPTIONS } from "@vulcantech/analytics/route";5. Mount CMSTags in the root layout (src/app/layout.tsx) — injects the project's GA4 / Google Ads / Meta Pixel tags from the CMS. It is an async server component; render it directly in <body>:
import { CMSTags } from "@vulcantech/analytics/tags";
// <body>
// <CMSTags />
// <AnalyticsTracker />
// {children}
// </body>CMSTags fetches once per ISR window (revalidate: 60, tag tracking-tags:{projectId}) and renders null when nothing is configured. It replaces any hand-rolled <GoogleAnalytics>, Google Ads gtag Script, and Meta Pixel fbq snippets — remove those from the layout when adopting it.
Required env
VULCANTECH_PROJECT_ID, VULCANTECH_API_KEY (required) · VULCANTECH_CMS_URL (optional, defaults to https://cms.vulcantech.io).
The API key must be granted the analytics:read scope in the CMS for CMSTags to resolve tag IDs (the /api/analytics/config endpoint). Without it (or with tags disabled) the CMS returns null and only the env fallbacks apply.
Event ingestion needs a separate scope — analytics:write — on the same key. See the scope table under Failure mode; a key with only analytics:read drops every event silently.
Env fallbacks (tags)
When the CMS omits a field (unreachable, unconfigured, or key lacks scope), each tag ID falls back to a public env var — CMS value wins when present:
| Tag | CMS field | Env fallback |
|---|---|---|
| GA4 | ga4Id | NEXT_PUBLIC_GA_ID |
| Google Ads | googleAdsId | NEXT_PUBLIC_GOOGLE_ADS_ID |
| Meta Pixel | metaPixelId | NEXT_PUBLIC_META_PIXEL_ID |
If all three end up empty, CMSTags renders nothing.
Per-app values
None beyond env — analytics and tags are fully config-driven from the CMS.
Verifying tags are live
- Use the CMS Integrations → Tracking Tags → Live Status panel: it fetches the deployed site and confirms each enabled tag ID is actually present in the HTML (green "Live" = the whole chain works). "Check again" re-probes.
- In-browser, verify via the Console, not by eyeballing the Elements DOM:
Object.keys(window.google_tag_manager) // includes both G-… and AW-… window.dataLayer.filter(a => a[0] === "config")
Notes
- The tracker skips all sends on localhost/dev hostnames; test conversions against a deployed URL. (Exact host list under Dev-host skip.)
CMSTagsnever throws — tag resolution failures are swallowed so a page build can never break on the CMS being down.- ISR invalidation: the CMS purges the
tracking-tags:{projectId}tag when the project's analytics config changes. No consumer redeploy is needed for CMS value changes — ISR picks them up within ~60s (or instantly if the site is registered as a revalidation consumer). - Google Ads shares the GA4 loader. There is one
gtag.jsrequest (keyed to the GA4 ID); the Ads ID rides on it via an inlinegtag('config','AW-…'). Don't expect a separategtag/js?id=AW-…request or a standalone DOM element for it. - Ad blockers strip
googletagmanager.com/facebook.netclient-side, so tags can look absent during manual inspection — verify in a clean browser.
