From d25c0f727660760710f754eeaafa3b2a86b32cf5 Mon Sep 17 00:00:00 2001 From: Remco Stoeten Date: Wed, 22 Apr 2026 19:23:55 +0200 Subject: [PATCH] fix(analytics): tighten ingestion and sdk types --- apps/ingestion/src/app.ts | 5 +- apps/ingestion/src/db/client.ts | 47 ++++++++++++++---- apps/ingestion/src/db/index.ts | 13 +---- .../migrations/0001_archive_legacy_tables.sql | 10 ++++ apps/ingestion/src/db/schema.ts | 49 ------------------- apps/ingestion/src/db/seed.ts | 48 +++++++++--------- apps/ingestion/src/handlers/ingest.ts | 25 ++++++---- apps/ingestion/tests/unit/ingest.test.ts | 32 ++++++++++++ apps/ingestion/tests/unit/schema.test.ts | 22 +-------- package.json | 4 +- packages/sdk/scripts/client-directive.ts | 12 +++++ packages/sdk/src/api/track.ts | 41 ++++++---------- packages/sdk/src/components/analytics.tsx | 15 +++--- packages/sdk/src/index.ts | 7 --- packages/sdk/src/observers/performance.ts | 13 ++++- packages/sdk/src/types/index.ts | 20 +++++--- packages/sdk/src/utilities/enrichment.ts | 12 ++++- packages/sdk/tsconfig.json | 2 +- packages/sdk/tsup.config.ts | 6 +-- 19 files changed, 198 insertions(+), 185 deletions(-) create mode 100644 apps/ingestion/src/db/migrations/0001_archive_legacy_tables.sql create mode 100644 packages/sdk/scripts/client-directive.ts diff --git a/apps/ingestion/src/app.ts b/apps/ingestion/src/app.ts index 4b1f8f1..a3e0aff 100644 --- a/apps/ingestion/src/app.ts +++ b/apps/ingestion/src/app.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import { type MiddlewareHandler } from "hono"; import { cors } from "hono/cors"; import { handleIngest } from "./handlers/ingest.js"; import { handleMetrics } from "./handlers/metrics.js"; @@ -28,10 +29,10 @@ function incrementRequestCount(req: Request) { } function requestCounter() { - return async function (c: any, next: any) { + return async function (c, next) { incrementRequestCount(c.req.raw); await next(); - }; + } satisfies MiddlewareHandler; } function getCorsOrigin(origin: string | undefined): string | undefined { diff --git a/apps/ingestion/src/db/client.ts b/apps/ingestion/src/db/client.ts index cf26233..5bf7eb9 100644 --- a/apps/ingestion/src/db/client.ts +++ b/apps/ingestion/src/db/client.ts @@ -1,21 +1,48 @@ import { drizzle } from "drizzle-orm/neon-http"; import { neon } from "@neondatabase/serverless"; -import { events, resume, visitors, visitorEvents } from "./schema"; +import { events, visitors } from "./schema"; -function getDbClient() { +function createDb(databaseUrl: string) { + const sql = neon(databaseUrl); + return drizzle(sql, { schema: { events, visitors } }); +} + +type DbClient = ReturnType; + +function createFallbackDb(): DbClient { + return { + select() { + return { + from() { + return []; + }, + }; + }, + insert() { + return { + values() { + return { + returning() { + return []; + }, + }; + }, + }; + }, + async execute() { + return { rows: [] }; + }, + } as unknown as DbClient; +} + +function getDbClient(): DbClient { const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { - // Return dummy client during build or test if url is missing, or if env not set - return { - select: () => ({ from: () => [] }), - insert: () => ({ values: () => ({ returning: () => [] }) }), - execute: async () => ({ rows: [] }), - } as any; + return createFallbackDb(); } - const sql = neon(databaseUrl); - return drizzle(sql, { schema: { events, resume, visitors, visitorEvents } }); + return createDb(databaseUrl); } export const db = getDbClient(); diff --git a/apps/ingestion/src/db/index.ts b/apps/ingestion/src/db/index.ts index 48cfd06..e50eea9 100644 --- a/apps/ingestion/src/db/index.ts +++ b/apps/ingestion/src/db/index.ts @@ -1,13 +1,4 @@ export { events } from "./schema"; -export { resume, visitors, visitorEvents } from "./schema"; -export type { - Event, - NewEvent, - Resume, - NewResume, - Visitor, - NewVisitor, - VisitorEvent, - NewVisitorEvent, -} from "./schema"; +export { visitors } from "./schema"; +export type { Event, NewEvent, Visitor, NewVisitor } from "./schema"; export { db } from "./client"; diff --git a/apps/ingestion/src/db/migrations/0001_archive_legacy_tables.sql b/apps/ingestion/src/db/migrations/0001_archive_legacy_tables.sql new file mode 100644 index 0000000..6560e2a --- /dev/null +++ b/apps/ingestion/src/db/migrations/0001_archive_legacy_tables.sql @@ -0,0 +1,10 @@ +DO $$ +BEGIN + IF to_regclass('public.resume') IS NOT NULL AND to_regclass('public.old_resume') IS NULL THEN + ALTER TABLE public.resume RENAME TO old_resume; + END IF; + + IF to_regclass('public.visitor_events') IS NOT NULL AND to_regclass('public.old_visitor_events') IS NULL THEN + ALTER TABLE public.visitor_events RENAME TO old_visitor_events; + END IF; +END $$; diff --git a/apps/ingestion/src/db/schema.ts b/apps/ingestion/src/db/schema.ts index c738546..b8d0e5b 100644 --- a/apps/ingestion/src/db/schema.ts +++ b/apps/ingestion/src/db/schema.ts @@ -1,7 +1,6 @@ import { pgTable, bigserial, - bigint, integer, text, timestamp, @@ -45,27 +44,6 @@ export const events = pgTable( }), ); -export const resume = pgTable("resume", { - id: bigserial("id", { mode: "bigint" }).primaryKey(), - event: text("event").notNull(), - ts: timestamp("ts", { withTimezone: true }).notNull().defaultNow(), - path: text("path"), - referrer: text("referrer"), - origin: text("origin"), - host: text("host"), - isLocalhost: boolean("is_localhost"), - ua: text("ua"), - lang: text("lang"), - ipHash: text("ip_hash"), - visitorId: text("visitor_id"), - country: text("country"), - region: text("region"), - city: text("city"), - deviceType: text("device_type"), - resumeVersion: text("resume_version"), - meta: jsonb("meta"), -}); - export const visitors = pgTable( "visitors", { @@ -96,34 +74,7 @@ export const visitors = pgTable( }), ); -export const visitorEvents = pgTable( - "visitor_events", - { - id: bigserial("id", { mode: "bigint" }).primaryKey(), - visitorId: bigint("visitor_id", { mode: "bigint" }) - .notNull() - .references(() => visitors.id), - eventType: text("event_type").notNull(), - ts: timestamp("ts", { withTimezone: true }).notNull().defaultNow(), - path: text("path"), - referrer: text("referrer"), - sessionId: text("session_id"), - durationMs: integer("duration_ms"), - meta: jsonb("meta"), - }, - (table) => ({ - visitorIdIdx: index("idx_visitor_events_visitor_id").on(table.visitorId), - eventTypeIdx: index("idx_visitor_events_event_type").on(table.eventType), - sessionIdIdx: index("idx_visitor_events_session_id").on(table.sessionId), - tsIdx: index("idx_visitor_events_ts").on(table.ts), - }), -); - export type Event = typeof events.$inferSelect; export type NewEvent = typeof events.$inferInsert; -export type Resume = typeof resume.$inferSelect; -export type NewResume = typeof resume.$inferInsert; export type Visitor = typeof visitors.$inferSelect; export type NewVisitor = typeof visitors.$inferInsert; -export type VisitorEvent = typeof visitorEvents.$inferSelect; -export type NewVisitorEvent = typeof visitorEvents.$inferInsert; diff --git a/apps/ingestion/src/db/seed.ts b/apps/ingestion/src/db/seed.ts index 34f60e0..8adc84f 100644 --- a/apps/ingestion/src/db/seed.ts +++ b/apps/ingestion/src/db/seed.ts @@ -1,5 +1,5 @@ import { db } from "./client"; -import { events } from "./schema"; +import { events, type NewEvent } from "./schema"; const PROJECTS = ["analytics-demo.io", "skriuw.dev", "personal-site.com"]; const BROWSERS = [ @@ -30,18 +30,21 @@ const REGIONS = { Netherlands: ["North Holland", "South Holland", "Utrecht", "North Brabant"], "United Kingdom": ["London", "Manchester", "Birmingham", "Scotland"], Germany: ["Berlin", "Bavaria", "Hamburg", "Hesse"], -}; +} satisfies Partial>; const CITIES = { California: ["San Francisco", "Los Angeles", "San Diego"], "New York": ["New York City", "Buffalo", "Rochester"], "North Holland": ["Amsterdam", "Haarlem", "Zaanstad"], London: ["Westminster", "Camden", "Greenwich"], -}; +} satisfies Record; const PATHS = ["/", "/features", "/pricing", "/docs", "/blog", "/about"]; const REFERRERS = ["https://google.com", "https://twitter.com", "https://github.com", ""]; const DEVICES = ["desktop", "mobile", "tablet"]; -function getRandomItem(arr: T[]): T { +type SeedValue = string | number | boolean | Record; +type SeedMeta = Record; + +function getRandomItem(arr: readonly T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } @@ -49,15 +52,24 @@ function getRandomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } +function getRegions(country: string): readonly string[] { + if (country in REGIONS) return REGIONS[country as keyof typeof REGIONS]; + return ["Unknown"]; +} + +function getCities(region: string): readonly string[] { + if (region in CITIES) return CITIES[region as keyof typeof CITIES]; + return ["Unknown"]; +} + async function seed() { - console.log("🌱 Starting advanced seed..."); + console.log("Starting advanced seed..."); const now = new Date(); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - const entries: any[] = []; + const entries: NewEvent[] = []; - // Generate 200 visitors for (let i = 0; i < 200; i++) { const visitorId = `v-${Math.random().toString(36).substring(7)}`; const projectId = getRandomItem(PROJECTS); @@ -68,14 +80,12 @@ async function seed() { const screenSize = deviceType === "mobile" ? "390x844" : "1920x1080"; const viewport = deviceType === "mobile" ? "390x844" : "1440x900"; - // Geographic data for this visitor const country = getRandomItem(COUNTRIES); - const regionList = (REGIONS as any)[country] || ["Unknown"]; - const region = getRandomItem(regionList) as string; - const cityList = (CITIES as any)[region] || ["Unknown"]; + const regionList = getRegions(country); + const region = getRandomItem(regionList); + const cityList = getCities(region); const city = getRandomItem(cityList); - // Advanced traits for this visitor (Segmentation & A/B tests) const userProperties = { plan: Math.random() > 0.8 ? "pro" : "free", role: Math.random() > 0.9 ? "admin" : "user", @@ -84,7 +94,6 @@ async function seed() { pricing_color: Math.random() > 0.5 ? "green" : "red", }; - // Each visitor has 1-5 sessions const sessionCount = getRandomInt(1, 5); for (let s = 0; s < sessionCount; s++) { const sessionId = `s-${Math.random().toString(36).substring(7)}`; @@ -94,7 +103,6 @@ async function seed() { ); let lastEventTime = sessionStart; - // Force a funnel for ~30% of sessions const isFunnelSession = Math.random() > 0.7; let funnelStep = 0; @@ -102,7 +110,6 @@ async function seed() { for (let p = 0; p < pageviewCount; p++) { let path = getRandomItem(PATHS); - // Force funnel path progression if (isFunnelSession) { if (funnelStep === 0) { path = "/"; @@ -117,8 +124,7 @@ async function seed() { const timestamp = new Date(lastEventTime.getTime() + getRandomInt(1000, 60000)); lastEventTime = timestamp; - // Base metadata (injecting user attributes & experiments uniformly) - const meta: any = { + const meta: SeedMeta = { browser: browser.name, browserVersion: browser.version, os: os.name, @@ -135,7 +141,6 @@ async function seed() { meta.utmMedium = "social"; } - // 1. Pageview entries.push({ projectId, type: "pageview", @@ -152,7 +157,6 @@ async function seed() { meta, }); - // 2. Funnel conversion events if (isFunnelSession && funnelStep === 1) { entries.push({ projectId, @@ -193,7 +197,6 @@ async function seed() { funnelStep++; - // 3. Search events if (Math.random() > 0.8) { entries.push({ projectId, @@ -215,7 +218,6 @@ async function seed() { }); } - // 4. Performance & Engagement if (Math.random() > 0.3) { entries.push({ projectId, @@ -268,12 +270,12 @@ async function seed() { await db.insert(events).values(entries); } - console.log("✅ Advanced seed complete!"); + console.log("Advanced seed complete!"); process.exit(0); } seed().catch((err) => { - console.error("❌ Seed failed:"); + console.error("Seed failed:"); console.error(err); process.exit(1); }); diff --git a/apps/ingestion/src/handlers/ingest.ts b/apps/ingestion/src/handlers/ingest.ts index 30c6c8a..3d0b00f 100644 --- a/apps/ingestion/src/handlers/ingest.ts +++ b/apps/ingestion/src/handlers/ingest.ts @@ -19,26 +19,31 @@ async function getDb(): Promise { return dbModule; } -export function __setDbModule(mock: any) { +export function __setDbModule(mock: DbModule) { dbModule = mock; } -const ORIGIN_ALLOWLIST: string[] = process.env.ORIGIN_ALLOWLIST - ? process.env.ORIGIN_ALLOWLIST.split(",").map(function (o) { - return o.trim(); - }) - : []; - const INTERNAL_IPS: string[] = process.env.INTERNAL_IP_HASHES ? process.env.INTERNAL_IP_HASHES.split(",").map(function (h) { return h.trim(); }) : []; +function getOriginAllowlist(): string[] { + if (!process.env.ORIGIN_ALLOWLIST) return []; + + return process.env.ORIGIN_ALLOWLIST.split(",") + .map(function (origin) { + return origin.trim(); + }) + .filter(Boolean); +} + function isOriginAllowed(origin: string | null): boolean { - if (ORIGIN_ALLOWLIST.length === 0) return true; - if (origin && ORIGIN_ALLOWLIST.includes(origin)) return true; - return true; + const allowlist = getOriginAllowlist(); + if (allowlist.length === 0) return true; + if (origin && allowlist.includes(origin)) return true; + return false; } function isInternalTraffic(ipHash: string | null, localhost: boolean): boolean { diff --git a/apps/ingestion/tests/unit/ingest.test.ts b/apps/ingestion/tests/unit/ingest.test.ts index f57df87..88cd892 100644 --- a/apps/ingestion/tests/unit/ingest.test.ts +++ b/apps/ingestion/tests/unit/ingest.test.ts @@ -45,6 +45,38 @@ describe("POST /ingest", () => { expect(data.ok).toBe(true); }); + test("rejects origins outside configured allowlist", async () => { + const previous = process.env.ORIGIN_ALLOWLIST; + process.env.ORIGIN_ALLOWLIST = "https://allowed.example"; + + try { + const response = await app.request("/ingest", { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://blocked.example", + }, + body: JSON.stringify({ + projectId: "example.com", + type: "pageview", + path: "/home", + }), + }); + + expect(response.status).toBe(403); + + const data = (await response.json()) as { ok: boolean; error: string }; + expect(data.ok).toBe(false); + expect(data.error).toBe("Origin not allowed"); + } finally { + if (previous) { + process.env.ORIGIN_ALLOWLIST = previous; + } else { + delete process.env.ORIGIN_ALLOWLIST; + } + } + }); + test("rejects invalid payload", async () => { const payload = { type: "pageview", diff --git a/apps/ingestion/tests/unit/schema.test.ts b/apps/ingestion/tests/unit/schema.test.ts index 0569995..c148a36 100644 --- a/apps/ingestion/tests/unit/schema.test.ts +++ b/apps/ingestion/tests/unit/schema.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { events, resume, visitors, visitorEvents } from "../../src/db/schema"; +import { events, visitors } from "../../src/db/schema"; describe("events schema", () => { test("has all required columns", () => { @@ -43,16 +43,7 @@ describe("events schema", () => { }); }); -describe("legacy tables schema", () => { - test("exports resume table columns", () => { - const columns = Object.keys(resume); - - expect(columns).toContain("id"); - expect(columns).toContain("event"); - expect(columns).toContain("ts"); - expect(columns).toContain("resumeVersion"); - }); - +describe("visitors schema", () => { test("exports visitors table columns", () => { const columns = Object.keys(visitors); @@ -61,13 +52,4 @@ describe("legacy tables schema", () => { expect(columns).toContain("firstSeen"); expect(columns).toContain("lastSeen"); }); - - test("exports visitorEvents table columns", () => { - const columns = Object.keys(visitorEvents); - - expect(columns).toContain("id"); - expect(columns).toContain("visitorId"); - expect(columns).toContain("eventType"); - expect(columns).toContain("sessionId"); - }); }); diff --git a/package.json b/package.json index 87de4ae..b6f24d5 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "test": "bun run --filter '*' test", "typecheck": "bun run --filter '*' typecheck", "lint": "oxlint apps packages --deny-warnings", - "fmt": "oxfmt apps packages", - "fmt:check": "oxfmt --check apps packages", + "fmt": "oxfmt apps packages '!**/dist/**'", + "fmt:check": "oxfmt --check apps packages '!**/dist/**'", "dev:ingestion": "bun run --cwd apps/ingestion dev", "deploy": "bun run deploy.ts" }, diff --git a/packages/sdk/scripts/client-directive.ts b/packages/sdk/scripts/client-directive.ts new file mode 100644 index 0000000..e6e47e5 --- /dev/null +++ b/packages/sdk/scripts/client-directive.ts @@ -0,0 +1,12 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const files = ["index.js", "index.cjs"]; +const directive = '"use client";'; + +for (const file of files) { + const path = join(import.meta.dirname, "..", "dist", file); + const source = readFileSync(path, "utf8"); + if (source.startsWith(directive)) continue; + writeFileSync(path, `${directive}\n${source}`); +} diff --git a/packages/sdk/src/api/track.ts b/packages/sdk/src/api/track.ts index 7cba02b..e059bce 100644 --- a/packages/sdk/src/api/track.ts +++ b/packages/sdk/src/api/track.ts @@ -2,7 +2,7 @@ import { getVisitorId } from "../identity/visitor"; import { getSessionId, extendSession } from "../identity/session"; import { isOptedOut, checkDoNotTrack } from "./privacy"; import { isRuntime, debugLog, collectEnrichment, noop } from "../utilities"; -import { type AnalyticsOptions, type EventPayload, type EventType } from "../types"; +import { type AnalyticsOptions, type EventPayload, type EventType, type TrackMeta } from "../types"; const recentEvents = new Set(); const DEDUPE_WINDOW_MS = 5000; @@ -12,10 +12,15 @@ function resolveDefaultProjectId(): string { return window.location?.hostname || "unknown"; } -function getEnv() { +type Env = Record; +type ImportMetaEnv = ImportMeta & { + env?: Env; +}; + +function getEnv(): Env { if (typeof process !== "undefined" && process.env) return process.env; - if (typeof import.meta !== "undefined" && (import.meta as any).env) - return (import.meta as any).env; + const meta = import.meta as ImportMetaEnv; + if (meta.env) return meta.env; return {}; } @@ -62,7 +67,7 @@ function isDuplicate(payload: EventPayload): boolean { function buildPayload( type: EventType, - meta: Record | undefined, + meta: TrackMeta | undefined, options: AnalyticsOptions, ): EventPayload | null { if (isRuntime("server")) return null; @@ -102,11 +107,7 @@ function sendWithFetch(url: string, payload: EventPayload): void { }).catch(noop); } -export function track( - type: EventType, - meta?: Record, - options: AnalyticsOptions = {}, -): void { +export function track(type: EventType, meta?: TrackMeta, options: AnalyticsOptions = {}): void { if (isOptedOut()) { debugLog(options.debug, "User opted out"); return; @@ -125,9 +126,7 @@ export function track( return; } - let ingestUrl = options.ingestUrl - ? normalizeIngestUrl(options.ingestUrl) - : undefined; + let ingestUrl = options.ingestUrl ? normalizeIngestUrl(options.ingestUrl) : undefined; if (ingestUrl && !validateIngestUrl(ingestUrl)) { debugLog(options.debug, `Invalid ingestUrl: "${ingestUrl}". Using default.`); ingestUrl = undefined; @@ -143,31 +142,23 @@ export function track( debugLog(options.debug, "Event tracked", payload); } -export function trackPageView(meta?: Record, options?: AnalyticsOptions): void { +export function trackPageView(meta?: TrackMeta, options?: AnalyticsOptions): void { track("pageview", meta, options); } -export function trackEvent( - eventName: string, - meta?: Record, - options?: AnalyticsOptions, -): void { +export function trackEvent(eventName: string, meta?: TrackMeta, options?: AnalyticsOptions): void { track("event", { eventName, ...meta }, options); } export function trackClick( elementName: string, - meta?: Record, + meta?: TrackMeta, options?: AnalyticsOptions, ): void { track("click", { elementName, ...meta }, options); } -export function trackError( - error: Error, - meta?: Record, - options?: AnalyticsOptions, -): void { +export function trackError(error: Error, meta?: TrackMeta, options?: AnalyticsOptions): void { track("error", { message: error.message, stack: error.stack, ...meta }, options); } diff --git a/packages/sdk/src/components/analytics.tsx b/packages/sdk/src/components/analytics.tsx index 0747980..3dc5bfe 100644 --- a/packages/sdk/src/components/analytics.tsx +++ b/packages/sdk/src/components/analytics.tsx @@ -1,18 +1,17 @@ -"use client"; - import { useEffect } from "react"; import { observePageViews } from "../observers/pageview"; import { observePerformance } from "../observers/performance"; import { observeScroll } from "../observers/scroll"; import { observeTimeOnPage } from "../observers/heartbeat"; -import { type AnalyticsOptions } from "../types"; +import { type AnalyticsProps } from "../types"; import { debugLog } from "../utilities"; -type Props = AnalyticsOptions & { - disabled?: boolean; -}; - -export function Analytics({ projectId, ingestUrl, disabled = false, debug = false }: Props) { +export function Analytics({ + projectId, + ingestUrl, + disabled = false, + debug = false, +}: AnalyticsProps) { useEffect(() => { if (disabled) { debugLog(debug, "Tracking disabled"); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 1a9ef18..0c22bd7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,10 +1,3 @@ -"use client"; - -/** - * @remcostoeten/analytics - * Privacy-focused analytics SDK for tracking page views and custom events. - */ - export { Analytics } from "./components/analytics"; export { track, diff --git a/packages/sdk/src/observers/performance.ts b/packages/sdk/src/observers/performance.ts index 1b8da9d..81cbd93 100644 --- a/packages/sdk/src/observers/performance.ts +++ b/packages/sdk/src/observers/performance.ts @@ -10,6 +10,15 @@ type WebVitals = { inp?: number; }; +type LayoutShift = PerformanceEntry & { + hadRecentInput?: boolean; + value?: number; +}; + +type InteractionEntry = PerformanceEntry & { + duration?: number; +}; + function getFcp(): number | undefined { if (isRuntime("server") || !window.performance) return undefined; const entry = performance.getEntriesByName("first-contentful-paint")[0]; @@ -46,7 +55,7 @@ function observeCls(callback: (value: number) => void): void { try { new PerformanceObserver((list) => { for (const entry of list.getEntries()) { - const shift = entry as any; + const shift = entry as LayoutShift; if (!shift.hadRecentInput && shift.value) clsValue += shift.value; } callback(Math.round(clsValue * 1000) / 1000); @@ -60,7 +69,7 @@ function observeInp(callback: (value: number) => void): void { if (typeof PerformanceObserver === "undefined") return; try { new PerformanceObserver((list) => { - const last = list.getEntries().at(-1) as any; + const last = list.getEntries().at(-1) as InteractionEntry | undefined; if (last?.duration) callback(Math.round(last.duration)); }).observe({ type: "event", buffered: true }); } catch { diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 7862a34..1288453 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -1,8 +1,8 @@ -/** - * Core types and options for the analytics SDK. - */ - -export type EventType = "pageview" | "event" | "click" | "error"; +export type KnownEventType = "pageview" | "event" | "click" | "error"; +export type EventType = KnownEventType | (string & {}); +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue | undefined }; +export type TrackMeta = Record; export type AnalyticsOptions = { projectId?: string; @@ -10,8 +10,12 @@ export type AnalyticsOptions = { debug?: boolean; }; -export type EventPayload = { - type: EventType; +export type AnalyticsProps = AnalyticsOptions & { + disabled?: boolean; +}; + +export type EventPayload = { + type: Type; projectId: string; path: string; referrer: string | null; @@ -21,5 +25,5 @@ export type EventPayload = { lang: string; visitorId: string; sessionId: string; - meta?: Record; + meta?: TrackMeta; }; diff --git a/packages/sdk/src/utilities/enrichment.ts b/packages/sdk/src/utilities/enrichment.ts index cb3530b..c82551e 100644 --- a/packages/sdk/src/utilities/enrichment.ts +++ b/packages/sdk/src/utilities/enrichment.ts @@ -1,6 +1,14 @@ import { isRuntime } from "./runtime"; +import { type TrackMeta } from "../types"; -type EnrichmentData = Record; +type EnrichmentData = TrackMeta; +type NetworkInformation = { + effectiveType?: string; + downlink?: number; +}; +type NavigatorConnection = Navigator & { + connection?: NetworkInformation; +}; function getScreenInfo(): EnrichmentData { if (isRuntime("server") || !window.screen) return {}; @@ -28,7 +36,7 @@ function getUtmParams(): EnrichmentData { function getConnectionInfo(): EnrichmentData { if (typeof navigator === "undefined") return {}; - const conn = (navigator as any).connection; + const conn = (navigator as NavigatorConnection).connection; if (!conn) return {}; return { connectionType: conn.effectiveType || null, diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index f0e40e5..945240b 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -5,6 +5,6 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["node"] }, - "include": ["src/**/*.ts", "src/**/*.tsx"], + "include": ["src/**/*.ts", "src/**/*.tsx", "scripts/**/*.ts"], "exclude": ["node_modules", "dist", "__tests__"] } diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts index 5966240..ece0811 100644 --- a/packages/sdk/tsup.config.ts +++ b/packages/sdk/tsup.config.ts @@ -12,9 +12,5 @@ export default defineConfig({ external: ["react"], target: "es2020", outDir: "dist", - esbuildOptions(options) { - options.banner = { - js: '"use client";', - }; - }, + onSuccess: "bun run scripts/client-directive.ts", });