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.
- package/CHANGE_LOG.md +64 -0
- package/config/hazo_umetrics_config.ini.sample +6 -2
- package/dist/api/index.d.ts +21 -2
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +224 -8
- package/dist/index.client.d.ts +2 -1
- package/dist/index.client.d.ts.map +1 -1
- package/dist/index.client.js +1 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +4 -0
- package/dist/lib/track_flow_step.d.ts +21 -0
- package/dist/lib/track_flow_step.d.ts.map +1 -0
- package/dist/lib/track_flow_step.js +33 -0
- package/dist/server/funnel.d.ts +38 -0
- package/dist/server/funnel.d.ts.map +1 -0
- package/dist/server/funnel.js +60 -0
- package/dist/server/ga4.d.ts +18 -15
- package/dist/server/ga4.d.ts.map +1 -1
- package/dist/server/ga4.js +84 -14
- package/dist/server/ga4_manifest.d.ts +58 -0
- package/dist/server/ga4_manifest.d.ts.map +1 -0
- package/dist/server/ga4_manifest.js +127 -0
- package/dist/server/ga4_metrics.d.ts +20 -0
- package/dist/server/ga4_metrics.d.ts.map +1 -0
- package/dist/server/ga4_metrics.js +84 -0
- package/dist/server/ga4_provision.d.ts +39 -0
- package/dist/server/ga4_provision.d.ts.map +1 -0
- package/dist/server/ga4_provision.js +313 -0
- package/dist/server/metrics.d.ts +30 -4
- package/dist/server/metrics.d.ts.map +1 -1
- package/dist/server/metrics.js +24 -7
- package/dist/ui/ga4_connection_card.d.ts +16 -0
- package/dist/ui/ga4_connection_card.d.ts.map +1 -0
- package/dist/ui/ga4_connection_card.js +249 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/journey_panel.d.ts.map +1 -1
- package/dist/ui/journey_panel.js +19 -15
- package/dist/ui/metrics_tab.d.ts +7 -1
- package/dist/ui/metrics_tab.d.ts.map +1 -1
- package/dist/ui/metrics_tab.js +4 -3
- package/migrations/db_setup_postgres.sql +4 -1
- package/migrations/db_setup_sqlite.sql +4 -1
- 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
|
-
;
|
|
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 =
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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. */
|
package/dist/api/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;
|
|
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
|
|
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 —
|
|
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
|
-
/**
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
378
|
-
|
|
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
|
}
|
package/dist/index.client.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/index.client.js
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/lib/index.d.ts.map
CHANGED
|
@@ -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
|
+
}
|