npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@valcis/analytics

v2.1.1

Published

Analytics ligero, sin cookies, multi-DB y multi-framework. Privacidad por diseño, RGPD-friendly.

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/analytics

Peer 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 next

Quick 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 only

Alerts

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 up

sendWebhook 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 Space and Enter key 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 /> or createTracker({ 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 headersX-Content-Type-Options: nosniff, Cache-Control: no-store
  • Security event hookonSecurityEvent callback for monitoring rejected requests
  • No PII — IP addresses are hashed and never stored

License

MIT