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,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 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAGA,cAAc,oBAAoB,CAAC"}
|
package/dist/ui/index.js
ADDED
|
@@ -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"}
|