mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(auth): migrate overview errors to clickhouse (#50174)
## Problem
Auth overview error tables call the legacy logs endpoint through
fetchLogs defaults, even with the ClickHouse migration enabled.
Success-rate cards also show zero when there are no requests and
misleading relative changes between small rates.
## Fix
Select matching BigQuery or ClickHouse queries with otelLegacyLogs,
separate caches by engine, and normalize numeric results.
Show No data for success rates without requests and omit comparisons
when either period has no requests. Show success-rate changes in
percentage points: 0% to 0.2% displays +0.2 pp. Omit undefined relative
changes from a zero baseline for activity and sign-up counts. Show
explicit errors for failed log requests, including error payloads
returned with HTTP 200, instead of empty tables.
## Validation
- Auth overview error tables compared against staging with matching
data.
- 84 focused tests passed across four suites, including 25 direct
formatter tests.
- 12 MSW integration tests exercise both endpoint/SQL pairs, HTTP and
embedded API failures, and rendered No data, genuine 0%, and +0.2 pp
states.
- Unit tests cover missing periods, zero requests, percentage-point and
relative changes, SQL structure, and numeric result parsing.
- Formatting and diff checks passed; code review found no actionable
issues.
- Full local lint/typecheck are limited by shared checkout dependencies.
Browser comparison confirmed the deployed rate display uses percentage
points and shows No data without a comparison for absent server
requests; populated error rows match staging. The final formatter
extraction (9886b78682) was also deployed and verified in the browser;
CI completion remains outstanding.
Split from #50173.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **New Features**
- Added OpenTelemetry support for authentication error and metrics
reporting.
- Added clearer handling of missing metric data, including “No data”
states.
- Improved success-rate change calculations using percentage-point
differences.
- Added user-visible error states when analytics requests fail.
- **Bug Fixes**
- Improved validation and handling of authentication metrics and error
data.
- Corrected formatting and rounding for metric values and changes.
- **Tests**
- Expanded coverage for legacy and OpenTelemetry analytics, error
handling, empty data, formatting, and edge cases.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
38e8f12b1b
commit
d2ed60da27
8 files changed
+577
-64
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { pickLogsQueryBuilder } from '@/data/logs/logs-endpoint'
|
||||
import { safeSql } from '@/data/logs/safe-analytics-sql'
|
||||
import { fetchLogs } from '@/data/reports/report.utils'
|
||||
|
||||
@@ -58,12 +60,76 @@ export const AUTH_TOP_ERROR_CODES_SQL = safeSql`
|
||||
limit 10
|
||||
`
|
||||
|
||||
export const fetchTopResponseErrors = async (projectRef: string) => {
|
||||
export const AUTH_TOP_RESPONSE_ERRORS_SQL_OTEL = safeSql`
|
||||
select
|
||||
log_attributes['request.method'] as method,
|
||||
log_attributes['request.path'] as path,
|
||||
toInt32OrZero(log_attributes['response.status_code']) as status_code,
|
||||
count() as count
|
||||
from logs
|
||||
where source = 'edge_logs'
|
||||
and path like '%auth/v1%'
|
||||
and status_code between 400 and 599
|
||||
group by method, path, status_code
|
||||
order by count desc
|
||||
limit 10
|
||||
`
|
||||
|
||||
export const AUTH_TOP_ERROR_CODES_SQL_OTEL = safeSql`
|
||||
select
|
||||
log_attributes['response.headers.x_sb_error_code'] as error_code,
|
||||
count() as count
|
||||
from logs
|
||||
where source = 'edge_logs'
|
||||
and log_attributes['request.path'] like '%auth/v1%'
|
||||
and toInt32OrZero(log_attributes['response.status_code']) between 400 and 599
|
||||
and error_code != ''
|
||||
group by error_code
|
||||
order by count desc
|
||||
limit 10
|
||||
`
|
||||
|
||||
const countSchema = z.union([z.number(), z.string().min(1)]).pipe(z.coerce.number().finite())
|
||||
const responseErrorSchema = z.object({
|
||||
method: z.string(),
|
||||
path: z.string(),
|
||||
status_code: countSchema,
|
||||
count: countSchema,
|
||||
})
|
||||
const authErrorCodeSchema = z.object({ error_code: z.string(), count: countSchema })
|
||||
|
||||
export const parseResponseErrors = (rows: unknown[]): ResponseErrorRow[] =>
|
||||
rows.flatMap((row) => {
|
||||
const result = responseErrorSchema.safeParse(row)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
|
||||
export const parseAuthErrorCodes = (rows: unknown[]): AuthErrorCodeRow[] =>
|
||||
rows.flatMap((row) => {
|
||||
const result = authErrorCodeSchema.safeParse(row)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
|
||||
export const fetchTopResponseErrors = async (projectRef: string, useOtel = false) => {
|
||||
const { start, end } = getDateRange()
|
||||
return await fetchLogs(projectRef, AUTH_TOP_RESPONSE_ERRORS_SQL, start, end)
|
||||
const sql = pickLogsQueryBuilder(
|
||||
useOtel,
|
||||
AUTH_TOP_RESPONSE_ERRORS_SQL_OTEL,
|
||||
AUTH_TOP_RESPONSE_ERRORS_SQL
|
||||
)
|
||||
const data = await fetchLogs(projectRef, sql, start, end, useOtel)
|
||||
if (data?.error) {
|
||||
throw new Error(typeof data.error === 'string' ? data.error : data.error.message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchTopAuthErrorCodes = async (projectRef: string) => {
|
||||
export const fetchTopAuthErrorCodes = async (projectRef: string, useOtel = false) => {
|
||||
const { start, end } = getDateRange()
|
||||
return await fetchLogs(projectRef, AUTH_TOP_ERROR_CODES_SQL, start, end)
|
||||
const sql = pickLogsQueryBuilder(useOtel, AUTH_TOP_ERROR_CODES_SQL_OTEL, AUTH_TOP_ERROR_CODES_SQL)
|
||||
const data = await fetchLogs(projectRef, sql, start, end, useOtel)
|
||||
if (data?.error) {
|
||||
throw new Error(typeof data.error === 'string' ? data.error : data.error.message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import type { platformComponents } from 'api-types'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
AUTH_TOP_ERROR_CODES_SQL,
|
||||
AUTH_TOP_ERROR_CODES_SQL_OTEL,
|
||||
AUTH_TOP_RESPONSE_ERRORS_SQL,
|
||||
AUTH_TOP_RESPONSE_ERRORS_SQL_OTEL,
|
||||
} from './OverviewErrors.constants'
|
||||
import { OverviewMetrics } from './OverviewMetrics'
|
||||
import type { AuthMetricsResponse } from './OverviewUsage.constants'
|
||||
import type { RawAuthMetricsRow } from './OverviewUsage.schema'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
|
||||
|
||||
type AnalyticsResponse = platformComponents['schemas']['AnalyticsResponse']
|
||||
|
||||
const flags = vi.hoisted(() => ({ otelLegacyLogs: false }))
|
||||
|
||||
vi.mock('common', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('common')>()
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({ ref: 'default' }),
|
||||
useFlag: (name: string) => name === 'otelLegacyLogs' && flags.otelLegacyLogs,
|
||||
}
|
||||
})
|
||||
|
||||
const logQueries = [
|
||||
{
|
||||
useOtel: false,
|
||||
endpoint: '/platform/projects/:ref/analytics/endpoints/logs.all',
|
||||
responseSql: AUTH_TOP_RESPONSE_ERRORS_SQL,
|
||||
codeSql: AUTH_TOP_ERROR_CODES_SQL,
|
||||
},
|
||||
{
|
||||
useOtel: true,
|
||||
endpoint: '/platform/projects/:ref/analytics/endpoints/logs.all.otel',
|
||||
responseSql: AUTH_TOP_RESPONSE_ERRORS_SQL_OTEL,
|
||||
codeSql: AUTH_TOP_ERROR_CODES_SQL_OTEL,
|
||||
},
|
||||
] as const
|
||||
|
||||
const metricRow = (
|
||||
period: RawAuthMetricsRow['period'],
|
||||
requests: number,
|
||||
errors: number
|
||||
): RawAuthMetricsRow => ({
|
||||
period,
|
||||
active_users: 0,
|
||||
sign_up_count: 0,
|
||||
password_reset_requests: 0,
|
||||
api_total_requests: requests,
|
||||
api_error_requests: errors,
|
||||
auth_total_requests: requests,
|
||||
auth_total_errors: errors,
|
||||
})
|
||||
|
||||
const mockEmptyLogs = () => {
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: logQueries[0].endpoint,
|
||||
response: () => HttpResponse.json<AnalyticsResponse>({ result: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
flags.otelLegacyLogs = false
|
||||
})
|
||||
|
||||
describe('Auth overview logs requests', () => {
|
||||
it.each(logQueries)('uses matching SQL and endpoint with useOtel=$useOtel', async (query) => {
|
||||
flags.otelLegacyLogs = query.useOtel
|
||||
const requests: URL[] = []
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: query.endpoint,
|
||||
response: ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
requests.push(url)
|
||||
if (url.searchParams.get('sql') === query.responseSql) {
|
||||
return HttpResponse.json<AnalyticsResponse>({
|
||||
result: [{ method: 'POST', path: '/auth/v1/token', status_code: '401', count: '7' }],
|
||||
})
|
||||
}
|
||||
if (url.searchParams.get('sql') === query.codeSql) {
|
||||
return HttpResponse.json<AnalyticsResponse>({
|
||||
result: [{ error_code: 'invalid_credentials', count: '3' }],
|
||||
})
|
||||
}
|
||||
return HttpResponse.json<APIErrorBody>({ message: 'Unexpected SQL' }, { status: 400 })
|
||||
},
|
||||
})
|
||||
|
||||
customRender(<OverviewMetrics isLoading={false} error={null} />)
|
||||
|
||||
expect(await screen.findByText('/auth/v1/token')).toBeVisible()
|
||||
expect(await screen.findByText('invalid_credentials')).toBeVisible()
|
||||
expect(screen.getByText('7')).toBeVisible()
|
||||
expect(screen.getByText('3')).toBeVisible()
|
||||
expect(requests).toHaveLength(2)
|
||||
for (const request of requests) {
|
||||
expect(request.pathname).toContain('/projects/default/analytics/endpoints/')
|
||||
const start = Date.parse(request.searchParams.get('iso_timestamp_start') ?? '')
|
||||
const end = Date.parse(request.searchParams.get('iso_timestamp_end') ?? '')
|
||||
expect(end - start).toBeCloseTo(24 * 60 * 60 * 1000, -2)
|
||||
}
|
||||
})
|
||||
|
||||
describe.each(logQueries)('failures with useOtel=$useOtel', (query) => {
|
||||
it.each(['http', 'string', 'object'] as const)(
|
||||
'shows %s errors instead of empty log tables',
|
||||
async (failure) => {
|
||||
flags.otelLegacyLogs = query.useOtel
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: query.endpoint,
|
||||
response: () => {
|
||||
if (failure === 'http') {
|
||||
return HttpResponse.json<APIErrorBody>(
|
||||
{ message: 'Analytics unavailable' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
const error =
|
||||
failure === 'string'
|
||||
? 'Analytics unavailable'
|
||||
: { code: 500, errors: [], message: 'Analytics unavailable', status: 'INTERNAL' }
|
||||
return HttpResponse.json<AnalyticsResponse>({ result: [], error })
|
||||
},
|
||||
})
|
||||
|
||||
customRender(<OverviewMetrics isLoading={false} error={null} />)
|
||||
|
||||
expect(await screen.findByText('Failed to retrieve Auth API errors')).toBeVisible()
|
||||
expect(await screen.findByText('Failed to retrieve Auth server errors')).toBeVisible()
|
||||
expect(screen.getAllByText('Error: Analytics unavailable')).toHaveLength(2)
|
||||
expect(screen.queryByText('No data to show')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auth success rate presentation', () => {
|
||||
it.each([
|
||||
{ name: 'no requests', current: metricRow('current', 0, 0) },
|
||||
{ name: 'missing current period', current: undefined },
|
||||
])('shows No data without a comparison for $name', async ({ current }) => {
|
||||
mockEmptyLogs()
|
||||
const metrics: AuthMetricsResponse = {
|
||||
result: [metricRow('previous', 500, 500), ...(current ? [current] : [])],
|
||||
error: null,
|
||||
}
|
||||
customRender(<OverviewMetrics metrics={metrics} isLoading={false} error={null} />)
|
||||
|
||||
expect(screen.getAllByText('No data')).toHaveLength(2)
|
||||
expect(screen.queryByText('0.0%')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/ pp$/)).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getAllByText('No data to show')).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('renders genuine zero success followed by a 0.2 percentage point increase', async () => {
|
||||
mockEmptyLogs()
|
||||
const metrics: AuthMetricsResponse = {
|
||||
result: [metricRow('current', 500, 500), metricRow('previous', 500, 500)],
|
||||
error: null,
|
||||
}
|
||||
const { rerender } = customRender(
|
||||
<OverviewMetrics metrics={metrics} isLoading={false} error={null} />
|
||||
)
|
||||
expect(screen.getAllByText('0.0%')).toHaveLength(2)
|
||||
expect(screen.getAllByText('0.0 pp')).toHaveLength(2)
|
||||
expect(screen.queryByText('No data')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<OverviewMetrics
|
||||
metrics={{ ...metrics, result: [metricRow('current', 500, 499), metrics.result[1]] }}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>
|
||||
)
|
||||
expect(screen.getAllByText('0.2%')).toHaveLength(2)
|
||||
expect(screen.getAllByText('+0.2 pp')).toHaveLength(2)
|
||||
expect(screen.queryByText('+100.0%')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getAllByText('No data to show')).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('shows a populated current rate without comparison when the previous period has no requests', async () => {
|
||||
mockEmptyLogs()
|
||||
customRender(
|
||||
<OverviewMetrics
|
||||
metrics={{
|
||||
result: [metricRow('current', 500, 499), metricRow('previous', 0, 0)],
|
||||
error: null,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('0.2%')).toHaveLength(2)
|
||||
expect(screen.queryByText(/ pp$/)).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getAllByText('No data to show')).toHaveLength(2))
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { useFlag, useParams } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { BarChart2, ChevronRight, ExternalLink, Telescope } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -29,12 +29,16 @@ import {
|
||||
AuthErrorCodeRow,
|
||||
fetchTopAuthErrorCodes,
|
||||
fetchTopResponseErrors,
|
||||
parseAuthErrorCodes,
|
||||
parseResponseErrors,
|
||||
ResponseErrorRow,
|
||||
} from './OverviewErrors.constants'
|
||||
import { formatMetricChange, formatMetricValue } from './OverviewMetrics.utils'
|
||||
import { OverviewTable } from './OverviewTable'
|
||||
import {
|
||||
AuthMetricsResponse,
|
||||
calculatePercentageChange,
|
||||
calculatePercentagePointChange,
|
||||
getApiSuccessRates,
|
||||
getAuthSuccessRates,
|
||||
getMetricValues,
|
||||
@@ -49,29 +53,21 @@ import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
||||
const StatCard = ({
|
||||
title,
|
||||
current,
|
||||
previous,
|
||||
change,
|
||||
loading,
|
||||
suffix = '',
|
||||
href,
|
||||
tooltip,
|
||||
}: {
|
||||
title: string
|
||||
current: number
|
||||
previous: number
|
||||
current: number | null
|
||||
change: number | null
|
||||
loading: boolean
|
||||
suffix?: string
|
||||
invert?: boolean
|
||||
href?: string
|
||||
tooltip?: string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const formattedCurrent =
|
||||
suffix === 'ms'
|
||||
? current.toFixed(2)
|
||||
: suffix === '%'
|
||||
? current.toFixed(1)
|
||||
: Math.round(current).toLocaleString()
|
||||
// const signChar = previous > 0 ? '+' : previous < 0 ? '-' : ''
|
||||
|
||||
const actions = [
|
||||
{
|
||||
@@ -89,8 +85,8 @@ const StatCard = ({
|
||||
className="pb-4"
|
||||
label={title}
|
||||
tooltip={tooltip}
|
||||
diffValue={`${previous.toFixed(1)}%`}
|
||||
value={`${formattedCurrent}${suffix}`}
|
||||
diffValue={formatMetricChange(change, suffix)}
|
||||
value={formatMetricValue(current, suffix)}
|
||||
/>
|
||||
<ChartActions actions={actions} />
|
||||
</ChartHeader>
|
||||
@@ -117,23 +113,6 @@ const LogsLink = ({ href }: { href: string }) => (
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
function isResponseErrorRow(row: unknown): row is ResponseErrorRow {
|
||||
if (!row || typeof row !== 'object') return false
|
||||
const r = row as Record<string, unknown>
|
||||
return (
|
||||
typeof r.method === 'string' &&
|
||||
typeof r.path === 'string' &&
|
||||
typeof r.status_code === 'number' &&
|
||||
typeof r.count === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isAuthErrorCodeRow(row: unknown): row is AuthErrorCodeRow {
|
||||
if (!row || typeof row !== 'object') return false
|
||||
const r = row as Record<string, unknown>
|
||||
return typeof r.error_code === 'string' && typeof r.count === 'number'
|
||||
}
|
||||
|
||||
interface OverviewMetricsProps {
|
||||
metrics?: AuthMetricsResponse
|
||||
isLoading: boolean
|
||||
@@ -142,6 +121,7 @@ interface OverviewMetricsProps {
|
||||
|
||||
export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsProps) => {
|
||||
const { ref } = useParams()
|
||||
const useOtel = useFlag('otelLegacyLogs')
|
||||
const endDate = dayjs().toISOString()
|
||||
const startDate = dayjs().subtract(24, 'hour').toISOString()
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
@@ -165,32 +145,42 @@ export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsPr
|
||||
const { current: authSuccessRateCurrent, previous: authSuccessRatePrevious } =
|
||||
getAuthSuccessRates(metrics)
|
||||
|
||||
const apiSuccessRateChange = calculatePercentageChange(
|
||||
const apiSuccessRateChange = calculatePercentagePointChange(
|
||||
apiSuccessRateCurrent,
|
||||
apiSuccessRatePrevious
|
||||
)
|
||||
const authSuccessRateChange = calculatePercentageChange(
|
||||
const authSuccessRateChange = calculatePercentagePointChange(
|
||||
authSuccessRateCurrent,
|
||||
authSuccessRatePrevious
|
||||
)
|
||||
|
||||
const { data: respErrData, isPending: isLoadingResp } = useQuery({
|
||||
queryKey: ['auth-overview', ref, 'top-response-errors'],
|
||||
queryFn: () => fetchTopResponseErrors(ref as string),
|
||||
const {
|
||||
data: respErrData,
|
||||
isPending: isLoadingResp,
|
||||
isError: isResponseError,
|
||||
error: responseError,
|
||||
} = useQuery({
|
||||
queryKey: ['auth-overview', ref, 'top-response-errors', { otel: useOtel }],
|
||||
queryFn: () => fetchTopResponseErrors(ref as string, useOtel),
|
||||
enabled: !!ref,
|
||||
})
|
||||
|
||||
const { data: codeErrData, isPending: isLoadingCodes } = useQuery({
|
||||
queryKey: ['auth-overview', ref, 'top-auth-error-codes'],
|
||||
queryFn: () => fetchTopAuthErrorCodes(ref as string),
|
||||
const {
|
||||
data: codeErrData,
|
||||
isPending: isLoadingCodes,
|
||||
isError: isCodeError,
|
||||
error: codeError,
|
||||
} = useQuery({
|
||||
queryKey: ['auth-overview', ref, 'top-auth-error-codes', { otel: useOtel }],
|
||||
queryFn: () => fetchTopAuthErrorCodes(ref as string, useOtel),
|
||||
enabled: !!ref,
|
||||
})
|
||||
|
||||
const responseErrors: ResponseErrorRow[] = Array.isArray(respErrData?.result)
|
||||
? (respErrData?.result as unknown[]).filter(isResponseErrorRow)
|
||||
? parseResponseErrors(respErrData.result)
|
||||
: []
|
||||
const errorCodes: AuthErrorCodeRow[] = Array.isArray(codeErrData?.result)
|
||||
? (codeErrData?.result as unknown[]).filter(isAuthErrorCodeRow)
|
||||
? parseAuthErrorCodes(codeErrData.result)
|
||||
: []
|
||||
|
||||
const errorCodesActions = [
|
||||
@@ -239,7 +229,7 @@ export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsPr
|
||||
<StatCard
|
||||
title="Auth Activity"
|
||||
current={activeUsersCurrent}
|
||||
previous={activeUsersChange}
|
||||
change={activeUsersChange}
|
||||
loading={isLoading}
|
||||
href={`/project/${ref}/reports/auth?its=${startDate}&ite=${endDate}#usage`}
|
||||
tooltip="Users who generated any Auth event in this period. This metric tracks authentication activity, not total product usage. Some active users won't appear here if their session stayed valid."
|
||||
@@ -247,7 +237,7 @@ export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsPr
|
||||
<StatCard
|
||||
title="Sign ups"
|
||||
current={signUpsCurrent}
|
||||
previous={signUpsChange}
|
||||
change={signUpsChange}
|
||||
loading={isLoading}
|
||||
href={`/project/${ref}/reports/auth?its=${startDate}&ite=${endDate}#usage`}
|
||||
/>
|
||||
@@ -266,23 +256,25 @@ export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsPr
|
||||
<StatCard
|
||||
title="Auth API Success Rate"
|
||||
current={apiSuccessRateCurrent}
|
||||
previous={apiSuccessRateChange}
|
||||
change={apiSuccessRateChange}
|
||||
loading={isLoading}
|
||||
suffix="%"
|
||||
tooltip="Change from the previous period in percentage points (pp); no data means no requests were recorded."
|
||||
href={`/project/${ref}/reports/auth?its=${startDate}&ite=${endDate}#monitoring`}
|
||||
/>
|
||||
<StatCard
|
||||
title="Auth Server Success Rate"
|
||||
current={authSuccessRateCurrent}
|
||||
previous={authSuccessRateChange}
|
||||
change={authSuccessRateChange}
|
||||
loading={isLoading}
|
||||
suffix="%"
|
||||
tooltip="Change from the previous period in percentage points (pp); no data means no requests were recorded."
|
||||
href={`/project/${ref}/reports/auth?its=${startDate}&ite=${endDate}#monitoring`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Chart isLoading={isLoadingResp}>
|
||||
<Chart isLoading={isLoadingResp} isErrored={isResponseError}>
|
||||
<ChartCard>
|
||||
<ChartHeader>
|
||||
<ChartTitle>Auth API Errors</ChartTitle>
|
||||
@@ -290,6 +282,15 @@ export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsPr
|
||||
<ChartContent
|
||||
className="p-0!"
|
||||
isEmpty={responseErrors.length === 0}
|
||||
errorState={
|
||||
<div className="p-6">
|
||||
<AlertError
|
||||
projectRef={ref}
|
||||
subject="Failed to retrieve Auth API errors"
|
||||
error={responseError}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
emptyState={
|
||||
<div className="p-6">
|
||||
<ChartEmptyState
|
||||
@@ -344,7 +345,7 @@ export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsPr
|
||||
</ChartCard>
|
||||
</Chart>
|
||||
|
||||
<Chart isLoading={isLoadingCodes}>
|
||||
<Chart isLoading={isLoadingCodes} isErrored={isCodeError}>
|
||||
<ChartCard>
|
||||
<ChartHeader>
|
||||
<ChartTitle>Auth Server Errors</ChartTitle>
|
||||
@@ -353,6 +354,15 @@ export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsPr
|
||||
<ChartContent
|
||||
className="p-0!"
|
||||
isEmpty={errorCodes.length === 0}
|
||||
errorState={
|
||||
<div className="p-6">
|
||||
<AlertError
|
||||
projectRef={ref}
|
||||
subject="Failed to retrieve Auth server errors"
|
||||
error={codeError}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
emptyState={
|
||||
<div className="p-6">
|
||||
<ChartEmptyState
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function formatMetricValue(value: number | null, suffix = ''): string {
|
||||
if (value === null) return 'No data'
|
||||
if (suffix === 'ms') return `${value.toFixed(2)}${suffix}`
|
||||
if (suffix === '%') return `${value.toFixed(1)}${suffix}`
|
||||
return `${Math.round(value).toLocaleString()}${suffix}`
|
||||
}
|
||||
|
||||
export function formatMetricChange(change: number | null, suffix = ''): string | undefined {
|
||||
if (change === null) return undefined
|
||||
const unit = suffix === '%' ? ' pp' : '%'
|
||||
const roundedChange = Number(change.toFixed(1))
|
||||
return `${roundedChange.toFixed(1)}${unit}`
|
||||
}
|
||||
@@ -73,9 +73,9 @@ export const getApiSuccessRates = (metrics: AuthMetricsResponse | undefined) =>
|
||||
'apiErrorRequests'
|
||||
)
|
||||
const current =
|
||||
apiTotalCurrent > 0 ? Math.max(0, 100 - (apiErrorCurrent / apiTotalCurrent) * 100) : 0
|
||||
apiTotalCurrent > 0 ? Math.max(0, 100 - (apiErrorCurrent / apiTotalCurrent) * 100) : null
|
||||
const previous =
|
||||
apiTotalPrevious > 0 ? Math.max(0, 100 - (apiErrorPrevious / apiTotalPrevious) * 100) : 0
|
||||
apiTotalPrevious > 0 ? Math.max(0, 100 - (apiErrorPrevious / apiTotalPrevious) * 100) : null
|
||||
return { current, previous }
|
||||
}
|
||||
|
||||
@@ -89,19 +89,27 @@ export const getAuthSuccessRates = (metrics: AuthMetricsResponse | undefined) =>
|
||||
const current =
|
||||
authTotalRequestsCurrent > 0
|
||||
? Math.max(0, 100 - (authTotalErrorsCurrent / authTotalRequestsCurrent) * 100)
|
||||
: 0
|
||||
: null
|
||||
const previous =
|
||||
authTotalRequestsPrevious > 0
|
||||
? Math.max(0, 100 - (authTotalErrorsPrevious / authTotalRequestsPrevious) * 100)
|
||||
: 0
|
||||
: null
|
||||
return { current, previous }
|
||||
}
|
||||
|
||||
export const calculatePercentageChange = (current: number, previous: number): number => {
|
||||
if (previous === 0) return current > 0 ? 100 : 0
|
||||
export const calculatePercentageChange = (current: number, previous: number): number | null => {
|
||||
if (previous === 0) return null
|
||||
return ((current - previous) / previous) * 100
|
||||
}
|
||||
|
||||
export const calculatePercentagePointChange = (
|
||||
current: number | null,
|
||||
previous: number | null
|
||||
): number | null => {
|
||||
if (current === null || previous === null) return null
|
||||
return current - previous
|
||||
}
|
||||
|
||||
export const getChangeColor = (percentageChange: number): string => {
|
||||
return percentageChange >= 0 ? 'text-brand' : 'text-destructive'
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { describe, expect, it, test } from 'vitest'
|
||||
|
||||
import {
|
||||
calculatePercentageChange,
|
||||
calculatePercentagePointChange,
|
||||
getApiSuccessRates,
|
||||
getAuthSuccessRates,
|
||||
getMetricValues,
|
||||
type AuthMetricsResponse,
|
||||
} from './OverviewUsage.constants'
|
||||
import { RawAuthMetricsResponseSchema } from './OverviewUsage.schema'
|
||||
import { RawAuthMetricsResponseSchema, type RawAuthMetricsRow } from './OverviewUsage.schema'
|
||||
|
||||
const validSample = {
|
||||
result: [
|
||||
@@ -109,11 +110,6 @@ describe('OverviewUsage helpers', () => {
|
||||
expect(previous).toBe(0)
|
||||
})
|
||||
|
||||
test('calculatePercentageChange handles zero previous', () => {
|
||||
expect(calculatePercentageChange(10, 0)).toBe(100)
|
||||
expect(calculatePercentageChange(0, 0)).toBe(0)
|
||||
})
|
||||
|
||||
test('getApiSuccessRates computes success rates correctly', () => {
|
||||
const { current, previous } = getApiSuccessRates(sampleMetrics)
|
||||
expect(current).toBeCloseTo(96)
|
||||
@@ -126,3 +122,106 @@ describe('OverviewUsage helpers', () => {
|
||||
expect(previous).toBeCloseTo(80)
|
||||
})
|
||||
})
|
||||
|
||||
const createRow = (
|
||||
period: RawAuthMetricsRow['period'],
|
||||
values: Partial<Omit<RawAuthMetricsRow, 'period'>> = {}
|
||||
): RawAuthMetricsRow => ({
|
||||
period,
|
||||
active_users: 0,
|
||||
api_error_requests: 0,
|
||||
api_total_requests: 0,
|
||||
auth_total_errors: 0,
|
||||
auth_total_requests: 0,
|
||||
password_reset_requests: 0,
|
||||
sign_up_count: 0,
|
||||
...values,
|
||||
})
|
||||
|
||||
describe.each([
|
||||
{ name: 'API', getRates: getApiSuccessRates },
|
||||
{ name: 'Auth server', getRates: getAuthSuccessRates },
|
||||
])('$name success rates', ({ getRates }) => {
|
||||
it.each<AuthMetricsResponse | undefined>([
|
||||
undefined,
|
||||
{ result: [], error: null },
|
||||
{ result: [createRow('current'), createRow('previous')], error: null },
|
||||
])('returns no rate when there are no requests: %j', (metrics) => {
|
||||
expect(getRates(metrics)).toEqual({ current: null, previous: null })
|
||||
})
|
||||
|
||||
it.each(['current', 'previous'] as const)(
|
||||
'preserves a populated %s period when the other period is missing',
|
||||
(period) => {
|
||||
const metrics = {
|
||||
result: [createRow(period, { api_total_requests: 10, auth_total_requests: 10 })],
|
||||
error: null,
|
||||
}
|
||||
|
||||
expect(getRates(metrics)).toEqual({ current: null, previous: null, [period]: 100 })
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ total: 100, errors: 100, expected: 0 },
|
||||
{ total: 100, errors: 0, expected: 100 },
|
||||
{ total: 100, errors: 25, expected: 75 },
|
||||
{ total: 500, errors: 499, expected: 0.2 },
|
||||
{ total: 100, errors: 101, expected: 0 },
|
||||
])(
|
||||
'returns $expected% for $errors failures out of $total requests',
|
||||
({ total, errors, expected }) => {
|
||||
const values = {
|
||||
api_total_requests: total,
|
||||
api_error_requests: errors,
|
||||
auth_total_requests: total,
|
||||
auth_total_errors: errors,
|
||||
}
|
||||
|
||||
const rates = getRates({
|
||||
result: [createRow('current', values), createRow('previous', values)],
|
||||
error: null,
|
||||
})
|
||||
|
||||
expect(rates.current).toBeCloseTo(expected)
|
||||
expect(rates.previous).toBeCloseTo(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('calculatePercentagePointChange', () => {
|
||||
it.each([
|
||||
{ current: 0.2, previous: 0, expected: 0.2 },
|
||||
{ current: 0.2, previous: 0.0667, expected: 0.1333 },
|
||||
{ current: 90, previous: 95, expected: -5 },
|
||||
{ current: 0, previous: 100, expected: -100 },
|
||||
{ current: 100, previous: 0, expected: 100 },
|
||||
{ current: 0, previous: 0, expected: 0 },
|
||||
{ current: 100, previous: 100, expected: 0 },
|
||||
])('returns $expected points from $previous% to $current%', ({ current, previous, expected }) => {
|
||||
expect(calculatePercentagePointChange(current, previous)).toBeCloseTo(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ current: null, previous: 100 },
|
||||
{ current: 100, previous: null },
|
||||
{ current: null, previous: null },
|
||||
])('omits a change when either rate is missing: %j', ({ current, previous }) => {
|
||||
expect(calculatePercentagePointChange(current, previous)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePercentageChange', () => {
|
||||
it.each([
|
||||
{ current: 150, previous: 100, expected: 50 },
|
||||
{ current: 50, previous: 100, expected: -50 },
|
||||
{ current: 0, previous: 100, expected: -100 },
|
||||
{ current: 100, previous: 100, expected: 0 },
|
||||
])('returns $expected% from $previous to $current', ({ current, previous, expected }) => {
|
||||
expect(calculatePercentageChange(current, previous)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([0, 1, 100])('omits a relative change from zero to %i', (current) => {
|
||||
expect(calculatePercentageChange(current, 0)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AUTH_TOP_ERROR_CODES_SQL_OTEL,
|
||||
AUTH_TOP_RESPONSE_ERRORS_SQL_OTEL,
|
||||
parseAuthErrorCodes,
|
||||
parseResponseErrors,
|
||||
} from '@/components/interfaces/Auth/Overview/OverviewErrors.constants'
|
||||
|
||||
describe('Auth overview ClickHouse queries', () => {
|
||||
it('filters gateway failures and preserves the nested error header path', () => {
|
||||
expect(AUTH_TOP_RESPONSE_ERRORS_SQL_OTEL).toContain("source = 'edge_logs'")
|
||||
expect(AUTH_TOP_RESPONSE_ERRORS_SQL_OTEL).toContain('status_code between 400 and 599')
|
||||
expect(AUTH_TOP_ERROR_CODES_SQL_OTEL).toContain('response.headers.x_sb_error_code')
|
||||
expect(AUTH_TOP_ERROR_CODES_SQL_OTEL).toContain("error_code != ''")
|
||||
for (const sql of [AUTH_TOP_ERROR_CODES_SQL_OTEL, AUTH_TOP_RESPONSE_ERRORS_SQL_OTEL]) {
|
||||
expect(sql).toContain('count()')
|
||||
expect(sql).toContain('limit 10')
|
||||
expect(sql).not.toContain('unnest')
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts ClickHouse string counts and legacy numeric counts', () => {
|
||||
expect(
|
||||
parseAuthErrorCodes([
|
||||
{ error_code: 'bad_jwt', count: '12' },
|
||||
{ error_code: 'expired', count: 3 },
|
||||
])
|
||||
).toEqual([
|
||||
{ error_code: 'bad_jwt', count: 12 },
|
||||
{ error_code: 'expired', count: 3 },
|
||||
])
|
||||
expect(
|
||||
parseResponseErrors([
|
||||
{ method: 'GET', path: '/auth/v1/user', status_code: '401', count: '4' },
|
||||
])
|
||||
).toEqual([{ method: 'GET', path: '/auth/v1/user', status_code: 401, count: 4 }])
|
||||
})
|
||||
|
||||
it('drops malformed rows and counts', () => {
|
||||
expect(
|
||||
parseAuthErrorCodes([
|
||||
null,
|
||||
{},
|
||||
{ error_code: 'error', count: 'oops' },
|
||||
{ error_code: 'error', count: null },
|
||||
])
|
||||
).toEqual([])
|
||||
expect(
|
||||
parseResponseErrors([
|
||||
null,
|
||||
{},
|
||||
{ method: 'GET', path: '/', status_code: 401, count: Infinity },
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatMetricChange,
|
||||
formatMetricValue,
|
||||
} from '@/components/interfaces/Auth/Overview/OverviewMetrics.utils'
|
||||
|
||||
describe('formatMetricValue', () => {
|
||||
it.each(['', '%', 'ms'])('shows missing data without the %s suffix', (suffix) => {
|
||||
expect(formatMetricValue(null, suffix)).toBe('No data')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, '%', '0.0%'],
|
||||
[0.2, '%', '0.2%'],
|
||||
[99.96, '%', '100.0%'],
|
||||
[0, 'ms', '0.00ms'],
|
||||
[12.346, 'ms', '12.35ms'],
|
||||
[12.4, '', '12'],
|
||||
[12.6, '', '13'],
|
||||
[12.6, ' users', '13 users'],
|
||||
])('formats %s with suffix %s as %s', (value, suffix, expected) => {
|
||||
expect(formatMetricValue(value, suffix)).toBe(expected)
|
||||
})
|
||||
|
||||
it('uses locale separators for counts by default', () => {
|
||||
expect(formatMetricValue(12345.6)).toBe((12346).toLocaleString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMetricChange', () => {
|
||||
it.each(['', '%', 'ms'])('omits missing changes with suffix %s', (suffix) => {
|
||||
expect(formatMetricChange(null, suffix)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0.2, '%', '0.2 pp'],
|
||||
[-0.2, '%', '-0.2 pp'],
|
||||
[12.36, '%', '12.4 pp'],
|
||||
[25, '', '25.0%'],
|
||||
[-25, 'ms', '-25.0%'],
|
||||
[0, '%', '0.0 pp'],
|
||||
[-0, '%', '0.0 pp'],
|
||||
[-0.01, '%', '0.0 pp'],
|
||||
[-0.01, '', '0.0%'],
|
||||
])('formats change %s with suffix %s as %s', (change, suffix, expected) => {
|
||||
expect(formatMetricChange(change, suffix)).toBe(expected)
|
||||
})
|
||||
|
||||
it('uses relative percentage changes by default', () => {
|
||||
expect(formatMetricChange(25)).toBe('25.0%')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user