@valcis/analytics
v2.1.1
Published
Analytics ligero, sin cookies, multi-DB y multi-framework. Privacidad por diseño, RGPD-friendly.
Maintainers
Readme
@valcis/analytics
Lightweight, cookie-free, GDPR-friendly web analytics for any framework and database.
- No cookies — no consent banner needed
- No PII stored — IP addresses are never persisted
- Multi-framework — Next.js, React, Express, Fastify, Hono, vanilla JS
- Multi-database — SQLite/LibSQL/Turso, PostgreSQL, MySQL via Drizzle ORM
- Multi-site — track multiple projects with a single database
- 15 query methods — from basic page views to funnels, trends, and Web Vitals
- CLI included — check your analytics from the terminal
- TypeScript-first — full types for everything
Install
npm install @valcis/analyticsPeer dependencies
# Required
npm install drizzle-orm
# Pick your database driver
npm install @libsql/client # For SQLite/LibSQL/Turso
# or: pg, mysql2, etc.
# Optional (only if using React/Next.js entry points)
npm install react
npm install nextQuick start
1. Create your schema
// db/schema.ts
import { createSqliteSchema } from '@valcis/analytics/schema';
export const { pageViews, analyticsEvents } = createSqliteSchema();
// Also available: createPostgresSchema(), createMysqlSchema()Run drizzle-kit push or drizzle-kit migrate to create the tables.
2. Initialize analytics
// lib/analytics.ts
import { createAnalytics } from '@valcis/analytics';
import { drizzleLibsqlAdapter } from '@valcis/analytics/adapters/libsql';
import { db } from './db';
export const analytics = createAnalytics({
adapter: drizzleLibsqlAdapter(db),
siteId: 'my-site',
});3. Add the API route
Next.js (App Router):
// app/api/track/route.ts
import { analytics } from '@/lib/analytics';
export const POST = analytics.handler();Express:
import { createExpressHandler } from '@valcis/analytics/server';
import { analytics } from './lib/analytics';
app.post('/api/track', createExpressHandler(analytics));Fastify:
import { createFastifyHandler } from '@valcis/analytics/server';
fastify.post('/api/track', createFastifyHandler(analytics));Hono:
import { createHonoHandler } from '@valcis/analytics/server';
app.post('/api/track', createHonoHandler(analytics));4. Add the client tracker
Next.js:
// app/layout.tsx
import { Analytics } from '@valcis/analytics/next';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}React (Vite, CRA, etc.):
import { Analytics } from '@valcis/analytics/react';
function App() {
return (
<>
<Router />
<Analytics />
</>
);
}Vanilla JS (no framework):
import { createTracker } from '@valcis/analytics/client';
const tracker = createTracker({
endpoint: '/api/track',
webVitals: true, // Collect LCP, CLS, INP, FCP, TTFB
scrollDepth: true, // Track scroll % (25/50/75/90)
outboundLinks: true, // Track external link clicks
// scrollDepth: { thresholds: [50, 100] }, // Custom thresholds
// outboundLinks: { excludeDomains: ['example.com'] },
});
tracker.start();
// Manual event tracking
tracker.trackEvent('click_cta', { button: 'hero' });
// Clean up
tracker.stop();Standalone plugins (without the tracker):
import { initScrollDepth, initOutboundTracking } from '@valcis/analytics/client';
const cleanupScroll = initScrollDepth({ endpoint: '/api/track' });
const cleanupOutbound = initOutboundTracking({ endpoint: '/api/track' });
// Clean up
cleanupScroll();
cleanupOutbound();Configuration
const analytics = createAnalytics({
// Required
adapter: drizzleLibsqlAdapter(db),
siteId: 'my-site',
// Bot filtering
extraBotPatterns: [/my-custom-bot/i], // Added to default ~40 patterns
replaceBotPatterns: [/only-this-bot/i], // Replaces all defaults
extraIgnoredPaths: [/^\/internal/], // Added to defaults
// IP exclusion
excludeIPs: ['127.0.0.1', '::1', '10.0.0.1'],
// Provider headers (auto-detected from Cloudflare, Vercel, AWS)
ipHeaders: ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for'],
countryHeader: 'cf-ipcountry',
// Session & privacy
sessionSalt: 'your-secret-salt', // Prevents brute-force hash matching
sampleRate: 0.5, // Track 50% of requests (default: 1 = 100%)
resolveCountry: async (ip) => { // Fallback when no CDN country header
return geoLookup(ip); // Your GeoIP resolution
},
// UTM & campaign tracking (enabled by default)
// Auto-attributes: gclid→google/cpc, fbclid→facebook/cpc, msclkid→bing/cpc, ttclid→tiktok/cpc
trackUTM: true,
// Batch inserts (disabled by default)
batch: { size: 10, intervalMs: 5000 },
// Rate limiting (disabled by default)
rateLimit: { maxPerMinute: 30 },
// Hooks
onTrack: (data) => console.log('Tracked:', data.path),
onEvent: (data) => console.log('Event:', data.name),
onError: (err) => console.error('Analytics error:', err),
// Development
debug: false,
dryRun: false,
});Queries
All query methods accept optional QueryOptions:
interface QueryOptions {
days?: number; // Default: 30
from?: Date; // Start date (overrides days)
to?: Date; // End date
limit?: number; // Default: 50
offset?: number; // Default: 0
filters?: {
device?: 'desktop' | 'mobile' | 'tablet';
browser?: 'chrome' | 'firefox' | 'safari' | 'edge' | 'opera' | 'other';
os?: 'windows' | 'macos' | 'linux' | 'ios' | 'android' | 'other';
country?: string; // ISO 2-letter code
siteId?: string; // Filter by site
path?: string; // Filter by path
};
}Available queries
// Overview — all-in-one summary
const overview = await analytics.queries.overview({ days: 30 });
// → { totalViews, uniqueVisitors, topPages, topReferrers, notFoundCount, viewsByDay, sessions }
// Pages
const pages = await analytics.queries.topPages({ days: 7, limit: 20 });
// → [{ path, views, uniqueVisitors }]
const entries = await analytics.queries.entryPages({ days: 30 });
const exits = await analytics.queries.exitPages({ days: 30 });
// Referrers
const referrers = await analytics.queries.referrerStats({ days: 30 });
// → [{ referrer, views, uniqueVisitors }]
// 404s
const notFound = await analytics.queries.notFoundPages({ days: 7 });
// → [{ path, hits, lastSeen }]
// Funnel
const funnel = await analytics.queries.funnel({
fromPath: '/pricing',
toPath: '/checkout',
days: 30,
});
// → { from, to, fromViews, toViews, conversionRate }
// Time series
const timeSeries = await analytics.queries.viewsByTime({
days: 30,
groupBy: 'day', // 'hour' | 'day' | 'week' | 'month'
});
// → [{ date, views, uniqueVisitors }]
// Sessions
const sessions = await analytics.queries.sessionStats({ days: 30 });
// → { totalSessions, avgPagesPerSession, avgDurationSeconds, bounceRate }
// Events
const events = await analytics.queries.topEvents({ days: 30 });
// → [{ name, count, uniqueSessions }]
const eventFunnel = await analytics.queries.eventFunnel({
steps: ['signup_start', 'email_verified', 'profile_completed'],
days: 30,
});
// → [{ step, count, dropoff, dropoffRate }]
// Trends (current vs previous period)
const trends = await analytics.queries.trends({ days: 30 });
// → { current: { views, uniqueVisitors, sessions }, previous: {...}, change: {...} }
// Real-time
const realtime = await analytics.queries.realtime({ minutes: 5 });
// → { activeVisitors, pageViewsLastMinutes, topActivePages }
// Web Vitals
const vitals = await analytics.queries.webVitals({ days: 7 });
// → [{ name, p75, p90, median, count, goodPercent }]
// Raw data export
const raw = await analytics.queries.rawPageViews({ days: 1 });
// Multi-site
const sites = await analytics.queries.sites();
// → [{ siteId, totalViews, uniqueVisitors, lastActivity }]Custom events
Server-side
await analytics.trackEvent({
name: 'purchase',
path: '/checkout',
metadata: { amount: 99.99, currency: 'EUR' },
ip: request.headers.get('x-forwarded-for'),
userAgent: request.headers.get('user-agent'),
});Client-side (Next.js / React)
import { trackEvent } from '@valcis/analytics/next';
// or: from '@valcis/analytics/react'
trackEvent('click_cta', { button: 'hero' });Client-side (vanilla JS)
const tracker = createTracker();
tracker.trackEvent('click_cta', { button: 'hero' });Web Vitals
Automatically collect LCP, CLS, INP, FCP, and TTFB.
Next.js:
import { initWebVitals } from '@valcis/analytics/next';
// Call once in a client component
initWebVitals('/api/track');Web Vitals are sent as events with name __web_vital and metadata { name, value, rating }.
Query them with:
const vitals = await analytics.queries.webVitals({ days: 7 });
// → [{ name: 'LCP', p75: 2100, p90: 3200, median: 1800, count: 500, goodPercent: 72 }]Multi-site analytics
Track multiple projects in a single database using siteId:
// Site A
const analyticsA = createAnalytics({
adapter: drizzleLibsqlAdapter(db), // Same DB
siteId: 'site-a',
});
// Site B
const analyticsB = createAnalytics({
adapter: drizzleLibsqlAdapter(db), // Same DB
siteId: 'site-b',
});
// Dashboard — read all sites
const dashboard = createAnalytics({
adapter: drizzleLibsqlAdapter(db),
siteId: 'dashboard',
});
await dashboard.queries.sites();
// → [{ siteId: 'site-a', totalViews: 1200, ... }, { siteId: 'site-b', ... }]
await dashboard.queries.overview({ filters: { siteId: 'site-a' } });
// → Overview for site-a onlyAlerts
Monitor your analytics and get notified via webhooks.
import {
createAlerts,
new404Rule,
trafficSpikeRule,
pageViewsThresholdRule,
webVitalsBudgetRule,
sendWebhook,
} from '@valcis/analytics/alerts';
const alerts = createAlerts({
analytics,
rules: [
new404Rule({ days: 1 }),
trafficSpikeRule({ threshold: 100, minutes: 5 }),
pageViewsThresholdRule({ path: '/pricing', threshold: 1000, days: 7 }),
webVitalsBudgetRule({ thresholds: { LCP: 2500, INP: 200 } }),
],
onAlert: (alert) => sendWebhook(process.env.SLACK_WEBHOOK_URL, alert),
});
alerts.start();
// alerts.stop() to clean upsendWebhook auto-detects Slack and Discord webhook formats.
Custom alert rules
import type { AlertRule } from '@valcis/analytics/alerts';
const myRule: AlertRule = {
name: 'high-bounce',
intervalMs: 300_000,
async check(analytics) {
const sessions = await analytics.queries.sessionStats({ days: 1 });
if (sessions.bounceRate > 80) {
return {
rule: 'high-bounce',
message: `Bounce rate is ${sessions.bounceRate}%`,
severity: 'warning',
};
}
return null;
},
};CLI
npx @valcis/analytics stats --db libsql://your-db.turso.io --token your-token| Command | Description |
|---------|-------------|
| stats | General overview (views, unique visitors, sessions, bounce rate) |
| top-pages | Most visited pages |
| 404s | Not found pages |
| events | Top events |
| sites | List all tracked sites |
| realtime | Active visitors (last 5 minutes) |
| purge | Delete old records |
| Option | Description | Default |
|--------|-------------|---------|
| --db <url> | Database URL (or env ANALYTICS_DB_URL) | — |
| --token <token> | Auth token (or env ANALYTICS_DB_TOKEN) | — |
| --site <id> | Site ID (or env ANALYTICS_SITE_ID) | default |
| --days <n> | Time range in days | 30 |
| --limit <n> | Result limit | 20 |
| --older-than <n> | For purge: minimum days | 90 |
| --format <fmt> | Output format: table or json | table |
| --version | Show version | — |
GDPR / Privacy
Data retention & cleanup
// Delete records older than 90 days
await analytics.purge({ olderThan: 90 });
// Anonymize: set sessionHash to null (keep aggregate stats)
await analytics.anonymize({ olderThan: 60 });
// Export data for a specific session (GDPR access request)
const data = await analytics.exportBySession('abc123def456');
// Delete data for a specific session (GDPR deletion request)
await analytics.purge({ sessionHash: 'abc123def456' });Privacy by design
- No cookies: nothing stored in the user's browser
- No PII: IP addresses are never persisted in the database
- Daily rotation: session hashes change every day (SHA-256 of IP + User-Agent + date, truncated to 16 chars)
- Irreversible: session hashes cannot be reversed to obtain the original IP
- Country only: only ISO 2-letter country codes, no precise geolocation
Schema options
Customize table names:
const { pageViews, analyticsEvents } = createSqliteSchema({
pageViewsTable: 'my_page_views',
eventsTable: 'my_events',
});Available schema factories:
| Function | Database |
|----------|----------|
| createSqliteSchema() | SQLite, LibSQL, Turso |
| createPostgresSchema() | PostgreSQL (Supabase, Neon, etc.) |
| createMysqlSchema() | MySQL (PlanetScale, etc.) |
Custom adapters
Implement the AnalyticsAdapter interface to support any database:
import type { AnalyticsAdapter } from '@valcis/analytics';
const myAdapter: AnalyticsAdapter = {
insertPageView: async (data) => { /* ... */ },
insertEvent: async (data) => { /* ... */ },
insertBatch: async (data) => { /* ... */ },
delete: async (filter) => { /* ... */ },
queryTopPages: async (opts) => { /* ... */ },
// ... (22 methods total)
};
const analytics = createAnalytics({
adapter: myAdapter,
siteId: 'my-site',
});See src/types.ts for the full AnalyticsAdapter interface.
Entry points
| Import | Size | Dependencies |
|--------|------|--------------|
| @valcis/analytics | Core | None |
| @valcis/analytics/schema | Schema | drizzle-orm |
| @valcis/analytics/adapters/libsql | Adapter | drizzle-orm, @libsql/client |
| @valcis/analytics/next | Next.js | react, next |
| @valcis/analytics/react | React | react |
| @valcis/analytics/client | Vanilla JS | None |
| @valcis/analytics/server | Server | None |
| @valcis/analytics/alerts | Alerts | None |
All entry points support both ESM (import) and CJS (require) with full TypeScript definitions.
Accessibility
The client-side tracking is designed to be non-intrusive and accessibility-friendly:
- No visual elements —
<Analytics />renders nothing (return null) - No focus traps — no modals, banners, or popups
- No layout shifts — zero-size component, no consent dialogs
- Keyboard support — outbound link tracking responds to both
SpaceandEnterkey activation, following WCAG 2.1 SC 2.1.1 - No cookies — eliminates the need for consent banners that can be accessibility barriers
- DNT/GPC opt-in — Does not respect Do Not Track / Global Privacy Control by default (this is first-party, cookieless, anonymous analytics — DNT targets cross-site tracking). Enable with
<Analytics respectDNT />orcreateTracker({ respectDNT: true }) - Reduced motion safe — no animations or transitions
Security
- SSRF protection — webhook URLs are validated (HTTPS-only, internal IPs blocked)
- Bot filtering — 40+ patterns including AI crawlers (GPTBot, ClaudeBot, etc.)
- Rate limiting — optional per-session rate limiting
- Input validation — body size, metadata size, and path length limits
- Security headers —
X-Content-Type-Options: nosniff,Cache-Control: no-store - Security event hook —
onSecurityEventcallback for monitoring rejected requests - No PII — IP addresses are hashed and never stored
License
MIT
