From e3956bd480f2b2f354141b93d707fedefb07cf23 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 2 Nov 2026 17:36:04 +0700 Subject: [PATCH 1/2] feat(dashboards): Omi TV device-vs-organic funnel row and gateway errors by lane Omi TV v3 gains a scroll-down funnel row under the fixed wall: the latest matured weekly new-user cohort split by "paired an Omi within 48 hours" (device) vs organic, step by step (share, onboarded, conversation day 1, conversation week 1, activated, kept weeks 2-5 from an older cohort), the weekly activation trend for six cohorts, and activation by entry platform. Install and first summary are not shown: PostHog sees nobody before sign-up, and first summary has no event. The funnel scan runs 28 days past each period's end so Kept (days 7-35) is complete for the older cohort; ending the scan at the period end cut that window to under a week. Core Features gains "Gateway errors by lane" and "Gateway error rate by lane", read from the lane_outcome:llm_gateway_requests:rate5m_offset5m recording rule. Thresholds are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../dashboards/general/omi-core-features.json | 110 +++++ web/admin/grafana/build_wall_dashboard.py | 35 ++ web/admin/grafana/dashboards/omi-tv-v2.json | 452 ++++++++++++++++++ .../grafana/test_build_wall_dashboard.py | 16 +- web/admin/lib/__tests__/wall-series.test.ts | 59 +++ web/admin/lib/wall-posthog.ts | 34 ++ web/admin/lib/wall-series.ts | 100 +++- 7 files changed, 802 insertions(+), 4 deletions(-) diff --git a/backend/charts/monitoring/dashboards/general/omi-core-features.json b/backend/charts/monitoring/dashboards/general/omi-core-features.json index 6ae810d2b87..61082229ac3 100644 --- a/backend/charts/monitoring/dashboards/general/omi-core-features.json +++ b/backend/charts/monitoring/dashboards/general/omi-core-features.json @@ -3871,6 +3871,116 @@ ], "title": "Rejections by class", "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Failed requests per second by lane, largest ten. A lane that fails on low traffic barely shows here, so read this with the error rate panel. Reads a recording rule, so it lags about 5 minutes.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 1, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "normal" + } + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 51 + }, + "id": 91, + "interval": "5m", + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "topk(10, sum by (lane_id) (lane_outcome:llm_gateway_requests:rate5m_offset5m{outcome=\"error\"}) > 0)", + "legendFormat": "{{lane_id}}", + "refId": "A" + } + ], + "title": "Gateway errors by lane", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Share of each lane's requests that failed, for lanes above about 1 request a minute, highest ten. Reads a recording rule, so it lags about 5 minutes.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 1, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 51 + }, + "id": 92, + "interval": "5m", + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "topk(10, 100 * sum by (lane_id) (lane_outcome:llm_gateway_requests:rate5m_offset5m{outcome=\"error\"}) / (sum by (lane_id) (lane_outcome:llm_gateway_requests:rate5m_offset5m) > 0.0167) > 0)", + "legendFormat": "{{lane_id}}", + "refId": "A" + } + ], + "title": "Gateway error rate by lane", + "type": "timeseries" } ], "title": "AI responses — the LLM gateway behind chat and extraction", diff --git a/web/admin/grafana/build_wall_dashboard.py b/web/admin/grafana/build_wall_dashboard.py index 42e3a5091d3..7f6294ee371 100644 --- a/web/admin/grafana/build_wall_dashboard.py +++ b/web/admin/grafana/build_wall_dashboard.py @@ -69,6 +69,7 @@ STAT_H = 3 STRIP_H = 1 WALL_H = 27 +FUNNEL_H = 9 # One value size and label size on every stat; sized so the widest value # ("10,287", "$72.4K") fits a three-value column at 1440 px. STAT_VALUE_SIZE, STAT_LABEL_SIZE = 26, 13 @@ -588,6 +589,35 @@ def curve_options(panel): d["noValue"] = "Cohorts still filling" # an empty frame, not "Cannot visualize data" +def funnel_row(): + """Below the TV's fixed wall: a full-width row reached by scrolling.""" + proxy = ("Device = paired an Omi within 48 hours of first seen. It is a proxy: a buyer who never paired counts as " + "organic, and pairing is itself an early success, so the gap between the two is overstated. ") + series_colors = [color("Device", MAGENTA), color("Organic", NEUTRAL)] + funnel = chart("Funnel · device vs organic", + proxy + "Latest matured weekly new-user cohort, except Kept, which uses the newest cohort old " + "enough to measure it. Install is not shown because PostHog sees nobody before sign-up; first " + "summary is not shown because it has no event.", + [target("activation", "funnel", [("step", "Step", "string"), ("Device", "Device", "number"), + ("Organic", "Organic", "number")])], + unit="percent", decimals=0, kind="barchart", overrides=series_colors, + extra=barchart_options(max_value=100, value_size=13)) + weekly = chart("Activated · device vs organic", + proxy + "Week-one activation for each matured weekly cohort, dated by its last day.", + [target("activation", "funnelWeekly", [DATE, ("Device", "Device", "number"), + ("Organic", "Organic", "number")])], + unit="percent", decimals=0, soft_min=0, time_from="60d", overrides=series_colors) + platforms = chart("Funnel by platform", + proxy + "Week-one activation in the latest matured weekly new-user cohort, by the platform users " + "first arrived on, with its size in the label. Small platforms move a lot week to week.", + [target("activation", "funnelPlatforms", [("platform", "Platform", "string"), + ("Device", "Device", "number"), + ("Organic", "Organic", "number")])], + unit="percent", decimals=0, kind="barchart", overrides=series_colors, + extra=barchart_options(horizontal=True, max_value=100, value_size=13)) + return [(11, funnel), (7, weekly), (6, platforms)] + + def build_wall_board(): panels = [] for name, items in columns().items(): @@ -602,6 +632,11 @@ def build_wall_board(): panels.append({**panel, "gridPos": {"x": x, "y": y, "w": w, "h": h}}) y += h assert y == WALL_H, (name, y) # every column fills the TV exactly, no scroll + x = 0 + for w, panel in funnel_row(): + panels.append({**panel, "gridPos": {"x": x, "y": WALL_H, "w": w, "h": FUNNEL_H}}) + x += w + assert x == 24 for i, panel in enumerate(panels, 1): panel["id"] = i return {"uid": "omi-tv-v2", "title": "Omi TV · v3", "schemaVersion": 39, "version": 1, "timezone": "utc", diff --git a/web/admin/grafana/dashboards/omi-tv-v2.json b/web/admin/grafana/dashboards/omi-tv-v2.json index 28a701dd0e9..08ee630e6e6 100644 --- a/web/admin/grafana/dashboards/omi-tv-v2.json +++ b/web/admin/grafana/dashboards/omi-tv-v2.json @@ -6981,6 +6981,458 @@ "h": 1 }, "id": 30 + }, + { + "type": "barchart", + "title": "Funnel \u00b7 device vs organic", + "description": "Device = paired an Omi within 48 hours of first seen. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Latest matured weekly new-user cohort, except Kept, which uses the newest cohort old enough to measure it. Install is not shown because PostHog sees nobody before sign-up; first summary is not shown because it has no event.", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "type": "json", + "source": "url", + "format": "table", + "parser": "backend", + "url": "http://127.0.0.1:8899/api/omi/stats/wall/series/activation", + "url_options": { + "method": "GET" + }, + "root_selector": "frames.funnel", + "columns": [ + { + "selector": "step", + "text": "Step", + "type": "string" + }, + { + "selector": "Device", + "text": "Device", + "type": "number" + }, + { + "selector": "Organic", + "text": "Organic", + "type": "number" + } + ] + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "fixed", + "fixedColor": "#e8e6df" + }, + "custom": { + "fillOpacity": 88, + "gradientMode": "none", + "lineWidth": 0, + "axisPlacement": "auto", + "axisLabel": "", + "axisSoftMin": 0, + "axisBorderShow": false + }, + "noValue": "Loading\u2026", + "decimals": 0, + "min": 0, + "max": 100 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Device" + }, + "properties": [ + { + "id": "displayName", + "value": "Device" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#d55181" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Organic" + }, + "properties": [ + { + "id": "displayName", + "value": "Organic" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#a8a69e" + } + } + ] + } + ] + }, + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + }, + "orientation": "vertical", + "barWidth": 0.8, + "groupWidth": 0.75, + "text": { + "valueSize": 13 + }, + "showValue": "never", + "stacking": "none", + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0, + "fullHighlight": false, + "barRadius": 0.08 + }, + "transformations": [ + { + "id": "organize", + "options": { + "indexByName": { + "Step": 0, + "Device": 1, + "Organic": 2 + }, + "excludeByName": {}, + "renameByName": {} + } + } + ], + "gridPos": { + "x": 0, + "y": 27, + "w": 11, + "h": 9 + }, + "id": 31 + }, + { + "type": "timeseries", + "title": "Activated \u00b7 device vs organic", + "description": "Device = paired an Omi within 48 hours of first seen. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Week-one activation for each matured weekly cohort, dated by its last day.", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "type": "json", + "source": "url", + "format": "table", + "parser": "backend", + "url": "http://127.0.0.1:8899/api/omi/stats/wall/series/activation", + "url_options": { + "method": "GET" + }, + "root_selector": "frames.funnelWeekly", + "columns": [ + { + "selector": "date", + "text": "date", + "type": "timestamp" + }, + { + "selector": "Device", + "text": "Device", + "type": "number" + }, + { + "selector": "Organic", + "text": "Organic", + "type": "number" + } + ] + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "fixed", + "fixedColor": "#e8e6df" + }, + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 0, + "gradientMode": "none", + "showPoints": "never", + "pointSize": 5, + "spanNulls": true, + "barAlignment": 0, + "axisPlacement": "left", + "axisLabel": "", + "axisSoftMin": 0, + "axisBorderShow": false, + "axisCenteredZero": false, + "stacking": { + "mode": "none", + "group": "A" + }, + "lineInterpolation": "smooth", + "thresholdsStyle": { + "mode": "off" + } + }, + "noValue": "Loading\u2026", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Device" + }, + "properties": [ + { + "id": "displayName", + "value": "Device" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#d55181" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Organic" + }, + "properties": [ + { + "id": "displayName", + "value": "Organic" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#a8a69e" + } + } + ] + } + ] + }, + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "transformations": [ + { + "id": "organize", + "options": { + "indexByName": { + "date": 0, + "Device": 1, + "Organic": 2 + }, + "excludeByName": {}, + "renameByName": {} + } + } + ], + "timeFrom": "60d", + "hideTimeOverride": true, + "gridPos": { + "x": 11, + "y": 27, + "w": 7, + "h": 9 + }, + "id": 32 + }, + { + "type": "barchart", + "title": "Funnel by platform", + "description": "Device = paired an Omi within 48 hours of first seen. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Week-one activation in the latest matured weekly new-user cohort, by the platform users first arrived on, with its size in the label. Small platforms move a lot week to week.", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "type": "json", + "source": "url", + "format": "table", + "parser": "backend", + "url": "http://127.0.0.1:8899/api/omi/stats/wall/series/activation", + "url_options": { + "method": "GET" + }, + "root_selector": "frames.funnelPlatforms", + "columns": [ + { + "selector": "platform", + "text": "Platform", + "type": "string" + }, + { + "selector": "Device", + "text": "Device", + "type": "number" + }, + { + "selector": "Organic", + "text": "Organic", + "type": "number" + } + ] + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "fixed", + "fixedColor": "#e8e6df" + }, + "custom": { + "fillOpacity": 88, + "gradientMode": "none", + "lineWidth": 0, + "axisPlacement": "auto", + "axisLabel": "", + "axisSoftMin": 0, + "axisBorderShow": false + }, + "noValue": "Loading\u2026", + "decimals": 0, + "min": 0, + "max": 100 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Device" + }, + "properties": [ + { + "id": "displayName", + "value": "Device" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#d55181" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Organic" + }, + "properties": [ + { + "id": "displayName", + "value": "Organic" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#a8a69e" + } + } + ] + } + ] + }, + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + }, + "orientation": "horizontal", + "barWidth": 0.8, + "groupWidth": 0.75, + "text": { + "valueSize": 13 + }, + "showValue": "always", + "stacking": "none", + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0, + "fullHighlight": false, + "barRadius": 0.08 + }, + "transformations": [ + { + "id": "organize", + "options": { + "indexByName": { + "Platform": 0, + "Device": 1, + "Organic": 2 + }, + "excludeByName": {}, + "renameByName": {} + } + } + ], + "gridPos": { + "x": 18, + "y": 27, + "w": 6, + "h": 9 + }, + "id": 33 } ], "links": [], diff --git a/web/admin/grafana/test_build_wall_dashboard.py b/web/admin/grafana/test_build_wall_dashboard.py index f1faa38849f..87fca2ef34a 100644 --- a/web/admin/grafana/test_build_wall_dashboard.py +++ b/web/admin/grafana/test_build_wall_dashboard.py @@ -24,6 +24,14 @@ def is_text_line(panel): class WallV2Contract(unittest.TestCase): + def test_funnel_row_sits_below_the_tv_wall(self): + from build_wall_dashboard import WALL_H + row = [p for p in load("omi-tv-v2")["panels"] if p["gridPos"]["y"] >= WALL_H] + self.assertEqual({p["gridPos"]["y"] for p in row}, {WALL_H}) + self.assertEqual(sum(p["gridPos"]["w"] for p in row), 24) + self.assertEqual({p["targets"][0]["root_selector"] for p in row}, + {"frames.funnel", "frames.funnelWeekly", "frames.funnelPlatforms"}) + def test_checked_in_wall_matches_builder(self): from build_wall_dashboard import build_wall_board self.assertEqual(load("omi-tv-v2"), build_wall_board()) @@ -51,7 +59,8 @@ def test_story_columns_stat_above_charts_on_one_screen(self): board = load("omi-tv-v2") for name in COLUMNS: x, w = GEOMETRY[name] - column = sorted((p for p in board["panels"] if p["gridPos"]["x"] == x), key=lambda p: p["gridPos"]["y"]) + column = sorted((p for p in board["panels"] if p["gridPos"]["x"] == x and p["gridPos"]["y"] < WALL_H), + key=lambda p: p["gridPos"]["y"]) self.assertEqual(column[0]["type"], "stat") self.assertTrue(column[0]["title"].startswith(name), column[0]["title"]) self.assertEqual(column[0]["gridPos"]["y"], 0) @@ -90,7 +99,7 @@ def test_charts_are_labelled_in_plain_english(self): def test_each_stat_has_an_api_delta_strip_in_field_order(self): import re - from build_wall_dashboard import BAD_MARK, COLUMNS, GEOMETRY, GOOD_MARK, STAT_H + from build_wall_dashboard import BAD_MARK, COLUMNS, GEOMETRY, GOOD_MARK, STAT_H, WALL_H board = load("omi-tv-v2") source = (HERE.parent / "lib/wall-series.ts").read_text() block = re.search(r"DELTA_STRIPS[^=]*= \{(.*?)\};", source, re.S).group(1) @@ -99,7 +108,8 @@ def test_each_stat_has_an_api_delta_strip_in_field_order(self): self.assertIn('BAD_MARK = "\\u2060"', source) for name in COLUMNS: x, _ = GEOMETRY[name] - column = sorted((p for p in board["panels"] if p["gridPos"]["x"] == x), key=lambda p: p["gridPos"]["y"]) + column = sorted((p for p in board["panels"] if p["gridPos"]["x"] == x and p["gridPos"]["y"] < WALL_H), + key=lambda p: p["gridPos"]["y"]) head, strip = column[0], column[1] self.assertTrue(is_strip(strip)) self.assertFalse(head["options"]["showPercentChange"]) # native change is relative only diff --git a/web/admin/lib/__tests__/wall-series.test.ts b/web/admin/lib/__tests__/wall-series.test.ts index 673e8950daa..be07f8465ad 100644 --- a/web/admin/lib/__tests__/wall-series.test.ts +++ b/web/admin/lib/__tests__/wall-series.test.ts @@ -17,6 +17,7 @@ import { STORE_FAIL_LIMIT, UNIT_DAYS, warmSeries, + funnelFrames, } from "../wall-series"; import { resetWallCache, wallPayload } from "../wall-cache"; import { @@ -1723,3 +1724,61 @@ describe("variable margin frames (David 2026-10-23)", () => { ); }); }); + +describe("funnelFrames", () => { + const now = new Date("2026-11-02T12:00:00Z"); + // period, entry, device, people, onboarded, conv1, conv7, activated, kept + const deviceFunnel = Object.assign( + [ + [0, "ios", 1, 40, 36, 20, 30, 28, 0], + [0, "ios", 0, 60, 54, 6, 12, 12, 0], + [0, "android", 0, 100, 80, 10, 20, 25, 0], + [4, "ios", 1, 10, 9, 5, 7, 7, 5], + [4, "ios", 0, 90, 80, 9, 18, 20, 3], + ], + { sourceRefreshedAt: null } + ); + + it("splits the latest cohort by paired and shows Kept from the older one", () => { + const { funnel } = funnelFrames({ deviceFunnel }, now); + expect(funnel[0]).toEqual({ + step: "Share of new users", + Device: 20, + Organic: 80, + }); + expect(funnel.find((r) => r.step === "Activated")).toEqual({ + step: "Activated", + Device: 70, + Organic: 23.1, + }); + expect(funnel.at(-1)).toEqual({ + step: "Kept, weeks 2–5 (older cohort)", + Device: 50, + Organic: 3.3, + }); + }); + + it("orders platforms by size and leaves an absent split empty", () => { + const { funnelPlatforms } = funnelFrames({ deviceFunnel }, now); + expect(funnelPlatforms).toEqual([ + { platform: "iOS · 100", Device: 70, Organic: 20 }, + { platform: "Android · 100", Device: null, Organic: 25 }, + ]); + }); + + it("dates weeks by the cohort's last day, oldest first, skipping missing ones", () => { + const { funnelWeekly } = funnelFrames({ deviceFunnel }, now); + expect(funnelWeekly.map((r) => r.date)).toEqual([ + "2026-09-27T00:00:00Z", + "2026-10-25T00:00:00Z", + ]); + }); + + it("is empty when the leg has not filled", () => { + expect(funnelFrames({}, now)).toEqual({ + funnel: [], + funnelPlatforms: [], + funnelWeekly: [], + }); + }); +}); diff --git a/web/admin/lib/wall-posthog.ts b/web/admin/lib/wall-posthog.ts index 9b7f67a246f..945a9243007 100644 --- a/web/admin/lib/wall-posthog.ts +++ b/web/admin/lib/wall-posthog.ts @@ -442,6 +442,40 @@ export const CAPTURE_SEGMENTS = [ "No capture", ] as const; +/** One matured weekly new-user cohort split by platform and by "device": paired + * within 48 hours of first seen. A buyer who never paired counts as organic, so + * the split is a proxy. Kept (activated, then active in days 7–35) needs the + * scan to run 28 days past `end`, so it is complete only once `end` is at least + * 28 days old; later scan days that have not happened yet read nothing. + * Columns: entry, device, people, onboarded, conv1, conv7, activated, kept. + */ +export function deviceFunnelQuery(end: Date) { + const cohortEnd = shiftDay(end, -7); + const start = shiftDay(cohortEnd, -7); + return `WITH ${raw( + shiftDay(start, -180), + shiftDay(end, 28), + `AND person.created_at >= ${sqlTime( + start + )} AND person.created_at < ${sqlTime(cohortEnd)}` + )}, + people AS (SELECT actor,min(anchor) a, + coalesce(nullIf(argMinIf(p,tuple(timestamp,${PLATFORM_RANK_SQL}),p!='unknown'),''),'unknown') entry, + min(timestamp) first_event, + countIf(event='Device Paired' AND timestamp < anchor + interval 2 day) > 0 device, + countIf(event='Onboarding Completed' AND timestamp < anchor + interval 7 day) > 0 onboarded, + countIf(cap AND qt >= anchor AND qt < anchor + interval 1 day) > 0 conv1, + countIf(cap AND qt >= anchor AND qt < anchor + interval 7 day) > 0 conv7, + uniqExactIf(${UTC_DAY( + "qt" + )},(fg OR cap) AND qt >= anchor AND qt < anchor + interval 7 day) days7, + countIf((fg OR cap) AND qt >= anchor + interval 7 day AND qt < anchor + interval 35 day) > 0 later + FROM qualified GROUP BY actor) + SELECT entry,device,count(),countIf(onboarded),countIf(conv1),countIf(conv7), + countIf(days7>=2),countIf(days7>=2 AND later) + FROM people WHERE first_event >= a GROUP BY entry,device LIMIT 100`; +} + /** Week-one activation by first-week capture source for the latest matured * weekly cohort: the snapshot cohort (same anchor, 180-day history check and * ≥2 qualified days), so segment people sum to the activation denominator. diff --git a/web/admin/lib/wall-series.ts b/web/admin/lib/wall-series.ts index ed65e2e376e..3b24759458f 100644 --- a/web/admin/lib/wall-series.ts +++ b/web/admin/lib/wall-series.ts @@ -25,6 +25,7 @@ import { AggregateRows, CAPTURE_SEGMENTS, captureSourceQuery, + deviceFunnelQuery, CHURNED_ROW, cohortQuery, DAYS_ROW_PREFIX, @@ -206,6 +207,8 @@ export interface Legs { growth?: AggregateRows; activationHistory?: AggregateRows; captureSource?: AggregateRows; + /** Weekly cohorts by platform and paired-within-48h; period 0 is the newest. */ + deviceFunnel?: AggregateRows; retainedHistory?: AggregateRows; establishedHistory?: AggregateRows; curve?: AggregateRows; @@ -241,7 +244,7 @@ type LegName = keyof Legs; const LEGS_FOR: Record = { headline: ["finops", "mauPrior", "billing"], growth: ["growth"], - activation: ["activationHistory", "captureSource"], + activation: ["activationHistory", "captureSource", "deviceFunnel"], retention: ["curve", "retainedHistory", "establishedHistory", "growth"], revenue: ["finops", "mauPrior", "mauDaily", "shopify"], health: [ @@ -359,6 +362,17 @@ export function legJobs( ), captureSource: () => read({ kind: "capture", end }, captureSourceQuery(end)), + // One cohort per read; partial cohorts are kept and only the missing retried. + deviceFunnel: () => + periods(FUNNEL_WEEKS, async (p) => + Object.assign( + (await sources.query(deviceFunnelQuery(back(p)))).map((r) => [ + 0, + ...r, + ]), + { sourceRefreshedAt: null } + ) + ), retainedHistory: () => history( (stop, n) => cohortQuery(stop, 37, n), @@ -601,6 +615,89 @@ const CAPTURE_LABELS: Record<(typeof CAPTURE_SEGMENTS)[number], string> = { "No capture": "No capture", }; +/** Weekly cohorts on the funnel row; the oldest two are old enough to show Kept. */ +const FUNNEL_WEEKS = 6; +const FUNNEL_KEPT_PERIOD = 4; +const FUNNEL_PLATFORMS: Record = { + ios: "iOS", + android: "Android", + macos: "macOS", + windows: "Windows", + web: "Web", +}; +// Columns of a `deviceFunnel` row: period, entry, device, then these counts. +const PEOPLE = 3; +const ACTIVATED = 7; +const KEPT = 8; +const FUNNEL_STEPS = [ + ["Onboarded", 4], + ["Conversation, day 1", 5], + ["Conversation, week 1", 6], + ["Activated", ACTIVATED], +] as const; + +export function funnelFrames(legs: Legs, now: Date) { + const all = (legs.deviceFunnel || []) as unknown as unknown[][]; + const week = (p: number) => all.filter((r) => Number(r[0]) === p); + const device = (rows: unknown[][]) => rows.filter((r) => Number(r[2]) === 1); + const organic = (rows: unknown[][]) => rows.filter((r) => Number(r[2]) !== 1); + const count = (rows: unknown[][], column: number) => + rows.reduce((sum, r) => sum + Number(r[column]), 0); + const percent = (part: number, whole: number) => + whole ? Math.round((1000 * part) / whole) / 10 : null; + const rate = (rows: unknown[][], column: number) => + percent(count(rows, column), count(rows, PEOPLE)); + const split = (step: string, rows: unknown[][], column: number): Row => ({ + step, + Device: rate(device(rows), column), + Organic: rate(organic(rows), column), + }); + + const latest = week(0); + const people = count(latest, PEOPLE); + const funnel: Row[] = people + ? [ + { + step: "Share of new users", + Device: percent(count(device(latest), PEOPLE), people), + Organic: percent(count(organic(latest), PEOPLE), people), + }, + ...FUNNEL_STEPS.map(([step, column]) => split(step, latest, column)), + ] + : []; + const older = week(FUNNEL_KEPT_PERIOD); + if (people && count(older, PEOPLE)) + funnel.push(split("Kept, weeks 2–5 (older cohort)", older, KEPT)); + + // Users ride in the label, largest platform first, as on Activation by capture. + const funnelPlatforms: Row[] = Object.entries(FUNNEL_PLATFORMS) + .map(([key, platform]) => ({ + platform, + rows: latest.filter((r) => r[1] === key), + })) + .filter(({ rows }) => count(rows, PEOPLE) > 0) + .sort((a, b) => count(b.rows, PEOPLE) - count(a.rows, PEOPLE)) + .map(({ platform, rows }) => ({ + platform: `${platform} · ${count(rows, PEOPLE).toLocaleString("en-US")}`, + Device: rate(device(rows), ACTIVATED), + Organic: rate(organic(rows), ACTIVATED), + })); + + // Dated by the cohort's last day, as the other cohort charts are. + const stop = shiftDay(utcDay(now), -7); + const funnelWeekly: Row[] = []; + for (let p = FUNNEL_WEEKS - 1; p >= 0; p--) { + const rows = week(p); + if (rows.length) + funnelWeekly.push({ + date: ts(isoDay(shiftDay(stop, -7 * p - 1))), + Device: rate(device(rows), ACTIVATED), + Organic: rate(organic(rows), ACTIVATED), + }); + } + return { funnel, funnelPlatforms, funnelWeekly }; +} + export function activationFrames( legs: Legs, snapshot: WallPayload | null, @@ -635,6 +732,7 @@ export function activationFrames( capture: captured, sources, maturing: maturingBand(stop, now), + ...funnelFrames(legs, now), }; } From ec2ca38b7e167723c1d133665ad460e2bb65e546 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 2 Nov 2026 18:18:52 +0700 Subject: [PATCH 2/2] feat(dashboards): count only Omi pairs as device; Onboarded on reporting clients Review round 1 (Luna): - Device Paired is also sent for Apple Watch, Limitless and other vendors. "Device" now requires device_vendor 'omi' (mobile and macOS both send it) or type omi/openglass for older mobile builds. Live: 48 of 379 "device" users in the latest cohort had paired only non-Omi hardware. - Windows and web never send Onboarding Completed, so the Onboarded step now counts only people who arrived on iOS, Android or macOS, and says so. Co-Authored-By: Claude Opus 5.5 --- web/admin/grafana/build_wall_dashboard.py | 5 +++-- web/admin/grafana/dashboards/omi-tv-v2.json | 6 +++--- web/admin/lib/__tests__/wall-series.test.ts | 16 ++++++++++++++++ web/admin/lib/wall-posthog.ts | 10 +++++++--- web/admin/lib/wall-series.ts | 10 +++++++++- 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/web/admin/grafana/build_wall_dashboard.py b/web/admin/grafana/build_wall_dashboard.py index 7f6294ee371..a2958a1c0c5 100644 --- a/web/admin/grafana/build_wall_dashboard.py +++ b/web/admin/grafana/build_wall_dashboard.py @@ -591,12 +591,13 @@ def curve_options(panel): def funnel_row(): """Below the TV's fixed wall: a full-width row reached by scrolling.""" - proxy = ("Device = paired an Omi within 48 hours of first seen. It is a proxy: a buyer who never paired counts as " + proxy = ("Device = paired an Omi within 48 hours of first seen; other paired hardware counts as organic. It is a proxy: a buyer who never paired counts as " "organic, and pairing is itself an early success, so the gap between the two is overstated. ") series_colors = [color("Device", MAGENTA), color("Organic", NEUTRAL)] funnel = chart("Funnel · device vs organic", proxy + "Latest matured weekly new-user cohort, except Kept, which uses the newest cohort old " - "enough to measure it. Install is not shown because PostHog sees nobody before sign-up; first " + "enough to measure it. Onboarded counts only people who arrived on iOS, Android or Mac, the " + "clients that report it. Install is not shown because PostHog sees nobody before sign-up; first " "summary is not shown because it has no event.", [target("activation", "funnel", [("step", "Step", "string"), ("Device", "Device", "number"), ("Organic", "Organic", "number")])], diff --git a/web/admin/grafana/dashboards/omi-tv-v2.json b/web/admin/grafana/dashboards/omi-tv-v2.json index 08ee630e6e6..577024bca38 100644 --- a/web/admin/grafana/dashboards/omi-tv-v2.json +++ b/web/admin/grafana/dashboards/omi-tv-v2.json @@ -6985,7 +6985,7 @@ { "type": "barchart", "title": "Funnel \u00b7 device vs organic", - "description": "Device = paired an Omi within 48 hours of first seen. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Latest matured weekly new-user cohort, except Kept, which uses the newest cohort old enough to measure it. Install is not shown because PostHog sees nobody before sign-up; first summary is not shown because it has no event.", + "description": "Device = paired an Omi within 48 hours of first seen; other paired hardware counts as organic. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Latest matured weekly new-user cohort, except Kept, which uses the newest cohort old enough to measure it. Onboarded counts only people who arrived on iOS, Android or Mac, the clients that report it. Install is not shown because PostHog sees nobody before sign-up; first summary is not shown because it has no event.", "datasource": { "type": "yesoreyeram-infinity-datasource", "uid": "omi-admin-api" @@ -7135,7 +7135,7 @@ { "type": "timeseries", "title": "Activated \u00b7 device vs organic", - "description": "Device = paired an Omi within 48 hours of first seen. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Week-one activation for each matured weekly cohort, dated by its last day.", + "description": "Device = paired an Omi within 48 hours of first seen; other paired hardware counts as organic. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Week-one activation for each matured weekly cohort, dated by its last day.", "datasource": { "type": "yesoreyeram-infinity-datasource", "uid": "omi-admin-api" @@ -7287,7 +7287,7 @@ { "type": "barchart", "title": "Funnel by platform", - "description": "Device = paired an Omi within 48 hours of first seen. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Week-one activation in the latest matured weekly new-user cohort, by the platform users first arrived on, with its size in the label. Small platforms move a lot week to week.", + "description": "Device = paired an Omi within 48 hours of first seen; other paired hardware counts as organic. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Week-one activation in the latest matured weekly new-user cohort, by the platform users first arrived on, with its size in the label. Small platforms move a lot week to week.", "datasource": { "type": "yesoreyeram-infinity-datasource", "uid": "omi-admin-api" diff --git a/web/admin/lib/__tests__/wall-series.test.ts b/web/admin/lib/__tests__/wall-series.test.ts index be07f8465ad..336811065e0 100644 --- a/web/admin/lib/__tests__/wall-series.test.ts +++ b/web/admin/lib/__tests__/wall-series.test.ts @@ -1758,6 +1758,22 @@ describe("funnelFrames", () => { }); }); + it("counts Onboarded only on clients that report it", () => { + const rows = Object.assign( + [ + [0, "ios", 0, 10, 5, 0, 0, 0, 0], + [0, "windows", 0, 10, 0, 0, 0, 0, 0], + ], + { sourceRefreshedAt: null } + ); + const { funnel } = funnelFrames({ deviceFunnel: rows }, now); + expect(funnel[1]).toEqual({ + step: "Onboarded (phone and Mac)", + Device: null, + Organic: 50, + }); + }); + it("orders platforms by size and leaves an absent split empty", () => { const { funnelPlatforms } = funnelFrames({ deviceFunnel }, now); expect(funnelPlatforms).toEqual([ diff --git a/web/admin/lib/wall-posthog.ts b/web/admin/lib/wall-posthog.ts index 945a9243007..3bb6478178b 100644 --- a/web/admin/lib/wall-posthog.ts +++ b/web/admin/lib/wall-posthog.ts @@ -443,8 +443,11 @@ export const CAPTURE_SEGMENTS = [ ] as const; /** One matured weekly new-user cohort split by platform and by "device": paired - * within 48 hours of first seen. A buyer who never paired counts as organic, so - * the split is a proxy. Kept (activated, then active in days 7–35) needs the + * Omi hardware within 48 hours of first seen. Mobile and macOS both send + * `device_vendor` 'omi' for Omi and Omi Glass; `type` covers older mobile + * builds. Other vendors (Apple Watch, Limitless, …) also send Device Paired and + * stay organic. A buyer who never paired counts as organic, so the split is a + * proxy. Kept (activated, then active in days 7–35) needs the * scan to run 28 days past `end`, so it is complete only once `end` is at least * 28 days old; later scan days that have not happened yet read nothing. * Columns: entry, device, people, onboarded, conv1, conv7, activated, kept. @@ -462,7 +465,8 @@ export function deviceFunnelQuery(end: Date) { people AS (SELECT actor,min(anchor) a, coalesce(nullIf(argMinIf(p,tuple(timestamp,${PLATFORM_RANK_SQL}),p!='unknown'),''),'unknown') entry, min(timestamp) first_event, - countIf(event='Device Paired' AND timestamp < anchor + interval 2 day) > 0 device, + countIf(event='Device Paired' AND (properties.device_vendor='omi' OR properties.type IN ('omi','openglass')) + AND timestamp < anchor + interval 2 day) > 0 device, countIf(event='Onboarding Completed' AND timestamp < anchor + interval 7 day) > 0 onboarded, countIf(cap AND qt >= anchor AND qt < anchor + interval 1 day) > 0 conv1, countIf(cap AND qt >= anchor AND qt < anchor + interval 7 day) > 0 conv7, diff --git a/web/admin/lib/wall-series.ts b/web/admin/lib/wall-series.ts index 3b24759458f..4f250187188 100644 --- a/web/admin/lib/wall-series.ts +++ b/web/admin/lib/wall-series.ts @@ -629,8 +629,11 @@ const FUNNEL_PLATFORMS: Record = { const PEOPLE = 3; const ACTIVATED = 7; const KEPT = 8; +// Only these clients send Onboarding Completed (Windows and web do not), so +// the Onboarded step counts people who entered on one of them. +const ONBOARDING_PLATFORMS: unknown[] = ["ios", "android", "macos"]; +const ONBOARDED = 4; const FUNNEL_STEPS = [ - ["Onboarded", 4], ["Conversation, day 1", 5], ["Conversation, week 1", 6], ["Activated", ACTIVATED], @@ -662,6 +665,11 @@ export function funnelFrames(legs: Legs, now: Date) { Device: percent(count(device(latest), PEOPLE), people), Organic: percent(count(organic(latest), PEOPLE), people), }, + split( + "Onboarded (phone and Mac)", + latest.filter((r) => ONBOARDING_PLATFORMS.includes(r[1])), + ONBOARDED + ), ...FUNNEL_STEPS.map(([step, column]) => split(step, latest, column)), ] : [];