hazo_umetrics 1.10.0 → 1.11.0

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 (46) hide show
  1. package/CHANGE_LOG.md +64 -0
  2. package/config/hazo_umetrics_config.ini.sample +6 -2
  3. package/dist/api/index.d.ts +21 -2
  4. package/dist/api/index.d.ts.map +1 -1
  5. package/dist/api/index.js +224 -8
  6. package/dist/index.client.d.ts +2 -1
  7. package/dist/index.client.d.ts.map +1 -1
  8. package/dist/index.client.js +1 -0
  9. package/dist/lib/index.d.ts +4 -0
  10. package/dist/lib/index.d.ts.map +1 -1
  11. package/dist/lib/index.js +4 -0
  12. package/dist/lib/track_flow_step.d.ts +21 -0
  13. package/dist/lib/track_flow_step.d.ts.map +1 -0
  14. package/dist/lib/track_flow_step.js +33 -0
  15. package/dist/server/funnel.d.ts +38 -0
  16. package/dist/server/funnel.d.ts.map +1 -0
  17. package/dist/server/funnel.js +60 -0
  18. package/dist/server/ga4.d.ts +18 -15
  19. package/dist/server/ga4.d.ts.map +1 -1
  20. package/dist/server/ga4.js +84 -14
  21. package/dist/server/ga4_manifest.d.ts +58 -0
  22. package/dist/server/ga4_manifest.d.ts.map +1 -0
  23. package/dist/server/ga4_manifest.js +127 -0
  24. package/dist/server/ga4_metrics.d.ts +20 -0
  25. package/dist/server/ga4_metrics.d.ts.map +1 -0
  26. package/dist/server/ga4_metrics.js +84 -0
  27. package/dist/server/ga4_provision.d.ts +39 -0
  28. package/dist/server/ga4_provision.d.ts.map +1 -0
  29. package/dist/server/ga4_provision.js +313 -0
  30. package/dist/server/metrics.d.ts +30 -4
  31. package/dist/server/metrics.d.ts.map +1 -1
  32. package/dist/server/metrics.js +24 -7
  33. package/dist/ui/ga4_connection_card.d.ts +16 -0
  34. package/dist/ui/ga4_connection_card.d.ts.map +1 -0
  35. package/dist/ui/ga4_connection_card.js +249 -0
  36. package/dist/ui/index.d.ts +1 -0
  37. package/dist/ui/index.d.ts.map +1 -1
  38. package/dist/ui/index.js +1 -0
  39. package/dist/ui/journey_panel.d.ts.map +1 -1
  40. package/dist/ui/journey_panel.js +19 -15
  41. package/dist/ui/metrics_tab.d.ts +7 -1
  42. package/dist/ui/metrics_tab.d.ts.map +1 -1
  43. package/dist/ui/metrics_tab.js +4 -3
  44. package/migrations/db_setup_postgres.sql +4 -1
  45. package/migrations/db_setup_sqlite.sql +4 -1
  46. package/package.json +10 -6
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # hazo_umetrics — Change Log
2
2
 
3
+ ## 1.11.0 — M1 GA4 live (OAuth connect + Admin provisioner + Data API funnels + windowed ga4 metrics)
4
+
5
+ **M1 GA4 half shipped (minor — stubs replaced with real implementations; signatures widened, not broken):**
6
+
7
+ ### GA4 Data API client (`src/server/ga4.ts`, rewritten)
8
+ - `createGa4Client({ propertyId, fetchToken, fetchImpl?, apiBaseUrl?, cacheTtlMs? })` replaces the M0 stub with a real client that issues raw `fetch` calls to `analyticsdata.googleapis.com/v1beta/properties/{propertyId}:runReport`. Token comes from the caller-supplied `fetchToken()` (delegated to `hazo_auth`, see D-12) — the client never persists or reads credentials itself.
9
+ - In-memory ~5min TTL cache, keyed per client instance; stale-if-error (a failed refetch serves the last good cached value rather than throwing). `cacheTtlMs` overridable for tests. (D-11, D-14)
10
+ - `Ga4Report` return shape unchanged from M0 — existing callers of the stub keep working.
11
+
12
+ ### GA4 manifest + PII guard (`src/server/ga4_manifest.ts`, new)
13
+ - `DEFAULT_GA4_MANIFEST` — the standard set of custom dimensions/metrics/key events `hazo_umetrics` provisions per app.
14
+ - `mergeManifest` — combines the default manifest with an app-supplied override, de-duped by name.
15
+ - `isPII` / `assertNoPII` — blocks dimension/metric names that look like PII (email, name, phone, etc.) before they're sent to the Admin API.
16
+ - `MAX_EVENT_SCOPED_DIMENSIONS = 50` — GA4's own event-scoped custom dimension cap, enforced client-side with a hard error rather than letting the Admin API reject it opaquely.
17
+ - `validateManifest` — runs both guards over a full manifest in one pass.
18
+ - Metric `step_duration_ms` display name is `Step Duration` (not `Step Duration (ms)`) — GA4 Admin `display_name` allows only `[A-Za-z0-9_ ]`; the `MILLISECONDS` `measurementUnit` already conveys the unit. Caught in live provision smoke (400 `INVALID_ARGUMENT`).
19
+
20
+ ### GA4 Admin API provisioner (`src/server/ga4_provision.ts`, new)
21
+ - Idempotent **list → create-missing → patch-drifted** flow for custom dimensions, custom metrics, and key events. Re-running provision on an already-provisioned property is a no-op (aside from patching any drifted display names/scopes).
22
+ - Runs the manifest through `validateManifest` (PII + cap) before touching the Admin API.
23
+ - Partial failures don't abort the whole run — the result comes back `status: 'error'` with a per-item error list, and any items that did succeed still have their resolved IDs returned.
24
+ - Returns resolved `dimension_ids` / `metric_ids` / `key_event_ids` for persistence on the connection row.
25
+
26
+ ### Funnel read (`src/server/funnel.ts`, new)
27
+ - `getFunnel(client, flow, dateRange)` — one `runReport` call (`eventCount` dimensioned by `eventName`, filtered with an `inListFilter` on the flow's step event names), then JS-side: reorders rows to match the flow's declared step order, computes per-step and cumulative drop-off, and overall conversion. (D-13)
28
+ - Output shaped to drop straight into `hazo_ui`'s `FunnelChart`.
29
+
30
+ ### Windowed ga4 metrics live (`src/server/metrics.ts`, `src/server/ga4_metrics.ts` new)
31
+ - `getMetricWindows` gains an optional third arg `{ ga4Reader }` — when supplied, `source: 'ga4'` metrics are no longer forced to `pending`.
32
+ - `createGa4MetricWindowReader(...)` (new) looks up the app's GA4 connection row, reads the Data API through `ga4.ts`, and fills `total`/`24h`/`7d`/`28d`/`90d` for each registered `ga4` metric. Falls back to `pending: true` when the app has no connection yet — unchanged behaviour for unconnected apps.
33
+ - Wired into `createStatHandlers.getMetricWindows` in `src/api/index.ts` so the `/stats/windows` route serves live GA4 numbers once an app is connected.
34
+
35
+ ### Connect / provision / funnel handlers (`src/api/index.ts`)
36
+ - `createGa4ConnectHandlers` — `connect` now actually persists `connected_user_id` + the property/measurement IDs to the connection row (`disconnect: true` clears the row instead of deleting it, keeping history). `provision` delegates the OAuth token fetch to `hazo_auth`'s `getGoogleToken`, runs `ga4_provision.ts`, persists the resolved dimension/metric/key-event IDs, and writes an audit entry. New `funnel` handler wraps `getFunnel` for the `/ga4/funnel` route. (D-12)
37
+
38
+ ### Canonical flow helper (`src/lib/track_flow_step.ts`, exported from `hazo_umetrics/client`)
39
+ - `trackFlowStep(flowId, step, params)` — gtag-guarded no-op when `window.gtag` isn't present (SSR-safe, no-op in tests). This is the shape intended to replace gotimer's `src/lib/ga-events.ts` — the gotimer migration itself is a separate, later session.
40
+
41
+ ### UI
42
+ - `GA4ConnectionCard` (`src/ui/ga4_connection_card.tsx`, exported from `hazo_umetrics/ui`) — connect/disconnect + provision status card.
43
+ - Test-app: new `/ga4` tab, plus `provision`, `funnel`, and jobs-snapshot demo routes.
44
+
45
+ ### DB migration
46
+ - `hazo_umetrics_ga4_connection` gains `connected_user_id`, `metric_ids`, `key_event_ids` columns — both SQLite and PostgreSQL DDL, plus seed data updated. `credential_ref` stays in the schema but reserved/unused: the OAuth token itself is never stored by `hazo_umetrics` (delegated to `hazo_auth`, D-12).
47
+
48
+ ### Decisions
49
+ - **D-11** Raw `fetch` to the GA4 Data/Admin REST APIs, not the `googleapis` SDK — keeps the dependency tree shallow.
50
+ - **D-12** GA4 token delegated to `hazo_auth`'s `getGoogleToken` — no second credential store.
51
+ - **D-13** Funnel step ordering/drop-off/conversion computed client-side in JS from a single `eventCount`×`eventName` report, not a multi-call server-side funnel API.
52
+ - **D-14** In-memory ~5min TTL cache, stale-if-error, per client instance — scheduled background refresh deferred.
53
+
54
+ ### Tests
55
+ - New mocked-`fetch` (no real Google calls) jest suites: manifest/PII guard, client + cache behaviour, funnel computation, provisioner (idempotency + partial failure), connect/provision/funnel handlers, live windowed-metrics read, and the flow-step helper. All green.
56
+
57
+ ## 1.10.1 — Windowed-metrics wiring fixes (exports `default` + `MetricsTab` `apiBase`)
58
+
59
+ **Fixes (patch — surfaced while wiring consumer apps; both additive/back-compatible):**
60
+
61
+ ### `package.json` `exports` — add a `"default"` condition to every subpath
62
+ - Each subpath (`.`, `./client`, `./api`, `./ui`) now declares `"default"` alongside `"import"`, mirroring `hazo_connect`/`hazo_jobs`. Without it, a static `import … from "hazo_umetrics"` under tooling that doesn't resolve the `"import"` condition (e.g. `tsx` standalone scripts) failed with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Consumer backfill/snapshot scripts had to use dynamic `await import()` as a workaround; they can now import statically.
63
+
64
+ ### `MetricsTab` — optional `apiBase` prop
65
+ - `MetricsTab({ appId, apiBase = '/api/hazo_umetrics' })` builds the windows fetch from `${apiBase}/stats/windows` (trailing slash tolerated) instead of hardcoding `/api/hazo_umetrics/stats/windows`. Apps mounting umetrics under a different base can now point the tab at their route. Default preserves the previous behaviour (back-compatible). Matches `JourneyPanel`'s `apiBase` pattern.
66
+
3
67
  ## 1.10.0 — Per-app windowed metrics (registry + Total/24h/7d/28d/90d + graph)
4
68
 
5
69
  **New features (minor — additive; existing `MetricsPanel`/`statSnapshotJob` unaffected):**
@@ -33,9 +33,13 @@ app_id = my_app
33
33
  ; env = development
34
34
 
35
35
  [ga4]
36
- ; GA4 property ID (format: "properties/123456789"). Leave blank until M1 GA4 connect.
36
+ ; These three keys are OPTIONAL fallbacks only. Once an app completes the
37
+ ; /ga4 connect flow, the hazo_umetrics_ga4_connection table (per app_id) is
38
+ ; the source of truth for property_id/measurement_id — these ini values are
39
+ ; not read at runtime for a connected app. Leave commented in normal use.
40
+ ; GA4 property ID — BARE numeric id (the code prefixes "properties/"). e.g. 497179388
37
41
  ; property_id =
38
- ; GA4 Measurement ID (format: "G-XXXXXXXX"). Required for client-side event tracking.
42
+ ; GA4 Measurement ID (format: "G-XXXXXXXX"). Required for client-side event tracking. e.g. G-CBY26RF5JD
39
43
  ; measurement_id =
40
44
  ; GA4 Data API base URL (default: https://analyticsdata.googleapis.com/v1beta/)
41
45
  ; api_base_url =
@@ -88,10 +88,29 @@ export declare function createExperimentHandlers(options: ApiFactoryOptions): {
88
88
  stopExperiment(req: Request): Promise<Response>;
89
89
  };
90
90
  export declare function createGa4ConnectHandlers(options: ApiFactoryOptions): {
91
- /** STUB (M1): Initiate GA4 OAuth connection for an app. */
91
+ /**
92
+ * POST /...?app_id=<id>
93
+ * Body: { property_id, measurement_id, connected_user_id } | { disconnect: true }
94
+ * Persists (or clears) the per-app GA4 connection row. The browser has
95
+ * already driven Google consent (`requestGoogleScopes`) before calling this —
96
+ * this handler only records the resulting property/user linkage. Requires
97
+ * metrics.manage. Revocation of the Google token itself is delegated to
98
+ * hazo_auth's DELETE /google/token (not called here).
99
+ */
92
100
  connect(req: Request): Promise<Response>;
93
- /** STUB (M1): Provision a GA4 property for an app. */
101
+ /**
102
+ * POST /...?app_id=<id>
103
+ * Idempotent GA4 Admin provision — creates/patches custom dimensions,
104
+ * custom metrics, and key events from DEFAULT_GA4_MANIFEST, then persists
105
+ * the resolved IDs + provision_status. Requires metrics.manage.
106
+ */
94
107
  provision(req: Request): Promise<Response>;
108
+ /**
109
+ * GET /...?app_id=<id>[&flow_id=<id>]
110
+ * Reads a funnel via the GA4 Data API for the manifest flow matching
111
+ * flow_id (defaults to the first flow). Requires metrics.view.
112
+ */
113
+ funnel(req: Request): Promise<Response>;
95
114
  };
96
115
  export declare function createQueryHandlers(options: ApiFactoryOptions): {
97
116
  /** STUB (M1): Execute a GA4 or stat-store query. */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"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"}
package/dist/api/index.js CHANGED
@@ -7,10 +7,15 @@
7
7
  import { createCrudService } from 'hazo_connect/server';
8
8
  import { recordStat as _recordStat, getLatestStat as _getLatestStat, getStatSeries as _getStatSeries, } from '../server/stats.js';
9
9
  import { getMetricWindows as _getMetricWindows } from '../server/metrics.js';
10
+ import { createGa4MetricWindowReader } from '../server/ga4_metrics.js';
10
11
  import { resolveVariant } from '../server/flags.js';
11
12
  import { getActivitySummary as _getActivitySummary, getActivityTrend as _getActivityTrend, } from '../server/activity.js';
12
13
  import { ensureActionDef as _ensureActionDef, listActions as _listActions, getActionTree as _getActionTree, updateActionDef as _updateActionDef, updateActionDefBulk as _updateActionDefBulk, applyLinksBulk as _applyLinksBulk, getLinks as _getLinks, addLink as _addLink, removeLink as _removeLink, } from '../server/actions.js';
13
14
  import { topActions as _topActions, firstSessionActions as _firstSessionActions, postReloginActions as _postReloginActions, actionsBeforeGoal as _actionsBeforeGoal, topErrors as _topErrors, tabDwell as _tabDwell, frequentActionsReturning as _frequentActionsReturning, eventsOverTime as _eventsOverTime, datapointStats as _datapointStats, userJourney as _userJourney, searchQueries as _searchQueries, } from '../server/behavior.js';
15
+ import { createGa4Client } from '../server/ga4.js';
16
+ import { createGa4Provisioner } from '../server/ga4_provision.js';
17
+ import { getFunnel } from '../server/funnel.js';
18
+ import { DEFAULT_GA4_MANIFEST } from '../server/ga4_manifest.js';
14
19
  // ---------------------------------------------------------------------------
15
20
  // Default auth — dynamically imports hazo_auth/server-lib to avoid the
16
21
  // server-only guard firing at module-evaluation time (e.g. in test runners).
@@ -184,7 +189,13 @@ export function createStatHandlers(options) {
184
189
  return jsonError('BAD_REQUEST', 'app_id is required', 400);
185
190
  }
186
191
  const adapter = await options.getAdapter();
187
- const windows = await _getMetricWindows(adapter, { app_id, days });
192
+ const ga4Reader = createGa4MetricWindowReader(adapter, {
193
+ getToken: async (userId, o) => {
194
+ const { getGoogleToken } = await import('hazo_auth/server-lib');
195
+ return getGoogleToken(userId, o);
196
+ },
197
+ });
198
+ const windows = await _getMetricWindows(adapter, { app_id, days }, { ga4Reader });
188
199
  return jsonOk({ windows });
189
200
  },
190
201
  };
@@ -344,12 +355,39 @@ export function createExperimentHandlers(options) {
344
355
  };
345
356
  }
346
357
  // ---------------------------------------------------------------------------
347
- // createGa4ConnectHandlers — STUB (M1)
358
+ // createGa4ConnectHandlers — GA4 OAuth connection, Admin provisioning, and
359
+ // funnel reads (M1 step 5, plan_m1_ga4.md).
360
+ //
361
+ // D-12/D-9: the Google OAuth token itself is never stored here — it's
362
+ // delegated to hazo_auth's `getGoogleToken(userId, opts)`, lazily imported
363
+ // inside each handler (mirrors `defaultGetAuth` above) so the `server-only`
364
+ // guard in `hazo_auth/server-lib` never fires at module-evaluation time.
348
365
  // ---------------------------------------------------------------------------
366
+ /** Google OAuth scopes needed to read + manage a GA4 property. */
367
+ const ANALYTICS_SCOPES = [
368
+ 'https://www.googleapis.com/auth/analytics.readonly',
369
+ 'https://www.googleapis.com/auth/analytics.edit',
370
+ ];
371
+ /** Read-only subset — sufficient for Data API funnel reads. */
372
+ const ANALYTICS_READONLY_SCOPES = [ANALYTICS_SCOPES[0]];
349
373
  export function createGa4ConnectHandlers(options) {
350
374
  const getAuth = options.getAuth ?? defaultGetAuth;
375
+ function getConnectionCrud(adapter) {
376
+ return createCrudService(adapter, 'hazo_umetrics_ga4_connection', {
377
+ primaryKeys: ['app_id'],
378
+ autoId: false,
379
+ });
380
+ }
351
381
  return {
352
- /** STUB (M1): Initiate GA4 OAuth connection for an app. */
382
+ /**
383
+ * POST /...?app_id=<id>
384
+ * Body: { property_id, measurement_id, connected_user_id } | { disconnect: true }
385
+ * Persists (or clears) the per-app GA4 connection row. The browser has
386
+ * already driven Google consent (`requestGoogleScopes`) before calling this —
387
+ * this handler only records the resulting property/user linkage. Requires
388
+ * metrics.manage. Revocation of the Google token itself is delegated to
389
+ * hazo_auth's DELETE /google/token (not called here).
390
+ */
353
391
  async connect(req) {
354
392
  const url = new URL(req.url);
355
393
  const app_id = url.searchParams.get('app_id') ?? '';
@@ -360,10 +398,70 @@ export function createGa4ConnectHandlers(options) {
360
398
  });
361
399
  if (!gate.ok)
362
400
  return gate.response;
363
- // STUB (M1)
364
- return jsonOk({ status: 'stub' });
401
+ let body;
402
+ try {
403
+ body = (await req.json());
404
+ }
405
+ catch {
406
+ return jsonError('BAD_REQUEST', 'Invalid JSON body', 400);
407
+ }
408
+ const { app_id: bodyAppId, property_id, measurement_id, connected_user_id, disconnect, } = body;
409
+ const resolvedAppId = (bodyAppId ?? app_id);
410
+ if (!resolvedAppId) {
411
+ return jsonError('BAD_REQUEST', 'app_id is required', 400);
412
+ }
413
+ const adapter = await options.getAdapter();
414
+ const crud = getConnectionCrud(adapter);
415
+ if (disconnect) {
416
+ await crud.deleteWhere({ app_id: resolvedAppId });
417
+ await options.onAudit?.({
418
+ action: 'ga4.disconnect',
419
+ actor_id: gate.userId,
420
+ app_id: resolvedAppId,
421
+ });
422
+ return jsonOk({ status: 'disconnected', app_id: resolvedAppId });
423
+ }
424
+ if (!property_id) {
425
+ return jsonError('BAD_REQUEST', 'property_id is required', 400);
426
+ }
427
+ const existing = await crud.findOneBy({ app_id: resolvedAppId });
428
+ const now = new Date().toISOString();
429
+ const merged = {
430
+ app_id: resolvedAppId,
431
+ scope_id: existing?.scope_id ?? '',
432
+ property_id,
433
+ measurement_id: measurement_id ?? existing?.measurement_id ?? null,
434
+ credential_ref: existing?.credential_ref ?? null,
435
+ provision_status: existing?.provision_status ?? 'unprovisioned',
436
+ last_provisioned_at: existing?.last_provisioned_at ?? null,
437
+ dimension_ids: existing?.dimension_ids ?? '[]',
438
+ connected_user_id: connected_user_id ?? existing?.connected_user_id ?? null,
439
+ metric_ids: existing?.metric_ids ?? '[]',
440
+ key_event_ids: existing?.key_event_ids ?? '[]',
441
+ created_at: existing?.created_at ?? now,
442
+ updated_at: now,
443
+ };
444
+ await crud.upsert(merged, ['app_id']);
445
+ await options.onAudit?.({
446
+ action: 'ga4.connect',
447
+ actor_id: gate.userId,
448
+ app_id: resolvedAppId,
449
+ details: { property_id },
450
+ });
451
+ return jsonOk({
452
+ status: 'connected',
453
+ app_id: resolvedAppId,
454
+ property_id: merged.property_id,
455
+ measurement_id: merged.measurement_id,
456
+ connected_user_id: merged.connected_user_id,
457
+ });
365
458
  },
366
- /** STUB (M1): Provision a GA4 property for an app. */
459
+ /**
460
+ * POST /...?app_id=<id>
461
+ * Idempotent GA4 Admin provision — creates/patches custom dimensions,
462
+ * custom metrics, and key events from DEFAULT_GA4_MANIFEST, then persists
463
+ * the resolved IDs + provision_status. Requires metrics.manage.
464
+ */
367
465
  async provision(req) {
368
466
  const url = new URL(req.url);
369
467
  const app_id = url.searchParams.get('app_id') ?? '';
@@ -374,8 +472,126 @@ export function createGa4ConnectHandlers(options) {
374
472
  });
375
473
  if (!gate.ok)
376
474
  return gate.response;
377
- // STUB (M1)
378
- return jsonOk({ status: 'stub' });
475
+ let body;
476
+ try {
477
+ body = (await req.json());
478
+ }
479
+ catch {
480
+ return jsonError('BAD_REQUEST', 'Invalid JSON body', 400);
481
+ }
482
+ const bodyAppId = body.app_id;
483
+ const resolvedAppId = (bodyAppId ?? app_id);
484
+ if (!resolvedAppId) {
485
+ return jsonError('BAD_REQUEST', 'app_id is required', 400);
486
+ }
487
+ const adapter = await options.getAdapter();
488
+ const crud = getConnectionCrud(adapter);
489
+ const row = await crud.findOneBy({ app_id: resolvedAppId });
490
+ if (!row) {
491
+ return jsonOk({ status: 'error', error: 'not_connected' });
492
+ }
493
+ const { getGoogleToken } = await import('hazo_auth/server-lib');
494
+ let result;
495
+ try {
496
+ const provisioner = createGa4Provisioner({
497
+ propertyId: row.property_id,
498
+ fetchToken: async () => {
499
+ const t = await getGoogleToken(row.connected_user_id, {
500
+ scopes: ANALYTICS_SCOPES,
501
+ });
502
+ if (!t.ok)
503
+ throw new Error('ga4_token_' + t.error);
504
+ return t.access_token;
505
+ },
506
+ });
507
+ result = await provisioner.provision();
508
+ }
509
+ catch (err) {
510
+ return jsonOk({
511
+ status: 'error',
512
+ error: err instanceof Error ? err.message : String(err),
513
+ });
514
+ }
515
+ const now = new Date().toISOString();
516
+ const updatedRow = {
517
+ ...row,
518
+ dimension_ids: JSON.stringify(result.dimension_ids),
519
+ metric_ids: JSON.stringify(result.metric_ids),
520
+ key_event_ids: JSON.stringify(result.key_event_ids),
521
+ provision_status: result.status,
522
+ last_provisioned_at: now,
523
+ updated_at: now,
524
+ };
525
+ await crud.upsert(updatedRow, ['app_id']);
526
+ await options.onAudit?.({
527
+ action: 'ga4.provision',
528
+ actor_id: gate.userId,
529
+ app_id: resolvedAppId,
530
+ details: { created: result.created, patched: result.patched, status: result.status, errors: result.errors },
531
+ });
532
+ return jsonOk({
533
+ status: result.status,
534
+ property_id: row.property_id,
535
+ provision: { created: result.created, patched: result.patched, skipped: result.skipped },
536
+ dimension_ids: result.dimension_ids,
537
+ metric_ids: result.metric_ids,
538
+ key_event_ids: result.key_event_ids,
539
+ errors: result.errors,
540
+ });
541
+ },
542
+ /**
543
+ * GET /...?app_id=<id>[&flow_id=<id>]
544
+ * Reads a funnel via the GA4 Data API for the manifest flow matching
545
+ * flow_id (defaults to the first flow). Requires metrics.view.
546
+ */
547
+ async funnel(req) {
548
+ const url = new URL(req.url);
549
+ const app_id = url.searchParams.get('app_id') ?? '';
550
+ const flow_id = url.searchParams.get('flow_id') ?? undefined;
551
+ const gate = await gateAuth(req, {
552
+ required_permissions: ['metrics.view'],
553
+ app_id: app_id || undefined,
554
+ getAuth,
555
+ });
556
+ if (!gate.ok)
557
+ return gate.response;
558
+ if (!app_id) {
559
+ return jsonError('BAD_REQUEST', 'app_id is required', 400);
560
+ }
561
+ const adapter = await options.getAdapter();
562
+ const crud = getConnectionCrud(adapter);
563
+ const row = await crud.findOneBy({ app_id });
564
+ if (!row) {
565
+ return jsonOk({ status: 'error', error: 'not_connected' });
566
+ }
567
+ const flow = flow_id
568
+ ? DEFAULT_GA4_MANIFEST.flows.find((f) => f.flowId === flow_id)
569
+ : DEFAULT_GA4_MANIFEST.flows[0];
570
+ if (!flow) {
571
+ return jsonOk({ status: 'error', error: 'unknown_flow' });
572
+ }
573
+ try {
574
+ const { getGoogleToken } = await import('hazo_auth/server-lib');
575
+ const client = createGa4Client({
576
+ propertyId: row.property_id,
577
+ fetchToken: async () => {
578
+ const t = await getGoogleToken(row.connected_user_id, {
579
+ scopes: ANALYTICS_READONLY_SCOPES,
580
+ });
581
+ if (!t.ok)
582
+ throw new Error('ga4_token_' + t.error);
583
+ return t.access_token;
584
+ },
585
+ });
586
+ const result = await getFunnel(client, flow, { startDate: '28daysAgo', endDate: 'today' });
587
+ return jsonOk({ status: 'ok', funnel: result, as_of: new Date().toISOString() });
588
+ }
589
+ catch (err) {
590
+ return jsonOk({
591
+ status: 'error',
592
+ error: err instanceof Error ? err.message : String(err),
593
+ });
594
+ }
379
595
  },
380
596
  };
381
597
  }
@@ -1,4 +1,6 @@
1
1
  import React from 'react';
2
+ export { trackFlowStep } from './lib/track_flow_step.js';
3
+ export type { TrackFlowStepParams } from './lib/track_flow_step.js';
2
4
  interface MetricsContextValue {
3
5
  appId: string | null | undefined;
4
6
  track: (metricKey: string, dimensions?: Record<string, string | number>) => void;
@@ -25,5 +27,4 @@ export declare function getIdentity(): {
25
27
  export declare function recordStat(_key: string, _value: number, _opts?: {
26
28
  unit?: string;
27
29
  }): void;
28
- export {};
29
30
  //# sourceMappingURL=index.client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,OAAO,CAAC;AAoD1B,UAAU,mBAAmB;IAC3B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACjC,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,KAAK,IAAI,CAAC;IACjF,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAA;KAAE,KAAK,IAAI,CAAC;IAC1G,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,IAAI,CAAC;CAClE;AASD,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,wBAAwB,4EAsJxF;AAMD,wBAAgB,UAAU,IAAI,mBAAmB,CAEhD;AAMD,wBAAgB,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,SAAS,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAK7C;AAED,wBAAgB,WAAW,IAAI;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAE5E;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAExF"}
1
+ {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,OAAO,CAAC;AAG1B,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AACzD,YAAY,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAmDpE,UAAU,mBAAmB;IAC3B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACjC,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,KAAK,IAAI,CAAC;IACjF,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAA;KAAE,KAAK,IAAI,CAAC;IAC1G,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,IAAI,CAAC;CAClE;AASD,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,wBAAwB,4EAsJxF;AAMD,wBAAgB,UAAU,IAAI,mBAAmB,CAEhD;AAMD,wBAAgB,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,SAAS,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAK7C;AAED,wBAAgB,WAAW,IAAI;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAE5E;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAExF"}
@@ -3,6 +3,7 @@
3
3
  'use client';
4
4
  import React from 'react';
5
5
  import { usePathname } from 'next/navigation';
6
+ export { trackFlowStep } from './lib/track_flow_step.js';
6
7
  // ---------------------------------------------------------------------------
7
8
  // Anonymous session identity (localStorage, SSR-guarded)
8
9
  // ---------------------------------------------------------------------------
@@ -5,6 +5,10 @@ export * from '../server/actions.js';
5
5
  export * from '../server/flags.js';
6
6
  export * from '../server/cookie.js';
7
7
  export * from '../server/ga4.js';
8
+ export * from '../server/ga4_manifest.js';
9
+ export * from '../server/ga4_provision.js';
10
+ export * from '../server/funnel.js';
11
+ export * from '../server/ga4_metrics.js';
8
12
  export * from '../server/experiments.js';
9
13
  export * from '../server/activity.js';
10
14
  export * from '../server/behavior.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC"}
package/dist/lib/index.js CHANGED
@@ -6,6 +6,10 @@ export * from '../server/actions.js';
6
6
  export * from '../server/flags.js';
7
7
  export * from '../server/cookie.js';
8
8
  export * from '../server/ga4.js';
9
+ export * from '../server/ga4_manifest.js';
10
+ export * from '../server/ga4_provision.js';
11
+ export * from '../server/funnel.js';
12
+ export * from '../server/ga4_metrics.js';
9
13
  export * from '../server/experiments.js';
10
14
  export * from '../server/activity.js';
11
15
  export * from '../server/behavior.js';
@@ -0,0 +1,21 @@
1
+ export interface TrackFlowStepParams {
2
+ variant?: string;
3
+ featureFlag?: string;
4
+ appSection?: string;
5
+ stepDurationMs?: number;
6
+ [extra: string]: string | number | undefined;
7
+ }
8
+ declare global {
9
+ interface Window {
10
+ gtag?: (...args: unknown[]) => void;
11
+ }
12
+ }
13
+ /**
14
+ * Emits a GA4 event named `step`, with manifest dimension params:
15
+ * `flow_id`, `flow_step`, `variant`, `feature_flag`, `app_section`, `step_duration_ms`,
16
+ * plus any extra keys passed through as-is. Undefined params are omitted.
17
+ *
18
+ * Safe no-op when `window.gtag` isn't present (SSR, no GA loaded).
19
+ */
20
+ export declare function trackFlowStep(flowId: string, step: string, params?: TrackFlowStepParams): void;
21
+ //# sourceMappingURL=track_flow_step.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"track_flow_step.d.ts","sourceRoot":"","sources":["../../src/lib/track_flow_step.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,mBAAmB;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAC9C;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,IAAI,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;KACrC;CACF;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,mBAAmB,GAAG,IAAI,CAoB9F"}
@@ -0,0 +1,33 @@
1
+ // hazo_umetrics/src/lib/track_flow_step.ts — canonical client-safe flow-event helper.
2
+ // Emits a GA4 event via window.gtag using the manifest's dimension params. Mirrors the
3
+ // shape of gotimer's src/lib/ga-events.ts (which this replaces — that repo is untouched here).
4
+ // Must stay client-safe: no Node.js builtins, no server-only imports.
5
+ /**
6
+ * Emits a GA4 event named `step`, with manifest dimension params:
7
+ * `flow_id`, `flow_step`, `variant`, `feature_flag`, `app_section`, `step_duration_ms`,
8
+ * plus any extra keys passed through as-is. Undefined params are omitted.
9
+ *
10
+ * Safe no-op when `window.gtag` isn't present (SSR, no GA loaded).
11
+ */
12
+ export function trackFlowStep(flowId, step, params) {
13
+ if (typeof window === 'undefined' || typeof window.gtag !== 'function')
14
+ return;
15
+ const { variant, featureFlag, appSection, stepDurationMs, ...extra } = params ?? {};
16
+ const eventParams = {
17
+ flow_id: flowId,
18
+ flow_step: step,
19
+ };
20
+ if (variant !== undefined)
21
+ eventParams.variant = variant;
22
+ if (featureFlag !== undefined)
23
+ eventParams.feature_flag = featureFlag;
24
+ if (appSection !== undefined)
25
+ eventParams.app_section = appSection;
26
+ if (stepDurationMs !== undefined)
27
+ eventParams.step_duration_ms = stepDurationMs;
28
+ for (const [key, value] of Object.entries(extra)) {
29
+ if (value !== undefined)
30
+ eventParams[key] = value;
31
+ }
32
+ window.gtag('event', step, eventParams);
33
+ }
@@ -0,0 +1,38 @@
1
+ import type { Ga4Flow } from './ga4_manifest.js';
2
+ export interface Ga4RunReportClient {
3
+ runReport(params: {
4
+ metrics: string[];
5
+ dimensions: string[];
6
+ dateRanges: Array<{
7
+ startDate: string;
8
+ endDate: string;
9
+ }>;
10
+ dimensionFilter?: unknown;
11
+ metricAggregations?: string[];
12
+ }): Promise<{
13
+ rows: Array<{
14
+ dimensions: string[];
15
+ metrics: string[];
16
+ }>;
17
+ rowCount: number;
18
+ }>;
19
+ }
20
+ export interface FunnelStep {
21
+ step: string;
22
+ label: string;
23
+ count: number;
24
+ dropOff: number;
25
+ dropOffPct: number;
26
+ conversionFromStart: number;
27
+ }
28
+ export interface FunnelResult {
29
+ flowId: string;
30
+ steps: FunnelStep[];
31
+ totalEntered: number;
32
+ overallConversion: number;
33
+ }
34
+ export declare function getFunnel(client: Ga4RunReportClient, flow: Ga4Flow, dateRange: {
35
+ startDate: string;
36
+ endDate: string;
37
+ }): Promise<FunnelResult>;
38
+ //# sourceMappingURL=funnel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"funnel.d.ts","sourceRoot":"","sources":["../../src/server/funnel.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEjD,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,MAAM,EAAE;QAChB,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,UAAU,EAAE,MAAM,EAAE,CAAC;QACrB,UAAU,EAAE,KAAK,CAAC;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QAC1D,eAAe,CAAC,EAAE,OAAO,CAAC;QAC1B,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;KAC/B,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,KAAK,CAAC;YAAE,UAAU,EAAE,MAAM,EAAE,CAAC;YAAC,OAAO,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC7F;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,mBAAmB,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAWD,wBAAsB,SAAS,CAC7B,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,OAAO,EACb,SAAS,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAChD,OAAO,CAAC,YAAY,CAAC,CAoDvB"}
@@ -0,0 +1,60 @@
1
+ // hazo_umetrics/src/server/funnel.ts — GA4-backed funnel read.
2
+ //
3
+ // D-13: funnels computed client-side from `eventCount x eventName` (Data API v1beta
4
+ // has no native funnelReport). G-6/G-13: a single runReport grouped by eventName,
5
+ // filtered to the flow's step events via an inListFilter — ordering + drop-off math
6
+ // happen here in JS, not in GA4.
7
+ /** Title-cases an event/step name: `timer_start` -> `Timer Start`. */
8
+ function titleCase(step) {
9
+ return step
10
+ .split('_')
11
+ .filter((word) => word.length > 0)
12
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
13
+ .join(' ');
14
+ }
15
+ export async function getFunnel(client, flow, dateRange) {
16
+ const report = await client.runReport({
17
+ metrics: ['eventCount'],
18
+ dimensions: ['eventName'],
19
+ dateRanges: [dateRange],
20
+ dimensionFilter: {
21
+ filter: {
22
+ fieldName: 'eventName',
23
+ inListFilter: { values: flow.steps },
24
+ },
25
+ },
26
+ });
27
+ const countsByEvent = new Map();
28
+ for (const row of report.rows) {
29
+ const eventName = row.dimensions[0];
30
+ const count = parseInt(row.metrics[0], 10);
31
+ countsByEvent.set(eventName, Number.isNaN(count) ? 0 : count);
32
+ }
33
+ const firstCount = countsByEvent.get(flow.steps[0]) ?? 0;
34
+ const steps = [];
35
+ let prevCount = 0;
36
+ flow.steps.forEach((step, index) => {
37
+ const count = countsByEvent.get(step) ?? 0;
38
+ const isFirst = index === 0;
39
+ const dropOff = isFirst ? 0 : Math.max(prevCount - count, 0);
40
+ const dropOffPct = isFirst ? 0 : prevCount > 0 ? Math.round(((prevCount - count) / prevCount) * 1000) / 10 : 0;
41
+ const conversionFromStart = firstCount > 0 ? count / firstCount : isFirst ? 0 : 0;
42
+ steps.push({
43
+ step,
44
+ label: titleCase(step),
45
+ count,
46
+ dropOff,
47
+ dropOffPct,
48
+ conversionFromStart: isFirst ? (firstCount > 0 ? 1 : 0) : conversionFromStart,
49
+ });
50
+ prevCount = count;
51
+ });
52
+ const lastCount = steps.length > 0 ? steps[steps.length - 1].count : 0;
53
+ const overallConversion = firstCount > 0 ? lastCount / firstCount : 0;
54
+ return {
55
+ flowId: flow.flowId,
56
+ steps,
57
+ totalEntered: firstCount,
58
+ overallConversion,
59
+ };
60
+ }