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 @@ + + + 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 }}

- +
- +
+ + @@ -464,10 +505,16 @@ watch([packageId, days], async () => { - + + + 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([ {
{{ t('native-observe-version') }} + {{ t('native-observe-platform') }} + + {{ t('native-observe-channel') }} + {{ t('events') }}
{{ version.version_name }} + {{ version.platform || '-' }} + + {{ version.channel_name || '-' }} + {{ formatCount(version.events) }} {{ formatCount(version.devices) }} {{ formatPercent(version.issue_free_rate) }}