hazo_umetrics 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGE_LOG.md +28 -0
- package/README.md +129 -0
- package/SETUP_CHECKLIST.md +118 -0
- package/config/hazo_umetrics_config.ini.sample +35 -0
- package/dist/api/index.d.ts +78 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +375 -0
- package/dist/index.client.d.ts +23 -0
- package/dist/index.client.d.ts.map +1 -0
- package/dist/index.client.js +44 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/lib/index.d.ts +7 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +7 -0
- package/dist/server/cookie.d.ts +9 -0
- package/dist/server/cookie.d.ts.map +1 -0
- package/dist/server/cookie.js +32 -0
- package/dist/server/experiments.d.ts +30 -0
- package/dist/server/experiments.d.ts.map +1 -0
- package/dist/server/experiments.js +17 -0
- package/dist/server/flags.d.ts +44 -0
- package/dist/server/flags.d.ts.map +1 -0
- package/dist/server/flags.js +207 -0
- package/dist/server/ga4.d.ts +30 -0
- package/dist/server/ga4.d.ts.map +1 -0
- package/dist/server/ga4.js +19 -0
- package/dist/server/stats.d.ts +51 -0
- package/dist/server/stats.d.ts.map +1 -0
- package/dist/server/stats.js +129 -0
- package/dist/server/types.d.ts +65 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +2 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +3 -0
- package/dist/ui/metrics_panel.d.ts +33 -0
- package/dist/ui/metrics_panel.d.ts.map +1 -0
- package/dist/ui/metrics_panel.js +195 -0
- package/migrations/db_setup_postgres.sql +116 -0
- package/migrations/db_setup_sqlite.sql +116 -0
- package/package.json +116 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { LineChart } from 'hazo_ui';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Metric metadata (display labels + format hints)
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
const METRIC_KEYS = [
|
|
9
|
+
'active_users',
|
|
10
|
+
'timers_completed',
|
|
11
|
+
'funnel_land',
|
|
12
|
+
'funnel_start',
|
|
13
|
+
'funnel_complete',
|
|
14
|
+
];
|
|
15
|
+
const METRIC_META = {
|
|
16
|
+
active_users: { label: 'Active Users', format: 'count' },
|
|
17
|
+
timers_completed: { label: 'Timers Completed', format: 'count' },
|
|
18
|
+
funnel_land: { label: 'Funnel: Landed', format: 'count' },
|
|
19
|
+
funnel_start: { label: 'Funnel: Started', format: 'count' },
|
|
20
|
+
funnel_complete: { label: 'Funnel: Completed', format: 'count' },
|
|
21
|
+
};
|
|
22
|
+
function formatValue(value, format) {
|
|
23
|
+
switch (format) {
|
|
24
|
+
case 'currency':
|
|
25
|
+
return `$${value.toLocaleString()}`;
|
|
26
|
+
case 'percent':
|
|
27
|
+
return `${value}%`;
|
|
28
|
+
case 'duration':
|
|
29
|
+
return `${value}s`;
|
|
30
|
+
default:
|
|
31
|
+
return value.toLocaleString();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function StatCards({ stats }) {
|
|
35
|
+
if (stats.length === 0) {
|
|
36
|
+
return (_jsx("div", { className: "rounded-lg border p-4 text-sm text-muted-foreground", children: "No stats" }));
|
|
37
|
+
}
|
|
38
|
+
return (_jsx("div", { className: "grid grid-cols-2 gap-4 sm:grid-cols-4", children: stats.map((s) => (_jsxs("div", { className: "rounded-lg border p-4", children: [_jsx("p", { className: "text-xs text-muted-foreground", children: s.label }), _jsx("p", { className: "text-2xl font-semibold", children: formatValue(s.value, s.format) })] }, s.key))) }));
|
|
39
|
+
}
|
|
40
|
+
function formatTrendLabel(ts) {
|
|
41
|
+
const d = new Date(ts);
|
|
42
|
+
if (Number.isNaN(d.getTime()))
|
|
43
|
+
return ts;
|
|
44
|
+
return d.toLocaleString(undefined, {
|
|
45
|
+
month: 'short',
|
|
46
|
+
day: 'numeric',
|
|
47
|
+
hour: '2-digit',
|
|
48
|
+
minute: '2-digit',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function StatTrend({ data, label }) {
|
|
52
|
+
if (data.length < 2) {
|
|
53
|
+
return (_jsx("div", { className: "rounded-lg border p-4 text-sm text-muted-foreground", children: "No trend data yet" }));
|
|
54
|
+
}
|
|
55
|
+
// hazo_ui LineChart gives us labelled X/Y axes + a hover cursor & value bubble.
|
|
56
|
+
const values = data.map((d) => d.value);
|
|
57
|
+
const dates = data.map((d) => formatTrendLabel(d.ts));
|
|
58
|
+
return (_jsxs("div", { className: "rounded-lg border p-4", children: [label != null && _jsx("p", { className: "mb-2 text-sm font-medium", children: label }), _jsx(LineChart, { data: values, dates: dates, color: "#6366f1", showTooltip: true })] }));
|
|
59
|
+
}
|
|
60
|
+
function typeBadgeClass(type) {
|
|
61
|
+
return type === 'flag'
|
|
62
|
+
? 'inline-block rounded px-1.5 py-0.5 text-xs font-medium bg-purple-100 text-purple-700'
|
|
63
|
+
: 'inline-block rounded px-1.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-700';
|
|
64
|
+
}
|
|
65
|
+
function statusBadgeClass(status) {
|
|
66
|
+
if (status === 'running') {
|
|
67
|
+
return 'inline-block rounded px-1.5 py-0.5 text-xs font-medium bg-green-100 text-green-700';
|
|
68
|
+
}
|
|
69
|
+
if (status === 'stopped') {
|
|
70
|
+
return 'inline-block rounded px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-700';
|
|
71
|
+
}
|
|
72
|
+
return 'inline-block rounded px-1.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-600';
|
|
73
|
+
}
|
|
74
|
+
export function ExperimentList({ experiments }) {
|
|
75
|
+
if (experiments.length === 0) {
|
|
76
|
+
return (_jsx("div", { className: "rounded-lg border p-4 text-sm text-muted-foreground", children: "No experiments" }));
|
|
77
|
+
}
|
|
78
|
+
return (_jsx("div", { className: "flex flex-col gap-3", children: experiments.map((exp) => (_jsxs("div", { className: "rounded-lg border p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("p", { className: "font-semibold", children: exp.name }), _jsxs("div", { className: "flex items-center gap-1.5 flex-shrink-0", children: [_jsx("span", { className: typeBadgeClass(exp.type), children: exp.type }), _jsx("span", { className: statusBadgeClass(exp.status), children: exp.status })] })] }), _jsx("p", { className: "mt-1 font-mono text-xs text-muted-foreground", children: exp.key })] }, exp.key))) }));
|
|
79
|
+
}
|
|
80
|
+
const INITIAL_STATE = {
|
|
81
|
+
loading: true,
|
|
82
|
+
unauthenticated: false,
|
|
83
|
+
error: null,
|
|
84
|
+
stats: [],
|
|
85
|
+
trend: [],
|
|
86
|
+
experiments: [],
|
|
87
|
+
};
|
|
88
|
+
export function MetricsPanel({ scopeId }) {
|
|
89
|
+
const [state, setState] = useState(INITIAL_STATE);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!scopeId)
|
|
92
|
+
return;
|
|
93
|
+
let cancelled = false;
|
|
94
|
+
async function fetchAll() {
|
|
95
|
+
setState(INITIAL_STATE);
|
|
96
|
+
// -----------------------------------------------------------------------
|
|
97
|
+
// 1. Experiments
|
|
98
|
+
// -----------------------------------------------------------------------
|
|
99
|
+
const expRes = await fetch(`/api/hazo_umetrics/experiments?scope_id=${encodeURIComponent(scopeId)}`);
|
|
100
|
+
if (expRes.status === 401) {
|
|
101
|
+
if (!cancelled)
|
|
102
|
+
setState({ ...INITIAL_STATE, loading: false, unauthenticated: true });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!expRes.ok) {
|
|
106
|
+
const body = (await expRes.json().catch(() => null));
|
|
107
|
+
if (!cancelled)
|
|
108
|
+
setState({
|
|
109
|
+
...INITIAL_STATE,
|
|
110
|
+
loading: false,
|
|
111
|
+
error: body?.error?.message ?? `HTTP ${expRes.status}`,
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const expBody = (await expRes.json());
|
|
116
|
+
const experiments = expBody.data?.experiments ?? [];
|
|
117
|
+
// -----------------------------------------------------------------------
|
|
118
|
+
// 2. Latest stat readings — fetch all metric keys in parallel
|
|
119
|
+
// -----------------------------------------------------------------------
|
|
120
|
+
const statResults = await Promise.all(METRIC_KEYS.map((key) => fetch(`/api/hazo_umetrics/stats?scope_id=${encodeURIComponent(scopeId)}&metric_key=${encodeURIComponent(key)}`)
|
|
121
|
+
.then((r) => {
|
|
122
|
+
if (r.status === 401)
|
|
123
|
+
return null; // propagate unauth up
|
|
124
|
+
if (!r.ok)
|
|
125
|
+
return null;
|
|
126
|
+
return r.json();
|
|
127
|
+
})
|
|
128
|
+
.catch(() => null)));
|
|
129
|
+
// Check for any 401 inside stat results (null return from 401 path)
|
|
130
|
+
// We already checked experiments for 401 — any null here is a soft failure.
|
|
131
|
+
const stats = METRIC_KEYS.reduce((acc, key, idx) => {
|
|
132
|
+
const result = statResults[idx];
|
|
133
|
+
const stat = result?.data?.stat;
|
|
134
|
+
if (stat != null) {
|
|
135
|
+
acc.push({
|
|
136
|
+
key,
|
|
137
|
+
label: METRIC_META[key].label,
|
|
138
|
+
value: stat.value,
|
|
139
|
+
unit: stat.unit ?? undefined,
|
|
140
|
+
format: METRIC_META[key].format,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return acc;
|
|
144
|
+
}, []);
|
|
145
|
+
// -----------------------------------------------------------------------
|
|
146
|
+
// 3. Trend series for the first metric key
|
|
147
|
+
// -----------------------------------------------------------------------
|
|
148
|
+
const trendKey = 'active_users';
|
|
149
|
+
const seriesRes = await fetch(`/api/hazo_umetrics/stats/series?scope_id=${encodeURIComponent(scopeId)}&metric_key=${encodeURIComponent(trendKey)}`).catch(() => null);
|
|
150
|
+
let trend = [];
|
|
151
|
+
if (seriesRes != null && seriesRes.ok) {
|
|
152
|
+
const seriesBody = (await seriesRes.json().catch(() => null));
|
|
153
|
+
trend = (seriesBody?.data?.series ?? []).map((s) => ({
|
|
154
|
+
ts: s.captured_at,
|
|
155
|
+
value: s.value,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
if (!cancelled) {
|
|
159
|
+
setState({
|
|
160
|
+
loading: false,
|
|
161
|
+
unauthenticated: false,
|
|
162
|
+
error: null,
|
|
163
|
+
stats,
|
|
164
|
+
trend,
|
|
165
|
+
experiments,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
fetchAll().catch((err) => {
|
|
170
|
+
if (!cancelled) {
|
|
171
|
+
const message = err instanceof Error ? err.message : 'Unexpected error';
|
|
172
|
+
setState({ ...INITIAL_STATE, loading: false, error: message });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return () => {
|
|
176
|
+
cancelled = true;
|
|
177
|
+
};
|
|
178
|
+
}, [scopeId]);
|
|
179
|
+
// -------------------------------------------------------------------------
|
|
180
|
+
// Render states
|
|
181
|
+
// -------------------------------------------------------------------------
|
|
182
|
+
if (state.loading) {
|
|
183
|
+
return (_jsx("div", { className: "flex items-center justify-center p-8 text-sm text-muted-foreground", children: "Loading metrics\u2026" }));
|
|
184
|
+
}
|
|
185
|
+
if (state.unauthenticated) {
|
|
186
|
+
return (_jsx("div", { className: "flex items-center justify-center rounded-lg border p-8 text-sm text-muted-foreground", children: "Log in to view metrics" }));
|
|
187
|
+
}
|
|
188
|
+
if (state.error != null) {
|
|
189
|
+
return (_jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700", children: ["Failed to load metrics: ", state.error] }));
|
|
190
|
+
}
|
|
191
|
+
// -------------------------------------------------------------------------
|
|
192
|
+
// Main render
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
return (_jsxs("div", { className: "flex flex-col gap-6 p-4", children: [_jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Stats" }), _jsx(StatCards, { stats: state.stats })] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Active Users Trend" }), _jsx(StatTrend, { data: state.trend, label: METRIC_META.active_users.label })] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Experiments & Flags" }), _jsx(ExperimentList, { experiments: state.experiments })] })] }));
|
|
195
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
-- hazo_umetrics database schema — PostgreSQL
|
|
2
|
+
--
|
|
3
|
+
-- Supports both PostgreSQL (this file) and SQLite (db_setup_sqlite.sql).
|
|
4
|
+
-- All tables use the `hazo_umetrics_` prefix per workspace standards.
|
|
5
|
+
-- Every table carries `scope_id` (TEXT) for multi-tenant isolation.
|
|
6
|
+
--
|
|
7
|
+
-- Tables:
|
|
8
|
+
-- hazo_umetrics_stat — append-only metric readings
|
|
9
|
+
-- hazo_umetrics_stat_def — metric key definitions / display metadata
|
|
10
|
+
-- hazo_umetrics_experiment — feature flags and A/B experiments
|
|
11
|
+
-- hazo_umetrics_assignment — sticky subject→variant assignments
|
|
12
|
+
-- hazo_umetrics_exposure — exposure + goal events
|
|
13
|
+
-- hazo_umetrics_ga4_connection — per-scope GA4 property credentials
|
|
14
|
+
-- hazo_umetrics_dashboard — saved dashboard layouts
|
|
15
|
+
|
|
16
|
+
-- ---------------------------------------------------------------------------
|
|
17
|
+
-- Stat store
|
|
18
|
+
-- ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_stat (
|
|
21
|
+
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
22
|
+
scope_id TEXT NOT NULL,
|
|
23
|
+
app_id TEXT NOT NULL DEFAULT '',
|
|
24
|
+
metric_key TEXT NOT NULL,
|
|
25
|
+
value DOUBLE PRECISION NOT NULL,
|
|
26
|
+
unit TEXT,
|
|
27
|
+
dimensions JSONB DEFAULT NULL,
|
|
28
|
+
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
29
|
+
);
|
|
30
|
+
-- Primary query pattern: latest/series for a (scope, key) over time
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_umetrics_stat_scope_key_ts
|
|
32
|
+
ON hazo_umetrics_stat (scope_id, metric_key, captured_at);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_stat_def (
|
|
35
|
+
metric_key TEXT NOT NULL,
|
|
36
|
+
scope_id TEXT NOT NULL,
|
|
37
|
+
label TEXT NOT NULL,
|
|
38
|
+
description TEXT NOT NULL DEFAULT '',
|
|
39
|
+
format TEXT NOT NULL DEFAULT 'count', -- count | currency | duration | percent
|
|
40
|
+
cadence TEXT,
|
|
41
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
42
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
43
|
+
PRIMARY KEY (metric_key, scope_id)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- ---------------------------------------------------------------------------
|
|
47
|
+
-- Feature flags / experiments
|
|
48
|
+
-- ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_experiment (
|
|
51
|
+
key TEXT NOT NULL,
|
|
52
|
+
scope_id TEXT NOT NULL,
|
|
53
|
+
name TEXT NOT NULL,
|
|
54
|
+
type TEXT NOT NULL DEFAULT 'flag',
|
|
55
|
+
variants JSONB NOT NULL DEFAULT '[]',
|
|
56
|
+
traffic_allocation DOUBLE PRECISION NOT NULL DEFAULT 100.0,
|
|
57
|
+
status TEXT NOT NULL DEFAULT 'draft', -- draft | running | stopped
|
|
58
|
+
targeting JSONB NOT NULL DEFAULT '{}',
|
|
59
|
+
goal_event TEXT,
|
|
60
|
+
target_sample_size INTEGER,
|
|
61
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
62
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
63
|
+
PRIMARY KEY (key, scope_id)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_assignment (
|
|
67
|
+
experiment_key TEXT NOT NULL,
|
|
68
|
+
scope_id TEXT NOT NULL,
|
|
69
|
+
subject_id TEXT NOT NULL,
|
|
70
|
+
variant TEXT NOT NULL,
|
|
71
|
+
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
72
|
+
PRIMARY KEY (experiment_key, scope_id, subject_id)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_exposure (
|
|
76
|
+
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
77
|
+
experiment_key TEXT NOT NULL,
|
|
78
|
+
scope_id TEXT NOT NULL,
|
|
79
|
+
subject_id TEXT NOT NULL,
|
|
80
|
+
variant TEXT NOT NULL,
|
|
81
|
+
event TEXT NOT NULL DEFAULT 'exposure',
|
|
82
|
+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
83
|
+
);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_umetrics_exposure_key_scope
|
|
85
|
+
ON hazo_umetrics_exposure (experiment_key, scope_id, subject_id);
|
|
86
|
+
|
|
87
|
+
-- ---------------------------------------------------------------------------
|
|
88
|
+
-- GA4 integration
|
|
89
|
+
-- ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_ga4_connection (
|
|
92
|
+
scope_id TEXT NOT NULL PRIMARY KEY,
|
|
93
|
+
property_id TEXT,
|
|
94
|
+
measurement_id TEXT,
|
|
95
|
+
credential_ref TEXT,
|
|
96
|
+
provision_status TEXT NOT NULL DEFAULT 'unprovisioned',
|
|
97
|
+
last_provisioned_at TIMESTAMPTZ,
|
|
98
|
+
dimension_ids JSONB NOT NULL DEFAULT '[]',
|
|
99
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
100
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
-- ---------------------------------------------------------------------------
|
|
104
|
+
-- Dashboard
|
|
105
|
+
-- ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_dashboard (
|
|
108
|
+
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
109
|
+
scope_id TEXT NOT NULL,
|
|
110
|
+
name TEXT NOT NULL,
|
|
111
|
+
layout JSONB NOT NULL DEFAULT '[]',
|
|
112
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
113
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
114
|
+
);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_umetrics_dashboard_scope
|
|
116
|
+
ON hazo_umetrics_dashboard (scope_id);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
-- hazo_umetrics database schema — SQLite
|
|
2
|
+
--
|
|
3
|
+
-- Supports both PostgreSQL (db_setup_postgres.sql) and SQLite (this file).
|
|
4
|
+
-- All tables use the `hazo_umetrics_` prefix per workspace standards.
|
|
5
|
+
-- Every table carries `scope_id` (TEXT) for multi-tenant isolation.
|
|
6
|
+
--
|
|
7
|
+
-- Tables:
|
|
8
|
+
-- hazo_umetrics_stat — append-only metric readings
|
|
9
|
+
-- hazo_umetrics_stat_def — metric key definitions / display metadata
|
|
10
|
+
-- hazo_umetrics_experiment — feature flags and A/B experiments
|
|
11
|
+
-- hazo_umetrics_assignment — sticky subject→variant assignments
|
|
12
|
+
-- hazo_umetrics_exposure — exposure + goal events
|
|
13
|
+
-- hazo_umetrics_ga4_connection — per-scope GA4 property credentials
|
|
14
|
+
-- hazo_umetrics_dashboard — saved dashboard layouts
|
|
15
|
+
|
|
16
|
+
-- ---------------------------------------------------------------------------
|
|
17
|
+
-- Stat store
|
|
18
|
+
-- ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_stat (
|
|
21
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
22
|
+
scope_id TEXT NOT NULL,
|
|
23
|
+
app_id TEXT NOT NULL DEFAULT '',
|
|
24
|
+
metric_key TEXT NOT NULL,
|
|
25
|
+
value REAL NOT NULL,
|
|
26
|
+
unit TEXT,
|
|
27
|
+
dimensions TEXT DEFAULT NULL, -- JSON object or null
|
|
28
|
+
captured_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
29
|
+
);
|
|
30
|
+
-- Primary query pattern: latest/series for a (scope, key) over time
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_umetrics_stat_scope_key_ts
|
|
32
|
+
ON hazo_umetrics_stat (scope_id, metric_key, captured_at);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_stat_def (
|
|
35
|
+
metric_key TEXT NOT NULL,
|
|
36
|
+
scope_id TEXT NOT NULL,
|
|
37
|
+
label TEXT NOT NULL,
|
|
38
|
+
description TEXT NOT NULL DEFAULT '',
|
|
39
|
+
format TEXT NOT NULL DEFAULT 'count', -- count | currency | duration | percent
|
|
40
|
+
cadence TEXT, -- e.g. 'daily', 'hourly', or null
|
|
41
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
42
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
43
|
+
PRIMARY KEY (metric_key, scope_id)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- ---------------------------------------------------------------------------
|
|
47
|
+
-- Feature flags / experiments
|
|
48
|
+
-- ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_experiment (
|
|
51
|
+
key TEXT NOT NULL,
|
|
52
|
+
scope_id TEXT NOT NULL,
|
|
53
|
+
name TEXT NOT NULL,
|
|
54
|
+
type TEXT NOT NULL DEFAULT 'flag', -- flag | experiment
|
|
55
|
+
variants TEXT NOT NULL DEFAULT '[]', -- JSON [{name,weight}]
|
|
56
|
+
traffic_allocation REAL NOT NULL DEFAULT 100.0, -- 0-100 %
|
|
57
|
+
status TEXT NOT NULL DEFAULT 'draft', -- draft | running | stopped
|
|
58
|
+
targeting TEXT NOT NULL DEFAULT '{}', -- JSON targeting rules
|
|
59
|
+
goal_event TEXT, -- null for flags
|
|
60
|
+
target_sample_size INTEGER, -- null until powered
|
|
61
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
62
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
63
|
+
PRIMARY KEY (key, scope_id)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_assignment (
|
|
67
|
+
experiment_key TEXT NOT NULL,
|
|
68
|
+
scope_id TEXT NOT NULL,
|
|
69
|
+
subject_id TEXT NOT NULL,
|
|
70
|
+
variant TEXT NOT NULL,
|
|
71
|
+
assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
72
|
+
PRIMARY KEY (experiment_key, scope_id, subject_id)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_exposure (
|
|
76
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
77
|
+
experiment_key TEXT NOT NULL,
|
|
78
|
+
scope_id TEXT NOT NULL,
|
|
79
|
+
subject_id TEXT NOT NULL,
|
|
80
|
+
variant TEXT NOT NULL,
|
|
81
|
+
event TEXT NOT NULL DEFAULT 'exposure', -- exposure | <goal-event-name>
|
|
82
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
83
|
+
);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_umetrics_exposure_key_scope
|
|
85
|
+
ON hazo_umetrics_exposure (experiment_key, scope_id, subject_id);
|
|
86
|
+
|
|
87
|
+
-- ---------------------------------------------------------------------------
|
|
88
|
+
-- GA4 integration
|
|
89
|
+
-- ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_ga4_connection (
|
|
92
|
+
scope_id TEXT NOT NULL PRIMARY KEY,
|
|
93
|
+
property_id TEXT,
|
|
94
|
+
measurement_id TEXT,
|
|
95
|
+
credential_ref TEXT, -- encrypted JSON (AES-GCM via hazo_secure)
|
|
96
|
+
provision_status TEXT NOT NULL DEFAULT 'unprovisioned',
|
|
97
|
+
last_provisioned_at TEXT,
|
|
98
|
+
dimension_ids TEXT NOT NULL DEFAULT '[]', -- JSON string array
|
|
99
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
100
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
-- ---------------------------------------------------------------------------
|
|
104
|
+
-- Dashboard
|
|
105
|
+
-- ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS hazo_umetrics_dashboard (
|
|
108
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
109
|
+
scope_id TEXT NOT NULL,
|
|
110
|
+
name TEXT NOT NULL,
|
|
111
|
+
layout TEXT NOT NULL DEFAULT '[]', -- JSON layout config
|
|
112
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
113
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
114
|
+
);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_umetrics_dashboard_scope
|
|
116
|
+
ON hazo_umetrics_dashboard (scope_id);
|
package/package.json
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hazo_umetrics",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Product analytics for hazo apps — GA4 hybrid + first-party stat store + feature flags",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./dist/index.client.d.ts",
|
|
15
|
+
"import": "./dist/index.client.js"
|
|
16
|
+
},
|
|
17
|
+
"./api": {
|
|
18
|
+
"types": "./dist/api/index.d.ts",
|
|
19
|
+
"import": "./dist/api/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./ui": {
|
|
22
|
+
"types": "./dist/ui/index.d.ts",
|
|
23
|
+
"import": "./dist/ui/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"migrations",
|
|
30
|
+
"config",
|
|
31
|
+
"README.md",
|
|
32
|
+
"CHANGE_LOG.md",
|
|
33
|
+
"SETUP_CHECKLIST.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json",
|
|
37
|
+
"type-check": "tsc --noEmit",
|
|
38
|
+
"test": "node --experimental-vm-modules ../node_modules/jest/bin/jest.js --config jest.config.cjs",
|
|
39
|
+
"dev:test-app": "npm run build && cd test-app && npm run dev",
|
|
40
|
+
"build:test-app": "npm run build && cd test-app && npm run build"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"hazo_core": "^1.1.0",
|
|
44
|
+
"hazo_logs": "^2.0.3",
|
|
45
|
+
"hazo_config": "^2.1.10",
|
|
46
|
+
"hazo_connect": "^3.5.1",
|
|
47
|
+
"hazo_api": "^2.3.1",
|
|
48
|
+
"hazo_auth": "^10.1.0",
|
|
49
|
+
"hazo_secure": "^1.3.0",
|
|
50
|
+
"hazo_audit": "^2.1.1",
|
|
51
|
+
"hazo_ui": "^3.5.0",
|
|
52
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
53
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"hazo_logs": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"hazo_config": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"hazo_connect": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"hazo_api": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"hazo_auth": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"hazo_secure": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"hazo_audit": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
77
|
+
"hazo_ui": {
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
80
|
+
"react": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"react-dom": {
|
|
84
|
+
"optional": true
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"dependencies": {
|
|
88
|
+
"clsx": "^2.1.1",
|
|
89
|
+
"tailwind-merge": "^3.5.0"
|
|
90
|
+
},
|
|
91
|
+
"devDependencies": {
|
|
92
|
+
"typescript": "^5.7.2",
|
|
93
|
+
"jest": "^30.2.0",
|
|
94
|
+
"ts-jest": "^29.4.5",
|
|
95
|
+
"@types/jest": "^30.0.0",
|
|
96
|
+
"jest-environment-node": "^30.2.0",
|
|
97
|
+
"@types/node": "^22.10.0",
|
|
98
|
+
"@types/react": "^19.0.0",
|
|
99
|
+
"@types/react-dom": "^19.0.0",
|
|
100
|
+
"tailwindcss": "^4.2.4",
|
|
101
|
+
"@tailwindcss/postcss": "^4.2.4",
|
|
102
|
+
"postcss": "^8.4.49"
|
|
103
|
+
},
|
|
104
|
+
"keywords": [
|
|
105
|
+
"analytics",
|
|
106
|
+
"product-analytics",
|
|
107
|
+
"metrics",
|
|
108
|
+
"feature-flags",
|
|
109
|
+
"ab-testing",
|
|
110
|
+
"experiments",
|
|
111
|
+
"ga4",
|
|
112
|
+
"hazo"
|
|
113
|
+
],
|
|
114
|
+
"author": "Pubs Abayasiri",
|
|
115
|
+
"license": "MIT"
|
|
116
|
+
}
|