diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index 723f1556d1..e04f7391aa 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -6,6 +6,7 @@ import { app as admin_stats } from '../../supabase/functions/_backend/private/ad import { app as channel_device } from '../../supabase/functions/_backend/private/channel_device.ts' import { app as channel_stats } from '../../supabase/functions/_backend/private/channel_stats.ts' import { app as native_observe_stats } from '../../supabase/functions/_backend/private/native_observe_stats.ts' +import { app as update_delivery_stats } from '../../supabase/functions/_backend/private/update_delivery_stats.ts' import { app as config } from '../../supabase/functions/_backend/private/config.ts' import { app as configBuilder } from '../../supabase/functions/_backend/private/config_builder.ts' import { app as create_device } from '../../supabase/functions/_backend/private/create_device.ts' @@ -129,6 +130,7 @@ appPrivate.route('/admin_stats', admin_stats) appPrivate.route('/stats', stats_priv) appPrivate.route('/channel_stats', channel_stats) appPrivate.route('/native_observe_stats', native_observe_stats) +appPrivate.route('/update_delivery_stats', update_delivery_stats) appPrivate.route('/stripe_checkout', stripe_checkout) appPrivate.route('/stripe_portal', stripe_portal) appPrivate.route('/verify_email_otp', verify_email_otp) diff --git a/docs/pr-assets/update-delivery-latency-observe.webp b/docs/pr-assets/update-delivery-latency-observe.webp new file mode 100644 index 0000000000..ef04062e49 Binary files /dev/null and b/docs/pr-assets/update-delivery-latency-observe.webp differ diff --git a/docs/pr-assets/update-delivery-latency.webp b/docs/pr-assets/update-delivery-latency.webp new file mode 100644 index 0000000000..a6c3e37c2f Binary files /dev/null and b/docs/pr-assets/update-delivery-latency.webp differ diff --git a/messages/en.json b/messages/en.json index 57ee226108..5fc40500e2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -880,6 +880,7 @@ "created-app-within-7-days": "Created App (within 7 days)", "created-channel-within-7-days": "Created Channel (within 7 days)", "demo-apps-created": "Demo Apps Created", + "demo": "Demo", "credits-pagination-label": "Page {current} / {total}", "credits-plan-overage": "{included}, then {price}", "credits-pricing-price": "{price} {unit}", @@ -1248,6 +1249,7 @@ "failed-to-create-api-key": "Failed to create API key", "failed-to-fetch-release-status": "Failed to fetch release status", "failed-to-fetch-native-observe-stats": "Failed to fetch native observe statistics", + "failed-to-fetch-update-delivery-stats": "Failed to fetch update delivery latency", "failed-to-fetch-statistics": "Failed to fetch statistics", "failed-to-get-user": "Failed to load the user", "failed-to-regenerate-api-key": "Failed to regenerate API key", @@ -2316,6 +2318,22 @@ "unsafe": "Unsafe", "update": "Update", "update-password-now": "Update Password Now", + "update-delivery-devices": "Devices measured", + "update-delivery-latency": "Time to deliver an update", + "update-delivery-latency-help": "Device-side download delivery latency percentiles from download start to download complete.", + "update-delivery-no-data": "No delivery latency data yet", + "update-delivery-no-data-help": "Percentiles appear when devices report download timing via stats metadata, or when download start and complete events can be paired.", + "update-delivery-no-data-help-platform": "Platform percentiles use download duration metadata only. Values appear when devices report duration_ms (or duration) on download complete events.", + "update-delivery-fetch-error": "Could not load delivery latency", + "update-delivery-fetch-error-help": "The request failed. Retry to refresh this chart.", + "update-delivery-retry": "Retry", + "update-delivery-p50": "P50", + "update-delivery-p75": "P75", + "update-delivery-p95": "P95", + "update-delivery-p99": "P99", + "update-delivery-samples": "Deliveries measured", + "update-delivery-trend": "Delivery latency trend", + "update-delivery-trend-help": "Daily P50, P75, P95, and P99 for time to deliver an update to devices.", "update_statistics": "Updates statistics", "updated-at": "Updated at", "updated-default-download-channel": "Updated default download channel.", diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 11e3a0b58e..696a00bca1 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -11,6 +11,7 @@ declare global { const WEBHOOK_EVENT_TYPES: typeof import('./stores/webhooks').WEBHOOK_EVENT_TYPES const asyncComputed: typeof import('@vueuse/core').asyncComputed const autoResetRef: typeof import('@vueuse/core').autoResetRef + const buildDemoUpdateDeliveryStats: typeof import('./composables/useUpdateDeliveryStats').buildDemoUpdateDeliveryStats const computed: typeof import('vue').computed const computedAsync: typeof import('@vueuse/core').computedAsync const computedEager: typeof import('@vueuse/core').computedEager @@ -294,6 +295,7 @@ declare global { const useToString: typeof import('@vueuse/core').useToString const useToggle: typeof import('@vueuse/core').useToggle const useTransition: typeof import('@vueuse/core').useTransition + const useUpdateDeliveryStats: typeof import('./composables/useUpdateDeliveryStats').useUpdateDeliveryStats const useUrlSearchParams: typeof import('@vueuse/core').useUrlSearchParams const useUserMedia: typeof import('@vueuse/core').useUserMedia const useVModel: typeof import('@vueuse/core').useVModel @@ -338,6 +340,9 @@ declare global { export type { CheckDomainResponse } from './composables/useSSORouting' import('./composables/useSSORouting') // @ts-ignore + export type { UpdateDeliveryScope, UpdateDeliveryStatsResponse } from './composables/useUpdateDeliveryStats' + import('./composables/useUpdateDeliveryStats') + // @ts-ignore export type { MetricCategory, DateRangeMode } from './stores/adminDashboard' import('./stores/adminDashboard') // @ts-ignore @@ -364,6 +369,7 @@ declare module 'vue' { readonly WEBHOOK_EVENT_TYPES: UnwrapRef readonly asyncComputed: UnwrapRef readonly autoResetRef: UnwrapRef + readonly buildDemoUpdateDeliveryStats: UnwrapRef readonly computed: UnwrapRef readonly computedAsync: UnwrapRef readonly computedEager: UnwrapRef @@ -644,6 +650,7 @@ declare module 'vue' { readonly useToString: UnwrapRef readonly useToggle: UnwrapRef readonly useTransition: UnwrapRef + readonly useUpdateDeliveryStats: UnwrapRef readonly useUrlSearchParams: UnwrapRef readonly useUserMedia: UnwrapRef readonly useVModel: UnwrapRef diff --git a/src/components.d.ts b/src/components.d.ts index 1a20483e87..ba9cb52d15 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -50,6 +50,7 @@ declare module 'vue' { ConnectAppPicker: typeof import('./components/connect/ConnectAppPicker.vue')['default'] CreditsCta: typeof import('./components/CreditsCta.vue')['default'] DataTable: typeof import('./components/DataTable.vue')['default'] + DeliveryLatencyPanel: typeof import('./components/dashboard/DeliveryLatencyPanel.vue')['default'] DemoOnboardingGate: typeof import('./components/dashboard/DemoOnboardingGate.vue')['default'] DemoOnboardingModal: typeof import('./components/dashboard/DemoOnboardingModal.vue')['default'] DeploymentBanner: typeof import('./components/dashboard/DeploymentBanner.vue')['default'] diff --git a/src/components/dashboard/DeliveryLatencyPanel.vue b/src/components/dashboard/DeliveryLatencyPanel.vue new file mode 100644 index 0000000000..8b9ed8a9c0 --- /dev/null +++ b/src/components/dashboard/DeliveryLatencyPanel.vue @@ -0,0 +1,312 @@ + + + diff --git a/src/components/dashboard/Usage.vue b/src/components/dashboard/Usage.vue index fa0cf0e222..bc3530e2c1 100644 --- a/src/components/dashboard/Usage.vue +++ b/src/components/dashboard/Usage.vue @@ -30,6 +30,7 @@ import { useDashboardAppsStore } from '~/stores/dashboardApps' import { useDialogV2Store } from '~/stores/dialogv2' import { useMainStore } from '~/stores/main' import { useOrganizationStore } from '~/stores/organization' +import DeliveryLatencyPanel from './DeliveryLatencyPanel.vue' import DeploymentStatsCard from './DeploymentStatsCard.vue' import UpdateStatsCard from './UpdateStatsCard.vue' import UsageCard from './UsageCard.vue' @@ -1168,4 +1169,13 @@ onBeforeUnmount(() => { + +
+ +
diff --git a/src/composables/useUpdateDeliveryStats.ts b/src/composables/useUpdateDeliveryStats.ts new file mode 100644 index 0000000000..8f949a37ba --- /dev/null +++ b/src/composables/useUpdateDeliveryStats.ts @@ -0,0 +1,165 @@ +import type { Ref } from 'vue' +import { ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { toast } from 'vue-sonner' +import { defaultApiHost, useSupabase } from '~/services/supabase' + +export type UpdateDeliveryScope = 'app' | 'org' | 'platform' + +export interface UpdateDeliveryStatsResponse { + scope: UpdateDeliveryScope + labels: string[] + period: { + requested_days: 1 | 3 | 7 | 30 + actual_days: number + start: string + end: string + } + overview: { + samples: number + devices: number + p50_ms: number | null + p75_ms: number | null + p95_ms: number | null + p99_ms: number | null + } + daily: { + samples: number[] + p50_ms: Array + p75_ms: Array + p95_ms: Array + p99_ms: Array + } +} + +export function useUpdateDeliveryStats( + params: () => { + scope: UpdateDeliveryScope + app_id?: string + org_id?: string + days: 1 | 3 | 7 | 30 + }, + logContext = 'update delivery stats', +) { + const supabase = useSupabase() + const { t } = useI18n() + const stats = ref(null) as Ref + const statsLoading = ref(false) + const statsError = ref(false) + let latestRequest = 0 + + async function fetchStats() { + const body = params() + if (body.scope === 'app' && !body.app_id) { + latestRequest += 1 + stats.value = null + statsError.value = false + statsLoading.value = false + return + } + if (body.scope === 'org' && !body.org_id) { + latestRequest += 1 + stats.value = null + statsError.value = false + statsLoading.value = false + return + } + + const requestId = ++latestRequest + statsLoading.value = true + statsError.value = false + stats.value = null + try { + const { data: sessionData } = await supabase.auth.getSession() + if (!sessionData.session) { + if (requestId === latestRequest) { + statsError.value = true + toast.error(t('not-authenticated')) + } + return + } + + const response = await fetch(`${defaultApiHost}/private/update_delivery_stats`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'authorization': `Bearer ${sessionData.session.access_token}`, + }, + body: JSON.stringify(body), + }) + + if (requestId !== latestRequest) + return + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + if (requestId !== latestRequest) + return + console.error(`Failed to fetch ${logContext}:`, errorData) + statsError.value = true + toast.error(t('failed-to-fetch-update-delivery-stats')) + return + } + + const payload = await response.json() as UpdateDeliveryStatsResponse + if (requestId !== latestRequest) + return + stats.value = payload + } + catch (error) { + if (requestId !== latestRequest) + return + console.error(`Error fetching ${logContext}:`, error) + statsError.value = true + toast.error(t('failed-to-fetch-update-delivery-stats')) + } + finally { + if (requestId === latestRequest) + statsLoading.value = false + } + } + + return { stats, statsLoading, statsError, fetchStats } +} + +export function buildDemoUpdateDeliveryStats(days: 1 | 3 | 7 | 30): UpdateDeliveryStatsResponse { + const labels: string[] = [] + const end = new Date() + for (let i = days - 1; i >= 0; i -= 1) { + const date = new Date(end) + date.setUTCDate(end.getUTCDate() - i) + labels.push(date.toISOString().slice(0, 10)) + } + + const p50_ms = labels.map((_, index) => 700 + index * 18) + const p75_ms = labels.map((_, index) => 1100 + index * 24) + const p95_ms = labels.map((_, index) => 2200 + index * 40) + const p99_ms = labels.map((_, index) => 3800 + index * 55) + const samples = labels.map((_, index) => 20 + index * 3) + + return { + scope: 'app', + labels, + period: { + requested_days: days, + actual_days: labels.length, + start: `${labels[0]}T00:00:00.000Z`, + end: `${labels[labels.length - 1]}T23:59:59.999Z`, + }, + overview: { + samples: samples.reduce((sum, value) => sum + value, 0), + devices: 42, + p50_ms: p50_ms[p50_ms.length - 1] ?? null, + p75_ms: p75_ms[p75_ms.length - 1] ?? null, + p95_ms: p95_ms[p95_ms.length - 1] ?? null, + p99_ms: p99_ms[p99_ms.length - 1] ?? null, + }, + daily: { + samples, + p50_ms, + p75_ms, + p95_ms, + p99_ms, + }, + } +} diff --git a/src/pages/admin/dashboard/updates.vue b/src/pages/admin/dashboard/updates.vue index fbce52e299..087992e43d 100644 --- a/src/pages/admin/dashboard/updates.vue +++ b/src/pages/admin/dashboard/updates.vue @@ -10,6 +10,7 @@ import { useRouter } from 'vue-router' import AdminFilterBar from '~/components/admin/AdminFilterBar.vue' import AdminMultiLineChart from '~/components/admin/AdminMultiLineChart.vue' import ChartCard from '~/components/dashboard/ChartCard.vue' +import DeliveryLatencyPanel from '~/components/dashboard/DeliveryLatencyPanel.vue' import PageLoader from '~/components/PageLoader.vue' import { formatNumberValue, formatOneDecimal } from '~/services/formatLocale' import { useAdminDashboardStore } from '~/stores/adminDashboard' @@ -290,6 +291,8 @@ displayStore.defaultBack = '/dashboard' /> + + diff --git a/src/pages/app/[app].observe.vue b/src/pages/app/[app].observe.vue index 2cfb2ef6b5..9cda4d1341 100644 --- a/src/pages/app/[app].observe.vue +++ b/src/pages/app/[app].observe.vue @@ -10,6 +10,7 @@ import IconAlertTriangle from '~icons/lucide/alert-triangle' import IconExternalLink from '~icons/lucide/external-link' import IconRocket from '~icons/lucide/rocket' import IconTimer from '~icons/lucide/timer' +import DeliveryLatencyPanel from '~/components/dashboard/DeliveryLatencyPanel.vue' import { useNativeObserveStats } from '~/composables/useNativeObserveStats' import { formatLocalDateShort } from '~/services/date' import { formatNumberValue } from '~/services/formatLocale' @@ -391,6 +392,13 @@ watch([packageId, days], async () => { +
diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 700a29f9f8..3249cd7c4c 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -3324,11 +3324,13 @@ export type Database = { country: string | null created_at: string | null created_via_invite: boolean + discord_username: string | null email: string email_preferences: Json enable_notifications: boolean first_name: string | null format_locale: string | null + github_username: string | null id: string image_url: string | null last_name: string | null @@ -3340,11 +3342,13 @@ export type Database = { country?: string | null created_at?: string | null created_via_invite?: boolean + discord_username?: string | null email: string email_preferences?: Json enable_notifications?: boolean first_name?: string | null format_locale?: string | null + github_username?: string | null id: string image_url?: string | null last_name?: string | null @@ -3356,11 +3360,13 @@ export type Database = { country?: string | null created_at?: string | null created_via_invite?: boolean + discord_username?: string | null email?: string email_preferences?: Json enable_notifications?: boolean first_name?: string | null format_locale?: string | null + github_username?: string | null id?: string image_url?: string | null last_name?: string | null diff --git a/supabase/functions/_backend/private/update_delivery_stats.ts b/supabase/functions/_backend/private/update_delivery_stats.ts new file mode 100644 index 0000000000..f266065304 --- /dev/null +++ b/supabase/functions/_backend/private/update_delivery_stats.ts @@ -0,0 +1,429 @@ +import type { Context } from 'hono' +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc.js' +import { Hono } from 'hono/tiny' +import { middlewareAuth, parseBody, simpleError, useCors } from '../utils/hono.ts' +import { cloudlog } from '../utils/logging.ts' +import { closeClient, getPgClient, logPgError } from '../utils/pg.ts' +import { checkPermission } from '../utils/rbac.ts' +import { supabaseClient as useSupabaseClient } from '../utils/supabase.ts' + +dayjs.extend(utc) + +const supportedPeriodDays = [1, 3, 7, 30] as const +type UpdateDeliveryPeriodDays = typeof supportedPeriodDays[number] +type UpdateDeliveryScope = 'app' | 'org' | 'platform' + +interface UpdateDeliveryStatsRequest { + scope?: UpdateDeliveryScope + app_id?: string + org_id?: string + days?: number +} + +interface UpdateDeliveryDailyRow { + day: string + samples: number | string + p50_ms: number | string | null + p75_ms: number | string | null + p95_ms: number | string | null + p99_ms: number | string | null +} + +interface UpdateDeliveryOverviewRow { + samples: number | string + devices: number | string + p50_ms: number | string | null + p75_ms: number | string | null + p95_ms: number | string | null + p99_ms: number | string | null +} + +type NumericValue = number | string | null | undefined + +const endActions = ['download_complete', 'download_zip_complete'] as const +const startActions = ['download_0', 'download_zip_start', 'download_manifest_start'] as const +const timingActions = [...endActions, ...startActions] as const + +const durationExpression = String.raw`CASE + WHEN s.metadata ? 'duration_ms' + AND s.metadata ->> 'duration_ms' ~ '^[0-9]+(\.[0-9]+)?$' + AND char_length(s.metadata ->> 'duration_ms') <= 15 + THEN (s.metadata ->> 'duration_ms')::double precision + WHEN s.metadata ? 'duration' + AND s.metadata ->> 'duration' ~ '^[0-9]+(\.[0-9]+)?$' + AND char_length(s.metadata ->> 'duration') <= 15 + THEN (s.metadata ->> 'duration')::double precision + ELSE NULL +END` + +function buildScopedDeliveriesCte(scope: UpdateDeliveryScope) { + const appFilter = scope === 'app' + ? 'AND s.app_id = $1' + : scope === 'org' + ? 'AND s.app_id IN (SELECT apps.app_id FROM public.apps WHERE apps.owner_org = $1)' + : '' + + // Platform stays metadata-only to avoid unbounded start/end pairing across all apps. + if (scope === 'platform') { + return `WITH deliveries AS ( + SELECT + to_char(date_trunc('day', s.created_at AT TIME ZONE 'UTC'), 'YYYY-MM-DD') AS day, + s.app_id, + s.device_id, + duration_ms + FROM ( + SELECT + s.created_at, + s.app_id, + s.device_id, + ${durationExpression} AS duration_ms + FROM public.stats s + WHERE s.created_at >= $1::timestamptz + AND s.created_at < $2::timestamptz + AND s.action = ANY($3::public.stats_action[]) + ) s + WHERE duration_ms IS NOT NULL + AND duration_ms >= 0 + AND duration_ms <= 7200000 +)` + } + + return `WITH scoped AS ( + SELECT + s.app_id, + s.device_id, + COALESCE(NULLIF(s.version_name, ''), 'unknown') AS version_name, + s.action, + s.created_at, + ${durationExpression} AS meta_duration_ms + FROM public.stats s + WHERE s.created_at >= ($2::timestamptz - INTERVAL '2 hours') + AND s.created_at < $3::timestamptz + AND s.action = ANY($4::public.stats_action[]) + ${appFilter} +), +ends AS ( + SELECT * + FROM scoped + WHERE action = ANY($5::public.stats_action[]) + AND created_at >= $2::timestamptz +), +starts AS ( + SELECT * + FROM scoped + WHERE action = ANY($6::public.stats_action[]) +), +deliveries AS ( + SELECT + to_char(date_trunc('day', e.created_at AT TIME ZONE 'UTC'), 'YYYY-MM-DD') AS day, + e.app_id, + e.device_id, + COALESCE( + e.meta_duration_ms, + EXTRACT(EPOCH FROM (e.created_at - start_event.created_at)) * 1000 + ) AS duration_ms + FROM ends e + LEFT JOIN LATERAL ( + SELECT s.created_at + FROM starts s + WHERE s.app_id = e.app_id + AND s.device_id = e.device_id + AND s.version_name = e.version_name + AND s.created_at <= e.created_at + AND s.created_at > e.created_at - INTERVAL '2 hours' + ORDER BY s.created_at DESC + LIMIT 1 + ) AS start_event ON TRUE + WHERE COALESCE( + e.meta_duration_ms, + EXTRACT(EPOCH FROM (e.created_at - start_event.created_at)) * 1000 + ) IS NOT NULL + AND COALESCE( + e.meta_duration_ms, + EXTRACT(EPOCH FROM (e.created_at - start_event.created_at)) * 1000 + ) >= 0 + AND COALESCE( + e.meta_duration_ms, + EXTRACT(EPOCH FROM (e.created_at - start_event.created_at)) * 1000 + ) <= 7200000 +)` +} + +function buildStatsQuery(scope: UpdateDeliveryScope) { + // One deliveries CTE shared by daily + overview so platform/org/app avoid a second full scan. + return `${buildScopedDeliveriesCte(scope)}, +daily AS ( + SELECT + day, + count(*)::integer AS samples, + percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) AS p50_ms, + percentile_cont(0.75) WITHIN GROUP (ORDER BY duration_ms) AS p75_ms, + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95_ms, + percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99_ms + FROM deliveries + GROUP BY day +), +overview AS ( + SELECT + count(*)::integer AS samples, + count(DISTINCT (app_id, device_id))::integer AS devices, + percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) AS p50_ms, + percentile_cont(0.75) WITHIN GROUP (ORDER BY duration_ms) AS p75_ms, + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95_ms, + percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99_ms + FROM deliveries +) +SELECT + COALESCE( + (SELECT json_agg(row_to_json(d) ORDER BY d.day) FROM daily d), + '[]'::json + ) AS daily, + COALESCE( + (SELECT row_to_json(o) FROM overview o), + json_build_object( + 'samples', 0, + 'devices', 0, + 'p50_ms', null, + 'p75_ms', null, + 'p95_ms', null, + 'p99_ms', null + ) + ) AS overview` +} + +function normalizePeriodDays(days: number | undefined = 7): UpdateDeliveryPeriodDays | null { + if (!Number.isInteger(days) || !supportedPeriodDays.includes(days as UpdateDeliveryPeriodDays)) + return null + return days as UpdateDeliveryPeriodDays +} + +function normalizeScope(value: unknown): UpdateDeliveryScope | null { + if (value === undefined || value === 'app') + return 'app' + if (value === 'org' || value === 'platform') + return value + return null +} + +function generateDateLabels(from: Date, to: Date) { + const start = dayjs(from).utc().startOf('day') + const end = dayjs(to).utc().startOf('day') + if (start.isAfter(end)) + return [] as string[] + + const labels: string[] = [] + let cursor = start + while (cursor.isBefore(end) || cursor.isSame(end)) { + labels.push(cursor.format('YYYY-MM-DD')) + cursor = cursor.add(1, 'day') + } + return labels +} + +function toCount(value: NumericValue) { + const numeric = Number(value ?? 0) + return Number.isFinite(numeric) ? Math.max(0, Math.round(numeric)) : 0 +} + +function toMetric(value: NumericValue, decimals = 0) { + if (value === null || value === undefined || value === '') + return null + const numeric = Number(value) + if (!Number.isFinite(numeric)) + return null + const factor = 10 ** decimals + return Math.round(numeric * factor) / factor +} + +function buildUpdateDeliveryResponse(input: { + labels: string[] + days: UpdateDeliveryPeriodDays + start: string + end: string + scope: UpdateDeliveryScope + dailyRows: UpdateDeliveryDailyRow[] + overviewRow: UpdateDeliveryOverviewRow | undefined +}) { + const labelIndex = new Map(input.labels.map((label, index) => [label, index])) + const samples = Array.from({ length: input.labels.length }).fill(0) + const p50 = Array.from({ length: input.labels.length }).fill(null) + const p75 = Array.from({ length: input.labels.length }).fill(null) + const p95 = Array.from({ length: input.labels.length }).fill(null) + const p99 = Array.from({ length: input.labels.length }).fill(null) + + for (const row of input.dailyRows) { + const index = labelIndex.get(row.day) + if (index === undefined) + continue + samples[index] = toCount(row.samples) + p50[index] = toMetric(row.p50_ms) + p75[index] = toMetric(row.p75_ms) + p95[index] = toMetric(row.p95_ms) + p99[index] = toMetric(row.p99_ms) + } + + const overview = input.overviewRow ?? { + samples: 0, + devices: 0, + p50_ms: null, + p75_ms: null, + p95_ms: null, + p99_ms: null, + } + + return { + scope: input.scope, + labels: input.labels, + period: { + requested_days: input.days, + actual_days: input.labels.length, + start: input.start, + end: input.end, + }, + overview: { + samples: toCount(overview.samples), + devices: toCount(overview.devices), + p50_ms: toMetric(overview.p50_ms), + p75_ms: toMetric(overview.p75_ms), + p95_ms: toMetric(overview.p95_ms), + p99_ms: toMetric(overview.p99_ms), + }, + daily: { + samples, + p50_ms: p50, + p75_ms: p75, + p95_ms: p95, + p99_ms: p99, + }, + } +} + +async function assertPlatformAdmin(c: Context) { + const authToken = c.req.header('authorization') + if (!authToken) + throw simpleError('not_authorized', 'Not authorized') + + const supabaseClient = useSupabaseClient(c, authToken) + const { data: isAdmin, error: adminError } = await supabaseClient.rpc('is_platform_admin') + if (adminError) { + cloudlog({ requestId: c.get('requestId'), message: 'is_admin_error', error: adminError }) + throw simpleError('is_admin_error', 'Is admin error', { adminError }) + } + if (!isAdmin) + throw simpleError('not_admin', 'Not admin - only admin users can access platform delivery latency') +} + +async function readUpdateDeliveryStats( + c: Context, + scope: UpdateDeliveryScope, + days: UpdateDeliveryPeriodDays, + scopeId?: string, +) { + 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()) + const db = getPgClient(c, true) + + try { + const query = buildStatsQuery(scope) + const params = scope === 'platform' + ? [start.toISOString(), endExclusive.toISOString(), [...endActions]] + : [scopeId, start.toISOString(), endExclusive.toISOString(), [...timingActions], [...endActions], [...startActions]] + + const result = await db.query<{ + daily: UpdateDeliveryDailyRow[] | string + overview: UpdateDeliveryOverviewRow | string + }>(query, params) + + const row = result.rows[0] + const dailyRows = typeof row?.daily === 'string' + ? JSON.parse(row.daily) as UpdateDeliveryDailyRow[] + : (row?.daily ?? []) + const overviewRow = typeof row?.overview === 'string' + ? JSON.parse(row.overview) as UpdateDeliveryOverviewRow + : row?.overview + + return buildUpdateDeliveryResponse({ + labels, + days, + start: start.toISOString(), + end: endInclusive.toISOString(), + scope, + dailyRows: Array.isArray(dailyRows) ? dailyRows : [], + overviewRow: overviewRow ?? undefined, + }) + } + catch (error) { + logPgError(c, 'readUpdateDeliveryStats', error) + throw error + } + finally { + await closeClient(c, db) + } +} + +export const app = new Hono() + +app.use('/', useCors) + +app.post('/', middlewareAuth, async (c) => { + const body = await parseBody(c) + cloudlog({ requestId: c.get('requestId'), message: 'post update_delivery_stats body', body }) + + const scope = normalizeScope(body.scope) + if (!scope) + throw simpleError('invalid_scope', 'scope must be app, org, or platform') + + const days = normalizePeriodDays(body.days) + if (!days) + throw simpleError('invalid_days', 'days must be one of 1, 3, 7, or 30') + + if (scope === 'platform') { + await assertPlatformAdmin(c) + try { + return c.json(await readUpdateDeliveryStats(c, scope, days)) + } + catch (error) { + cloudlog({ requestId: c.get('requestId'), message: 'Error fetching platform update delivery stats', error }) + throw simpleError('fetch_error', 'Failed to fetch update delivery statistics', { error: String(error) }) + } + } + + if (scope === 'app') { + if (!body.app_id) + throw simpleError('missing_params', 'app_id is required for app scope') + 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 }) + try { + return c.json(await readUpdateDeliveryStats(c, scope, days, body.app_id)) + } + catch (error) { + cloudlog({ requestId: c.get('requestId'), message: 'Error fetching app update delivery stats', error }) + throw simpleError('fetch_error', 'Failed to fetch update delivery statistics', { error: String(error) }) + } + } + + if (!body.org_id) + throw simpleError('missing_params', 'org_id is required for org scope') + if (!(await checkPermission(c, 'org.read', { orgId: body.org_id }))) + throw simpleError('org_access_denied', 'You can\'t access this organization', { org_id: body.org_id }) + + try { + return c.json(await readUpdateDeliveryStats(c, scope, days, body.org_id)) + } + catch (error) { + cloudlog({ requestId: c.get('requestId'), message: 'Error fetching org update delivery stats', error }) + throw simpleError('fetch_error', 'Failed to fetch update delivery statistics', { error: String(error) }) + } +}) + +export const updateDeliveryStatsTestUtils = { + buildUpdateDeliveryResponse, + generateDateLabels, + normalizePeriodDays, + normalizeScope, + toMetric, +} diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 700a29f9f8..3249cd7c4c 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -3324,11 +3324,13 @@ export type Database = { country: string | null created_at: string | null created_via_invite: boolean + discord_username: string | null email: string email_preferences: Json enable_notifications: boolean first_name: string | null format_locale: string | null + github_username: string | null id: string image_url: string | null last_name: string | null @@ -3340,11 +3342,13 @@ export type Database = { country?: string | null created_at?: string | null created_via_invite?: boolean + discord_username?: string | null email: string email_preferences?: Json enable_notifications?: boolean first_name?: string | null format_locale?: string | null + github_username?: string | null id: string image_url?: string | null last_name?: string | null @@ -3356,11 +3360,13 @@ export type Database = { country?: string | null created_at?: string | null created_via_invite?: boolean + discord_username?: string | null email?: string email_preferences?: Json enable_notifications?: boolean first_name?: string | null format_locale?: string | null + github_username?: string | null id?: string image_url?: string | null last_name?: string | null diff --git a/tests/update-delivery-stats.unit.test.ts b/tests/update-delivery-stats.unit.test.ts new file mode 100644 index 0000000000..9b229dbf9f --- /dev/null +++ b/tests/update-delivery-stats.unit.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { updateDeliveryStatsTestUtils } from '../supabase/functions/_backend/private/update_delivery_stats.ts' + +describe('update delivery stats helpers', () => { + it.concurrent('normalizes supported period presets', () => { + expect(updateDeliveryStatsTestUtils.normalizePeriodDays(undefined)).toBe(7) + expect(updateDeliveryStatsTestUtils.normalizePeriodDays(1)).toBe(1) + expect(updateDeliveryStatsTestUtils.normalizePeriodDays(3)).toBe(3) + expect(updateDeliveryStatsTestUtils.normalizePeriodDays(7)).toBe(7) + expect(updateDeliveryStatsTestUtils.normalizePeriodDays(30)).toBe(30) + expect(updateDeliveryStatsTestUtils.normalizePeriodDays(2)).toBeNull() + expect(updateDeliveryStatsTestUtils.normalizePeriodDays(7.5)).toBeNull() + }) + + it.concurrent('normalizes supported scopes', () => { + expect(updateDeliveryStatsTestUtils.normalizeScope(undefined)).toBe('app') + expect(updateDeliveryStatsTestUtils.normalizeScope('app')).toBe('app') + expect(updateDeliveryStatsTestUtils.normalizeScope('org')).toBe('org') + expect(updateDeliveryStatsTestUtils.normalizeScope('platform')).toBe('platform') + expect(updateDeliveryStatsTestUtils.normalizeScope('unknown')).toBeNull() + }) + + it.concurrent('generates inclusive UTC day labels', () => { + expect(updateDeliveryStatsTestUtils.generateDateLabels( + new Date('2026-07-01T18:00:00Z'), + new Date('2026-07-03T02:00:00Z'), + )).toEqual(['2026-07-01', '2026-07-02', '2026-07-03']) + }) + + it('builds overview and daily percentile series', () => { + const response = updateDeliveryStatsTestUtils.buildUpdateDeliveryResponse({ + labels: ['2026-07-01', '2026-07-02'], + days: 7, + start: '2026-07-01T00:00:00.000Z', + end: '2026-07-02T23:59:59.999Z', + scope: 'app', + dailyRows: [ + { day: '2026-07-01', samples: 12, p50_ms: 820.4, p75_ms: 1100, p95_ms: 2400.2, p99_ms: 4100 }, + { day: '2026-07-02', samples: 8, p50_ms: 900, p75_ms: 1300, p95_ms: 2800, p99_ms: 5000 }, + ], + overviewRow: { + samples: 20, + devices: 9, + p50_ms: 860.2, + p75_ms: 1200, + p95_ms: 2600.8, + p99_ms: 4500, + }, + }) + + expect(response.scope).toBe('app') + expect(response.overview).toMatchObject({ + samples: 20, + devices: 9, + p50_ms: 860, + p75_ms: 1200, + p95_ms: 2601, + p99_ms: 4500, + }) + expect(response.daily.samples).toEqual([12, 8]) + expect(response.daily.p50_ms).toEqual([820, 900]) + expect(response.daily.p75_ms).toEqual([1100, 1300]) + expect(response.daily.p95_ms).toEqual([2400, 2800]) + expect(response.daily.p99_ms).toEqual([4100, 5000]) + }) + + it('keeps null percentiles as null', () => { + expect(updateDeliveryStatsTestUtils.toMetric(null)).toBeNull() + expect(updateDeliveryStatsTestUtils.toMetric(undefined)).toBeNull() + expect(updateDeliveryStatsTestUtils.toMetric('')).toBeNull() + expect(updateDeliveryStatsTestUtils.toMetric(12.4)).toBe(12) + }) +})