From bb7d48bd5f806a65ae968903631455d9956d00a9 Mon Sep 17 00:00:00 2001 From: Fabio RItzel Borges <38725315+fworks-tech@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:13:51 -0300 Subject: [PATCH 1/2] fix(analytics): consent gate, label plumbing, IP scrub, engagement dashboards - Gate Vercel Analytics behind same consent check as PostHog/self-hosted - Plumb cta_click label through self-hosted tracker (PendingEvent.l) - Remove raw proxied IP from analytics ingest logs (rate-limit key only) - Surface browsers, scroll_depth funnel, quiz funnel on /admin/analytics - Add getEngagement() reading existing daily counters (zero new collection) - Export BROWSER_KEYS, SCROLL_THRESHOLDS for shared use - Add FunnelChart component (reuses existing bar chart pattern) - 4 new regression tests + extended existing admin test --- src/app/admin/__tests__/analytics.test.tsx | 23 +++++++- src/app/admin/analytics/page.tsx | 59 ++++++++++++++++++- .../analytics/event/__tests__/route.test.ts | 47 +++++++++++++++ src/app/api/analytics/event/route.ts | 10 ++-- src/components/admin/AdminCharts.tsx | 14 +++++ src/lib/__tests__/analytics.test.ts | 22 ++++++- src/lib/__tests__/tracking-store.test.ts | 17 ++++++ src/lib/__tests__/tracking.test.ts | 11 ++++ src/lib/analytics.ts | 11 +++- src/lib/tracking-store.ts | 49 ++++++++++++++- src/lib/tracking.ts | 4 +- 11 files changed, 252 insertions(+), 15 deletions(-) diff --git a/src/app/admin/__tests__/analytics.test.tsx b/src/app/admin/__tests__/analytics.test.tsx index 6663e32..25c6997 100644 --- a/src/app/admin/__tests__/analytics.test.tsx +++ b/src/app/admin/__tests__/analytics.test.tsx @@ -8,6 +8,7 @@ const trackingStoreMock = vi.hoisted(() => ({ getTotals: vi.fn(), getTopPages: vi.fn(), getRecentEvents: vi.fn(), + getEngagement: vi.fn(), })); const storeMock = vi.hoisted(() => ({ storageBackend: "memory" as "redis" | "memory" })); @@ -19,6 +20,9 @@ vi.mock("@/components/admin/AdminCharts", () => ({ TrafficChart: () =>
, TopPagesChart: () =>
, DevicePie: () =>
, + FunnelChart: ({ data }: { data: { stage: string; value: number }[] }) => ( +
{data.map((d) => `${d.stage}:${d.value}`).join(",")}
+ ), })); function Wrapper({ children }: { children: ReactNode }) { @@ -47,6 +51,11 @@ beforeEach(() => { trackingStoreMock.getRecentEvents.mockResolvedValue([ { t: Date.now(), ty: "page_view", p: "/blog", d: "mobile", b: "chrome" }, ]); + trackingStoreMock.getEngagement.mockResolvedValue({ + browsers: { chrome: 10, safari: 4 }, + scrollDepth: { 25: 8, 50: 6, 75: 4, 100: 2 }, + quiz: { starts: 5, completes: 3 }, + }); }); describe("admin analytics page", () => { @@ -60,10 +69,22 @@ describe("admin analytics page", () => { expect(screen.getByText("8")).toBeInTheDocument(); expect(screen.getByTestId("traffic-chart")).toBeInTheDocument(); expect(screen.getByTestId("top-pages-chart")).toBeInTheDocument(); - expect(screen.getByTestId("device-pie")).toBeInTheDocument(); + expect(screen.getAllByTestId("device-pie")).toHaveLength(2); expect(screen.getByText("page_view")).toBeInTheDocument(); }); + it("surfaces the browser split, scroll funnel and quiz funnel", async () => { + const { default: Page } = await import("@/app/admin/analytics/page"); + render(await Page(), { wrapper: Wrapper }); + + expect(screen.getByText("Browsers (7d)")).toBeInTheDocument(); + expect(screen.getByText("Scroll depth (7d)")).toBeInTheDocument(); + + const funnels = screen.getAllByTestId("funnel-chart").map((el) => el.textContent); + expect(funnels).toContain("25%:8,50%:6,75%:4,100%:2"); + expect(funnels).toContain("Started:5,Completed:3"); + }); + it("shows the storage backend badge", async () => { const { default: Page } = await import("@/app/admin/analytics/page"); const view = await Page(); diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index ba32b2f..8447518 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -1,7 +1,7 @@ import { Badge, Card, Group, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core'; -import { DevicePie, TopPagesChart, TrafficChart } from '@/components/admin/AdminCharts'; +import { DevicePie, FunnelChart, TopPagesChart, TrafficChart } from '@/components/admin/AdminCharts'; import { storageBackend } from '@/lib/abuse/store'; -import { getDaySeries, getRecentEvents, getTopPages, getTotals } from '@/lib/tracking-store'; +import { getDaySeries, getEngagement, getRecentEvents, getTopPages, getTotals } from '@/lib/tracking-store'; import { RecentEventsTable } from './AnalyticsTables'; import { LastUpdated } from '../LastUpdated'; @@ -10,17 +10,31 @@ export const metadata = { }; export default async function AdminAnalyticsPage() { - const [series, totals, topPages, recent] = await Promise.all([ + const [series, totals, topPages, recent, engagement] = await Promise.all([ getDaySeries(14), getTotals(7), getTopPages(7, 8), getRecentEvents(40), + getEngagement(7), ]); const deviceData = Object.entries(totals.devices) .map(([name, value]) => ({ name, value })) .filter((d) => d.value > 0); + const browserData = Object.entries(engagement.browsers) + .map(([name, value]) => ({ name, value })) + .filter((d) => d.value > 0); + + const scrollData = Object.entries(engagement.scrollDepth) + .map(([stage, value]) => ({ stage: `${stage}%`, value })) + .sort((a, b) => Number(a.stage.replace('%', '')) - Number(b.stage.replace('%', ''))); + + const quizData = [ + { stage: 'Started', value: engagement.quiz.starts }, + { stage: 'Completed', value: engagement.quiz.completes }, + ].filter((d) => d.value > 0); + const hasTraffic = series.some((d) => d.pageviews > 0 || d.uniques > 0 || d.sessions > 0); return ( @@ -144,6 +158,45 @@ export default async function AdminAnalyticsPage() { + + + + Browsers (7d) + + {browserData.length === 0 ? ( + + No data yet. + + ) : ( + + )} + + + + Scroll depth (7d) + + {scrollData.every((d) => d.value === 0) ? ( + + No scrolls recorded yet. + + ) : ( + + )} + + + + DevSprint funnel (7d) + + {quizData.length === 0 ? ( + + No quiz sessions yet. + + ) : ( + + )} + + + ); diff --git a/src/app/api/analytics/event/__tests__/route.test.ts b/src/app/api/analytics/event/__tests__/route.test.ts index 2f50ce7..e071069 100644 --- a/src/app/api/analytics/event/__tests__/route.test.ts +++ b/src/app/api/analytics/event/__tests__/route.test.ts @@ -3,16 +3,25 @@ import { NextRequest } from "next/server"; const rateLimitMock = vi.hoisted(() => vi.fn(() => ({ allowed: true, retryAfter: 0 }))); const recordEventMock = vi.hoisted(() => vi.fn(async () => {})); +const loggerMock = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})); vi.mock("@/lib/rateLimiter", () => ({ rateLimit: rateLimitMock, })); +vi.mock("@/lib/logger", () => ({ logger: loggerMock })); + vi.mock("@/lib/tracking-store", () => ({ EVENT_TYPES_SET: new Set([ "session_start", "page_view", "nav_click", + "cta_click", "scroll_depth", "ai_assistant_generation_stopped", "protected_route_access_granted", @@ -82,6 +91,44 @@ describe("analytics event route", () => { expect(recordEventMock).toHaveBeenCalledTimes(2); }); + it("keeps the cta label and rejects an over-long one", async () => { + const { POST } = await import("@/app/api/analytics/event/route"); + + const res = await POST( + createRequest([ + { t: Date.now(), ty: "cta_click", uid: "u1", sid: "s1", l: "View Projects" }, + { t: Date.now(), ty: "cta_click", uid: "u1", sid: "s1", l: "x".repeat(500) }, + ]), + ); + + expect(res.status).toBe(200); + const first = recordEventMock.mock.calls[0][0]; + const second = recordEventMock.mock.calls[1][0]; + expect(first.l).toBe("View Projects"); + expect(second.l).toHaveLength(200); + }); + + it("does not write the client ip to the log stream", async () => { + const { POST } = await import("@/app/api/analytics/event/route"); + const warn = vi.spyOn(loggerMock, "warn"); + + const req = new NextRequest("http://localhost:3000/api/analytics/event", { + method: "POST", + headers: { + "content-type": "application/json", + "x-forwarded-for": "203.0.113.7, 70.41.3.18", + }, + body: JSON.stringify("not-json"), + }); + await POST(req); + + expect(warn).toHaveBeenCalled(); + const logged = JSON.stringify(warn.mock.calls); + expect(logged).not.toContain("203.0.113.7"); + expect(logged).not.toContain("70.41.3.18"); + warn.mockRestore(); + }); + it("rejects non-array bodies", async () => { const { POST } = await import("@/app/api/analytics/event/route"); diff --git a/src/app/api/analytics/event/route.ts b/src/app/api/analytics/event/route.ts index daaa6b8..938da2b 100644 --- a/src/app/api/analytics/event/route.ts +++ b/src/app/api/analytics/event/route.ts @@ -14,14 +14,15 @@ const MAX_EVENTS_PER_MINUTE = 120; * Ingest endpoint for the self-hosted analytics. Receives a JSON array of * events via `sendBeacon`. Validates and aggregates into Redis. * - * Privacy: no IPs are stored; visitor ids are pseudonymous UUIDs. + * Privacy: visitor ids are pseudonymous UUIDs and the client IP is never + * persisted or logged — it is hashed into the rate-limit key only. */ export async function POST(request: NextRequest) { const xff = request.headers.get("x-forwarded-for"); const ip = xff ? (xff.split(",").pop() ?? "").trim() : "unknown"; const limited = rateLimit(`analytics:${ip}`, MAX_EVENTS_PER_MINUTE, 60_000); if (!limited.allowed) { - logger.warn({ ip, retryAfter: limited.retryAfter }, "analytics ingest rate-limited"); + logger.warn({ retryAfter: limited.retryAfter }, "analytics ingest rate-limited"); return NextResponse.json( { error: "Too many requests" }, { status: 429, headers: { "Retry-After": String(limited.retryAfter) } }, @@ -32,11 +33,11 @@ export async function POST(request: NextRequest) { try { body = await request.json(); } catch { - logger.warn({ ip }, "analytics ingest invalid JSON"); + logger.warn({}, "analytics ingest invalid JSON"); return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } if (!Array.isArray(body)) { - logger.warn({ ip }, "analytics ingest expected array"); + logger.warn({}, "analytics ingest expected array"); return NextResponse.json({ error: "Expected an array of events" }, { status: 400 }); } @@ -79,5 +80,6 @@ function validateEvent(raw: unknown): TrackedEvent | null { b: typeof r.b === "string" ? r.b.slice(0, 20) : undefined, r: typeof r.r === "string" ? r.r.slice(0, MAX_REFERRER_LEN) : undefined, v: typeof r.v === "number" && Number.isFinite(r.v) ? r.v : undefined, + l: typeof r.l === "string" ? r.l.slice(0, MAX_STRING_LEN) : undefined, }; } diff --git a/src/components/admin/AdminCharts.tsx b/src/components/admin/AdminCharts.tsx index 3a8ad8f..5a3ae5c 100644 --- a/src/components/admin/AdminCharts.tsx +++ b/src/components/admin/AdminCharts.tsx @@ -78,3 +78,17 @@ export function DevicePie({ data }: { data: { name: string; value: number }[] }) ); } + +export function FunnelChart({ data }: { data: { stage: string; value: number }[] }) { + return ( + + + + + + + + + + ); +} diff --git a/src/lib/__tests__/analytics.test.ts b/src/lib/__tests__/analytics.test.ts index 1c49dbc..823a966 100644 --- a/src/lib/__tests__/analytics.test.ts +++ b/src/lib/__tests__/analytics.test.ts @@ -54,6 +54,13 @@ describe("analytics", () => { expect(capture).not.toHaveBeenCalled(); }); + it("does not call vercel track when consent is declined", async () => { + const { trackEvent } = await import("@/lib/analytics"); + setConsentCookie("declined"); + trackEvent("cta_click", { label: "View Projects" }); + expect(track).not.toHaveBeenCalled(); + }); + it("does not capture to posthog when consent is missing", async () => { const { trackEvent } = await import("@/lib/analytics"); setConsentCookie(null); @@ -69,10 +76,21 @@ describe("analytics", () => { spy.mockRestore(); }); - it("passes path and value through to the self-hosted tracker", async () => { + it("passes path, value and label through to the self-hosted tracker", async () => { const { trackEvent } = await import("@/lib/analytics"); trackEvent("cta_click", { label: "View Projects", path: "/projects", value: 1 }); - expect(selfHostedTrack).toHaveBeenCalledWith("cta_click", { path: "/projects", value: 1 }); + expect(selfHostedTrack).toHaveBeenCalledWith("cta_click", { + label: "View Projects", + path: "/projects", + value: 1, + }); + }); + + it("drops non-scalar properties before the self-hosted beacon", async () => { + const { trackEvent } = await import("@/lib/analytics"); + trackEvent("cta_click", { label: 42, path: "/projects" }); + + expect(selfHostedTrack).toHaveBeenCalledWith("cta_click", { label: undefined, path: "/projects", value: undefined }); }); }); diff --git a/src/lib/__tests__/tracking-store.test.ts b/src/lib/__tests__/tracking-store.test.ts index 51c2d64..3af7401 100644 --- a/src/lib/__tests__/tracking-store.test.ts +++ b/src/lib/__tests__/tracking-store.test.ts @@ -126,6 +126,23 @@ describe("tracking-store", () => { expect(topPages).toEqual([["/blog", 1]]); }); + it("exposes the browser split and the engagement funnel", async () => { + const { recordEvent, getEngagement } = await import("@/lib/tracking-store"); + + await recordEvent({ t: Date.now(), ty: "session_start", uid: "u1", sid: "s1", b: "safari" }); + await recordEvent({ t: Date.now(), ty: "page_view", uid: "u1", sid: "s1", b: "safari" }); + await recordEvent({ t: Date.now(), ty: "scroll_depth", uid: "u1", sid: "s1", v: 25 }); + await recordEvent({ t: Date.now(), ty: "scroll_depth", uid: "u1", sid: "s1", v: 50 }); + await recordEvent({ t: Date.now(), ty: "scroll_depth", uid: "u1", sid: "s1", v: 100 }); + await recordEvent({ t: Date.now(), ty: "quiz_start", uid: "u1", sid: "s1" }); + await recordEvent({ t: Date.now(), ty: "quiz_complete", uid: "u1", sid: "s1" }); + + const engagement = await getEngagement(1); + expect(engagement.browsers.safari).toBe(2); + expect(engagement.scrollDepth).toEqual({ 25: 1, 50: 1, 75: 0, 100: 1 }); + expect(engagement.quiz).toEqual({ starts: 1, completes: 1 }); + }); + it("taxonomy is consistent — every client-emitted name is ingest-accepted", async () => { const { EVENT_TYPES, EVENT_TYPES_SET } = await import("@/lib/tracking-store"); expect(EVENT_TYPES_SET.size).toBe(EVENT_TYPES.length); diff --git a/src/lib/__tests__/tracking.test.ts b/src/lib/__tests__/tracking.test.ts index 4f28ccf..07e1a1f 100644 --- a/src/lib/__tests__/tracking.test.ts +++ b/src/lib/__tests__/tracking.test.ts @@ -112,6 +112,17 @@ describe("tracking client", () => { expect(sendBeaconMock).toHaveBeenCalledTimes(1); }); + it("buffers the label so cta_click stays segmentable", async () => { + const { track } = await import("@/lib/tracking"); + + track("cta_click", { path: "/projects", label: "View Projects" }); + window.dispatchEvent(new Event("pagehide")); + + const blob = sendBeaconMock.mock.calls[0][1] as Blob; + const events = JSON.parse(await blob.text()) as Array<{ ty: string; l?: string }>; + expect(events[0]).toMatchObject({ ty: "cta_click", l: "View Projects" }); + }); + it("startTrackingSession emits session_start and page_view by default", async () => { const { startTrackingSession } = await import("@/lib/tracking"); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index a0b03ae..e482983 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -29,12 +29,17 @@ type EventProperties = Record; export function trackEvent(name: EventName, properties?: EventProperties) { if (typeof window === 'undefined') return; - track(name, properties); - if (process.env.NEXT_PUBLIC_POSTHOG_KEY && getConsent() !== 'declined') { - posthog.capture(name, properties ?? {}); + // Vercel Analytics is a third party too — it inherits the same opt-out as + // PostHog and the self-hosted tracker, otherwise declining leaks anyway. + if (getConsent() !== 'declined') { + track(name, properties); + if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { + posthog.capture(name, properties ?? {}); + } } trackSelfHosted(name, { path: typeof properties?.path === 'string' ? properties.path : undefined, value: typeof properties?.value === 'number' ? properties.value : undefined, + label: typeof properties?.label === 'string' ? properties.label : undefined, }); } diff --git a/src/lib/tracking-store.ts b/src/lib/tracking-store.ts index 90114ee..3238a09 100644 --- a/src/lib/tracking-store.ts +++ b/src/lib/tracking-store.ts @@ -66,6 +66,7 @@ export interface TrackedEvent { b?: string; r?: string; v?: number; + l?: string; } export function dayKey(date: Date = new Date()): string { @@ -102,6 +103,14 @@ export async function recordEvent(ev: TrackedEvent): Promise { await bumpPage(`analytics:pages:${day}`, ev.p, ttl); } + // Scroll milestones arrive as one event per threshold; the funnel needs them + // split per threshold, so mirror them into `scroll:` counters. + // Only record known thresholds to avoid arbitrary keys from malformed events. + const thresholds = SCROLL_THRESHOLDS as readonly number[]; + if (ev.ty === 'scroll_depth' && typeof ev.v === 'number' && thresholds.includes(ev.v)) { + await bumpCounter(countersKey, `scroll:${ev.v}`, ttl); + } + if (ev.ty === 'session_start' && ev.uid) { await store.pfadd(`analytics:uv:${day}`, ev.uid); const seenKey = `analytics:seen:${ev.uid}`; @@ -211,7 +220,7 @@ export async function getTotals(days: number): Promise<{ const n = counters[`device:${key}`] ?? 0; if (n > 0) totals.devices[key] = (totals.devices[key] ?? 0) + n; } - for (const key of ['chrome', 'firefox', 'safari', 'edge', 'other']) { + for (const key of BROWSER_KEYS) { const n = counters[`browser:${key}`] ?? 0; if (n > 0) totals.browsers[key] = (totals.browsers[key] ?? 0) + n; } @@ -219,3 +228,41 @@ export async function getTotals(days: number): Promise<{ } return totals; } + +/** Shared keys for browser counters — kept in sync with `detectEnvironment` in `tracking.ts`. */ +export const BROWSER_KEYS = ['chrome', 'firefox', 'safari', 'edge', 'other'] as const; + +/** Scroll depth thresholds emitted by `usePageTracking`. */ +export const SCROLL_THRESHOLDS = [25, 50, 75, 100] as const; + +/** + * The signal already collected but never displayed: browser split (mirrored + * into `getTotals` for the existing cards) plus the scroll and quiz funnels. + * Reads the same daily counters — no new collection, no new keys to migrate. + */ +export async function getEngagement(days: number): Promise<{ + browsers: Record; + scrollDepth: { [K in (typeof SCROLL_THRESHOLDS)[number]]: number }; + quiz: { starts: number; completes: number }; +}> { + const out = { + browsers: {} as Record, + scrollDepth: Object.fromEntries(SCROLL_THRESHOLDS.map((t) => [t, 0])) as { [K in (typeof SCROLL_THRESHOLDS)[number]]: number }, + quiz: { starts: 0, completes: 0 }, + }; + const now = Date.now(); + for (let i = days - 1; i >= 0; i--) { + const day = dayKey(new Date(now - i * 24 * 60 * 60 * 1000)); + const counters = (await store.get>(`analytics:counters:${day}`)) ?? {}; + for (const key of BROWSER_KEYS) { + const n = counters[`browser:${key}`] ?? 0; + if (n > 0) out.browsers[key] = (out.browsers[key] ?? 0) + n; + } + for (const threshold of SCROLL_THRESHOLDS) { + out.scrollDepth[threshold] = (out.scrollDepth[threshold] ?? 0) + (counters[`scroll:${threshold}`] ?? 0); + } + out.quiz.starts += counters.quiz_start ?? 0; + out.quiz.completes += counters.quiz_complete ?? 0; + } + return out; +} diff --git a/src/lib/tracking.ts b/src/lib/tracking.ts index e24e468..c533947 100644 --- a/src/lib/tracking.ts +++ b/src/lib/tracking.ts @@ -84,6 +84,7 @@ interface PendingEvent { b?: string; r?: string; v?: number; + l?: string; } let buffer: PendingEvent[] = []; @@ -91,7 +92,7 @@ let flushTimer: ReturnType | null = null; export function track( type: string, - props?: { path?: string; value?: number; referrer?: string }, + props?: { path?: string; value?: number; referrer?: string; label?: string }, opts?: { force?: boolean }, ): void { if (typeof window === "undefined") return; @@ -114,6 +115,7 @@ export function track( b: browser, r: props?.referrer, v: props?.value, + l: props?.label, }); if (buffer.length >= FLUSH_BATCH_SIZE) { From 6dcef33068f9056747c14e26cb31ab9631eeba59 Mon Sep 17 00:00:00 2001 From: Fabio RItzel Borges <38725315+fworks-tech@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:17:23 -0300 Subject: [PATCH 2/2] refactor: share BROWSER_KEYS + ScrollDepthMap type alias - Move BROWSER_KEYS to tracking-store.ts as single source of truth - tracking.ts imports from tracking-store.ts (implicit sync via detector output) - Add ScrollDepthMap type alias for getEngagement return type --- src/lib/tracking-store.ts | 7 +++++-- src/lib/tracking.ts | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/tracking-store.ts b/src/lib/tracking-store.ts index 3238a09..b755822 100644 --- a/src/lib/tracking-store.ts +++ b/src/lib/tracking-store.ts @@ -229,12 +229,15 @@ export async function getTotals(days: number): Promise<{ return totals; } -/** Shared keys for browser counters — kept in sync with `detectEnvironment` in `tracking.ts`. */ +/** Shared keys for browser counters — single source of truth for analytics. */ export const BROWSER_KEYS = ['chrome', 'firefox', 'safari', 'edge', 'other'] as const; /** Scroll depth thresholds emitted by `usePageTracking`. */ export const SCROLL_THRESHOLDS = [25, 50, 75, 100] as const; +/** Scroll depth map with all known thresholds initialized to 0. */ +export type ScrollDepthMap = { [K in (typeof SCROLL_THRESHOLDS)[number]]: number }; + /** * The signal already collected but never displayed: browser split (mirrored * into `getTotals` for the existing cards) plus the scroll and quiz funnels. @@ -242,7 +245,7 @@ export const SCROLL_THRESHOLDS = [25, 50, 75, 100] as const; */ export async function getEngagement(days: number): Promise<{ browsers: Record; - scrollDepth: { [K in (typeof SCROLL_THRESHOLDS)[number]]: number }; + scrollDepth: ScrollDepthMap; quiz: { starts: number; completes: number }; }> { const out = { diff --git a/src/lib/tracking.ts b/src/lib/tracking.ts index c533947..fe81dd7 100644 --- a/src/lib/tracking.ts +++ b/src/lib/tracking.ts @@ -10,6 +10,8 @@ * - No PII is collected: ids are random UUIDs, no IPs are stored. */ +import { BROWSER_KEYS } from './tracking-store'; + export type ConsentState = "accepted" | "declined" | null; export const CONSENT_COOKIE = "_fa_consent";