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

@gagandeep023/event-analyzer

v0.6.3

Published

Self-hosted product analytics: event capture SDK, Express collector, zero-dependency funnel/retention/cohort engine, and a React dashboard

Readme


npm i @gagandeep023/event-analyzer

Nothing else is installed. express, react and react-dom are optional peers, so pulling this in for the analysis engine alone adds exactly one package to your tree. Charts are hand-rolled SVG; there is no charting library.


Why this exists

Hosted analytics gives you good tooling and takes your users' behaviour off your server in exchange. This gives you the tooling and keeps the data on disk.

| | | |---|---| | The data stays yours | Events land as JSONL on your own disk. Nothing is sent anywhere else. | | No consent banner | No third-party cookies, no cross-site tracking, nothing to disclose. | | Nothing to outgrow | Storage is an interface. Start on a file, move to SQLite or Postgres without touching the router. | | Correct, not just present | All three retention measures, incomplete cohorts flagged, partial batch acceptance. Details below. |


Four pieces, use any of them

  ./sdk       capture   browser + node, batching, retry,
                        offline queue
  ./backend   collect   express router, validation,
                        pluggable storage
  ./core      analyse   9 analyses: funnels, retention,
                        cohorts, sessions, events, segments,
                        breakdowns, growth, activity
  ./frontend  render    react dashboard: 9 pages, SVG charts,
                        no chart lib

Capture

import { createClient, Identify, Revenue }
  from '@gagandeep023/event-analyzer/sdk';

const ea = createClient({
  endpoint: '/api/events/collect',
  apiKey: import.meta.env.VITE_EA_WRITE_KEY,
  autocapture: { pageViews: true, sessions: true },
});

ea.track('Checkout Started', { cart_value: 4200, items: 3 });
ea.identify(new Identify().set('plan', 'pro').add('logins', 1));
ea.revenue(new Revenue().setPrice(29).setProductId('pro_monthly'));

Fire-and-forget by default; the promise is there when you need it.

const { code } = await ea.track('Signup Completed').promise;

Collect

import express from 'express';
import { createEventAnalyzerRouter, JsonlFileStore }
  from '@gagandeep023/event-analyzer/backend';

app.use('/api/events', createEventAnalyzerRouter(express, {
  store: new JsonlFileStore({ dir: './data/events' }),
  apiKeys: [process.env.EA_WRITE_KEY],   // public: guards /collect
  // yours: guards /query and /stream
  queryAuth: requireOwner,
}));

express is passed in, not imported. That is what keeps it an optional peer: installing this package for core alone never pulls express into your graph.

Analyse

core is pure. Events in, results out, no I/O. Useful on its own against an array from any database, with no server involved.

import { funnel, retention, buildIdentityGraph }
  from '@gagandeep023/event-analyzer/core';

// build once, pass into every analysis
const ids = buildIdentityGraph(events);

funnel(events, {
  steps: [{ event_type: 'Signed Up' }, { event_type: 'Plan Upgraded' }],
  order: 'ordered',
  conversionWindowMs: 30 * 86_400_000,
  countBy: 'uniques',
  range: { from, to },
}, ids);

Render

import { EventAnalyzerDashboard }
  from '@gagandeep023/event-analyzer/frontend';
import '@gagandeep023/event-analyzer/frontend/styles.css';

<EventAnalyzerDashboard baseUrl="/api/events" fetcher={authedFetch} />

What you actually get to look at

Eight dashboard pages, all from the same event stream:

| Page | Answers | |---|---| | Overview | Is the product healthy, and where is the biggest drop-off | | Audience | Who is visiting: browser, OS, device, language, and when they are here | | Pages | Top paths, traffic channels, referrers, campaigns | | Clicks | What people press, where it takes them, and from which page | | Events | Every event type with counts, unique users, share and change | | Funnels | Step conversion, drop-off, median time per hop, three ordering modes | | Retention | Three measures, cohort grid, plus growth accounting | | Live | Throughput and the raw feed as the collector receives it |

The SDK parses browser, OS and device from the user agent at capture time, pulls UTM parameters off the URL, and classifies the referrer into a channel, so those breakdowns work without any extra configuration.

Three things most reimplementations get wrong

1. Retention is three different questions.

| Measure | Retained in period N when | Good for | |---|---|---| | n-day | Returned on exactly day N | Daily habit loops | | unbounded | Returned on day N or any day after | Irregular use, most B2B | | bracket | Returned within a custom [lo, hi] | Onboarding windows |

They disagree enormously. On this package's own demo data, n-day reports about a third of what unbounded reports for the same users. Ship only n-day and a healthy weekly-rhythm product looks like it is bleeding users.

2. A young cohort cannot be measured at Day 30.

It has not had thirty days to return. Count it in the denominator anyway and your curve dives at the right-hand edge, which is an artifact of your date range, not a fact about your product.

Every curve point here carries its own cohortSize, computed from only the cohorts with a full N periods of observable data, plus an incomplete flag so the renderer can show uncertainty instead of the engine hiding it.

3. One bad event should cost you one event.

/collect accepts partially. The response names failures by array index, so the client drops exactly the poison and retries the rest:

{ "code": 200, "events_ingested": 497, "events_rejected": 3,
  "rejected": { "events_with_missing_fields": { "event_type": [12] } } }

A batch rejected purely as duplicates returns 200, not an error. That is the retry-after-timeout case insert_id exists for, and reporting failure there tells the caller its retry failed when the data actually arrived.


Funnel modes: read this once

ordered      steps in order, other events allowed
             between                              ← the intuitive one
unordered    all steps, any order
sequential   steps in order, NO other event
             between them                         ← stricter than
                                                    it sounds

sequential is not the plain in-order mode. ordered is. These names are the established convention and are kept verbatim, because a same-word-different-meaning collision is worse than an awkward word.

The conversion window bounds the whole funnel, not each hop. The segment filter applies to the first step only.


Security: two trust levels

/collect, /identify, /group-identify and /alias are public by nature. Anything running the SDK must reach them, so the write key ships in your client bundle and is not a secret.

/query/*, /meta, /export and /stream expose every event in the store and need real authentication via queryAuth. The router warns at startup when it is missing, but it cannot supply one for you.

Other defaults chosen on the cautious side:

  • Autocapture is entirely off. Capturing tracking you did not ask for is how people ship surprise data collection.
  • Regex filters are refused unless enabled. Untrusted regex compiled from a request body is a denial-of-service vector.
  • Click capture never reads input values and never touches a password field.

Documentation

| | | |---|---| | GUIDE.md | Usage: capture, serve, analyse, render. Ships in the tarball. | | docs/ | Design: 12 documents covering architecture, API, algorithms, tests, risks | | Live guide | The same material, rendered, with a working dashboard behind it |


Try it

git clone https://github.com/Gagandeep023/event-analyzer.git
cd event-analyzer && npm i
# seeds 90 days of data, prints a curl for every endpoint
npm run demo

Known limits

  • JsonlFileStore scans the files overlapping a range per query. Comfortable to a few million events; a SQLite store is the v0.2 answer.
  • A fixed tzOffsetMin cannot express a DST transition mid-range.
  • /export materialises the range before streaming, so keep export windows bounded.
  • Journey paths, taxonomy validation and lookup tables are not in v0.1.

Requests and feedback

Request a feature Report a bug

Ideas and questions go to Discussions, bugs to Issues.

License

MIT