Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions src/types/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
37 changes: 35 additions & 2 deletions supabase/functions/_backend/private/public_stats.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)

@cubic-dev-ai cubic-dev-ai Bot Jul 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: During daily shard processing, /live_updates can still show a success rate that differs from admin because this query admits partially completed global_stats rows that the admin query excludes. Filtering on completed_shards with REQUIRED_GLOBAL_STATS_SHARDS keeps the KPI and daily series aligned with admin.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/_backend/private/public_stats.ts, line 48:

<comment>During daily shard processing, `/live_updates` can still show a success rate that differs from admin because this query admits partially completed `global_stats` rows that the admin query excludes. Filtering on `completed_shards` with `REQUIRED_GLOBAL_STATS_SHARDS` keeps the KPI and daily series aligned with admin.</comment>

<file context>
@@ -33,6 +34,34 @@ export function getLatestCompletedGlobalStatsDateId(referenceDate = new Date())
+    .select('date_id, success_rate')
+    .gte('date_id', startDateId)
+    .lte('date_id', latestCompletedDateId)
+    .not('success_rate', 'is', null)
+    .order('date_id', { ascending: true })
+
</file context>
Suggested change
.not('success_rate', 'is', null)
.contains('completed_shards', [...REQUIRED_GLOBAL_STATS_SHARDS])
Fix with cubic

.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)
Expand Down Expand Up @@ -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)
}
Expand Down
27 changes: 8 additions & 19 deletions supabase/functions/_backend/utils/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublicLiveUpdateMetrics> {
export type PublicLiveUpdateMetrics = PublicLiveUpdateBreakdownMetrics & {
success_rate: number
daily: Array<{ date: string, success_rate: number }>
}

export async function getPublicLiveUpdateMetricsCF(c: Context, referenceDate = new Date()): Promise<PublicLiveUpdateBreakdownMetrics> {

@cubic-dev-ai cubic-dev-ai Bot Jul 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Callers can reasonably expect getPublicLiveUpdateMetricsCF to return the complete live-update metric contract, but it now returns only breakdowns and omits success_rate/daily. Renaming it to getPublicLiveUpdateBreakdownMetricsCF would keep the exported API aligned with its new responsibility.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/_backend/utils/cloudflare.ts, line 2601:

<comment>Callers can reasonably expect `getPublicLiveUpdateMetricsCF` to return the complete live-update metric contract, but it now returns only breakdowns and omits `success_rate`/`daily`. Renaming it to `getPublicLiveUpdateBreakdownMetricsCF` would keep the exported API aligned with its new responsibility.</comment>

<file context>
@@ -2587,15 +2587,18 @@ export async function getPluginBreakdownCF(c: Context, referenceDate?: Date): Pr
+  daily: Array<{ date: string, success_rate: number }>
+}
+
+export async function getPublicLiveUpdateMetricsCF(c: Context, referenceDate = new Date()): Promise<PublicLiveUpdateBreakdownMetrics> {
   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')
</file context>
Fix with cubic

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')

Expand All @@ -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 }))
Expand All @@ -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
Expand Down
3 changes: 0 additions & 3 deletions supabase/functions/_backend/utils/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
19 changes: 3 additions & 16 deletions tests/public-live-update-metrics.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down Expand Up @@ -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')
})
})
29 changes: 23 additions & 6 deletions tests/public-stats.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 }],
Expand All @@ -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', {
Expand Down
Loading