@uptimizr/db 0.6.0 → 0.7.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/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/parity/cases.d.ts.map +1 -1
- package/dist/parity/cases.js +125 -7
- package/dist/parity/cases.js.map +1 -1
- package/dist/parity/fixtures.d.ts.map +1 -1
- package/dist/parity/fixtures.js +28 -0
- package/dist/parity/fixtures.js.map +1 -1
- package/dist/query/aggregations.d.ts +278 -1
- package/dist/query/aggregations.d.ts.map +1 -1
- package/dist/query/aggregations.js +794 -1
- package/dist/query/aggregations.js.map +1 -1
- package/dist/query/clickhouseDialect.d.ts.map +1 -1
- package/dist/query/clickhouseDialect.js +8 -0
- package/dist/query/clickhouseDialect.js.map +1 -1
- package/dist/query/dialect.d.ts +12 -1
- package/dist/query/dialect.d.ts.map +1 -1
- package/dist/query/dialect.js +6 -0
- package/dist/query/dialect.js.map +1 -1
- package/dist/query/duckdbDialect.d.ts.map +1 -1
- package/dist/query/duckdbDialect.js +6 -0
- package/dist/query/duckdbDialect.js.map +1 -1
- package/dist/query/types.d.ts +241 -0
- package/dist/query/types.d.ts.map +1 -1
- package/package.json +5 -5
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Invariant: no multi-tenant concepts here. Filtering is by `project_id` and the
|
|
10
10
|
* optional scene/source/session dimensions only.
|
|
11
11
|
*/
|
|
12
|
-
import { ParamBag, cameraModeClause, dayRangeClause, rangeClause, regionClause, sceneClause, sessionClause, sourceClause, } from "./dialect.js";
|
|
12
|
+
import { ParamBag, cameraModeClause, dayRangeClause, meshClause, rangeClause, regionClause, sceneClause, sessionClause, sourceClause, } from "./dialect.js";
|
|
13
13
|
/** World/gaze voxel coordinates derive from the raycast `hit_point` vector. */
|
|
14
14
|
const HIT_POINT_COLS = { x: "hit_point[1]", y: "hit_point[2]", z: "hit_point[3]" };
|
|
15
15
|
/** Floor-plan cells derive from the camera `position` vector (Y is height). */
|
|
@@ -84,6 +84,48 @@ export function buildPointerHeatmap(projectId, opts, d) {
|
|
|
84
84
|
query_params: bag.values,
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Per-mesh texture-space (UV) heatmap (#149): bin the `uv` texture coordinates of
|
|
89
|
+
* interaction events (`pointer_click`, `mesh_interaction`, `hover_dwell`) on one
|
|
90
|
+
* mesh into a `bins x bins` grid over the object's own `[0, 1]` UV space. This is
|
|
91
|
+
* the surface-attention companion to the world-space voxel heatmap — "which part
|
|
92
|
+
* of the product model gets attention", independent of where the object sits in
|
|
93
|
+
* the scene. `uv` rides in the JSON `payload` (no promoted column), so it is read
|
|
94
|
+
* with the dialect's `jsonFloat`. The promoted `mesh` column already coalesces a
|
|
95
|
+
* pointer hit's `hitMesh` with an interaction's `mesh`, so one filter spans all
|
|
96
|
+
* three event types. Output matches {@link buildPointerHeatmap}'s `HeatmapBinRow`
|
|
97
|
+
* (`gx`, `gy`, `count`) so it reuses the 2D heatmap renderer.
|
|
98
|
+
*/
|
|
99
|
+
export function buildMeshUvHeatmap(projectId, opts, d) {
|
|
100
|
+
const bag = new ParamBag(d);
|
|
101
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
102
|
+
const bins = bag.add("bins", "u32", opts.bins ?? 50);
|
|
103
|
+
const range = rangeClause(bag, opts);
|
|
104
|
+
const scene = sceneClause(bag, opts);
|
|
105
|
+
const source = sourceClause(bag, opts);
|
|
106
|
+
const session = sessionClause(bag, opts);
|
|
107
|
+
const mesh = meshClause(bag, opts);
|
|
108
|
+
const u = d.jsonFloat("payload", "uv", "0");
|
|
109
|
+
const v = d.jsonFloat("payload", "uv", "1");
|
|
110
|
+
return {
|
|
111
|
+
query: `
|
|
112
|
+
SELECT gx, gy, count() AS count
|
|
113
|
+
FROM (
|
|
114
|
+
SELECT
|
|
115
|
+
floor(${u} * ${bins}) AS gx,
|
|
116
|
+
floor(${v} * ${bins}) AS gy
|
|
117
|
+
FROM events
|
|
118
|
+
WHERE project_id = ${pid}
|
|
119
|
+
AND event_type IN ('pointer_click', 'mesh_interaction', 'hover_dwell')
|
|
120
|
+
AND ${u} IS NOT NULL
|
|
121
|
+
AND ${v} IS NOT NULL${range}${scene}${source}${session}${mesh}
|
|
122
|
+
) AS uv_hits
|
|
123
|
+
GROUP BY gx, gy
|
|
124
|
+
ORDER BY count DESC
|
|
125
|
+
`,
|
|
126
|
+
query_params: bag.values,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
87
129
|
/**
|
|
88
130
|
* World-space (3D) pointer heatmap: voxel-bin the raycast hit points of pointer
|
|
89
131
|
* events into a uniform grid of `cellSize`-sized cubes. Results are capped to the
|
|
@@ -251,6 +293,55 @@ export function buildCameraDirectionHeatmap(projectId, opts, d) {
|
|
|
251
293
|
query_params: bag.values,
|
|
252
294
|
};
|
|
253
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* 360° view-coverage histogram (#146): how much of a 3D object each session
|
|
298
|
+
* actually looked at, bucketed across sessions. Each session's `camera_sample`
|
|
299
|
+
* `direction` samples are binned into the **same** azimuth/elevation grid as the
|
|
300
|
+
* view-direction dome ({@link buildCameraDirectionHeatmap}); the fraction of the
|
|
301
|
+
* `bins × bins` cells a session visited is its coverage score (0–100%). Sessions
|
|
302
|
+
* are then grouped into four coverage buckets — `0` (0–25%), `25` (25–50%), `50`
|
|
303
|
+
* (50–75%), `75` (75–100%) — answering "how many visitors saw <25% of the
|
|
304
|
+
* product". A single integer `bin_id = azimuth_bin * bins + elevation_bin` keeps
|
|
305
|
+
* the distinct-cell count cross-dialect (no `COUNT(DISTINCT a, b)`), and
|
|
306
|
+
* `least(floor(pct / 25), 3)` folds a full-coverage (100%) session into the top
|
|
307
|
+
* bucket instead of a spurious fifth `100` bucket. Purely derived from the
|
|
308
|
+
* existing `camera_sample` stream — no schema change.
|
|
309
|
+
*/
|
|
310
|
+
export function buildViewCoverageHistogram(projectId, opts, d) {
|
|
311
|
+
const bag = new ParamBag(d);
|
|
312
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
313
|
+
const bins = bag.add("bins", "u32", opts.bins ?? 36);
|
|
314
|
+
const range = rangeClause(bag, opts);
|
|
315
|
+
const scene = sceneClause(bag, opts);
|
|
316
|
+
const session = sessionClause(bag, opts);
|
|
317
|
+
const cameraMode = cameraModeClause(bag, d, projectId, opts);
|
|
318
|
+
return {
|
|
319
|
+
query: `
|
|
320
|
+
SELECT
|
|
321
|
+
least(floor(coverage_pct / 25), 3) * 25 AS bucket,
|
|
322
|
+
count() AS sessions
|
|
323
|
+
FROM (
|
|
324
|
+
SELECT
|
|
325
|
+
session_id,
|
|
326
|
+
count(DISTINCT bin_id) * 100.0 / (${bins} * ${bins}) AS coverage_pct
|
|
327
|
+
FROM (
|
|
328
|
+
SELECT
|
|
329
|
+
session_id,
|
|
330
|
+
floor((atan2(direction[3], direction[1]) + pi()) / (2 * pi()) * ${bins}) * ${bins}
|
|
331
|
+
+ floor((asin(direction[2] / greatest(${d.vectorNorm("direction")}, 1e-6)) + pi() / 2) / pi() * ${bins}) AS bin_id
|
|
332
|
+
FROM events
|
|
333
|
+
WHERE project_id = ${pid}
|
|
334
|
+
AND event_type = 'camera_sample'
|
|
335
|
+
AND length(direction) = 3${range}${scene}${session}${cameraMode}
|
|
336
|
+
) binned
|
|
337
|
+
GROUP BY session_id
|
|
338
|
+
) per_session
|
|
339
|
+
GROUP BY bucket
|
|
340
|
+
ORDER BY bucket ASC
|
|
341
|
+
`,
|
|
342
|
+
query_params: bag.values,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
254
345
|
/**
|
|
255
346
|
* Top-down "floor plan" camera-position heatmap (ADR 0026): bin `camera_sample`
|
|
256
347
|
* world positions onto the X/Z ground plane in `cellSize`-sized cells, tracking the
|
|
@@ -699,6 +790,59 @@ export function buildMeshDwell(projectId, opts, d) {
|
|
|
699
790
|
query_params: bag.values,
|
|
700
791
|
};
|
|
701
792
|
}
|
|
793
|
+
/**
|
|
794
|
+
* Blind-spot / never-noticed meshes (#143): the inverse of the Top-meshes and
|
|
795
|
+
* part-popularity leaderboards. Cross-references what was *rendered* against what
|
|
796
|
+
* was *engaged with* — per mesh, the total `mesh_visibility` on-screen time vs.
|
|
797
|
+
* the count of `mesh_interaction` events and the `hover_dwell` hesitation. A mesh
|
|
798
|
+
* with high visibility but zero (or near-zero) interaction + hover is a blind
|
|
799
|
+
* spot: a product detail nobody noticed, or a prop/room that renders but nobody
|
|
800
|
+
* investigates.
|
|
801
|
+
*
|
|
802
|
+
* Engagement is deliberately the two *active-attention* signals the issue names —
|
|
803
|
+
* `mesh_interaction` (the source-neutral pick/hover/drag signal, ADR 0011) and
|
|
804
|
+
* `hover_dwell` (hover-without-action, #48). Passive gaze (`camera_sample`) is
|
|
805
|
+
* not engagement, and raw `pointer_click` is excluded because a mesh-hitting click
|
|
806
|
+
* already surfaces as a `mesh_interaction`. Both durations live in the shared
|
|
807
|
+
* `visible_ms` column (events.ts maps `mesh_visibility.visibleMs` and
|
|
808
|
+
* `hover_dwell.dwellMs` onto it), so the whole report is one grouped scan.
|
|
809
|
+
*
|
|
810
|
+
* `HAVING sum(visible_ms) WHERE mesh_visibility > 0` keeps only meshes that were
|
|
811
|
+
* actually seen (a blind spot must first be visible). Ranked by engagement
|
|
812
|
+
* ascending, then visibility descending, so the most-seen-yet-least-touched
|
|
813
|
+
* meshes rank first.
|
|
814
|
+
*/
|
|
815
|
+
export function buildMeshBlindSpots(projectId, opts, d) {
|
|
816
|
+
const bag = new ParamBag(d);
|
|
817
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
818
|
+
const range = rangeClause(bag, opts);
|
|
819
|
+
const scene = sceneClause(bag, opts);
|
|
820
|
+
const session = sessionClause(bag, opts);
|
|
821
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 25);
|
|
822
|
+
return {
|
|
823
|
+
query: `
|
|
824
|
+
SELECT
|
|
825
|
+
mesh,
|
|
826
|
+
sum(CASE WHEN event_type = 'mesh_visibility' THEN visible_ms ELSE 0 END) AS visible_ms,
|
|
827
|
+
sum(CASE WHEN event_type = 'mesh_visibility' THEN 1 ELSE 0 END) AS vis_samples,
|
|
828
|
+
sum(CASE WHEN event_type = 'mesh_interaction' THEN 1 ELSE 0 END) AS interactions,
|
|
829
|
+
sum(CASE WHEN event_type = 'hover_dwell' THEN visible_ms ELSE 0 END) AS hover_ms,
|
|
830
|
+
sum(CASE WHEN event_type = 'hover_dwell' THEN 1 ELSE 0 END) AS hover_episodes
|
|
831
|
+
FROM events
|
|
832
|
+
WHERE project_id = ${pid}
|
|
833
|
+
AND event_type IN ('mesh_visibility', 'mesh_interaction', 'hover_dwell')
|
|
834
|
+
AND mesh != ''${range}${scene}${session}
|
|
835
|
+
GROUP BY mesh
|
|
836
|
+
HAVING sum(CASE WHEN event_type = 'mesh_visibility' THEN visible_ms ELSE 0 END) > 0
|
|
837
|
+
ORDER BY
|
|
838
|
+
sum(CASE WHEN event_type = 'mesh_interaction' THEN 1 ELSE 0 END)
|
|
839
|
+
+ sum(CASE WHEN event_type = 'hover_dwell' THEN 1 ELSE 0 END) ASC,
|
|
840
|
+
sum(CASE WHEN event_type = 'mesh_visibility' THEN visible_ms ELSE 0 END) DESC
|
|
841
|
+
LIMIT ${limit}
|
|
842
|
+
`,
|
|
843
|
+
query_params: bag.values,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
702
846
|
/**
|
|
703
847
|
* Interaction-kind breakdown (#72): per-mesh counts of each interaction *kind*
|
|
704
848
|
* (hover / pick / click / drag / select / squeeze / grab / release / teleport)
|
|
@@ -731,6 +875,73 @@ export function buildMeshInteractionKinds(projectId, opts, d) {
|
|
|
731
875
|
query_params: bag.values,
|
|
732
876
|
};
|
|
733
877
|
}
|
|
878
|
+
/**
|
|
879
|
+
* Reachability report (#151): how far each interacted mesh sat from where the
|
|
880
|
+
* user actually stood. ASOF-join every `mesh_interaction` that carries a world
|
|
881
|
+
* `point` (→ `hit_point`) to the nearest **preceding** `camera_sample` in the
|
|
882
|
+
* same session, take the Euclidean standpoint→hit distance, and histogram it per
|
|
883
|
+
* mesh in `bucketSize`-wide world-unit bands. Meshes/UI whose interactions
|
|
884
|
+
* cluster in far bands are consistently reached from an uncomfortable range —
|
|
885
|
+
* actionable feedback for VR UI placement and first-person layout.
|
|
886
|
+
*
|
|
887
|
+
* The standpoint is the click-time camera **position** (the shared coordinate
|
|
888
|
+
* frame, ADR 0018); interactions with no preceding camera sample in range can't
|
|
889
|
+
* be measured, so the inner ASOF join drops them. Same nearest-in-time caveat as
|
|
890
|
+
* the click-gaze / navigation joins: camera samples are frequent enough that the
|
|
891
|
+
* approximation is sound for discrete interaction events. Honors the shared
|
|
892
|
+
* scene/source/session filters (source constrains the *interaction* side only —
|
|
893
|
+
* a `camera_sample`'s `source` is the realized `'mouse'` default, ADR 0011).
|
|
894
|
+
*/
|
|
895
|
+
export function buildReachability(projectId, opts, d) {
|
|
896
|
+
const bag = new ParamBag(d);
|
|
897
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
898
|
+
const bucketSize = bag.add("bucketSize", "f64", opts.bucketSize ?? 0.5);
|
|
899
|
+
const range = rangeClause(bag, opts);
|
|
900
|
+
const scene = sceneClause(bag, opts);
|
|
901
|
+
const source = sourceClause(bag, opts);
|
|
902
|
+
const session = sessionClause(bag, opts);
|
|
903
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 500);
|
|
904
|
+
return {
|
|
905
|
+
query: `
|
|
906
|
+
SELECT
|
|
907
|
+
seg.mesh AS mesh,
|
|
908
|
+
floor(seg.dist / ${bucketSize}) AS bucket,
|
|
909
|
+
count() AS count,
|
|
910
|
+
avg(seg.dist) AS avg_distance
|
|
911
|
+
FROM (
|
|
912
|
+
SELECT
|
|
913
|
+
i.mesh AS mesh,
|
|
914
|
+
sqrt(
|
|
915
|
+
(i.hx - m.px) * (i.hx - m.px) +
|
|
916
|
+
(i.hy - m.py) * (i.hy - m.py) +
|
|
917
|
+
(i.hz - m.pz) * (i.hz - m.pz)
|
|
918
|
+
) AS dist
|
|
919
|
+
FROM (
|
|
920
|
+
SELECT session_id, ts, mesh,
|
|
921
|
+
hit_point[1] AS hx, hit_point[2] AS hy, hit_point[3] AS hz
|
|
922
|
+
FROM events
|
|
923
|
+
WHERE project_id = ${pid}
|
|
924
|
+
AND event_type = 'mesh_interaction'
|
|
925
|
+
AND mesh != ''
|
|
926
|
+
AND length(hit_point) = 3${range}${scene}${source}${session}
|
|
927
|
+
) AS i
|
|
928
|
+
${d.asofInnerJoin} (
|
|
929
|
+
SELECT session_id, ts,
|
|
930
|
+
position[1] AS px, position[2] AS py, position[3] AS pz
|
|
931
|
+
FROM events
|
|
932
|
+
WHERE project_id = ${pid}
|
|
933
|
+
AND event_type = 'camera_sample'
|
|
934
|
+
AND length(position) = 3${range}${scene}${session}
|
|
935
|
+
) AS m
|
|
936
|
+
ON i.session_id = m.session_id AND i.ts >= m.ts
|
|
937
|
+
) AS seg
|
|
938
|
+
GROUP BY mesh, bucket
|
|
939
|
+
ORDER BY count DESC
|
|
940
|
+
LIMIT ${limit}
|
|
941
|
+
`,
|
|
942
|
+
query_params: bag.values,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
734
945
|
/**
|
|
735
946
|
* Dead-click rate (#46): of all `pointer_click` events, how many hit nothing
|
|
736
947
|
* (the hit-test missed, so `mesh` is empty / no `hitMesh`). A high dead-click
|
|
@@ -998,6 +1209,62 @@ export function buildGraphicsDiagnosticCounts(projectId, opts, d) {
|
|
|
998
1209
|
query_params: bag.values,
|
|
999
1210
|
};
|
|
1000
1211
|
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Spatial error heatmap (issue #154): voxel-bin the world `position` of
|
|
1214
|
+
* positioned `runtime_error` and `graphics_diagnostic` events into a uniform grid
|
|
1215
|
+
* of `cellSize`-sized cubes, returning the busiest `limit` voxels. This reveals
|
|
1216
|
+
* *where* in the scene things break — errors/crashes clustering around specific
|
|
1217
|
+
* geometry, a shader-heavy area, or a level region — instead of only *when*.
|
|
1218
|
+
*
|
|
1219
|
+
* `position` is best-effort connector-side (the camera pose at the moment the
|
|
1220
|
+
* error/diagnostic fired), so only rows that carry a full 3-vector participate
|
|
1221
|
+
* (`length(position) = 3`); errors from pages with no 3D connector are naturally
|
|
1222
|
+
* excluded. Reuses the already-promoted `position` column — no migration.
|
|
1223
|
+
*
|
|
1224
|
+
* Optional {@link ErrorHeatmapOptions} filters (severity/category/errorKind) read
|
|
1225
|
+
* from the `payload` JSON, mirroring {@link buildGraphicsDiagnosticCounts}: a
|
|
1226
|
+
* severity/category filter narrows to engine diagnostics, an errorKind filter
|
|
1227
|
+
* narrows to JS errors. `region` drill-down and `scene`/`session` scoping compose
|
|
1228
|
+
* like the other spatial heatmaps.
|
|
1229
|
+
*/
|
|
1230
|
+
export function buildErrorHeatmap(projectId, opts, d) {
|
|
1231
|
+
const bag = new ParamBag(d);
|
|
1232
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1233
|
+
const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 1);
|
|
1234
|
+
const range = rangeClause(bag, opts);
|
|
1235
|
+
const scene = sceneClause(bag, opts);
|
|
1236
|
+
const session = sessionClause(bag, opts);
|
|
1237
|
+
const region = regionClause(bag, opts, POSITION_COLS);
|
|
1238
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 1000);
|
|
1239
|
+
const filters = [];
|
|
1240
|
+
if (opts.severity != null && opts.severity.length > 0) {
|
|
1241
|
+
filters.push(`${d.jsonText("payload", "severity")} = ${bag.add("severity", "string", opts.severity)}`);
|
|
1242
|
+
}
|
|
1243
|
+
if (opts.category != null && opts.category.length > 0) {
|
|
1244
|
+
filters.push(`${d.jsonText("payload", "category")} = ${bag.add("category", "string", opts.category)}`);
|
|
1245
|
+
}
|
|
1246
|
+
if (opts.errorKind != null && opts.errorKind.length > 0) {
|
|
1247
|
+
filters.push(`${d.jsonText("payload", "kind")} = ${bag.add("errorKind", "string", opts.errorKind)}`);
|
|
1248
|
+
}
|
|
1249
|
+
const filter = filters.length ? ` AND ${filters.join(" AND ")}` : "";
|
|
1250
|
+
return {
|
|
1251
|
+
query: `
|
|
1252
|
+
SELECT
|
|
1253
|
+
floor(position[1] / ${cellSize}) AS vx,
|
|
1254
|
+
floor(position[2] / ${cellSize}) AS vy,
|
|
1255
|
+
floor(position[3] / ${cellSize}) AS vz,
|
|
1256
|
+
count() AS count
|
|
1257
|
+
FROM events
|
|
1258
|
+
WHERE project_id = ${pid}
|
|
1259
|
+
AND event_type IN ('runtime_error', 'graphics_diagnostic')
|
|
1260
|
+
AND length(position) = 3${range}${scene}${session}${region}${filter}
|
|
1261
|
+
GROUP BY vx, vy, vz
|
|
1262
|
+
ORDER BY count DESC
|
|
1263
|
+
LIMIT ${limit}
|
|
1264
|
+
`,
|
|
1265
|
+
query_params: bag.values,
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1001
1268
|
/**
|
|
1002
1269
|
* Always-on rendering-technology mix from `session_start.graphics` (ADR 0021 part
|
|
1003
1270
|
* 1): the fully-crossed `(api, backend, api_version, shading_language)` group with
|
|
@@ -1294,6 +1561,82 @@ export function buildJankRate(projectId, opts, d) {
|
|
|
1294
1561
|
query_params: bag.values,
|
|
1295
1562
|
};
|
|
1296
1563
|
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Perf-correlated churn (#144): does a stutter actually cost sessions? Correlates
|
|
1566
|
+
* perf dips against early session end. Of the sessions that ended in range
|
|
1567
|
+
* (`sessions`), `churn_sessions` ended within `windowMs` of an FPS dip (a
|
|
1568
|
+
* `frame_perf` sample below `fpsThreshold`) or a `compile_stall` of at least
|
|
1569
|
+
* `stallMs` — the felt hitches that plausibly drove the user away, as opposed to
|
|
1570
|
+
* background noise that the perf-distribution panel averages over.
|
|
1571
|
+
*
|
|
1572
|
+
* Semantics — a session churns iff it has a `session_end` **and** at least one
|
|
1573
|
+
* qualifying dip whose timestamp lies in `[end - windowMs, end]` (its earliest
|
|
1574
|
+
* `session_end` is the anchor). `fps_churn_sessions` / `stall_churn_sessions`
|
|
1575
|
+
* attribute the cause; a session whose window held both is counted in each cause
|
|
1576
|
+
* column but only once in `churn_sessions`, so the cause columns can sum to more
|
|
1577
|
+
* than the total.
|
|
1578
|
+
*
|
|
1579
|
+
* Implementation — an `ends` CTE (each session's first `session_end`) joined to a
|
|
1580
|
+
* `dips` CTE (the qualifying `frame_perf` / `compile_stall` rows) on `session_id`,
|
|
1581
|
+
* with the window enforced through the dialect's `epochMs` so the timestamp math
|
|
1582
|
+
* is engine-neutral. This uses only `JOIN` / `min` / `max` / `count` — **no window
|
|
1583
|
+
* or ASOF functions** — so it renders identically on DuckDB (OSS) and ClickHouse
|
|
1584
|
+
* (scale tier). Aggregating over the (possibly empty) `correlated` set always
|
|
1585
|
+
* yields one row; the `sessions` denominator is an uncorrelated scalar sub-select
|
|
1586
|
+
* so it stands even when nothing churned. Privacy (ADR 0003): aggregate counts
|
|
1587
|
+
* only, no per-session identifiers leave the query.
|
|
1588
|
+
*/
|
|
1589
|
+
export function buildPerfChurn(projectId, opts, d) {
|
|
1590
|
+
const bag = new ParamBag(d);
|
|
1591
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1592
|
+
const range = rangeClause(bag, opts);
|
|
1593
|
+
const scene = sceneClause(bag, opts);
|
|
1594
|
+
const session = sessionClause(bag, opts);
|
|
1595
|
+
const windowMs = bag.add("windowMs", "u32", opts.windowMs ?? 30_000);
|
|
1596
|
+
const fpsThreshold = bag.add("fpsThreshold", "f64", opts.fpsThreshold ?? 30);
|
|
1597
|
+
const stallMs = bag.add("stallMs", "f64", opts.stallMs ?? 100);
|
|
1598
|
+
const dipTs = d.epochMs("dips.ts");
|
|
1599
|
+
const endTs = d.epochMs("ends.end_ts");
|
|
1600
|
+
return {
|
|
1601
|
+
query: `
|
|
1602
|
+
WITH ends AS (
|
|
1603
|
+
SELECT session_id, min(ts) AS end_ts
|
|
1604
|
+
FROM events
|
|
1605
|
+
WHERE project_id = ${pid} AND event_type = 'session_end'${range}${scene}${session}
|
|
1606
|
+
GROUP BY session_id
|
|
1607
|
+
),
|
|
1608
|
+
dips AS (
|
|
1609
|
+
SELECT
|
|
1610
|
+
session_id,
|
|
1611
|
+
ts,
|
|
1612
|
+
CASE WHEN event_type = 'frame_perf' THEN 1 ELSE 0 END AS is_fps,
|
|
1613
|
+
CASE WHEN event_type = 'compile_stall' THEN 1 ELSE 0 END AS is_stall
|
|
1614
|
+
FROM events
|
|
1615
|
+
WHERE project_id = ${pid}${range}${scene}${session}
|
|
1616
|
+
AND (
|
|
1617
|
+
(event_type = 'frame_perf' AND fps < ${fpsThreshold})
|
|
1618
|
+
OR (event_type = 'compile_stall' AND visible_ms >= ${stallMs})
|
|
1619
|
+
)
|
|
1620
|
+
),
|
|
1621
|
+
correlated AS (
|
|
1622
|
+
SELECT
|
|
1623
|
+
ends.session_id AS session_id,
|
|
1624
|
+
max(dips.is_fps) AS had_fps,
|
|
1625
|
+
max(dips.is_stall) AS had_stall
|
|
1626
|
+
FROM ends JOIN dips ON ends.session_id = dips.session_id
|
|
1627
|
+
WHERE ${dipTs} <= ${endTs} AND ${dipTs} >= ${endTs} - ${windowMs}
|
|
1628
|
+
GROUP BY ends.session_id
|
|
1629
|
+
)
|
|
1630
|
+
SELECT
|
|
1631
|
+
(SELECT count() FROM ends) AS sessions,
|
|
1632
|
+
count() AS churn_sessions,
|
|
1633
|
+
sum(had_fps) AS fps_churn_sessions,
|
|
1634
|
+
sum(had_stall) AS stall_churn_sessions
|
|
1635
|
+
FROM correlated
|
|
1636
|
+
`,
|
|
1637
|
+
query_params: bag.values,
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1297
1640
|
/**
|
|
1298
1641
|
* FPS segmented by device class, computed **per-session then aggregated** (ADR
|
|
1299
1642
|
* 0028 §2). Each session's median FPS is attributed to the graphics backend,
|
|
@@ -1547,6 +1890,47 @@ export function buildSceneCoverage(projectId, opts, d) {
|
|
|
1547
1890
|
query_params: bag.values,
|
|
1548
1891
|
};
|
|
1549
1892
|
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Spatial FPS heatmap (#145): voxel-bin `frame_perf` samples by their captured
|
|
1895
|
+
* camera `position` into a uniform grid of `cellSize`-sized cubes, reporting each
|
|
1896
|
+
* occupied cell's sample count, mean FPS, and worst single FPS. This answers
|
|
1897
|
+
* *where* performance degrades ("FPS is bad in the boss room"), the spatial
|
|
1898
|
+
* complement to the time-bucketed {@link buildPerfDistribution}/{@link buildFpsHistogram}.
|
|
1899
|
+
*
|
|
1900
|
+
* It reads the same promoted `position` column the camera-position heatmaps use —
|
|
1901
|
+
* `frame_perf` now carries an optional camera position, filled by the connector at
|
|
1902
|
+
* sample time — so no join against the separately-sampled `camera_sample` stream is
|
|
1903
|
+
* needed. Rows are ordered worst-FPS-first so the capped top-`limit` slice surfaces
|
|
1904
|
+
* the jankiest cells rather than an arbitrary corner of the scene.
|
|
1905
|
+
*/
|
|
1906
|
+
export function buildPerfHeatmap(projectId, opts, d) {
|
|
1907
|
+
const bag = new ParamBag(d);
|
|
1908
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1909
|
+
const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 1);
|
|
1910
|
+
const range = rangeClause(bag, opts);
|
|
1911
|
+
const scene = sceneClause(bag, opts);
|
|
1912
|
+
const session = sessionClause(bag, opts);
|
|
1913
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 2000);
|
|
1914
|
+
return {
|
|
1915
|
+
query: `
|
|
1916
|
+
SELECT
|
|
1917
|
+
floor(position[1] / ${cellSize}) AS vx,
|
|
1918
|
+
floor(position[2] / ${cellSize}) AS vy,
|
|
1919
|
+
floor(position[3] / ${cellSize}) AS vz,
|
|
1920
|
+
count() AS samples,
|
|
1921
|
+
avg(fps) AS avg_fps,
|
|
1922
|
+
min(fps) AS min_fps
|
|
1923
|
+
FROM events
|
|
1924
|
+
WHERE project_id = ${pid}
|
|
1925
|
+
AND event_type = 'frame_perf'
|
|
1926
|
+
AND length(position) = 3${range}${scene}${session}
|
|
1927
|
+
GROUP BY vx, vy, vz
|
|
1928
|
+
ORDER BY avg_fps ASC
|
|
1929
|
+
LIMIT ${limit}
|
|
1930
|
+
`,
|
|
1931
|
+
query_params: bag.values,
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1550
1934
|
/**
|
|
1551
1935
|
* Camera distance / zoom distribution (derived, scene-metrics §B): histogram the
|
|
1552
1936
|
* distance from the camera *position* of each `camera_sample` to a reference
|
|
@@ -1637,6 +2021,91 @@ export function buildNavigationStats(projectId, opts, d) {
|
|
|
1637
2021
|
query_params: bag.values,
|
|
1638
2022
|
};
|
|
1639
2023
|
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Path-retrace / backtracking ratio (#153): a confusion signal derived from the
|
|
2026
|
+
* same `camera_sample` position stream that feeds desire lines, surfaced as a
|
|
2027
|
+
* per-scene leaderboard. It answers "which areas do visitors keep re-walking?" —
|
|
2028
|
+
* a high backtrack ratio flags a dead end, a missed cue, or a puzzle that isn't
|
|
2029
|
+
* reading clearly.
|
|
2030
|
+
*
|
|
2031
|
+
* Algorithm — the coarse-grid revisit proxy (the cheap first cut, not true
|
|
2032
|
+
* reverse-segment retracing): bin each session's positions onto a `cellSize`
|
|
2033
|
+
* X/Z grid, then collapse consecutive samples in the same cell into ordered cell
|
|
2034
|
+
* *entries* (so standing still / dwelling never counts) via an ASOF self-join to
|
|
2035
|
+
* the immediately preceding sample. A row is an entry when it has no predecessor
|
|
2036
|
+
* (the session's first sample) or its cell differs from the predecessor's. The
|
|
2037
|
+
* `present` sentinel makes the unmatched-predecessor test engine-agnostic:
|
|
2038
|
+
* DuckDB null-fills a LEFT-join miss while ClickHouse zero-fills it, so `present`
|
|
2039
|
+
* (`1` only on a real match) is the portable "has a predecessor" flag.
|
|
2040
|
+
*
|
|
2041
|
+
* Per (session, scene): `revisits = entries − distinct_cells` — every entry into
|
|
2042
|
+
* a cell beyond its first is a re-entry. Pooled per scene, the leaderboard
|
|
2043
|
+
* reports `backtrack_ratio = Σ revisits / Σ entries` alongside the raw counts.
|
|
2044
|
+
* Only plain `count()` and a dedup subquery are used (no multi-column
|
|
2045
|
+
* `COUNT(DISTINCT …)`), so DuckDB and ClickHouse agree.
|
|
2046
|
+
*/
|
|
2047
|
+
export function buildBacktrackRatio(projectId, opts, d) {
|
|
2048
|
+
const bag = new ParamBag(d);
|
|
2049
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
2050
|
+
const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 2);
|
|
2051
|
+
const range = rangeClause(bag, opts);
|
|
2052
|
+
const scene = sceneClause(bag, opts);
|
|
2053
|
+
const session = sessionClause(bag, opts);
|
|
2054
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 100);
|
|
2055
|
+
const sampleSelect = `
|
|
2056
|
+
SELECT session_id, scene_id AS scene, ts,
|
|
2057
|
+
floor(position[1] / ${cellSize}) AS gx,
|
|
2058
|
+
floor(position[3] / ${cellSize}) AS gz
|
|
2059
|
+
FROM events
|
|
2060
|
+
WHERE project_id = ${pid}
|
|
2061
|
+
AND event_type = 'camera_sample'
|
|
2062
|
+
AND length(position) = 3${range}${scene}${session}`;
|
|
2063
|
+
// Ordered cell entries (consecutive same-cell samples collapsed): a sample is
|
|
2064
|
+
// an entry when it has no predecessor or its cell changed vs. the predecessor.
|
|
2065
|
+
const entries = `
|
|
2066
|
+
SELECT c.session_id AS session_id, c.scene AS scene, c.gx AS gx, c.gz AS gz
|
|
2067
|
+
FROM (${sampleSelect}
|
|
2068
|
+
) AS c
|
|
2069
|
+
${d.asofLeftJoin} (
|
|
2070
|
+
SELECT session_id, ts, 1 AS present, gx, gz FROM (${sampleSelect}
|
|
2071
|
+
) AS s
|
|
2072
|
+
) AS m
|
|
2073
|
+
ON c.session_id = m.session_id AND c.ts > m.ts
|
|
2074
|
+
WHERE m.present IS NULL OR m.present = 0 OR c.gx <> m.gx OR c.gz <> m.gz`;
|
|
2075
|
+
return {
|
|
2076
|
+
query: `
|
|
2077
|
+
SELECT
|
|
2078
|
+
scene,
|
|
2079
|
+
count() AS sessions,
|
|
2080
|
+
sum(total_entries) AS entries,
|
|
2081
|
+
sum(revisits) AS revisits,
|
|
2082
|
+
CASE WHEN sum(total_entries) > 0
|
|
2083
|
+
THEN sum(revisits) * 1.0 / sum(total_entries) ELSE 0 END AS backtrack_ratio
|
|
2084
|
+
FROM (
|
|
2085
|
+
SELECT
|
|
2086
|
+
e.session_id AS session_id,
|
|
2087
|
+
e.scene AS scene,
|
|
2088
|
+
count() AS total_entries,
|
|
2089
|
+
count() - dc.distinct_cells AS revisits
|
|
2090
|
+
FROM (${entries}
|
|
2091
|
+
) AS e
|
|
2092
|
+
JOIN (
|
|
2093
|
+
SELECT session_id, scene, count() AS distinct_cells
|
|
2094
|
+
FROM (
|
|
2095
|
+
SELECT DISTINCT session_id, scene, gx, gz FROM (${entries}
|
|
2096
|
+
) AS de
|
|
2097
|
+
) AS ded
|
|
2098
|
+
GROUP BY session_id, scene
|
|
2099
|
+
) AS dc ON e.session_id = dc.session_id AND e.scene = dc.scene
|
|
2100
|
+
GROUP BY e.session_id, e.scene, dc.distinct_cells
|
|
2101
|
+
) AS per_session
|
|
2102
|
+
GROUP BY scene
|
|
2103
|
+
ORDER BY backtrack_ratio DESC, entries DESC
|
|
2104
|
+
LIMIT ${limit}
|
|
2105
|
+
`,
|
|
2106
|
+
query_params: bag.values,
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
1640
2109
|
/** XR input sources that distinguish hand-tracking, controllers and gaze. */
|
|
1641
2110
|
const XR_SOURCES = "('xr-controller', 'hand', 'gaze', 'transient')";
|
|
1642
2111
|
/**
|
|
@@ -1834,6 +2303,54 @@ export function buildXrAbandonment(projectId, opts, d) {
|
|
|
1834
2303
|
query_params: bag.values,
|
|
1835
2304
|
};
|
|
1836
2305
|
}
|
|
2306
|
+
/**
|
|
2307
|
+
* XR locomotion & comfort (#148): per session that used an XR input source, its
|
|
2308
|
+
* locomotion-style breakdown and wall-clock span. This turns the existing
|
|
2309
|
+
* `camera_gesture` / `mesh_interaction` streams into a comfort signal for VR
|
|
2310
|
+
* developers — constant smooth `fly` locomotion (a motion-sickness risk) vs.
|
|
2311
|
+
* teleport-dominant sessions — and lets the consumer correlate heavy locomotion
|
|
2312
|
+
* with early exits (a short span, a discomfort / "rage-quit" proxy).
|
|
2313
|
+
*
|
|
2314
|
+
* A teleport emits **both** a `camera_gesture { kind: "fly" }` and a
|
|
2315
|
+
* `mesh_interaction { kind: "teleport" }` (ADR 0025), so `fly_gestures` counts
|
|
2316
|
+
* every fly (smooth + teleport) while `teleports` isolates the discrete jumps;
|
|
2317
|
+
* the consumer derives smooth locomotion as `fly_gestures - teleports`. Sessions
|
|
2318
|
+
* with no XR input are omitted entirely (same XR-session gate as
|
|
2319
|
+
* {@link buildXrAbandonment}). Wall-clock bounds are engine-specific and excluded
|
|
2320
|
+
* from parity; the counts are compared.
|
|
2321
|
+
*/
|
|
2322
|
+
export function buildXrLocomotionComfort(projectId, opts, d) {
|
|
2323
|
+
const bag = new ParamBag(d);
|
|
2324
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
2325
|
+
const range = rangeClause(bag, opts);
|
|
2326
|
+
const scene = sceneClause(bag, opts);
|
|
2327
|
+
const session = sessionClause(bag, opts);
|
|
2328
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 500);
|
|
2329
|
+
return {
|
|
2330
|
+
query: `
|
|
2331
|
+
SELECT
|
|
2332
|
+
session_id,
|
|
2333
|
+
sum(CASE WHEN event_type = 'camera_gesture' AND name = 'fly' THEN 1 ELSE 0 END) AS fly_gestures,
|
|
2334
|
+
sum(CASE WHEN event_type = 'camera_gesture' AND name = 'navigate' THEN 1 ELSE 0 END) AS navigate_gestures,
|
|
2335
|
+
sum(CASE WHEN event_type = 'mesh_interaction' AND name = 'teleport' THEN 1 ELSE 0 END) AS teleports,
|
|
2336
|
+
sum(CASE WHEN event_type = 'camera_gesture' AND name IN ('fly', 'navigate') THEN visible_ms ELSE 0 END) AS locomotion_ms,
|
|
2337
|
+
min(ts) AS started_at,
|
|
2338
|
+
max(ts) AS ended_at
|
|
2339
|
+
FROM events
|
|
2340
|
+
WHERE project_id = ${pid}${range}${scene}${session}
|
|
2341
|
+
AND session_id IN (
|
|
2342
|
+
SELECT session_id
|
|
2343
|
+
FROM events
|
|
2344
|
+
WHERE project_id = ${pid}
|
|
2345
|
+
AND source IN ${XR_SOURCES}
|
|
2346
|
+
)
|
|
2347
|
+
GROUP BY session_id
|
|
2348
|
+
ORDER BY locomotion_ms DESC
|
|
2349
|
+
LIMIT ${limit}
|
|
2350
|
+
`,
|
|
2351
|
+
query_params: bag.values,
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
1837
2354
|
/**
|
|
1838
2355
|
* Render one funnel step's predicate against the wide `events` columns (ADR
|
|
1839
2356
|
* 0038). Every field compiles to plain equality on a promoted column, so the
|
|
@@ -1918,4 +2435,280 @@ export function buildFunnel(projectId, opts, d) {
|
|
|
1918
2435
|
query_params: bag.values,
|
|
1919
2436
|
};
|
|
1920
2437
|
}
|
|
2438
|
+
/**
|
|
2439
|
+
* Canned scene/level retention funnel (#147): session counts flowing scene →
|
|
2440
|
+
* scene in the order they were observed, built directly from `scene_change`
|
|
2441
|
+
* markers with **no caller-authored steps** (the zero-config complement to the
|
|
2442
|
+
* ADR 0038 funnel). Each `scene_change` envelope carries the scene now active in
|
|
2443
|
+
* `scene_id`, so a session's ordered `scene_change` targets are the levels it
|
|
2444
|
+
* moved through; every **consecutive pair** is a directed link.
|
|
2445
|
+
*
|
|
2446
|
+
* Semantics — for one session, order its `scene_change` events by `ts`; the link
|
|
2447
|
+
* `A → B` exists whenever `B`'s marker is the *next* `scene_change` after an `A`
|
|
2448
|
+
* marker. A link's weight is the number of **distinct sessions** that made that
|
|
2449
|
+
* consecutive transition, so it reads as level-to-level retention. Sessions with
|
|
2450
|
+
* a single `scene_change` contribute no link (there is no "from").
|
|
2451
|
+
*
|
|
2452
|
+
* Implementation — `sc` is the per-session ordered `scene_change` stream, and an
|
|
2453
|
+
* `ASOF INNER JOIN` matches each marker `a` to the single nearest later marker
|
|
2454
|
+
* `b` in the same session (`a.ts < b.ts`): exactly the "next event" shape, so the
|
|
2455
|
+
* link is `(a.scene_id → b.scene_id)`. ASOF renders through the {@link Dialect}
|
|
2456
|
+
* contract and needs only equality plus one inequality, so it runs on stock
|
|
2457
|
+
* ClickHouse with **no** `allow_experimental_join_condition` flag (the plain
|
|
2458
|
+
* self-join it replaces mixed left/right columns in an inequality `ON`, which
|
|
2459
|
+
* ClickHouse rejects without that experimental setting). Both engines resolve the
|
|
2460
|
+
* same nearest match, so the golden holds by transitivity. The final `GROUP BY`
|
|
2461
|
+
* counts distinct sessions per `(from_scene, to_scene)` pair; the last marker of
|
|
2462
|
+
* each session has no later match and is dropped by the inner join (no "to").
|
|
2463
|
+
*/
|
|
2464
|
+
export function buildSceneRetention(projectId, opts, d) {
|
|
2465
|
+
const bag = new ParamBag(d);
|
|
2466
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
2467
|
+
const range = rangeClause(bag, opts);
|
|
2468
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 100);
|
|
2469
|
+
return {
|
|
2470
|
+
query: `
|
|
2471
|
+
WITH sc AS (
|
|
2472
|
+
SELECT session_id, ts, scene_id
|
|
2473
|
+
FROM events
|
|
2474
|
+
WHERE project_id = ${pid} AND event_type = 'scene_change'${range}
|
|
2475
|
+
)
|
|
2476
|
+
SELECT a.scene_id AS from_scene, b.scene_id AS to_scene,
|
|
2477
|
+
count(DISTINCT a.session_id) AS sessions
|
|
2478
|
+
FROM sc AS a
|
|
2479
|
+
${d.asofInnerJoin} sc AS b
|
|
2480
|
+
ON a.session_id = b.session_id AND a.ts < b.ts
|
|
2481
|
+
GROUP BY from_scene, to_scene
|
|
2482
|
+
ORDER BY sessions DESC, from_scene ASC, to_scene ASC
|
|
2483
|
+
LIMIT ${limit}
|
|
2484
|
+
`,
|
|
2485
|
+
query_params: bag.values,
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
/** Default load-time band boundaries (ms) for the load→bounce funnel (#152). */
|
|
2489
|
+
const DEFAULT_LOAD_BANDS = [1000, 3000, 5000];
|
|
2490
|
+
/**
|
|
2491
|
+
* Interaction event types that count as post-load engagement (#152). A session
|
|
2492
|
+
* that produces none of these at/after its initial load is a "bounce". Mirrors
|
|
2493
|
+
* the issue's `pointer_*` / `mesh_interaction` / `camera_gesture` set.
|
|
2494
|
+
*/
|
|
2495
|
+
const INTERACTION_EVENT_TYPES = "('pointer_move', 'pointer_down', 'pointer_up', 'pointer_click', 'mesh_interaction', 'camera_gesture')";
|
|
2496
|
+
/**
|
|
2497
|
+
* Load → bounce/abandon funnel (#152): bucket sessions by their initial load
|
|
2498
|
+
* time and report how many **bounced** per band — a bounce being a session that
|
|
2499
|
+
* produced no interaction event (`pointer_*` / `mesh_interaction` /
|
|
2500
|
+
* `camera_gesture`) at or after its first `asset_load`. Turns "slow load costs
|
|
2501
|
+
* you customers" into a concrete per-band number.
|
|
2502
|
+
*
|
|
2503
|
+
* Semantics — a session's load time is the `loadMs` of its **earliest**
|
|
2504
|
+
* `asset_load` (the initial scene load). Engagement is any interaction event in
|
|
2505
|
+
* the same session at a timestamp `>=` that load event's timestamp, across
|
|
2506
|
+
* scenes — bounce is a session-level signal, so the engagement check is not
|
|
2507
|
+
* re-bounded by the range's `until` (a load near the window's end is not counted
|
|
2508
|
+
* as a false bounce). Sessions with no `asset_load` in scope are excluded.
|
|
2509
|
+
* `loadMs` lives in the `payload` JSON (it is not a promoted column), so it is
|
|
2510
|
+
* read with `jsonInt`.
|
|
2511
|
+
*
|
|
2512
|
+
* Bands come from `opts.bands` (ascending exclusive upper bounds in ms), or the
|
|
2513
|
+
* `[1000, 3000, 5000]` default → four bands. The builder emits a plain `CASE`
|
|
2514
|
+
* over the bound band values plus `JOIN` / `min` / `count` / `sum` — **no window
|
|
2515
|
+
* or ASOF functions** — so it renders identically on DuckDB (OSS) and ClickHouse
|
|
2516
|
+
* (scale tier). Band labels are the caller's concern.
|
|
2517
|
+
*/
|
|
2518
|
+
export function buildLoadBounceFunnel(projectId, opts, d) {
|
|
2519
|
+
const bag = new ParamBag(d);
|
|
2520
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
2521
|
+
const range = rangeClause(bag, opts);
|
|
2522
|
+
const scene = sceneClause(bag, opts);
|
|
2523
|
+
const loadMs = d.jsonInt("payload", "loadMs");
|
|
2524
|
+
const bands = opts.bands != null && opts.bands.length > 0 ? opts.bands : DEFAULT_LOAD_BANDS;
|
|
2525
|
+
const bandCase = `CASE\n${bands
|
|
2526
|
+
.map((upper, i) => ` WHEN load_ms < ${bag.add(`band${i}`, "f64", upper)} THEN ${i}`)
|
|
2527
|
+
.join("\n")}\n ELSE ${bands.length}\n END`;
|
|
2528
|
+
return {
|
|
2529
|
+
query: `
|
|
2530
|
+
WITH first_load AS (
|
|
2531
|
+
SELECT session_id, min(ts) AS load_ts
|
|
2532
|
+
FROM events
|
|
2533
|
+
WHERE project_id = ${pid} AND event_type = 'asset_load'${range}${scene}
|
|
2534
|
+
GROUP BY session_id
|
|
2535
|
+
),
|
|
2536
|
+
load_ms AS (
|
|
2537
|
+
SELECT fl.session_id AS session_id, fl.load_ts AS load_ts,
|
|
2538
|
+
min(${loadMs}) AS load_ms
|
|
2539
|
+
FROM events AS e JOIN first_load AS fl
|
|
2540
|
+
ON e.session_id = fl.session_id AND e.ts = fl.load_ts
|
|
2541
|
+
WHERE e.project_id = ${pid} AND e.event_type = 'asset_load'
|
|
2542
|
+
GROUP BY fl.session_id, fl.load_ts
|
|
2543
|
+
),
|
|
2544
|
+
engaged AS (
|
|
2545
|
+
SELECT lm.session_id AS session_id, count() AS interactions
|
|
2546
|
+
FROM events AS e JOIN load_ms AS lm
|
|
2547
|
+
ON e.session_id = lm.session_id
|
|
2548
|
+
WHERE e.project_id = ${pid}
|
|
2549
|
+
AND e.event_type IN ${INTERACTION_EVENT_TYPES}
|
|
2550
|
+
AND e.ts >= lm.load_ts
|
|
2551
|
+
GROUP BY lm.session_id
|
|
2552
|
+
)
|
|
2553
|
+
SELECT
|
|
2554
|
+
${bandCase} AS band,
|
|
2555
|
+
count() AS sessions,
|
|
2556
|
+
sum(CASE WHEN coalesce(eng.interactions, 0) = 0 THEN 1 ELSE 0 END) AS bounced
|
|
2557
|
+
FROM load_ms AS lm LEFT JOIN engaged AS eng ON lm.session_id = eng.session_id
|
|
2558
|
+
WHERE lm.load_ms IS NOT NULL
|
|
2559
|
+
GROUP BY band
|
|
2560
|
+
ORDER BY band ASC
|
|
2561
|
+
`,
|
|
2562
|
+
query_params: bag.values,
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
function leaderboardPredicate(bag, step, prefix) {
|
|
2566
|
+
const parts = [`event_type = ${bag.add(`${prefix}Type`, "string", step.type)}`];
|
|
2567
|
+
if (step.name != null && step.name.length > 0) {
|
|
2568
|
+
parts.push(`name = ${bag.add(`${prefix}Name`, "string", step.name)}`);
|
|
2569
|
+
}
|
|
2570
|
+
if (step.mesh != null && step.mesh.length > 0) {
|
|
2571
|
+
parts.push(`mesh = ${bag.add(`${prefix}Mesh`, "string", step.mesh)}`);
|
|
2572
|
+
}
|
|
2573
|
+
return parts.join(" AND ");
|
|
2574
|
+
}
|
|
2575
|
+
/**
|
|
2576
|
+
* Variant → conversion leaderboard for product configurators (#150).
|
|
2577
|
+
*
|
|
2578
|
+
* A **variant** is an event matching the `variant` predicate (default: every
|
|
2579
|
+
* `custom` event), grouped by its promoted `name` column — the color / material /
|
|
2580
|
+
* SKU discriminator configurators emit as custom-event names (payload `props` are
|
|
2581
|
+
* not portably queryable, so `name` is the grouping key; ADR 0038). Per variant
|
|
2582
|
+
* the leaderboard reports:
|
|
2583
|
+
*
|
|
2584
|
+
* - **views** — how many matching events fired, and over how many distinct
|
|
2585
|
+
* **sessions**;
|
|
2586
|
+
* - **conversions** — distinct sessions that fired the optional `conversion`
|
|
2587
|
+
* event at or after their first view of that variant (ordered, first-touch);
|
|
2588
|
+
* `0` when no `conversion` predicate is supplied. The consumer derives the rate
|
|
2589
|
+
* as `conversions / sessions`;
|
|
2590
|
+
* - **avg_dwell_ms** — the mean gap from each view to the next *boundary* in the
|
|
2591
|
+
* same session: a later view of a **different** variant (a switch) or a later
|
|
2592
|
+
* conversion event. A re-view of the *same* variant is not a boundary. Views
|
|
2593
|
+
* with no later boundary are excluded from the average.
|
|
2594
|
+
*
|
|
2595
|
+
* Implementation — a CTE chain using only `JOIN` / `min` / `avg` / `count` /
|
|
2596
|
+
* `UNION ALL` (no window or ASOF functions). Every join keys on `session_id`
|
|
2597
|
+
* alone and keeps its ordered / relative guards (`c.ts >= fv.t0`, the boundary
|
|
2598
|
+
* rule) in `WHERE`, so no `ON` mixes left/right columns in an inequality — it
|
|
2599
|
+
* renders identically on DuckDB (OSS) and ClickHouse (scale tier) and runs on
|
|
2600
|
+
* stock ClickHouse without the `allow_experimental_join_condition` flag
|
|
2601
|
+
* (ADR 0020). Injection-safe. Session scope (range / scene / camera-mode) applies
|
|
2602
|
+
* to both the variant and conversion event sets. Ranked by views, capped to
|
|
2603
|
+
* `limit`.
|
|
2604
|
+
*
|
|
2605
|
+
* The predicates come from the caller (request input / CLI / hosted) — OSS has no
|
|
2606
|
+
* authoring surface (ADR 0038).
|
|
2607
|
+
*/
|
|
2608
|
+
export function buildVariantLeaderboard(projectId, opts, d) {
|
|
2609
|
+
const bag = new ParamBag(d);
|
|
2610
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
2611
|
+
const range = rangeClause(bag, opts);
|
|
2612
|
+
const scene = sceneClause(bag, opts);
|
|
2613
|
+
const cameraMode = cameraModeClause(bag, d, projectId, opts);
|
|
2614
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 50);
|
|
2615
|
+
const variantPred = leaderboardPredicate(bag, opts.variant ?? { type: "custom" }, "v");
|
|
2616
|
+
const hasConversion = opts.conversion != null;
|
|
2617
|
+
const conversionPred = hasConversion
|
|
2618
|
+
? leaderboardPredicate(bag, opts.conversion, "c")
|
|
2619
|
+
: "";
|
|
2620
|
+
// Every variant event: (session, variant name, ts). `name` is the discriminator.
|
|
2621
|
+
const ctes = [
|
|
2622
|
+
`variant_views AS (
|
|
2623
|
+
SELECT session_id, name AS variant, ts
|
|
2624
|
+
FROM events
|
|
2625
|
+
WHERE project_id = ${pid} AND ${variantPred}${range}${scene}${cameraMode}
|
|
2626
|
+
)`,
|
|
2627
|
+
];
|
|
2628
|
+
// Conversion events (optional): (session, ts), same session scope.
|
|
2629
|
+
if (hasConversion) {
|
|
2630
|
+
ctes.push(`conversions AS (
|
|
2631
|
+
SELECT session_id, ts
|
|
2632
|
+
FROM events
|
|
2633
|
+
WHERE project_id = ${pid} AND ${conversionPred}${range}${scene}${cameraMode}
|
|
2634
|
+
)`);
|
|
2635
|
+
}
|
|
2636
|
+
// Per-view aggregates: total views and distinct sessions per variant.
|
|
2637
|
+
ctes.push(`view_counts AS (
|
|
2638
|
+
SELECT variant, count() AS views, count(DISTINCT session_id) AS sessions
|
|
2639
|
+
FROM variant_views
|
|
2640
|
+
GROUP BY variant
|
|
2641
|
+
)`);
|
|
2642
|
+
// First time each session saw each variant — the ordered anchor for conversion.
|
|
2643
|
+
ctes.push(`first_view AS (
|
|
2644
|
+
SELECT session_id, variant, min(ts) AS t0
|
|
2645
|
+
FROM variant_views
|
|
2646
|
+
GROUP BY session_id, variant
|
|
2647
|
+
)`);
|
|
2648
|
+
// Distinct sessions that converted at/after first seeing the variant (ordered).
|
|
2649
|
+
// The join keys on `session_id` only and moves the ordered `c.ts >= fv.t0`
|
|
2650
|
+
// guard to WHERE, so the emitted `ON` carries no mixed left/right inequality —
|
|
2651
|
+
// it runs on stock ClickHouse without `allow_experimental_join_condition`.
|
|
2652
|
+
if (hasConversion) {
|
|
2653
|
+
ctes.push(`converted AS (
|
|
2654
|
+
SELECT fv.variant AS variant, count(DISTINCT fv.session_id) AS conversions
|
|
2655
|
+
FROM first_view fv
|
|
2656
|
+
JOIN conversions c ON c.session_id = fv.session_id
|
|
2657
|
+
WHERE c.ts >= fv.t0
|
|
2658
|
+
GROUP BY fv.variant
|
|
2659
|
+
)`);
|
|
2660
|
+
}
|
|
2661
|
+
// Boundaries for dwell: every variant view (carrying its variant, is_conv = 0)
|
|
2662
|
+
// plus every conversion event (is_conv = 1). A view's next boundary is the
|
|
2663
|
+
// earliest later boundary that is a conversion OR a different variant.
|
|
2664
|
+
const boundaryParts = [
|
|
2665
|
+
`SELECT session_id, ts, variant AS b_variant, 0 AS is_conv FROM variant_views`,
|
|
2666
|
+
];
|
|
2667
|
+
if (hasConversion) {
|
|
2668
|
+
boundaryParts.push(`SELECT session_id, ts, '' AS b_variant, 1 AS is_conv FROM conversions`);
|
|
2669
|
+
}
|
|
2670
|
+
ctes.push(`boundaries AS (
|
|
2671
|
+
${boundaryParts.join("\n UNION ALL ")}
|
|
2672
|
+
)`);
|
|
2673
|
+
// Per view: gap to its next boundary. The boundary rule mixes the view's own
|
|
2674
|
+
// `variant` with the candidate row, so it stays a plain equi-join on
|
|
2675
|
+
// `session_id` with the ordered / relative predicates in WHERE (an ASOF join
|
|
2676
|
+
// takes only one inequality and can't express the "different variant" guard).
|
|
2677
|
+
// Keeping the inequality out of `ON` lets stock ClickHouse plan it with no
|
|
2678
|
+
// `allow_experimental_join_condition`. The WHERE drops views with no boundary,
|
|
2679
|
+
// so they are excluded from the average (as specified).
|
|
2680
|
+
ctes.push(`view_dwell AS (
|
|
2681
|
+
SELECT v.variant AS variant,
|
|
2682
|
+
${d.epochMs("min(b.ts)")} - ${d.epochMs("v.ts")} AS dwell_ms
|
|
2683
|
+
FROM variant_views v
|
|
2684
|
+
JOIN boundaries b
|
|
2685
|
+
ON b.session_id = v.session_id
|
|
2686
|
+
WHERE b.ts > v.ts
|
|
2687
|
+
AND NOT (b.is_conv = 0 AND b.b_variant = v.variant)
|
|
2688
|
+
GROUP BY v.session_id, v.variant, v.ts
|
|
2689
|
+
)`);
|
|
2690
|
+
ctes.push(`dwell AS (
|
|
2691
|
+
SELECT variant, avg(dwell_ms) AS avg_dwell_ms
|
|
2692
|
+
FROM view_dwell
|
|
2693
|
+
GROUP BY variant
|
|
2694
|
+
)`);
|
|
2695
|
+
const conversionsSelect = hasConversion ? `coalesce(cv.conversions, 0)` : `0`;
|
|
2696
|
+
const convJoin = hasConversion ? `\n LEFT JOIN converted cv ON cv.variant = vc.variant` : "";
|
|
2697
|
+
return {
|
|
2698
|
+
query: `
|
|
2699
|
+
WITH ${ctes.join(",\n ")}
|
|
2700
|
+
SELECT
|
|
2701
|
+
vc.variant AS variant,
|
|
2702
|
+
vc.views AS views,
|
|
2703
|
+
vc.sessions AS sessions,
|
|
2704
|
+
${conversionsSelect} AS conversions,
|
|
2705
|
+
coalesce(dw.avg_dwell_ms, 0) AS avg_dwell_ms
|
|
2706
|
+
FROM view_counts vc
|
|
2707
|
+
LEFT JOIN dwell dw ON dw.variant = vc.variant${convJoin}
|
|
2708
|
+
ORDER BY vc.views DESC, vc.variant ASC
|
|
2709
|
+
LIMIT ${limit}
|
|
2710
|
+
`,
|
|
2711
|
+
query_params: bag.values,
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
1921
2714
|
//# sourceMappingURL=aggregations.js.map
|