Skip to content
Merged
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
184 changes: 98 additions & 86 deletions supabase/functions/_backend/public/statistics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@
const STATS_QUERY_RETRY_ATTEMPTS = 3
const STATS_QUERY_RETRY_DELAY_MS = 250
const STORAGE_BYTE_HOURS_PAGE_SIZE = 1000
// PostgREST max_rows is 1000. Org dashboards return apps × days and must paginate
// or bandwidth/MAU for later app_ids are silently dropped while /app/:id still works.
const APP_METRICS_PAGE_SIZE = 1000

// Helper to get authenticated supabase client based on auth type
function getAuthenticatedSupabase(c: Context, auth: AuthInfo) {
Expand Down Expand Up @@ -273,12 +276,73 @@
return ownerOrg
}

async function fetchAppMetricsRows(
c: Context,
supabase: ReturnType<typeof supabaseClient>,
params: {
orgId: string
appId?: string | null
startDate: string
endDate: string
},
): Promise<QueryResult<AppMetricRow[]>> {
const rows: AppMetricRow[] = []

for (let pageStart = 0; ; pageStart += APP_METRICS_PAGE_SIZE) {
const { data, error, status } = await executeStatsQueryWithRetry<AppMetricRow[]>(
c,
params.appId ? 'get_app_metrics_for_app' : 'get_app_metrics_for_org',
async () => {
// Org dashboards return apps × days. Without range pagination, PostgREST
// max_rows (1000) silently drops later app_ids — single-app RPC still works
// because it filters inside SQL before the row limit applies.
const query = params.appId
? supabase.rpc('get_app_metrics' as any, {
p_org_id: params.orgId,
p_app_id: params.appId,
p_start_date: params.startDate,
p_end_date: params.endDate,
})
: supabase.rpc('get_app_metrics', {
org_id: params.orgId,
start_date: params.startDate,
end_date: params.endDate,
})

return await (query as any)
.order('app_id', { ascending: true })
.order('date', { ascending: true })
.range(pageStart, pageStart + APP_METRICS_PAGE_SIZE - 1) as QueryResult<AppMetricRow[]>
},
)

if (error)
return { data: null, error, status }

rows.push(...(data ?? []))
if ((data?.length ?? 0) < APP_METRICS_PAGE_SIZE)
break
}

return { data: rows, error: null }
}

function metricDayNumber(metricDate: string, from: Date, graphDays: number): number | null {
const dayNumber = dayjs(metricDate).utc().startOf('day').diff(dayjs(from).utc().startOf('day'), 'day')
if (dayNumber < 0 || dayNumber >= graphDays)
return null
return dayNumber
}

export const statisticsTestUtils = {
APP_METRICS_PAGE_SIZE,
executeStatsQueryWithRetry,
fetchAppMetricsRows,
getMissingAppStatsError,
getRetryableStatus: getRetryablePostgrestStatus,
isRetryableStatsError,
getStatsAppOwnerOrgOrThrow,
metricDayNumber,
resolveAppOwnerOrg,
}

Expand Down Expand Up @@ -390,32 +454,12 @@
const startDate = dayjs(from).utc().format('YYYY-MM-DD')
const endDate = dayjs(to).utc().format('YYYY-MM-DD')

let rawMetrics: AppMetricRow[] | null
let metricsError: unknown

if (appId) {
({ data: rawMetrics, error: metricsError } = await executeStatsQueryWithRetry<AppMetricRow[]>(
c,
'get_app_metrics_for_app',
async () => await supabase.rpc('get_app_metrics' as any, {
p_org_id: ownerOrgId!,
p_app_id: appId,
p_start_date: startDate,
p_end_date: endDate,
}) as QueryResult<AppMetricRow[]>,
))
}
else {
({ data: rawMetrics, error: metricsError } = await executeStatsQueryWithRetry<AppMetricRow[]>(
c,
'get_app_metrics_for_org',
async () => await supabase.rpc('get_app_metrics', {
org_id: ownerOrgId!,
start_date: startDate,
end_date: endDate,
}) as QueryResult<AppMetricRow[]>,
))
}
const { data: rawMetrics, error: metricsError } = await fetchAppMetricsRows(c, supabase, {
orgId: ownerOrgId!,
appId,
startDate,
endDate,
})

if (metricsError)
return { data: null, error: metricsError }
Expand Down Expand Up @@ -478,36 +522,20 @@
.forEach((arrItem) => {
const sortedArrItem = arrItem.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
cloudlog({ requestId: c.get('requestId'), message: 'sortedArrItem', data: sortedArrItem })
arrItem?.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()).forEach((item, i) => {
if (item.date) {
const dayNumber = i
if (mau[dayNumber])
mau[dayNumber] += item.mau
else
mau[dayNumber] = item.mau

const storageVal = item.storage
if (storage[dayNumber])
storage[dayNumber] += storageVal
else
storage[dayNumber] = storageVal

const bandwidthVal = item.bandwidth ?? 0
if (bandwidth[dayNumber])
bandwidth[dayNumber] += bandwidthVal
else
bandwidth[dayNumber] = bandwidthVal

const buildTimeVal = item.build_time_unit ?? 0
if (buildTime[dayNumber])
buildTime[dayNumber] += buildTimeVal
else
buildTime[dayNumber] = buildTimeVal

if (isDashboard) {
gets[dayNumber] = item.get
}
}
sortedArrItem.forEach((item) => {
if (!item.date)
return
const dayNumber = metricDayNumber(item.date, from, graphDays)
if (dayNumber === null)
return

mau[dayNumber] = (mau[dayNumber] ?? 0) + item.mau
storage[dayNumber] = (storage[dayNumber] ?? 0) + item.storage
bandwidth[dayNumber] = (bandwidth[dayNumber] ?? 0) + (item.bandwidth ?? 0)
buildTime[dayNumber] = (buildTime[dayNumber] ?? 0) + (item.build_time_unit ?? 0)

if (isDashboard)
gets[dayNumber] = (gets[dayNumber] ?? 0) + item.get
})
})

Expand Down Expand Up @@ -576,36 +604,20 @@
let appGets = isDashboard ? createUndefinedArray(graphDays) as number[] : []

// Process metrics for this app (same logic as aggregated version)
appMetrics.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()).forEach((item, i) => {
if (item.date) {
const dayNumber = i
if (appMau[dayNumber])
appMau[dayNumber] += item.mau
else
appMau[dayNumber] = item.mau

const storageVal = item.storage
if (appStorage[dayNumber])
appStorage[dayNumber] += storageVal
else
appStorage[dayNumber] = storageVal

const bandwidthVal = item.bandwidth ?? 0
if (appBandwidth[dayNumber])
appBandwidth[dayNumber] += bandwidthVal
else
appBandwidth[dayNumber] = bandwidthVal

const buildTimeVal = item.build_time_unit ?? 0
if (appBuildTime[dayNumber])
appBuildTime[dayNumber] += buildTimeVal
else
appBuildTime[dayNumber] = buildTimeVal

if (isDashboard) {
appGets[dayNumber] = item.get
}
}
appMetrics.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()).forEach((item) => {

Check warning on line 607 in supabase/functions/_backend/public/statistics/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move this array "sort" operation to a separate statement or replace it with "toSorted".

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ-PhRpUE2HCc5z45cqn&open=AZ-PhRpUE2HCc5z45cqn&pullRequest=2738
if (!item.date)
return
const dayNumber = metricDayNumber(item.date, from, graphDays)
if (dayNumber === null)
return

appMau[dayNumber] = (appMau[dayNumber] ?? 0) + item.mau
appStorage[dayNumber] = (appStorage[dayNumber] ?? 0) + item.storage
appBandwidth[dayNumber] = (appBandwidth[dayNumber] ?? 0) + (item.bandwidth ?? 0)
appBuildTime[dayNumber] = (appBuildTime[dayNumber] ?? 0) + (item.build_time_unit ?? 0)

if (isDashboard)
appGets[dayNumber] = (appGets[dayNumber] ?? 0) + item.get
})

// Accumulate data if requested (default behavior for backward compatibility)
Expand Down
157 changes: 157 additions & 0 deletions tests/org-statistics-bandwidth-pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
BASE_URL,
executeSQL,
getAuthHeaders,
getSupabaseClient,
USER_EMAIL,
USER_ID,
} from './test-utils.ts'

// PostgREST max_rows is 1000. Org dashboards return apps × days; a single-app
// RPC filters in SQL and stays under the limit, so app pages look fine while
// the all-apps org dashboard silently loses later app_ids (and their bandwidth).
const DAYS = 30
const QUIET_APPS = 40 // 40 × 30 = 1200 rows > 1000
const expectedBandwidth = 5_242_880
const orgId = randomUUID()
const testPrefix = `org.bw.page.${randomUUID().slice(0, 8)}`
const quietAppPrefix = `com.${testPrefix}.aaa.`
const busyAppId = `com.${testPrefix}.zzz.busy`

function rangeDates() {
const end = new Date()
end.setUTCHours(0, 0, 0, 0)
const start = new Date(end)
start.setUTCDate(start.getUTCDate() - (DAYS - 1))
// getDaysBetweenDates(from, to) is exclusive of `to`, so put bandwidth on a
// day that is inside the generated graph day indexes (not the end date).
const bandwidthDate = new Date(start)
bandwidthDate.setUTCDate(bandwidthDate.getUTCDate() + 10)
return {
startDate: start.toISOString().slice(0, 10),
endDate: end.toISOString().slice(0, 10),
bandwidthDate: bandwidthDate.toISOString().slice(0, 10),
}
}

async function seedQuietApps(count: number) {
await executeSQL(
`
INSERT INTO public.apps (app_id, icon_url, name, last_version, owner_org, user_id, created_at, updated_at)
SELECT
$1 || lpad(i::text, 3, '0'),
'',
'Quiet org bandwidth app ' || i::text,
'1.0.0',
$2::uuid,
$3::uuid,
NOW() - INTERVAL '90 days',
NOW() - INTERVAL '90 days'
FROM generate_series(1, $4::integer) AS i
`,
[quietAppPrefix, orgId, USER_ID, count],
)
}

describe('org dashboard bandwidth pagination', () => {
const { startDate, endDate, bandwidthDate } = rangeDates()

beforeAll(async () => {
// created_by bootstraps org_users + org_super_admin role_bindings
await getSupabaseClient().from('orgs').insert({
created_by: USER_ID,

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: This test mutates the shared USER_ID fixture's organization membership, so parallel tests using the same user can observe the random org and become order-dependent. A dedicated seeded test user (or an isolated fixture identity) would keep the pagination regression data independent from other backend tests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/org-statistics-bandwidth-pagination.test.ts, line 64:

<comment>This test mutates the shared `USER_ID` fixture's organization membership, so parallel tests using the same user can observe the random org and become order-dependent. A dedicated seeded test user (or an isolated fixture identity) would keep the pagination regression data independent from other backend tests.</comment>

<file context>
@@ -0,0 +1,157 @@
+  beforeAll(async () => {
+    // created_by bootstraps org_users + org_super_admin role_bindings
+    await getSupabaseClient().from('orgs').insert({
+      created_by: USER_ID,
+      id: orgId,
+      management_email: USER_EMAIL,
</file context>

id: orgId,
management_email: USER_EMAIL,
name: `Org Bandwidth Pagination ${orgId}`,
updated_at: new Date().toISOString(),
}).throwOnError()

await seedQuietApps(QUIET_APPS)

await getSupabaseClient().from('apps').insert({
app_id: busyAppId,
created_at: new Date().toISOString(),
icon_url: '',
last_version: '1.0.0',
name: 'Busy org bandwidth app',
owner_org: orgId,
updated_at: new Date().toISOString(),
user_id: USER_ID,
}).throwOnError()

await getSupabaseClient().from('daily_bandwidth').upsert({
app_id: busyAppId,
date: bandwidthDate,
bandwidth: expectedBandwidth,
}, {
onConflict: 'app_id,date',
}).throwOnError()

await getSupabaseClient().from('app_metrics_cache').delete().eq('org_id', orgId)
}, 120_000)

afterAll(async () => {
await getSupabaseClient().from('daily_bandwidth').delete().eq('app_id', busyAppId)
await getSupabaseClient().from('app_metrics_cache').delete().eq('org_id', orgId)
await executeSQL(`DELETE FROM public.apps WHERE app_id LIKE $1`, [`${quietAppPrefix}%`])
await getSupabaseClient().from('apps').delete().eq('app_id', busyAppId)
await getSupabaseClient().from('role_bindings').delete().eq('org_id', orgId)
await getSupabaseClient().from('org_users').delete().eq('org_id', orgId)
await getSupabaseClient().from('orgs').delete().eq('id', orgId)
}, 120_000)

it('proves unpaginated org metrics drop the late busy app under max_rows', async () => {
const { data, error } = await getSupabaseClient()
.rpc('get_app_metrics', {
org_id: orgId,
start_date: startDate,
end_date: endDate,
})
.order('app_id', { ascending: true })
.order('date', { ascending: true })
.range(0, 999)

expect(error).toBeNull()
expect(data?.length).toBe(1000)
expect(data?.some(row => row.app_id === busyAppId)).toBe(false)

const { data: appOnly, error: appError } = await getSupabaseClient().rpc('get_app_metrics' as any, {
p_org_id: orgId,
p_app_id: busyAppId,
p_start_date: startDate,
p_end_date: endDate,
})

expect(appError).toBeNull()
const busyRow = (appOnly as any[])?.find(row => row.app_id === busyAppId && row.date === bandwidthDate)
expect(busyRow?.bandwidth).toBe(expectedBandwidth)
}, 60_000)

it('returns busy-app bandwidth on org dashboard and app dashboard', async () => {
const authHeaders = await getAuthHeaders()

const orgRes = await fetch(
`${BASE_URL}/statistics/org/${orgId}?from=${startDate}&to=${endDate}&breakdown=true&noAccumulate=true`,
{ method: 'GET', headers: authHeaders },
)
const orgBody = await orgRes.json() as { global: any[], byApp: any[] }
expect(orgRes.status, JSON.stringify(orgBody)).toBe(200)

const orgBandwidth = (orgBody.global ?? []).reduce((sum, row) => sum + Number(row.bandwidth ?? 0), 0)
expect(orgBandwidth).toBe(expectedBandwidth)
expect((orgBody.byApp ?? []).some(row =>
row.app_id === busyAppId && Number(row.bandwidth ?? 0) === expectedBandwidth,
)).toBe(true)

const appRes = await fetch(
`${BASE_URL}/statistics/app/${busyAppId}?from=${startDate}&to=${endDate}&noAccumulate=true`,
{ method: 'GET', headers: authHeaders },
)
const appBody = await appRes.json() as any[]
expect(appRes.status, JSON.stringify(appBody)).toBe(200)
const appBandwidth = (appBody ?? []).reduce((sum, row) => sum + Number(row.bandwidth ?? 0), 0)
expect(appBandwidth).toBe(expectedBandwidth)
}, 120_000)
})
Loading
Loading