hazo_umetrics
v2.1.0
Published
Product analytics for hazo apps — GA4 hybrid + first-party stat store + feature flags
Maintainers
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_umetricsPeer dependencies (install the ones you use):
npm install hazo_core hazo_connect hazo_auth hazo_secure hazo_audit hazo_api hazo_ui hazo_datavizThe
hazo_umetrics/uipanel renders its trend chart withhazo_dataviz(the chart primitives moved out ofhazo_uiin the hazo_ui 4.0.0 migration), so installhazo_datavizwhen you useMetricsPanel/StatTrend.
hazo_apiis a required peer as of 2.0.0 — everyhazo_umetrics/apihandler factory imports itsok/fail/withRequestContextenvelope 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_idin 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 byHAZO_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.)
