diff --git a/messages/en.json b/messages/en.json
index 40d93ba078..ab5c6846a2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1590,8 +1590,16 @@
"native-observe-subtitle": "Native app health, launch timing, WebView load timing, and release impact from the updater plugin.",
"native-observe-tracked-devices": "Tracked devices",
"native-observe-version": "Version",
+ "native-observe-version-group": "Version grouping",
+ "native-observe-group-version": "Version",
+ "native-observe-group-version-platform": "Version + platform",
+ "native-observe-group-version-platform-channel": "Version + platform + channel",
+ "native-observe-platform": "Platform",
+ "native-observe-channel": "Channel",
"native-observe-version-health": "Version breakdown",
- "native-observe-version-health-help": "Each row is one reported native app version in the same selected period; the summary above is the total across versions.",
+ "native-observe-version-health-help": "Each row is one reported app version in the same selected period; the summary above is the total across versions.",
+ "native-observe-version-health-help-platform": "Each row is one version and platform combo in the selected period (for example 1.0.0 android).",
+ "native-observe-version-health-help-platform-channel": "Each row is one version, platform, and channel combo in the selected period (for example 1.0.0 android production).",
"native-observe-volume": "Events and issues",
"native-observe-volume-help": "Daily native events, launches, WebView loads, and issue counts across all reported app versions.",
"native-observe-webview-loads": "WebView loads",
diff --git a/src/components/dashboard/VersionGroupSelector.vue b/src/components/dashboard/VersionGroupSelector.vue
new file mode 100644
index 0000000000..e1929014fc
--- /dev/null
+++ b/src/components/dashboard/VersionGroupSelector.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+ {{ t('native-observe-version-group') }}: {{ t(selectedLabel) }}
+
+
+ {{ t(labels[option]) }}
+
+
+
diff --git a/src/pages/app/[app].observe.native.vue b/src/pages/app/[app].observe.native.vue
index 280ffc6e2d..8dbb886cd2 100644
--- a/src/pages/app/[app].observe.native.vue
+++ b/src/pages/app/[app].observe.native.vue
@@ -1,5 +1,6 @@
@@ -428,24 +460,33 @@ watch([packageId, days], async () => {
-
-
-
- {{ t('native-observe-version-health') }}
-
+
+
+
+
+ {{ t('native-observe-version-health') }}
+
+
+
- {{ t('native-observe-version-health-help') }}
+ {{ versionHealthHelp }}
-
+
-
+
{{ t('native-observe-version') }}
+
+ {{ t('native-observe-platform') }}
+
+
+ {{ t('native-observe-channel') }}
+
{{ t('events') }}
@@ -464,10 +505,16 @@ watch([packageId, days], async () => {
-
+
{{ version.version_name }}
+
+ {{ version.platform || '-' }}
+
+
+ {{ version.channel_name || '-' }}
+
{{ formatCount(version.events) }}
{{ formatCount(version.devices) }}
{{ formatPercent(version.issue_free_rate) }}
diff --git a/supabase/functions/_backend/private/native_observe_stats.ts b/supabase/functions/_backend/private/native_observe_stats.ts
index 5605c621ec..53aad1106e 100644
--- a/supabase/functions/_backend/private/native_observe_stats.ts
+++ b/supabase/functions/_backend/private/native_observe_stats.ts
@@ -1,11 +1,11 @@
-import type { Context } from 'hono'
import type { Dayjs } from 'dayjs'
+import type { Context } from 'hono'
import type { UpdateDeliveryTimingEventCF } from '../utils/cloudflare.ts'
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
import { Hono } from 'hono/tiny'
-import { parseStatsDurationMs, readNativeObservePluginVersionsCF, readUpdateDeliveryTimingEventsCF, resolveUpdateDeliveryTimingDurationMs } from '../utils/cloudflare.ts'
+import { parseStatsDurationMs, readDevicePlatformChannelByIdsCF, readNativeObservePluginVersionsCF, readUpdateDeliveryTimingEventsCF, resolveUpdateDeliveryTimingDurationMs } from '../utils/cloudflare.ts'
import { parseBody, simpleError, useCors } from '../utils/hono.ts'
import { middlewareAuth } from '../utils/hono_jwt.ts'
import { cloudlog } from '../utils/logging.ts'
@@ -17,11 +17,13 @@ dayjs.extend(utc)
const supportedPeriodDays = [1, 3, 7, 30] as const
type NativeObservePeriodDays = typeof supportedPeriodDays[number]
type NativeObserveView = 'global' | 'plugins'
+type NativeObserveVersionGroup = 'version' | 'version_platform' | 'version_platform_channel'
interface NativeObserveStatsRequest {
app_id: string
days?: number
view?: NativeObserveView
+ version_group?: NativeObserveVersionGroup
}
interface NativeObserveMetricRow {
@@ -36,6 +38,8 @@ interface NativeObserveMetricRow {
interface NativeObserveVersionRow {
version_name: string
+ platform?: string | null
+ channel_name?: string | null
events: number | string
devices: number | string
issue_count: number | string
@@ -74,6 +78,8 @@ interface NativeObserveEventSample {
version_name: string
device_id: string
duration_ms: number | null
+ platform?: string
+ channel_name?: string
}
type NativeObserveNumericValue = number | string | null | undefined
@@ -83,6 +89,7 @@ interface BuildNativeObserveResponseInput {
days: NativeObservePeriodDays
start: string
end: string
+ versionGroup: NativeObserveVersionGroup
dailyRows: NativeObserveMetricRow[]
actionRows: NativeObserveMetricRow[]
versionRows: NativeObserveVersionRow[]
@@ -197,6 +204,8 @@ FROM filtered`
const versionStatsQuery = `${filteredStatsCte}
SELECT
version_name,
+ NULL::text AS platform,
+ NULL::text AS channel_name,
count(*)::integer AS events,
count(DISTINCT device_id)::integer AS devices,
count(*) FILTER (WHERE action = ANY($5::public.stats_action[]))::integer AS issue_count,
@@ -208,6 +217,56 @@ GROUP BY version_name
ORDER BY events DESC, version_name ASC
LIMIT 12`
+const filteredStatsWithDevicesCte = `WITH filtered AS (
+ SELECT
+ to_char(date_trunc('day', stats.created_at AT TIME ZONE 'UTC'), 'YYYY-MM-DD') AS day,
+ stats.action,
+ COALESCE(NULLIF(stats.version_name, ''), 'unknown') AS version_name,
+ COALESCE(NULLIF(devices.platform::text, ''), 'unknown') AS platform,
+ COALESCE(NULLIF(devices.default_channel, ''), 'unknown') AS channel_name,
+ stats.device_id,
+ ${durationExpression.replaceAll('metadata', 'stats.metadata')} AS duration_ms
+ FROM public.stats
+ LEFT JOIN public.devices
+ ON devices.app_id = stats.app_id
+ AND devices.device_id = stats.device_id
+ WHERE stats.app_id = $1
+ AND stats.created_at >= $2::timestamptz
+ AND stats.created_at < $3::timestamptz
+ AND stats.action = ANY($4::public.stats_action[])
+)`
+
+function buildVersionStatsQuery(versionGroup: NativeObserveVersionGroup): string {
+ if (versionGroup === 'version')
+ return versionStatsQuery
+
+ const groupColumns = versionGroup === 'version_platform'
+ ? 'version_name, platform'
+ : 'version_name, platform, channel_name'
+ const channelSelect = versionGroup === 'version_platform'
+ ? 'NULL::text AS channel_name,'
+ : 'channel_name,'
+ const orderColumns = versionGroup === 'version_platform'
+ ? 'events DESC, version_name ASC, platform ASC'
+ : 'events DESC, version_name ASC, platform ASC, channel_name ASC'
+
+ return `${filteredStatsWithDevicesCte}
+SELECT
+ version_name,
+ platform,
+ ${channelSelect}
+ count(*)::integer AS events,
+ count(DISTINCT device_id)::integer AS devices,
+ count(*) FILTER (WHERE action = ANY($5::public.stats_action[]))::integer AS issue_count,
+ count(DISTINCT device_id) FILTER (WHERE action = ANY($5::public.stats_action[]))::integer AS affected_devices,
+ percentile_cont(0.9) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE action = 'app_launch_ready'::public.stats_action AND duration_ms IS NOT NULL) AS launch_p90_ms,
+ percentile_cont(0.9) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE action = 'webview_page_loaded'::public.stats_action AND duration_ms IS NOT NULL) AS webview_load_p90_ms
+FROM filtered
+GROUP BY ${groupColumns}
+ORDER BY ${orderColumns}
+LIMIT 36`
+}
+
const pluginVersionStatsQuery = `SELECT
COALESCE(NULLIF(plugin_version, ''), 'unknown') AS plugin_version,
count(*)::integer AS devices,
@@ -247,6 +306,31 @@ function normalizeNativeObserveView(value: unknown): NativeObserveView | null {
return 'plugins'
return null
}
+
+function normalizeNativeObserveVersionGroup(value: unknown): NativeObserveVersionGroup | null {
+ if (value === undefined || value === 'version')
+ return 'version'
+ if (value === 'version_platform' || value === 'version_platform_channel')
+ return value
+ return null
+}
+
+function needsVersionDeviceDimensions(versionGroup: NativeObserveVersionGroup) {
+ return versionGroup !== 'version'
+}
+
+function versionGroupBucketKey(
+ versionGroup: NativeObserveVersionGroup,
+ versionName: string,
+ platform = 'unknown',
+ channelName = 'unknown',
+) {
+ if (versionGroup === 'version')
+ return versionName
+ if (versionGroup === 'version_platform')
+ return `${versionName}\0${platform}`
+ return `${versionName}\0${platform}\0${channelName}`
+}
function generateDateLabels(from: Date, to: Date) {
const start = dayjs(from).utc().startOf('day')
const end = dayjs(to).utc().startOf('day')
@@ -340,17 +424,23 @@ function toNativeObserveEventSamples(events: UpdateDeliveryTimingEventCF[]): Nat
return samples
}
+interface NativeObserveVersionBucket {
+ version_name: string
+ platform: string | null
+ channel_name: string | null
+ devices: Set
+ issueDevices: Set
+ events: number
+ issueCount: number
+ launchDurations: number[]
+ webviewDurations: number[]
+}
+
interface NativeObserveAggregateState {
dailyMap: Map, durations: number[] }>
actionMap: Map, durations: number[] }>
- versionMap: Map
- issueDevices: Set
- events: number
- issueCount: number
- launchDurations: number[]
- webviewDurations: number[]
- }>
+ versionMap: Map
+ versionGroup: NativeObserveVersionGroup
allDevices: Set
issueDevices: Set
issueCount: number
@@ -360,11 +450,14 @@ interface NativeObserveAggregateState {
events: number
}
-function createNativeObserveAggregateState(): NativeObserveAggregateState {
+function createNativeObserveAggregateState(
+ versionGroup: NativeObserveVersionGroup = 'version',
+): NativeObserveAggregateState {
return {
dailyMap: new Map(),
actionMap: new Map(),
versionMap: new Map(),
+ versionGroup,
allDevices: new Set(),
issueDevices: new Set(),
issueCount: 0,
@@ -375,7 +468,7 @@ function createNativeObserveAggregateState(): NativeObserveAggregateState {
}
}
-type NativeObserveMetricBucket = { events: number, devices: Set, durations: number[] }
+interface NativeObserveMetricBucket { events: number, devices: Set, durations: number[] }
function bumpMetricBucket(
map: Map,
key: string,
@@ -412,7 +505,22 @@ function foldOverviewCounters(state: NativeObserveAggregateState, sample: Native
}
function foldVersionBucket(state: NativeObserveAggregateState, sample: NativeObserveEventSample) {
- const version = state.versionMap.get(sample.version_name) ?? {
+ const platform = needsVersionDeviceDimensions(state.versionGroup)
+ ? (sample.platform || 'unknown')
+ : null
+ const channelName = state.versionGroup === 'version_platform_channel'
+ ? (sample.channel_name || 'unknown')
+ : null
+ const key = versionGroupBucketKey(
+ state.versionGroup,
+ sample.version_name,
+ platform ?? 'unknown',
+ channelName ?? 'unknown',
+ )
+ const version = state.versionMap.get(key) ?? {
+ version_name: sample.version_name,
+ platform,
+ channel_name: channelName,
devices: new Set(),
issueDevices: new Set(),
events: 0,
@@ -427,7 +535,7 @@ function foldVersionBucket(state: NativeObserveAggregateState, sample: NativeObs
version.issueDevices.add(sample.device_id)
}
pushTimedActionDuration(sample.action, sample.duration_ms, version.launchDurations, version.webviewDurations)
- state.versionMap.set(sample.version_name, version)
+ state.versionMap.set(key, version)
}
function foldNativeObserveSamples(state: NativeObserveAggregateState, samples: NativeObserveEventSample[]) {
@@ -473,12 +581,15 @@ function finalizeNativeObserveAggregate(state: NativeObserveAggregateState): {
return eventCmp !== 0 ? eventCmp : a.action.localeCompare(b.action)
})
- const versionRows: NativeObserveVersionRow[] = [...state.versionMap.entries()]
- .map(([version_name, value]) => {
+ const versionLimit = state.versionGroup === 'version' ? 12 : 36
+ const versionRows: NativeObserveVersionRow[] = [...state.versionMap.values()]
+ .map((value) => {
const launchMetrics = metricFromDurations(value.launchDurations)
const webviewMetrics = metricFromDurations(value.webviewDurations)
return {
- version_name,
+ version_name: value.version_name,
+ platform: value.platform,
+ channel_name: value.channel_name,
events: value.events,
devices: value.devices.size,
issue_count: value.issueCount,
@@ -489,9 +600,17 @@ function finalizeNativeObserveAggregate(state: NativeObserveAggregateState): {
})
.sort((a, b) => {
const eventCmp = toCount(b.events) - toCount(a.events)
- return eventCmp !== 0 ? eventCmp : a.version_name.localeCompare(b.version_name)
+ if (eventCmp !== 0)
+ return eventCmp
+ const versionCmp = a.version_name.localeCompare(b.version_name)
+ if (versionCmp !== 0)
+ return versionCmp
+ const platformCmp = (a.platform ?? '').localeCompare(b.platform ?? '')
+ if (platformCmp !== 0)
+ return platformCmp
+ return (a.channel_name ?? '').localeCompare(b.channel_name ?? '')
})
- .slice(0, 12)
+ .slice(0, versionLimit)
const launchSorted = [...state.launchDurations].sort((a, b) => a - b)
const webviewSorted = [...state.webviewDurations].sort((a, b) => a - b)
@@ -514,13 +633,15 @@ function finalizeNativeObserveAggregate(state: NativeObserveAggregateState): {
}
}
-function aggregateNativeObserveSamples(samples: NativeObserveEventSample[]) {
- const state = createNativeObserveAggregateState()
+function aggregateNativeObserveSamples(
+ samples: NativeObserveEventSample[],
+ versionGroup: NativeObserveVersionGroup = 'version',
+) {
+ const state = createNativeObserveAggregateState(versionGroup)
foldNativeObserveSamples(state, samples)
return finalizeNativeObserveAggregate(state)
}
-
function buildNativeObserveResponse(input: BuildNativeObserveResponseInput) {
const labelIndex = new Map(input.labels.map((label, index) => [label, index]))
const totalEvents = createSeries(input.labels.length)
@@ -584,6 +705,7 @@ function buildNativeObserveResponse(input: BuildNativeObserveResponseInput) {
start: input.start,
end: input.end,
},
+ version_group: input.versionGroup,
overview: {
total_events: toCount(overview.events),
total_devices: totalDevices,
@@ -617,6 +739,8 @@ function buildNativeObserveResponse(input: BuildNativeObserveResponseInput) {
})),
versions: input.versionRows.map(row => ({
version_name: row.version_name,
+ platform: row.platform ?? null,
+ channel_name: row.channel_name ?? null,
events: toCount(row.events),
devices: toCount(row.devices),
issue_count: toCount(row.issue_count),
@@ -667,6 +791,30 @@ async function readReleaseMarkers(
const MAX_NATIVE_OBSERVE_EVENTS = 100_000
const NATIVE_OBSERVE_CHUNK_CONCURRENCY = 4
+async function enrichNativeObserveSamplesWithDeviceMeta(
+ c: Context,
+ appId: string,
+ samples: NativeObserveEventSample[],
+ versionGroup: NativeObserveVersionGroup,
+) {
+ if (!needsVersionDeviceDimensions(versionGroup) || !samples.length)
+ return samples
+
+ const deviceMeta = await readDevicePlatformChannelByIdsCF(
+ c,
+ appId,
+ samples.map(sample => sample.device_id),
+ )
+ return samples.map((sample) => {
+ const meta = deviceMeta.get(sample.device_id)
+ return {
+ ...sample,
+ platform: meta?.platform ?? 'unknown',
+ channel_name: meta?.channel_name ?? 'unknown',
+ }
+ })
+}
+
async function foldNativeObserveTimingEventsCFChunked(
c: Context,
appId: string,
@@ -700,20 +848,32 @@ async function foldNativeObserveTimingEventsCFChunked(
for (const chunk of chunks) {
if (state.events >= MAX_NATIVE_OBSERVE_EVENTS)
break
- foldNativeObserveSamples(state, toNativeObserveEventSamples(chunk))
+ const samples = await enrichNativeObserveSamplesWithDeviceMeta(
+ c,
+ appId,
+ toNativeObserveEventSamples(chunk),
+ state.versionGroup,
+ )
+ foldNativeObserveSamples(state, samples)
}
}
}
+interface ReadNativeObserveStatsParams {
+ appId: string
+ days: NativeObservePeriodDays
+ labels: string[]
+ start: Dayjs
+ endExclusive: Dayjs
+ endInclusive: Dayjs
+ versionGroup: NativeObserveVersionGroup
+}
+
async function readNativeObserveStatsSB(
c: Context,
- appId: string,
- days: NativeObservePeriodDays,
- labels: string[],
- start: Dayjs,
- endExclusive: Dayjs,
- endInclusive: Dayjs,
+ input: ReadNativeObserveStatsParams,
) {
+ const { appId, days, labels, start, endExclusive, endInclusive, versionGroup } = input
const params = [appId, start.toISOString(), endExclusive.toISOString(), nativeObserveActions]
const paramsWithIssues = [...params, issueActions]
const db = getPgClient(c, true)
@@ -722,7 +882,10 @@ async function readNativeObserveStatsSB(
const dailyResult = await db.query(dailyStatsQuery, params)
const actionResult = await db.query(actionStatsQuery, params)
const overviewResult = await db.query(overviewStatsQuery, paramsWithIssues)
- const versionResult = await db.query(versionStatsQuery, paramsWithIssues)
+ const versionResult = await db.query(
+ buildVersionStatsQuery(versionGroup),
+ paramsWithIssues,
+ )
const releaseMarkersResult = await db.query(releaseMarkersQuery, params.slice(0, 3))
return buildNativeObserveResponse({
@@ -730,6 +893,7 @@ async function readNativeObserveStatsSB(
days,
start: start.toISOString(),
end: endInclusive.toISOString(),
+ versionGroup,
dailyRows: dailyResult.rows,
actionRows: actionResult.rows,
versionRows: versionResult.rows,
@@ -748,14 +912,10 @@ async function readNativeObserveStatsSB(
async function readNativeObserveStatsCF(
c: Context,
- appId: string,
- days: NativeObservePeriodDays,
- labels: string[],
- start: Dayjs,
- endExclusive: Dayjs,
- endInclusive: Dayjs,
+ input: ReadNativeObserveStatsParams,
) {
- const state = createNativeObserveAggregateState()
+ const { appId, days, labels, start, endExclusive, endInclusive, versionGroup } = input
+ const state = createNativeObserveAggregateState(versionGroup)
await foldNativeObserveTimingEventsCFChunked(c, appId, start, endExclusive, state)
const aggregates = finalizeNativeObserveAggregate(state)
const releaseMarkers = await readReleaseMarkers(c, appId, start, endExclusive)
@@ -765,6 +925,7 @@ async function readNativeObserveStatsCF(
days,
start: start.toISOString(),
end: endInclusive.toISOString(),
+ versionGroup,
dailyRows: aggregates.dailyRows,
actionRows: aggregates.actionRows,
versionRows: aggregates.versionRows,
@@ -773,34 +934,31 @@ async function readNativeObserveStatsCF(
})
}
-async function readNativeObserveStats(c: Context, appId: string, days: NativeObservePeriodDays) {
+async function readNativeObserveStats(
+ c: Context,
+ appId: string,
+ days: NativeObservePeriodDays,
+ versionGroup: NativeObserveVersionGroup,
+) {
const endExclusive = dayjs().utc().add(1, 'day').startOf('day')
const start = endExclusive.subtract(days, 'day')
const endInclusive = endExclusive.subtract(1, 'millisecond')
const labels = generateDateLabels(start.toDate(), endExclusive.subtract(1, 'day').toDate())
-
- // Same dual-path pattern as private/stats and update_delivery_stats.
- if (c.env.APP_LOG) {
- return readNativeObserveStatsCF(
- c,
- appId,
- days,
- labels,
- start,
- endExclusive,
- endInclusive,
- )
- }
-
- return readNativeObserveStatsSB(
- c,
+ const input: ReadNativeObserveStatsParams = {
appId,
days,
labels,
start,
endExclusive,
endInclusive,
- )
+ versionGroup,
+ }
+
+ // Same dual-path pattern as private/stats and update_delivery_stats.
+ if (c.env.APP_LOG)
+ return readNativeObserveStatsCF(c, input)
+
+ return readNativeObserveStatsSB(c, input)
}
async function readNativeObservePluginStatsSB(c: Context, appId: string) {
@@ -853,6 +1011,10 @@ app.post('/', middlewareAuth, async (c) => {
if (view === 'global' && !days)
throw simpleError('invalid_days', 'days must be one of 1, 3, 7, or 30')
+ const versionGroup = normalizeNativeObserveVersionGroup(body.version_group)
+ if (!versionGroup)
+ throw simpleError('invalid_version_group', 'version_group must be version, version_platform, or version_platform_channel')
+
if (!(await checkPermission(c, 'app.read', { appId: body.app_id })))
throw simpleError('app_access_denied', 'You can\'t access this app', { app_id: body.app_id })
@@ -860,7 +1022,12 @@ app.post('/', middlewareAuth, async (c) => {
if (view === 'plugins')
return c.json(await readNativeObservePluginStats(c, body.app_id))
- return c.json(await readNativeObserveStats(c, body.app_id, days as NativeObservePeriodDays))
+ return c.json(await readNativeObserveStats(
+ c,
+ body.app_id,
+ days as NativeObservePeriodDays,
+ versionGroup,
+ ))
}
catch (error) {
cloudlog({ requestId: c.get('requestId'), message: 'Error fetching native observe stats', error })
@@ -874,8 +1041,10 @@ export const nativeObserveStatsTestUtils = {
buildNativeObserveResponse,
generateDateLabels,
normalizeNativeObservePeriodDays,
+ normalizeNativeObserveVersionGroup,
normalizeNativeObserveView,
parseMetaDurationMs,
percentileCont,
toNativeObserveEventSamples,
+ versionGroupBucketKey,
}
diff --git a/supabase/functions/_backend/utils/cloudflare.ts b/supabase/functions/_backend/utils/cloudflare.ts
index 8b372b0ca9..788c52b967 100644
--- a/supabase/functions/_backend/utils/cloudflare.ts
+++ b/supabase/functions/_backend/utils/cloudflare.ts
@@ -1293,6 +1293,68 @@ export async function readDevicesCF(c: Context, params: ReadDevicesParams, custo
return [] as DeviceRes[]
}
+const DEVICE_PLATFORM_CHANNEL_LOOKUP_CHUNK = 200
+
+function platformOsFromCFDouble(platform: number | null | undefined): string {
+ if (platform === 1)
+ return 'ios'
+ if (platform === 2)
+ return 'electron'
+ if (platform === 0)
+ return 'android'
+ return 'unknown'
+}
+
+/**
+ * Resolve latest platform + default_channel for a set of device ids from DEVICE_INFO.
+ * Used by native observe version breakdown when grouping by platform/channel.
+ */
+export async function readDevicePlatformChannelByIdsCF(
+ c: Context,
+ appId: string,
+ deviceIds: string[],
+): Promise> {
+ const result = new Map()
+ if (!c.env.DEVICE_INFO || !deviceIds.length)
+ return result
+
+ const uniqueIds = [...new Set(deviceIds.filter(Boolean))]
+ for (let i = 0; i < uniqueIds.length; i += DEVICE_PLATFORM_CHANNEL_LOOKUP_CHUNK) {
+ const chunk = uniqueIds.slice(i, i + DEVICE_PLATFORM_CHANNEL_LOOKUP_CHUNK)
+ const devicesList = chunk.map(id => `'${escapeSqlString(id)}'`).join(', ')
+ const query = `SELECT
+ device_id,
+ platform,
+ channel_name
+FROM (
+ SELECT
+ blob1 AS device_id,
+ argMax(double1, timestamp) AS platform,
+ argMax(blob7, timestamp) AS channel_name
+ FROM device_info
+ WHERE index1 = '${escapeSqlString(appId)}'
+ AND blob1 IN (${devicesList})
+ GROUP BY blob1
+)`
+
+ cloudlog({ requestId: c.get('requestId'), message: 'readDevicePlatformChannelByIdsCF query', deviceCount: chunk.length })
+ try {
+ const rows = await runQueryToCFA<{ device_id: string, platform: number, channel_name: string }>(c, query)
+ for (const row of rows) {
+ result.set(row.device_id, {
+ platform: platformOsFromCFDouble(row.platform),
+ channel_name: row.channel_name?.trim() ? row.channel_name : 'unknown',
+ })
+ }
+ }
+ catch (e) {
+ cloudlogErr({ requestId: c.get('requestId'), message: 'Error reading device platform/channel map', error: serializeError(e), query })
+ }
+ }
+
+ return result
+}
+
interface StatRowCF {
app_id: string
device_id: string
diff --git a/tests/native-observe-stats.unit.test.ts b/tests/native-observe-stats.unit.test.ts
index 0d1cbcefed..1c441e09f4 100644
--- a/tests/native-observe-stats.unit.test.ts
+++ b/tests/native-observe-stats.unit.test.ts
@@ -19,6 +19,14 @@ describe('native observe stats helpers', () => {
expect(nativeObserveStatsTestUtils.normalizeNativeObserveView('unknown')).toBeNull()
})
+ it.concurrent('normalizes supported version groups', () => {
+ expect(nativeObserveStatsTestUtils.normalizeNativeObserveVersionGroup(undefined)).toBe('version')
+ expect(nativeObserveStatsTestUtils.normalizeNativeObserveVersionGroup('version')).toBe('version')
+ expect(nativeObserveStatsTestUtils.normalizeNativeObserveVersionGroup('version_platform')).toBe('version_platform')
+ expect(nativeObserveStatsTestUtils.normalizeNativeObserveVersionGroup('version_platform_channel')).toBe('version_platform_channel')
+ expect(nativeObserveStatsTestUtils.normalizeNativeObserveVersionGroup('unknown')).toBeNull()
+ })
+
it.concurrent('generates inclusive UTC day labels', () => {
expect(nativeObserveStatsTestUtils.generateDateLabels(
new Date('2026-07-01T18:00:00Z'),
@@ -32,6 +40,7 @@ describe('native observe stats helpers', () => {
days: 7,
start: '2026-07-01T00:00:00.000Z',
end: '2026-07-02T23:59:59.999Z',
+ versionGroup: 'version',
dailyRows: [
{ day: '2026-07-01', action: 'app_launch_ready', events: 4, devices: 3, p50_ms: 410.4, p90_ms: 912.8, p99_ms: 1200 },
{ day: '2026-07-01', action: 'webview_page_loaded', events: 3, devices: 2, p50_ms: 720, p90_ms: 1450, p99_ms: 1800 },
@@ -62,13 +71,14 @@ describe('native observe stats helpers', () => {
expect(response.overview.issue_free_rate).toBe(75)
expect(response.overview.launch_p90_ms).toBe(913)
+ expect(response.version_group).toBe('version')
expect(response.daily.launches).toEqual([4, 0])
expect(response.daily.webview_loads).toEqual([3, 0])
expect('pluginVersions' in response).toBe(false)
expect(response.daily.issue_events).toEqual([0, 1])
expect(response.daily.launch_p50_ms).toEqual([410, null])
expect(response.actionBreakdown[1]).toMatchObject({ action: 'app_crash', is_issue: true })
- expect(response.versions[0]).toMatchObject({ version_name: '1.2.3', issue_free_rate: 75, launch_p90_ms: 913 })
+ expect(response.versions[0]).toMatchObject({ version_name: '1.2.3', platform: null, channel_name: null, issue_free_rate: 75, launch_p90_ms: 913 })
expect(response.releaseMarkers).toHaveLength(1)
})
@@ -152,6 +162,45 @@ describe('native observe stats helpers', () => {
])
})
+ it.concurrent('aggregates version rows by platform and channel when requested', () => {
+ const aggregates = nativeObserveStatsTestUtils.aggregateNativeObserveSamples([
+ {
+ day: '2026-07-01',
+ action: 'app_launch_ready',
+ version_name: '1.0.0',
+ device_id: 'android-prod',
+ duration_ms: 400,
+ platform: 'android',
+ channel_name: 'production',
+ },
+ {
+ day: '2026-07-01',
+ action: 'app_launch_ready',
+ version_name: '1.0.0',
+ device_id: 'android-beta',
+ duration_ms: 500,
+ platform: 'android',
+ channel_name: 'beta',
+ },
+ {
+ day: '2026-07-01',
+ action: 'app_launch_ready',
+ version_name: '1.0.0',
+ device_id: 'ios-prod',
+ duration_ms: 450,
+ platform: 'ios',
+ channel_name: 'production',
+ },
+ ], 'version_platform_channel')
+
+ expect(aggregates.versionRows).toEqual(expect.arrayContaining([
+ expect.objectContaining({ version_name: '1.0.0', platform: 'android', channel_name: 'production', devices: 1, events: 1 }),
+ expect.objectContaining({ version_name: '1.0.0', platform: 'android', channel_name: 'beta', devices: 1, events: 1 }),
+ expect.objectContaining({ version_name: '1.0.0', platform: 'ios', channel_name: 'production', devices: 1, events: 1 }),
+ ]))
+ expect(aggregates.versionRows).toHaveLength(3)
+ })
+
it.concurrent('maps CF timing events into observe samples', () => {
expect(nativeObserveStatsTestUtils.toNativeObserveEventSamples([
{