diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 28e1266ba5..3249cd7c4c 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -3329,7 +3329,6 @@ export type Database = { email_preferences: Json enable_notifications: boolean first_name: string | null - discord_username: string | null format_locale: string | null github_username: string | null id: string @@ -3348,7 +3347,6 @@ export type Database = { email_preferences?: Json enable_notifications?: boolean first_name?: string | null - discord_username?: string | null format_locale?: string | null github_username?: string | null id: string @@ -3367,7 +3365,6 @@ export type Database = { email_preferences?: Json enable_notifications?: boolean first_name?: string | null - discord_username?: string | null format_locale?: string | null github_username?: string | null id?: string diff --git a/supabase/functions/_backend/private/public_stats.ts b/supabase/functions/_backend/private/public_stats.ts index aefd18c592..822a9b9c1c 100644 --- a/supabase/functions/_backend/private/public_stats.ts +++ b/supabase/functions/_backend/private/public_stats.ts @@ -1,3 +1,4 @@ +import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../utils/hono.ts' import type { PublicLiveUpdateMetrics } from '../utils/cloudflare.ts' import { Hono } from 'hono/tiny' @@ -33,6 +34,34 @@ export function getLatestCompletedGlobalStatsDateId(referenceDate = new Date()) return completedDay.toISOString().slice(0, 10) } +export async function getGlobalStatsSuccessRates(c: Context, referenceDate = new Date()) { + const latestCompletedDateId = getLatestCompletedGlobalStatsDateId(referenceDate) + const start = new Date(`${latestCompletedDateId}T00:00:00.000Z`) + start.setUTCDate(start.getUTCDate() - 29) + const startDateId = start.toISOString().slice(0, 10) + + const { data, error } = await supabaseAdmin(c) + .from('global_stats') + .select('date_id, success_rate') + .gte('date_id', startDateId) + .lte('date_id', latestCompletedDateId) + .not('success_rate', 'is', null) + .order('date_id', { ascending: true }) + + if (error) + throw error + + const daily = (data ?? []).map(row => ({ + date: row.date_id, + success_rate: Number(row.success_rate) || 0, + })) + + return { + success_rate: daily.at(-1)?.success_rate ?? 0, + daily, + } +} + app.get('/', async (c) => { const latestCompletedDateId = getLatestCompletedGlobalStatsDateId() const { data, error } = await supabaseAdmin(c) @@ -65,11 +94,15 @@ app.get('/live_updates', async (c) => { if (!response) { try { - const metrics = await getPublicLiveUpdateMetricsCF(c) + const [successRates, breakdown] = await Promise.all([ + getGlobalStatsSuccessRates(c), + getPublicLiveUpdateMetricsCF(c), + ]) response = { period_days: 30, updated_at: new Date().toISOString(), - ...metrics, + ...breakdown, + ...successRates, } await cache.putJson(cacheKey, response, LIVE_UPDATE_METRICS_CACHE_TTL_SECONDS) } diff --git a/supabase/functions/_backend/utils/cloudflare.ts b/supabase/functions/_backend/utils/cloudflare.ts index 587e1fe947..d34660a20b 100644 --- a/supabase/functions/_backend/utils/cloudflare.ts +++ b/supabase/functions/_backend/utils/cloudflare.ts @@ -2587,15 +2587,18 @@ export async function getPluginBreakdownCF(c: Context, referenceDate?: Date): Pr const PUBLIC_FAILURE_ACTIONS = ['set_fail', 'update_fail', 'download_fail', 'windows_path_fail', 'canonical_path_fail', 'directory_path_fail', 'unzip_fail', 'low_mem_fail', 'download_manifest_file_fail', 'download_manifest_checksum_fail', 'download_manifest_brotli_fail', 'finish_download_fail', 'manifest_path_fail', 'decrypt_fail', 'insufficient_disk_space', 'cannotGetBundle', 'checksum_fail', 'blocked_by_server_url', 'backend_refusal'] as const -export interface PublicLiveUpdateMetrics { - success_rate: number - daily: Array<{ date: string, success_rate: number }> +export interface PublicLiveUpdateBreakdownMetrics { failures: Array<{ reason: string, share: number }> platforms: { ios: number, android: number, electron: number } updater_versions: Array<{ date: string, version: string, share: number }> } -export async function getPublicLiveUpdateMetricsCF(c: Context, referenceDate = new Date()): Promise { +export type PublicLiveUpdateMetrics = PublicLiveUpdateBreakdownMetrics & { + success_rate: number + daily: Array<{ date: string, success_rate: number }> +} + +export async function getPublicLiveUpdateMetricsCF(c: Context, referenceDate = new Date()): Promise { if (!c.env.APP_LOG || !c.env.DEVICE_USAGE || !c.env.DEVICE_INFO || !getEnv(c, 'CF_ANALYTICS_TOKEN') || !getEnv(c, 'CF_ACCOUNT_ANALYTICS_ID')) throw new Error('Public live update metric bindings are unavailable') @@ -2605,28 +2608,16 @@ export async function getPublicLiveUpdateMetricsCF(c: Context, referenceDate = n const window = `timestamp >= toDateTime('${formatDateCF(start)}') AND timestamp < toDateTime('${formatDateCF(end)}')` const failureActions = PUBLIC_FAILURE_ACTIONS.map(action => `'${action}'`).join(', ') const day = `formatDateTime(toStartOfInterval(timestamp, INTERVAL '1' DAY), '%Y-%m-%d')` - const outcomesQuery = `SELECT date, sum(succeeded) AS successes, sum(if(succeeded = 0, failed, 0)) AS failures FROM (SELECT ${day} AS date, index1 AS app_id, blob1 AS device_id, max(if(blob2 = 'set', 1, 0)) AS succeeded, max(if(blob2 IN (${failureActions}), 1, 0)) AS failed FROM app_log WHERE ${window} AND (blob2 = 'set' OR blob2 IN (${failureActions})) GROUP BY date, app_id, device_id) GROUP BY date` const failuresQuery = `SELECT action, count() AS devices FROM (SELECT ${day} AS date, blob2 AS action, index1 AS app_id, blob1 AS device_id FROM app_log WHERE ${window} AND blob2 IN (${failureActions}) GROUP BY date, action, app_id, device_id) GROUP BY action` const platformsQuery = `SELECT platform, count() AS devices FROM (SELECT double1 AS platform, index1 AS app_id, blob1 AS device_id FROM device_usage WHERE ${window} AND double1 IN (0.0, 1.0, 2.0) GROUP BY platform, app_id, device_id) GROUP BY platform` const updaterVersionsQuery = `SELECT date, version, count() AS devices FROM (SELECT ${day} AS date, index1 AS app_id, blob1 AS device_id, argMax(blob3, timestamp) AS version FROM device_info WHERE ${window} AND blob3 != '' GROUP BY date, app_id, device_id) GROUP BY date, version` try { - const [outcomeRows, failureRows, platformRows, versionRows] = await Promise.all([ - runQueryToCFA<{ date: string, successes: number, failures: number }>(c, outcomesQuery), + const [failureRows, platformRows, versionRows] = await Promise.all([ runQueryToCFA<{ action: string, devices: number }>(c, failuresQuery), runQueryToCFA<{ platform: number, devices: number }>(c, platformsQuery), runQueryToCFA<{ date: string, version: string, devices: number }>(c, updaterVersionsQuery), ]) - const daily = outcomeRows.map((row) => { - const successes = Number(row.successes) || 0 - const failures = Number(row.failures) || 0 - const outcomes = successes + failures - return { date: row.date, success_rate: outcomes ? (successes / outcomes) * 100 : 0 } - }).sort((a, b) => a.date.localeCompare(b.date)) - const totalSuccesses = outcomeRows.reduce((sum, row) => sum + (Number(row.successes) || 0), 0) - const totalFailures = outcomeRows.reduce((sum, row) => sum + (Number(row.failures) || 0), 0) - const totalOutcomes = totalSuccesses + totalFailures - const success_rate = totalOutcomes ? (totalSuccesses / totalOutcomes) * 100 : 0 const failureTotal = failureRows.reduce((sum, row) => sum + (Number(row.devices) || 0), 0) const failures = failureRows .map(row => ({ reason: row.action, share: failureTotal ? ((Number(row.devices) || 0) / failureTotal) * 100 : 0 })) @@ -2652,8 +2643,6 @@ export async function getPublicLiveUpdateMetricsCF(c: Context, referenceDate = n for (const row of versionRows) versionTotals.set(row.date, (versionTotals.get(row.date) ?? 0) + (Number(row.devices) || 0)) return { - success_rate, - daily, failures, platforms, updater_versions: versionRows diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 28e1266ba5..3249cd7c4c 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -3329,7 +3329,6 @@ export type Database = { email_preferences: Json enable_notifications: boolean first_name: string | null - discord_username: string | null format_locale: string | null github_username: string | null id: string @@ -3348,7 +3347,6 @@ export type Database = { email_preferences?: Json enable_notifications?: boolean first_name?: string | null - discord_username?: string | null format_locale?: string | null github_username?: string | null id: string @@ -3367,7 +3365,6 @@ export type Database = { email_preferences?: Json enable_notifications?: boolean first_name?: string | null - discord_username?: string | null format_locale?: string | null github_username?: string | null id?: string diff --git a/tests/public-live-update-metrics.unit.test.ts b/tests/public-live-update-metrics.unit.test.ts index 8b41f1b217..2bc2be78ae 100644 --- a/tests/public-live-update-metrics.unit.test.ts +++ b/tests/public-live-update-metrics.unit.test.ts @@ -44,17 +44,6 @@ describe('public live update metrics', () => { const query = String(init?.body ?? '') queries.push(query) - if (query.includes('sum(succeeded)')) { - return analyticsResponse( - [ - { name: 'date', type: 'String' }, - { name: 'successes', type: 'UInt64' }, - { name: 'failures', type: 'UInt64' }, - ], - [{ date: '2026-06-30', successes: '117', failures: '3' }], - ) - } - if (query.includes('SELECT action, count() AS devices')) { return analyticsResponse( [ @@ -95,17 +84,15 @@ describe('public live update metrics', () => { ) expect(metrics).toEqual({ - success_rate: 97.5, - daily: [{ date: '2026-06-30', success_rate: 97.5 }], failures: [{ reason: 'download_fail', share: 100 }], platforms: { ios: 25, android: 66.66666666666666, electron: 8.333333333333332 }, updater_versions: [{ date: '2026-06-30', version: '8.1.0', share: 100 }], }) - expect(queries).toHaveLength(4) + expect(queries).toHaveLength(3) expect(queries.join('\n')).not.toContain('toString') expect(queries.join('\n')).not.toContain('concat') + expect(queries.join('\n')).not.toContain('sum(succeeded)') expect(queries.find(query => query.includes('FROM device_usage'))).toContain('double1 IN (0.0, 1.0, 2.0)') - expect(queries.find(query => query.includes('sum(succeeded)'))).toContain('GROUP BY date, app_id, device_id') - expect(queries.find(query => query.includes('sum(succeeded)'))).toContain('sum(if(succeeded = 0, failed, 0))') + expect(queries.find(query => query.includes('SELECT action, count() AS devices'))).toContain('GROUP BY date, action, app_id, device_id') }) }) diff --git a/tests/public-stats.unit.test.ts b/tests/public-stats.unit.test.ts index 02194e650e..9e1e6beb2b 100644 --- a/tests/public-stats.unit.test.ts +++ b/tests/public-stats.unit.test.ts @@ -6,8 +6,10 @@ const mocks = vi.hoisted(() => { const limit = vi.fn(() => ({ maybeSingle })) const order = vi.fn(() => ({ limit })) const contains = vi.fn(() => ({ order })) - const lte = vi.fn(() => ({ contains })) - const select = vi.fn(() => ({ lte })) + const not = vi.fn(() => ({ order })) + const lte = vi.fn(() => ({ contains, not })) + const gte = vi.fn(() => ({ lte })) + const select = vi.fn(() => ({ lte, gte })) const from = vi.fn(() => ({ select })) return { @@ -16,9 +18,11 @@ const mocks = vi.hoisted(() => { contains, from, getPublicLiveUpdateMetricsCF: vi.fn(), + gte, limit, lte, maybeSingle, + not, order, select, serializeError: vi.fn(error => error), @@ -47,6 +51,10 @@ describe('public stats endpoint', () => { vi.clearAllMocks() vi.useFakeTimers() vi.setSystemTime(new Date('2026-05-11T12:00:00.000Z')) + mocks.order.mockImplementation(() => ({ limit: mocks.limit })) + mocks.lte.mockImplementation(() => ({ contains: mocks.contains, not: mocks.not })) + mocks.gte.mockImplementation(() => ({ lte: mocks.lte })) + mocks.select.mockImplementation(() => ({ lte: mocks.lte, gte: mocks.gte })) }) afterEach(() => { @@ -84,9 +92,11 @@ describe('public stats endpoint', () => { }) it('serves runtime live update metrics with browser CORS', async () => { + mocks.order.mockImplementationOnce(() => Promise.resolve({ + data: [{ date_id: '2026-05-10', success_rate: 84 }], + error: null, + }) as any) mocks.getPublicLiveUpdateMetricsCF.mockResolvedValueOnce({ - success_rate: 97.5, - daily: [{ date: '2026-05-10', success_rate: 97.5 }], failures: [{ reason: 'download_fail', share: 100 }], platforms: { ios: 25, android: 66.7, electron: 8.3 }, updater_versions: [{ date: '2026-05-10', version: '8.1.0', share: 100 }], @@ -102,17 +112,24 @@ describe('public stats endpoint', () => { expect(await res.json()).toEqual({ period_days: 30, updated_at: '2026-05-11T12:00:00.000Z', - success_rate: 97.5, - daily: [{ date: '2026-05-10', success_rate: 97.5 }], + success_rate: 84, + daily: [{ date: '2026-05-10', success_rate: 84 }], failures: [{ reason: 'download_fail', share: 100 }], platforms: { ios: 25, android: 66.7, electron: 8.3 }, updater_versions: [{ date: '2026-05-10', version: '8.1.0', share: 100 }], }) + expect(mocks.from).toHaveBeenCalledWith('global_stats') + expect(mocks.gte).toHaveBeenCalledWith('date_id', '2026-04-11') + expect(mocks.lte).toHaveBeenCalledWith('date_id', '2026-05-10') expect(mocks.getPublicLiveUpdateMetricsCF).toHaveBeenCalledOnce() }) it('does not cache an unavailable live metric query as zero data', async () => { const error = new Error('Analytics Engine query failed') + mocks.order.mockImplementationOnce(() => Promise.resolve({ + data: [{ date_id: '2026-05-10', success_rate: 84 }], + error: null, + }) as any) mocks.getPublicLiveUpdateMetricsCF.mockRejectedValueOnce(error) const res = await app.request('http://localhost/live_updates', {