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.
Files changed (43) hide show
  1. package/CHANGE_LOG.md +28 -0
  2. package/README.md +129 -0
  3. package/SETUP_CHECKLIST.md +118 -0
  4. package/config/hazo_umetrics_config.ini.sample +35 -0
  5. package/dist/api/index.d.ts +78 -0
  6. package/dist/api/index.d.ts.map +1 -0
  7. package/dist/api/index.js +375 -0
  8. package/dist/index.client.d.ts +23 -0
  9. package/dist/index.client.d.ts.map +1 -0
  10. package/dist/index.client.js +44 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +5 -0
  14. package/dist/lib/index.d.ts +7 -0
  15. package/dist/lib/index.d.ts.map +1 -0
  16. package/dist/lib/index.js +7 -0
  17. package/dist/server/cookie.d.ts +9 -0
  18. package/dist/server/cookie.d.ts.map +1 -0
  19. package/dist/server/cookie.js +32 -0
  20. package/dist/server/experiments.d.ts +30 -0
  21. package/dist/server/experiments.d.ts.map +1 -0
  22. package/dist/server/experiments.js +17 -0
  23. package/dist/server/flags.d.ts +44 -0
  24. package/dist/server/flags.d.ts.map +1 -0
  25. package/dist/server/flags.js +207 -0
  26. package/dist/server/ga4.d.ts +30 -0
  27. package/dist/server/ga4.d.ts.map +1 -0
  28. package/dist/server/ga4.js +19 -0
  29. package/dist/server/stats.d.ts +51 -0
  30. package/dist/server/stats.d.ts.map +1 -0
  31. package/dist/server/stats.js +129 -0
  32. package/dist/server/types.d.ts +65 -0
  33. package/dist/server/types.d.ts.map +1 -0
  34. package/dist/server/types.js +2 -0
  35. package/dist/ui/index.d.ts +2 -0
  36. package/dist/ui/index.d.ts.map +1 -0
  37. package/dist/ui/index.js +3 -0
  38. package/dist/ui/metrics_panel.d.ts +33 -0
  39. package/dist/ui/metrics_panel.d.ts.map +1 -0
  40. package/dist/ui/metrics_panel.js +195 -0
  41. package/migrations/db_setup_postgres.sql +116 -0
  42. package/migrations/db_setup_sqlite.sql +116 -0
  43. package/package.json +116 -0
@@ -0,0 +1,207 @@
1
+ // hazo_umetrics/src/server/flags.ts — weighted-variant deterministic bucket engine,
2
+ // resolveVariant, getVariant, and isEnabled.
3
+ //
4
+ // Uses cyrb53 (pure JS, no node:crypto) for deterministic, distribution-uniform hashing.
5
+ // Sticky assignments are stored in hazo_umetrics_assignment; exposures in hazo_umetrics_exposure.
6
+ import { createCrudService } from 'hazo_connect/server';
7
+ import { HazoError } from 'hazo_core';
8
+ import { signSubjectCookie, readSubjectCookie } from './cookie.js';
9
+ // ---------------------------------------------------------------------------
10
+ // cyrb53 — pure-JS 53-bit hash (no node:crypto)
11
+ // ---------------------------------------------------------------------------
12
+ function cyrb53(str, seed = 0) {
13
+ let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
14
+ for (let i = 0; i < str.length; i++) {
15
+ const ch = str.charCodeAt(i);
16
+ h1 = Math.imul(h1 ^ ch, 2654435761);
17
+ h2 = Math.imul(h2 ^ ch, 1597334677);
18
+ }
19
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
20
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
21
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
22
+ }
23
+ /**
24
+ * Given a list of variants with weights, deterministically assign a bucket
25
+ * using the hashInput string. Weights are normalized to sum to 100.
26
+ *
27
+ * Throws HazoError if variants is empty.
28
+ */
29
+ export function weightedBucket(variants, hashInput) {
30
+ if (variants.length === 0) {
31
+ throw new HazoError({
32
+ code: 'INVALID_ARGUMENT',
33
+ pkg: 'hazo_umetrics',
34
+ message: 'weightedBucket: variants array must not be empty',
35
+ httpStatus: 400,
36
+ retryable: false,
37
+ });
38
+ }
39
+ const totalWeight = variants.reduce((sum, v) => sum + v.weight, 0);
40
+ const hash = cyrb53(hashInput);
41
+ // Map hash to [0, 1) using the lower 32 bits
42
+ const position = (hash >>> 0) / 0xFFFFFFFF;
43
+ let cumulative = 0;
44
+ for (const variant of variants) {
45
+ const normalized = (variant.weight / totalWeight) * 100;
46
+ cumulative += normalized;
47
+ if (position < cumulative / 100) {
48
+ return variant.name;
49
+ }
50
+ }
51
+ // Floating-point edge case: return the last variant
52
+ return variants[variants.length - 1].name;
53
+ }
54
+ /**
55
+ * Resolve the variant for a subject in an experiment.
56
+ *
57
+ * - draft/stopped experiment → first variant (or 'control'), no DB writes
58
+ * - running + subjectId (logged-in) → deterministic bucket, upsert assignment, write exposure
59
+ * - running + no subjectId + consent=true → ephemeral anon ID signed in cookie, sticky
60
+ * - running + no subjectId + consent=false/undefined → per-request bucket, no persistence
61
+ */
62
+ export async function resolveVariant(adapter, opts) {
63
+ const { experimentKey, scope_id, consent } = opts;
64
+ // Fetch the experiment
65
+ const expCrud = createCrudService(adapter, 'hazo_umetrics_experiment', {
66
+ primaryKeys: ['key', 'scope_id'],
67
+ autoId: false,
68
+ });
69
+ const expRow = await expCrud.findOneBy({ key: experimentKey, scope_id });
70
+ if (!expRow) {
71
+ // Experiment not found — return 'control' with no DB writes
72
+ return { variant: 'control', sticky: false };
73
+ }
74
+ const variants = parseVariants(expRow.variants);
75
+ // Path 1: draft or stopped — no DB writes
76
+ if (expRow.status === 'draft' || expRow.status === 'stopped') {
77
+ const firstVariant = variants.length > 0 ? variants[0].name : 'control';
78
+ return { variant: firstVariant, sticky: false };
79
+ }
80
+ // Experiment is running — determine subjectId
81
+ const assignmentCrud = createCrudService(adapter, 'hazo_umetrics_assignment', {
82
+ primaryKeys: ['experiment_key', 'scope_id', 'subject_id'],
83
+ autoId: false,
84
+ });
85
+ const exposureCrud = createCrudService(adapter, 'hazo_umetrics_exposure');
86
+ let effectiveSubjectId = opts.subjectId;
87
+ let isAnon = false;
88
+ let anonCookieValue;
89
+ // Check for a cookie-restored anon identity
90
+ if (!effectiveSubjectId && opts.cookieValue && consent) {
91
+ const restored = await readSubjectCookie(opts.cookieValue);
92
+ if (restored) {
93
+ effectiveSubjectId = restored;
94
+ isAnon = true;
95
+ // We'll re-sign the same cookie so the caller can refresh it
96
+ anonCookieValue = opts.cookieValue;
97
+ }
98
+ }
99
+ // Path 2: logged-in user (or cookie-restored anon with consent)
100
+ if (effectiveSubjectId) {
101
+ // Check for existing sticky assignment
102
+ const existing = await assignmentCrud.findOneBy({
103
+ experiment_key: experimentKey,
104
+ scope_id,
105
+ subject_id: effectiveSubjectId,
106
+ });
107
+ let variant;
108
+ if (existing) {
109
+ variant = existing.variant;
110
+ }
111
+ else {
112
+ variant = weightedBucket(variants, `${experimentKey}:${effectiveSubjectId}`);
113
+ // Upsert assignment
114
+ await assignmentCrud.upsert({
115
+ experiment_key: experimentKey,
116
+ scope_id,
117
+ subject_id: effectiveSubjectId,
118
+ variant,
119
+ assigned_at: new Date().toISOString(),
120
+ });
121
+ }
122
+ // Write exposure only once per (experiment_key, scope_id, subject_id, event)
123
+ const existingExposure = await exposureCrud.findOneBy({
124
+ experiment_key: experimentKey,
125
+ scope_id,
126
+ subject_id: effectiveSubjectId,
127
+ event: 'exposure',
128
+ });
129
+ if (!existingExposure) {
130
+ await exposureCrud.insert({
131
+ experiment_key: experimentKey,
132
+ scope_id,
133
+ subject_id: effectiveSubjectId,
134
+ variant,
135
+ event: 'exposure',
136
+ timestamp: new Date().toISOString(),
137
+ });
138
+ }
139
+ const result = { variant, sticky: true };
140
+ if (isAnon && anonCookieValue) {
141
+ result.cookieValue = anonCookieValue;
142
+ }
143
+ return result;
144
+ }
145
+ // Path 3: anon + consent=true → create ephemeral ID, sign cookie, persist
146
+ if (consent === true) {
147
+ const ephemeralId = `anon_${Date.now()}_${cyrb53(experimentKey) % 1000000}`;
148
+ const variant = weightedBucket(variants, `${experimentKey}:${ephemeralId}`);
149
+ await assignmentCrud.upsert({
150
+ experiment_key: experimentKey,
151
+ scope_id,
152
+ subject_id: ephemeralId,
153
+ variant,
154
+ assigned_at: new Date().toISOString(),
155
+ });
156
+ await exposureCrud.insert({
157
+ experiment_key: experimentKey,
158
+ scope_id,
159
+ subject_id: ephemeralId,
160
+ variant,
161
+ event: 'exposure',
162
+ timestamp: new Date().toISOString(),
163
+ });
164
+ const signedCookie = await signSubjectCookie(ephemeralId);
165
+ return { variant, sticky: true, cookieValue: signedCookie };
166
+ }
167
+ // Path 4: anon + no consent → per-request ephemeral bucket, no persistence
168
+ const requestId = `req_${Date.now()}_${Math.random()}`;
169
+ const variant = weightedBucket(variants, `${experimentKey}:${requestId}`);
170
+ // Still write an exposure event (no subject identity, use a transient id)
171
+ await exposureCrud.insert({
172
+ experiment_key: experimentKey,
173
+ scope_id,
174
+ subject_id: requestId,
175
+ variant,
176
+ event: 'exposure',
177
+ timestamp: new Date().toISOString(),
178
+ });
179
+ return { variant, sticky: false };
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Helpers
183
+ // ---------------------------------------------------------------------------
184
+ function parseVariants(raw) {
185
+ try {
186
+ return JSON.parse(raw);
187
+ }
188
+ catch {
189
+ return [];
190
+ }
191
+ }
192
+ // ---------------------------------------------------------------------------
193
+ // Public convenience wrappers
194
+ // ---------------------------------------------------------------------------
195
+ /**
196
+ * Resolve the variant name for a subject. Wraps resolveVariant.
197
+ */
198
+ export async function getVariant(adapter, opts) {
199
+ const { variant } = await resolveVariant(adapter, opts);
200
+ return variant;
201
+ }
202
+ /**
203
+ * Returns true when the resolved variant is 'on'. Useful for simple feature flags.
204
+ */
205
+ export async function isEnabled(adapter, opts) {
206
+ return (await getVariant(adapter, opts)) === 'on';
207
+ }
@@ -0,0 +1,30 @@
1
+ export interface Ga4Report {
2
+ rows: Array<{
3
+ dimensions: string[];
4
+ metrics: string[];
5
+ }>;
6
+ rowCount: number;
7
+ }
8
+ export interface Ga4PropertyConfig {
9
+ propertyId: string;
10
+ measurementId: string;
11
+ }
12
+ export interface Ga4Client {
13
+ runReport(params: {
14
+ metrics: string[];
15
+ dimensions: string[];
16
+ dateRanges: Array<{
17
+ startDate: string;
18
+ endDate: string;
19
+ }>;
20
+ }): Promise<Ga4Report>;
21
+ }
22
+ export interface Ga4Provisioner {
23
+ provision(scopeId: string, config: Ga4PropertyConfig): Promise<{
24
+ status: 'provisioned' | 'error';
25
+ message?: string;
26
+ }>;
27
+ }
28
+ export declare function createGa4Client(_config?: Partial<Ga4PropertyConfig>): Ga4Client;
29
+ export declare function createGa4Provisioner(): Ga4Provisioner;
30
+ //# sourceMappingURL=ga4.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ga4.d.ts","sourceRoot":"","sources":["../../src/server/ga4.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,KAAK,CAAC;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IACzD,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,SAAS;IACxB,SAAS,CAAC,MAAM,EAAE;QAAE,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,EAAE,KAAK,CAAC;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;CAC/I;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACvH;AAGD,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,iBAAiB,CAAC,GAAG,SAAS,CAM/E;AAGD,wBAAgB,oBAAoB,IAAI,cAAc,CAMrD"}
@@ -0,0 +1,19 @@
1
+ // hazo_umetrics/src/server/ga4.ts — GA4 client and provisioner stubs.
2
+ // STUB: Real Data API + Admin API clients are M1. These return mocked shapes
3
+ // so downstream code can import and type-check against them today.
4
+ // STUB (M1/deferred) — returns empty report
5
+ export function createGa4Client(_config) {
6
+ return {
7
+ async runReport() {
8
+ return { rows: [], rowCount: 0 }; // STUB
9
+ },
10
+ };
11
+ }
12
+ // STUB (M1/deferred) — returns mock provision status
13
+ export function createGa4Provisioner() {
14
+ return {
15
+ async provision() {
16
+ return { status: 'provisioned', message: 'stub — no real GA4 provisioning in M0' }; // STUB
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,51 @@
1
+ import type { HazoConnectAdapter } from 'hazo_connect/server';
2
+ import type { Stat, StatCollectorDef, MaintenanceJob } from './types.js';
3
+ /**
4
+ * Records a new stat reading. Always inserts — append-only, never upserts.
5
+ */
6
+ export declare function recordStat(adapter: HazoConnectAdapter, params: {
7
+ scope_id: string;
8
+ app_id?: string;
9
+ metric_key: string;
10
+ value: number;
11
+ unit?: string;
12
+ dimensions?: Record<string, string | number>;
13
+ }): Promise<void>;
14
+ /**
15
+ * Returns the most recent reading for a metric in a scope, or null if none.
16
+ */
17
+ export declare function getLatestStat(adapter: HazoConnectAdapter, params: {
18
+ scope_id: string;
19
+ metric_key: string;
20
+ }): Promise<Stat | null>;
21
+ /**
22
+ * Returns readings for a metric in a scope, ascending by captured_at.
23
+ * Optionally filtered by a `since` ISO timestamp and/or a result limit.
24
+ */
25
+ export declare function getStatSeries(adapter: HazoConnectAdapter, params: {
26
+ scope_id: string;
27
+ metric_key: string;
28
+ since?: string;
29
+ limit?: number;
30
+ }): Promise<Stat[]>;
31
+ /**
32
+ * Registers a stat collector. Collectors are module-level singletons that
33
+ * contribute to `runStatSnapshot`.
34
+ */
35
+ export declare function registerStatCollector(def: StatCollectorDef): void;
36
+ /**
37
+ * Runs all registered collectors for a scope and inserts one stat row per
38
+ * collector. Returns the key+value pairs that were recorded.
39
+ */
40
+ export declare function runStatSnapshot(adapter: HazoConnectAdapter, params: {
41
+ scope_id: string;
42
+ }): Promise<Array<{
43
+ key: string;
44
+ value: number;
45
+ }>>;
46
+ /**
47
+ * MaintenanceJob-shaped export for M1 job wiring. Wire this into hazo_jobs
48
+ * so snapshots run on a schedule without manual orchestration.
49
+ */
50
+ export declare const statSnapshotJob: MaintenanceJob;
51
+ //# sourceMappingURL=stats.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../../src/server/stats.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,KAAK,EAAE,IAAI,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAmDzE;;GAEG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;CAC9C,GACA,OAAO,CAAC,IAAI,CAAC,CAef;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC/C,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,CAWtB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GACA,OAAO,CAAC,IAAI,EAAE,CAAC,CAgBjB;AAQD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,gBAAgB,GAAG,IAAI,CAMjE;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC3B,OAAO,CAAC,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAchD;AAED;;;GAGG;AACH,eAAO,MAAM,eAAe,EAAE,cAM7B,CAAC"}
@@ -0,0 +1,129 @@
1
+ // hazo_umetrics/src/server/stats.ts — stat store: append-only metric readings,
2
+ // collector registry, and snapshot job.
3
+ import { createCrudService } from 'hazo_connect/server';
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+ function rowToStat(row) {
8
+ let dimensions = null;
9
+ if (row.dimensions) {
10
+ try {
11
+ dimensions = JSON.parse(row.dimensions);
12
+ }
13
+ catch {
14
+ dimensions = null;
15
+ }
16
+ }
17
+ return {
18
+ id: row.id,
19
+ scope_id: row.scope_id,
20
+ app_id: row.app_id,
21
+ metric_key: row.metric_key,
22
+ value: row.value,
23
+ unit: row.unit,
24
+ dimensions,
25
+ captured_at: row.captured_at,
26
+ };
27
+ }
28
+ function getCrud(adapter) {
29
+ return createCrudService(adapter, 'hazo_umetrics_stat');
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // Public API
33
+ // ---------------------------------------------------------------------------
34
+ /**
35
+ * Records a new stat reading. Always inserts — append-only, never upserts.
36
+ */
37
+ export async function recordStat(adapter, params) {
38
+ const crud = getCrud(adapter);
39
+ const dimensionsJson = params.dimensions != null
40
+ ? JSON.stringify(params.dimensions)
41
+ : null;
42
+ await crud.insert({
43
+ scope_id: params.scope_id,
44
+ app_id: params.app_id ?? '',
45
+ metric_key: params.metric_key,
46
+ value: params.value,
47
+ unit: params.unit ?? null,
48
+ dimensions: dimensionsJson,
49
+ captured_at: new Date().toISOString(),
50
+ });
51
+ }
52
+ /**
53
+ * Returns the most recent reading for a metric in a scope, or null if none.
54
+ */
55
+ export async function getLatestStat(adapter, params) {
56
+ const crud = getCrud(adapter);
57
+ const rows = await crud.list((qb) => qb
58
+ .where('scope_id', 'eq', params.scope_id)
59
+ .where('metric_key', 'eq', params.metric_key)
60
+ .order('captured_at', 'desc')
61
+ .limit(1));
62
+ if (rows.length === 0)
63
+ return null;
64
+ return rowToStat(rows[0]);
65
+ }
66
+ /**
67
+ * Returns readings for a metric in a scope, ascending by captured_at.
68
+ * Optionally filtered by a `since` ISO timestamp and/or a result limit.
69
+ */
70
+ export async function getStatSeries(adapter, params) {
71
+ const crud = getCrud(adapter);
72
+ const rows = await crud.list((qb) => {
73
+ let q = qb
74
+ .where('scope_id', 'eq', params.scope_id)
75
+ .where('metric_key', 'eq', params.metric_key)
76
+ .order('captured_at', 'asc');
77
+ if (params.since) {
78
+ q = q.where('captured_at', 'gte', params.since);
79
+ }
80
+ if (params.limit != null) {
81
+ q = q.limit(params.limit);
82
+ }
83
+ return q;
84
+ });
85
+ return rows.map(rowToStat);
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Collector registry
89
+ // ---------------------------------------------------------------------------
90
+ const _collectors = [];
91
+ /**
92
+ * Registers a stat collector. Collectors are module-level singletons that
93
+ * contribute to `runStatSnapshot`.
94
+ */
95
+ export function registerStatCollector(def) {
96
+ // Prevent duplicate registrations (idempotent for HMR safety)
97
+ const exists = _collectors.some((c) => c.key === def.key);
98
+ if (!exists) {
99
+ _collectors.push(def);
100
+ }
101
+ }
102
+ /**
103
+ * Runs all registered collectors for a scope and inserts one stat row per
104
+ * collector. Returns the key+value pairs that were recorded.
105
+ */
106
+ export async function runStatSnapshot(adapter, params) {
107
+ const results = [];
108
+ for (const collector of _collectors) {
109
+ const value = await collector.collect(params.scope_id);
110
+ await recordStat(adapter, {
111
+ scope_id: params.scope_id,
112
+ metric_key: collector.key,
113
+ value,
114
+ });
115
+ results.push({ key: collector.key, value });
116
+ }
117
+ return results;
118
+ }
119
+ /**
120
+ * MaintenanceJob-shaped export for M1 job wiring. Wire this into hazo_jobs
121
+ * so snapshots run on a schedule without manual orchestration.
122
+ */
123
+ export const statSnapshotJob = {
124
+ type: 'hazo_umetrics.stat_snapshot',
125
+ defaultCron: '0 * * * *', // hourly
126
+ handler: async ({ scope_id, adapter }) => {
127
+ return runStatSnapshot(adapter, { scope_id });
128
+ },
129
+ };
@@ -0,0 +1,65 @@
1
+ export interface Stat {
2
+ id: string;
3
+ scope_id: string;
4
+ app_id: string;
5
+ metric_key: string;
6
+ value: number;
7
+ unit: string | null;
8
+ dimensions: Record<string, string | number> | null;
9
+ captured_at: string;
10
+ }
11
+ export interface StatDef {
12
+ metric_key: string;
13
+ scope_id: string;
14
+ label: string;
15
+ description: string;
16
+ format: 'count' | 'currency' | 'duration' | 'percent';
17
+ cadence: string | null;
18
+ }
19
+ export interface Experiment {
20
+ key: string;
21
+ scope_id: string;
22
+ name: string;
23
+ type: 'flag' | 'experiment';
24
+ variants: Array<{
25
+ name: string;
26
+ weight: number;
27
+ }>;
28
+ traffic_allocation: number;
29
+ status: 'draft' | 'running' | 'stopped';
30
+ targeting: Record<string, unknown>;
31
+ goal_event: string | null;
32
+ target_sample_size: number | null;
33
+ }
34
+ export interface Assignment {
35
+ experiment_key: string;
36
+ scope_id: string;
37
+ subject_id: string;
38
+ variant: string;
39
+ assigned_at: string;
40
+ }
41
+ export interface Exposure {
42
+ id: string;
43
+ experiment_key: string;
44
+ scope_id: string;
45
+ subject_id: string;
46
+ variant: string;
47
+ event: string;
48
+ timestamp: string;
49
+ }
50
+ export interface MaintenanceJob {
51
+ type: string;
52
+ defaultCron: string;
53
+ handler: (ctx: {
54
+ scope_id: string;
55
+ adapter: import('hazo_connect').HazoConnectAdapter;
56
+ }) => Promise<unknown>;
57
+ }
58
+ export interface StatCollectorDef {
59
+ key: string;
60
+ label: string;
61
+ format: StatDef['format'];
62
+ cadence?: string;
63
+ collect: (scope_id: string) => Promise<number>;
64
+ }
65
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC;IACnD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,OAAO;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAAC;IACtD,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,YAAY,CAAC;IAC5B,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClD,kBAAkB,EAAE,MAAM,CAAC;IAC3B,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;IACxC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,UAAU;IACzB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAID,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,CAAC,GAAG,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,cAAc,EAAE,kBAAkB,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9G;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CAChD"}
@@ -0,0 +1,2 @@
1
+ // hazo_umetrics/src/server/types.ts — shared TypeScript types for the server layer
2
+ export {};
@@ -0,0 +1,2 @@
1
+ export * from './metrics_panel.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAGA,cAAc,oBAAoB,CAAC"}
@@ -0,0 +1,3 @@
1
+ // hazo_umetrics/src/ui/index.ts — Client-safe UI components (no Node.js imports).
2
+ // Full MetricsPanel wiring is deferred to M1 interactive session.
3
+ export * from './metrics_panel.js';
@@ -0,0 +1,33 @@
1
+ export interface StatCardsProps {
2
+ stats: Array<{
3
+ key: string;
4
+ label: string;
5
+ value: number;
6
+ unit?: string;
7
+ format: string;
8
+ }>;
9
+ }
10
+ export declare function StatCards({ stats }: StatCardsProps): import("react").JSX.Element;
11
+ export interface StatTrendProps {
12
+ data: Array<{
13
+ ts: string;
14
+ value: number;
15
+ }>;
16
+ label?: string;
17
+ }
18
+ export declare function StatTrend({ data, label }: StatTrendProps): import("react").JSX.Element;
19
+ export interface ExperimentListProps {
20
+ experiments: Array<{
21
+ key: string;
22
+ name: string;
23
+ type: string;
24
+ status: string;
25
+ variants: string;
26
+ }>;
27
+ }
28
+ export declare function ExperimentList({ experiments }: ExperimentListProps): import("react").JSX.Element;
29
+ export interface MetricsPanelProps {
30
+ scopeId: string;
31
+ }
32
+ export declare function MetricsPanel({ scopeId }: MetricsPanelProps): import("react").JSX.Element;
33
+ //# sourceMappingURL=metrics_panel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics_panel.d.ts","sourceRoot":"","sources":["../../src/ui/metrics_panel.tsx"],"names":[],"mappings":"AA8FA,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC5F;AAeD,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,EAAE,cAAc,+BAmBlD;AAMD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAaD,wBAAgB,SAAS,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,cAAc,+BAmBxD;AAMD,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE,KAAK,CAAC;QACjB,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC,CAAC;CACJ;AAkBD,wBAAgB,cAAc,CAAC,EAAE,WAAW,EAAE,EAAE,mBAAmB,+BAyBlE;AAMD,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;CACjB;AAoBD,wBAAgB,YAAY,CAAC,EAAE,OAAO,EAAE,EAAE,iBAAiB,+BAyK1D"}