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:
Jordi Enric authored and GitHub committed 2026-09-14 15:35:03 +02:00
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%')
})
})