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

hazo_umetrics

v2.1.0

Published

Product analytics for hazo apps — GA4 hybrid + first-party stat store + feature flags

Readme

hazo_umetrics

Product analytics for hazo apps — GA4 hybrid + first-party stat store + feature flags.

Gives every hazo Next.js app one consistent, auth-gated way to understand user behaviour:

  • GA4 hybrid — traffic, SEO, and conversion funnels read via the GA4 Data API; auto-provisioned via GA4 Admin API (M1).
  • Stat store — first-party numeric metrics over time, collector pattern, sparkline history.
  • Feature flags — deterministic weighted-variant bucketing, signed consent cookie, sticky assignment.
  • A/B experiments — same engine as flags + significance (deferred until traffic justifies it).
  • SEO SERP experiments — rotate a page's title/description through variants over calendar-date phases; score on Search Console CTR (primary) plus GA4 engagement (secondary).

All surfaces mount inside hazo_admin via the existing kind:'metrics' section; every read/write is app_id-gated.

Status

M0 Core shipped. M0 Interactive test-app is live (Admin Mount, Event Log, Metrics, Autotest). See design/master_plan.md.

Installation

npm install hazo_umetrics

Peer dependencies (install the ones you use):

npm install hazo_core hazo_connect hazo_auth hazo_secure hazo_audit hazo_api hazo_ui hazo_dataviz

The hazo_umetrics/ui panel renders its trend chart with hazo_dataviz (the chart primitives moved out of hazo_ui in the hazo_ui 4.0.0 migration), so install hazo_dataviz when you use MetricsPanel/StatTrend.

hazo_api is a required peer as of 2.0.0 — every hazo_umetrics/api handler factory imports its ok/fail/withRequestContext envelope helpers unconditionally, so the package cannot function without it. It is not optional despite the "install the ones you use" framing above.

Required env vars for cookie signing (A/B assignment):

HAZO_UMETRICS_COOKIE_KEY_CURRENT=v1
HAZO_UMETRICS_COOKIE_KEY_v1=<base64-encoded 32-byte AES-256 key>

Entry points

| Import path | Contents | |---|---| | hazo_umetrics | Server: recordStat, getLatestStat, getStatSeries, resolveVariant, weightedBucket, computeSignificance, estimateSampleSize, openSeoPhase, closeSeoPhase, listSeoPhases, scoreSeoExperiment, getMetricsByVariant | | hazo_umetrics/api | Route factories: createStatHandlers, createExperimentHandlers, createSeoExperimentHandlers, createGa4ConnectHandlers, createQueryHandlers, createEventHandlers (anon ingestion), createBehaviorHandlers, createActionHandlers, createLinkHandlers, createActivityHandlers | | hazo_umetrics/client | Client: HazoMetricsProvider, useMetrics, getVariant, isEnabled, identify, trackAction, trackError, recordStat | | hazo_umetrics/ui | UI: MetricsPanel, StatCards, StatTrend, AnalyticsPanel, ActionsPanel, SeoExperimentPanel |

Stat store

import { recordStat, getLatestStat, getStatSeries, registerStatCollector, runStatSnapshot } from 'hazo_umetrics';

// Append a reading (always inserts, never upserts)
await recordStat(adapter, { app_id: 'my-app', metric_key: 'active_users', value: 42 });

// Latest value
const stat = await getLatestStat(adapter, { app_id: 'my-app', metric_key: 'active_users' });

// Series (ascending by captured_at)
const series = await getStatSeries(adapter, { app_id: 'my-app', metric_key: 'active_users', limit: 30 });

// Register a collector (runs in runStatSnapshot)
registerStatCollector({
  key: 'active_users',
  label: 'Active users',
  format: 'count',
  collect: async (app_id) => countActiveUsers(app_id),
});

// Run all collectors (call on a schedule via hazo_jobs in M1)
const results = await runStatSnapshot(adapter, { app_id: 'my-app' });

Feature flags

import { resolveVariant, getVariant, isEnabled } from 'hazo_umetrics';

// Evaluate a flag with logged-in user (sticky assignment)
const { variant } = await resolveVariant(adapter, {
  experimentKey: 'new_dashboard',
  app_id: 'my-app',
  subjectId: user.id,
  consent: true,
});

// Boolean flag shorthand
const enabled = await isEnabled(adapter, { experimentKey: 'new_feature', app_id, subjectId });

computeSignificance (two-proportion z-test) and estimateSampleSize (required sample size for a target minimum detectable effect) are real as of 1.18.0 — they were typed stubs in earlier releases:

import { computeSignificance, estimateSampleSize } from 'hazo_umetrics';

const result = computeSignificance({
  control: { conversions: 40, visitors: 1000 },
  variant: { conversions: 55, visitors: 1000 },
});
// → { pValue, confidenceInterval: [lo, hi], significant, sampleSize, power }

const estimate = estimateSampleSize({ baselineRate: 0.04, minimumDetectableEffect: 0.01 });
// → { requiredPerVariant, estimatedDaysToComplete }

SEO SERP experiments

Rotate a page's title/description through variants over calendar-date phases and score which one wins on Search Console CTR. SERP CTR is the primary metric — GA4/on-page engagement is explicitly secondary: Google serves one title/description per URL to everyone, so there's no per-visitor bucketing the way there is for on-page A/B tests; variants are scored per calendar window instead. GSC ingestion and GA4 wiring are the caller's responsibility — this package stores the phase ledger and scores whatever series you hand it.

import { openSeoPhase, closeSeoPhase, listSeoPhases, scoreSeoExperiment } from 'hazo_umetrics';

// Open a new phase (closes the previous open phase for this experiment/app automatically).
// Idempotent per (experimentKey, app_id, phaseIndex) — safe to replay.
await openSeoPhase(adapter, {
  experimentKey: 'homepage_title_test',
  app_id: 'my-app',
  path: '/',
  variant: 'b',
  title: 'New title variant',
  description: 'New description',
  phaseIndex: 1,
  burnInDays: 7, // Google needs time to re-index/re-rank before CTR is representative
});

await closeSeoPhase(adapter, { experimentKey: 'homepage_title_test', app_id: 'my-app' });

const phases = await listSeoPhases(adapter, { experimentKey: 'homepage_title_test', app_id: 'my-app' });

// Pure function — score phases against a Search Console daily series you supply.
const results = scoreSeoExperiment(phases, gscDailySeries);
// → { variants: [{ variant, phases, windowDays, clicks, impressions, ctr, avgPosition }],
//     best, comparisonToControl?, positionShift, notes }

Secondary GA4 engagement, filtered by the seo_variant custom dimension (auto-provisioned via DEFAULT_GA4_MANIFEST):

import { getMetricsByVariant } from 'hazo_umetrics';

const secondary = await getMetricsByVariant(ga4Client, 'homepage_title_test', {
  startDate: '2026-08-01',
  endDate: '2026-08-31',
});
// → [{ variant, sessions, engagedSessions, engagementRate, conversions }]

Render both together with SeoExperimentPanel (hazo_umetrics/ui) — it self-fetches the phase timeline, and renders the CTR/significance section and the secondary-metrics section only once you supply results / secondaryMetrics as props (each is optional and each section is labelled independently, "secondary" called out explicitly in the UI):

import { SeoExperimentPanel } from 'hazo_umetrics/ui';

<SeoExperimentPanel
  appId="my-app"
  apiBase="/api/hazo_umetrics/seo-experiments"
  experimentKey="homepage_title_test"
  results={results}             // from scoreSeoExperiment, once you have a GSC series
  secondaryMetrics={secondary}  // from getMetricsByVariant, once you have a GA4 client
/>

Analytics dashboard

import { AnalyticsPanel } from 'hazo_umetrics/ui';

// Mount in a server or client page — fetches all behavioral data from /api/hazo_umetrics/analytics/*
<AnalyticsPanel appId="my-app" />

Seven behavioral panels answer:

  • Top actions (all users) — most frequent action.* events, no user_id required
  • First-session actions — what new users do first (requires user_id in events)
  • Post re-login actions — what users do on their second session
  • Before "share" — the N actions preceding a goal event (configurable goal key)
  • Common errors — ranked ui.error.* frequency
  • Time per tab — real foreground dwell time per path (from ui.pageexit.* events)
  • Returning-user actions — most common actions for users with >1 session

For user_id to be captured, call identify(userId) after login or mount UserIdentifier (see test-app src/components/user_identifier.tsx).

Action tracking (client)

import { HazoMetricsProvider, useMetrics } from 'hazo_umetrics/client';

// Wrap your app
<HazoMetricsProvider appId="my-app" userId={currentUser?.id}>
  {children}
</HazoMetricsProvider>

// In any component
const { trackAction, trackError } = useMetrics();
trackAction('timer.complete', { url: '/timer' });

Auto-tracked on mount: page views (ui.pageview.*), clicks (ui.click.*), errors (ui.error.*), page-exit dwell time (ui.pageexit.*).

Bulk Action Catalog edits

// POST /api/hazo_umetrics/actions/bulk
// Body: { app_id, action_keys, patch?, add_related?, remove_related? }
// Permission: metrics.manage
// Response: { ok: true, data: { updated: string[], missing: string[], links: { added, removed } } }

// Set status on many actions at once
await fetch('/api/hazo_umetrics/actions/bulk', {
  method: 'POST',
  body: JSON.stringify({
    app_id: 'my-app',
    action_keys: ['timer.start', 'timer.complete'],
    patch: { status: 'active', is_key_event: true },
  }),
});

// Add related links in bulk (cartesian: every key in action_keys ↔ every key in add_related)
await fetch('/api/hazo_umetrics/actions/bulk', {
  method: 'POST',
  body: JSON.stringify({
    app_id: 'my-app',
    action_keys: ['timer.start'],
    add_related: ['timer.complete', 'timer.pause'],
  }),
});

action_keys must be a non-empty array — an empty array returns 400. Both patch and add_related/remove_related can be combined in one request.

Per-action datapoint schema

Declare which dimensions keys are analytically meaningful for a given action:

// PATCH the action to set a datapoint_schema
await fetch('/api/hazo_umetrics/actions?app_id=my-app', {
  method: 'PATCH',
  body: JSON.stringify({
    app_id: 'my-app',
    action_key: 'timer.complete',
    patch: {
      datapoint_schema: [
        { key: 'duration_ms', label: 'Duration (ms)', type: 'number' },
        { key: 'mode', label: 'Timer mode', type: 'string' },
      ],
    },
  }),
});

Types exported from hazo_umetrics: DatapointField, DatapointSchema.

Once a schema is set, the DatapointPanel UI component shows per-key cards: histogram bars for type:'number' keys, ranked frequency lists for type:'string' keys.

Journey analytics

// GET /api/hazo_umetrics/analytics/journey?app_id=<id>[&user_id=<id>][&session_id=<id>]
// Permission: metrics.view
// Response: { ok: true, data: { sessions: Session[], graph: JourneyGraph } }

sessions is an ordered list of per-session step arrays with timing. graph.nodes and graph.edges provide the aggregated action-transition graph for all sessions (or filtered by user/session). Each edge carries avgMs (mean inter-step latency).

import { JourneyPanel } from 'hazo_umetrics/ui';

// Embedded panel — fetches journey data from /api/hazo_umetrics/analytics/journey
<JourneyPanel
  appId="my-app"
  resolveUserProfiles={resolveUserProfiles}  // optional — enriches session headers
/>

JourneyPanel renders a self-contained inline-SVG flow graph — a left-to-right DAG laid out by shortest-path BFS (source nodes on the left, depth growing rightward). Node fill encodes step kind (action / pageview / error) and opacity scales with visit count; edge stroke width and opacity scale with transition count. No external graph dependency is required. A built-in user filter dropdown (top-right of the panel) scopes the graph and session list to a single user; it refetches with &user_id=<id>. The known-user option list only grows, so it stays stable while a filter is active. Below the graph, per-session collapsible step lists show timing between steps.

Datapoint analytics

// GET /api/hazo_umetrics/analytics/datapoints?app_id=<id>&action_key=<key>
// Permission: metrics.view
// Response: { ok: true, data: { action_key, total_events, keys: DatapointKeyStats[] } }

Each entry in keys describes one dimension: { key, type, total, histogram | topValues }. Use the DatapointPanel component to render these automatically. The panel's action dropdown defaults to the most useful action: first an action with a declared datapoint_schema, else the first app-domain action (skipping auto-tracked btn.* / ui.* events), else the first action — so a user lands on data-bearing dimensions instead of an empty auto-tracked event.

resolveUserProfiles — wiring user identity

AnalyticsPanel and JourneyPanel both accept an optional resolveUserProfiles prop that enriches user IDs with real names, emails, and avatars:

import type { ResolveUserProfiles } from 'hazo_umetrics/ui';

const resolveUserProfiles: ResolveUserProfiles = async (ids) => {
  const r = await fetch('/api/my-app/user_profiles', {
    method: 'POST',
    body: JSON.stringify({ ids }),
    headers: { 'Content-Type': 'application/json' },
  });
  const j = await r.json();
  return j.profiles ?? [];
};

// Each profile: { user_id, email?, name?, profile_picture_url? }

Wiring with hazo_auth (gotimer / kinstripe pattern):

// app/api/hazo_umetrics/user_profiles/route.ts
import { authConnect } from '@/lib/db';
import { hazo_get_user_profiles } from 'hazo_auth/server-lib';

export async function POST(req: Request) {
  const { ids } = await req.json();
  const result = await hazo_get_user_profiles(authConnect, ids);
  return Response.json({ profiles: result.profiles ?? [] });
}

Event ingestion — defaultActionLabels

Pass defaultActionLabels to createEventHandlers to declare human-readable labels for auto-tracked ui.* events before an operator edits the catalog. On first ingest of a key, the label is stored instead of the derived PageEntry:* / Click:* fallback:

import { createEventHandlers } from 'hazo_umetrics/api';

const handlers = createEventHandlers({
  getAdapter: () => myAdapter,
  defaultActionLabels: {
    'ui.pageview.root':    'Homepage',
    'ui.pageview.timer':   'Timer Page',
    'ui.click.sign_in':    'Sign In Button',
    'ui.pageexit.root':    'Homepage Exit',
  },
});

// app/api/hazo_umetrics/events/route.ts
export const POST = (req: Request) => handlers.recordEvent(req);

Action Catalog panel — refreshTrigger

ActionsPanel accepts an optional refreshTrigger prop. Increment it to force a catalog reload from the parent (e.g. after seeding events):

import { ActionsPanel } from 'hazo_umetrics/ui';

const [refreshTrigger, setRefreshTrigger] = useState(0);

// After seeding:
setRefreshTrigger(n => n + 1);

<ActionsPanel appId="my-app" refreshTrigger={refreshTrigger} />

API route factories

import { createStatHandlers, createExperimentHandlers, createSeoExperimentHandlers } from 'hazo_umetrics/api';

const statHandlers = createStatHandlers({ getAdapter: () => myAdapter });
// → statHandlers.getStat, statHandlers.getStatSeries, statHandlers.recordStat

const expHandlers = createExperimentHandlers({ getAdapter: () => myAdapter });
// → expHandlers.listExperiments (metrics.view), expHandlers.startExperiment (metrics.manage)

const seoExpHandlers = createSeoExperimentHandlers({ getAdapter: () => myAdapter });
// → seoExpHandlers.listSeoExperiments / getSeoExperiment (metrics.view),
//   seoExpHandlers.advanceSeoPhase / getSeoExperimentResults (metrics.manage / metrics.view)
//   seoExpHandlers.createSeoExperiment (metrics.manage) — inserts a new type='seo_serp' row
//   (app_id, key, path, variants, schedule, status?); no more hand-written SQL to seed one.
// SEO experiments are hazo_umetrics_experiment rows with type = 'seo_serp'; the phase
// ledger (path/variant/title/description per date window) lives in hazo_umetrics_seo_phase.

Every handler returned by these factories is wrapped in hazo_api's withRequestContext and replies with hazo_api's ok()/fail() envelopes — { ok: true, data, meta } on success, { ok: false, error: { code, message }, meta } on failure. Validation failures use error code 'VALIDATION_FAILED' (as of 2.0.0 — previously 'BAD_REQUEST').

createSeoAdvancePort — wiring for hazo_jobs's auto-advance job

import { createSeoAdvancePort } from 'hazo_umetrics';
import { resolveSeoVariant } from 'hazo_seo/experiments';

const port = createSeoAdvancePort(myAdapter, { resolve: resolveSeoVariant });
// → port.listRunning() / port.advance(...) — matches hazo_jobs's SeoAdvancePort shape
// structurally (hazo_umetrics never imports hazo_jobs or hazo_seo — resolve is injected
// by the consumer to keep the peer graph acyclic).

DB setup

Run db_setup_sqlite.sql (SQLite) or db_setup_postgres.sql (PostgreSQL) once against your umetrics DB. Creates 10 tables, all prefixed hazo_umetrics_ (as of 1.18.0 — includes hazo_umetrics_seo_phase, the SEO experiment phase ledger).

Run the migration against the dedicated umetrics database, not the host app's main DB. If you use createUmetricsConnect() (recommended), the umetrics DB is the one pointed to by HAZO_UMETRICS_DATABASE_URL.

Separate database for umetrics

hazo_umetrics data (events, stats, flags, experiments) should live in its own database, separate from the host app's main DB. This keeps analytics writes from competing with product data, lets you point multiple apps at one shared analytics store, and lets you scale each independently.

Use createUmetricsConnect() to build a dedicated adapter in one line:

import { createUmetricsConnect } from 'hazo_umetrics';

// Reads HAZO_UMETRICS_DATABASE_URL + HAZO_UMETRICS_DATABASE_API_KEY from env.
// Falls back to ./umetrics.sqlite when type=sqlite.
export const getUmetricsAdapter = createUmetricsConnect;

Then inject it into every route factory:

const handlers = createStatHandlers({ getAdapter: createUmetricsConnect });

Env vars

| Variable | Required | Description | |---|---|---| | HAZO_UMETRICS_DATABASE_URL | PostgREST only | Base URL of the umetrics PostgREST server | | HAZO_UMETRICS_DATABASE_API_KEY | PostgREST only | Bearer token for the umetrics PostgREST server | | HAZO_UMETRICS_DATABASE_TYPE | No | postgrest (default) or sqlite | | HAZO_UMETRICS_DATABASE_PATH | SQLite only | Path to the SQLite file (default: ./umetrics.sqlite) |

These are independent of the host app's POSTGREST_URL / POSTGREST_API_KEY — the analytics DB can be a completely different PostgREST instance (or a local SQLite file for dev).

Config

Copy config/hazo_umetrics_config.ini.sample to hazo_umetrics_config.ini and fill in values. Sections: [env], [ga4], [stats], [ab_cookie], [retention].

Tailwind / shadcn setup for UI components

MetricsPanel renders inside HazoUiDialog and uses shadcn token classes (bg-background, text-muted-foreground, etc.). If those tokens aren't defined, the dialog panel will appear transparent. In your globals.css:

@import "tailwindcss";
/* shadcn tokens — required for bg-background, border, muted colors etc. */
@import "../node_modules/hazo_ui/dist/styles.css";

@source "../node_modules/hazo_umetrics/dist";
@source "../node_modules/hazo_ui/dist";
@source "../node_modules/hazo_dataviz/dist";  /* StatTrend's LineChart lives here */

/* Tailwind v4 — map tokens to utilities */
@theme inline {
  --color-background: hsl(var(--background));
  --color-foreground: hsl(var(--foreground));
  --color-muted-foreground: hsl(var(--muted-foreground));
  --color-border: hsl(var(--border));
  /* add remaining tokens as needed — see hazo_ui/dist/styles.css for the full list */
}

(Adjust @import and @source depth to match your project's distance from node_modules.)