From d2ed60da2728a0aeffe1351463cab4bf56819541 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:35:03 +0200 Subject: [PATCH] fix(auth): migrate overview errors to clickhouse (#50174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. ## 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. --- .../Auth/Overview/OverviewErrors.constants.ts | 74 ++++++- .../Auth/Overview/OverviewMetrics.test.tsx | 207 ++++++++++++++++++ .../Auth/Overview/OverviewMetrics.tsx | 104 +++++---- .../Auth/Overview/OverviewMetrics.utils.ts | 13 ++ .../Auth/Overview/OverviewUsage.constants.ts | 20 +- .../Auth/Overview/OverviewUsage.test.ts | 113 +++++++++- .../components/Auth/OverviewErrors.test.ts | 57 +++++ .../Auth/OverviewMetrics.utils.test.ts | 53 +++++ 8 files changed, 577 insertions(+), 64 deletions(-) create mode 100644 apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.test.tsx create mode 100644 apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.utils.ts create mode 100644 apps/studio/tests/components/Auth/OverviewErrors.test.ts create mode 100644 apps/studio/tests/components/Auth/OverviewMetrics.utils.test.ts diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts b/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts index 616b14e8ca9..f7c3daa0fe1 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts @@ -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 } diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.test.tsx b/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.test.tsx new file mode 100644 index 00000000000..9c56e55bbe1 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.test.tsx @@ -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() + 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({ 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({ + result: [{ method: 'POST', path: '/auth/v1/token', status_code: '401', count: '7' }], + }) + } + if (url.searchParams.get('sql') === query.codeSql) { + return HttpResponse.json({ + result: [{ error_code: 'invalid_credentials', count: '3' }], + }) + } + return HttpResponse.json({ message: 'Unexpected SQL' }, { status: 400 }) + }, + }) + + customRender() + + 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( + { message: 'Analytics unavailable' }, + { status: 500 } + ) + } + const error = + failure === 'string' + ? 'Analytics unavailable' + : { code: 500, errors: [], message: 'Analytics unavailable', status: 'INTERNAL' } + return HttpResponse.json({ result: [], error }) + }, + }) + + customRender() + + 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() + + 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( + + ) + expect(screen.getAllByText('0.0%')).toHaveLength(2) + expect(screen.getAllByText('0.0 pp')).toHaveLength(2) + expect(screen.queryByText('No data')).not.toBeInTheDocument() + + rerender( + + ) + 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( + + ) + + expect(screen.getAllByText('0.2%')).toHaveLength(2) + expect(screen.queryByText(/ pp$/)).not.toBeInTheDocument() + await waitFor(() => expect(screen.getAllByText('No data to show')).toHaveLength(2)) + }) +}) diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx b/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx index 9e21fa65f1d..1599446c9f4 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewMetrics.tsx @@ -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)} /> @@ -117,23 +113,6 @@ const LogsLink = ({ href }: { href: string }) => ( ) -function isResponseErrorRow(row: unknown): row is ResponseErrorRow { - if (!row || typeof row !== 'object') return false - const r = row as Record - 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 - 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 @@ -266,23 +256,25 @@ export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsPr
- + Auth API Errors @@ -290,6 +282,15 @@ export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsPr + +
+ } emptyState={
- + Auth Server Errors @@ -353,6 +354,15 @@ export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsPr + +
+ } emptyState={
'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' } diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.test.ts b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.test.ts index 343ef22dbe4..3e942d6f9b0 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.test.ts +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.test.ts @@ -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> = {} +): 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([ + 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() + }) +}) diff --git a/apps/studio/tests/components/Auth/OverviewErrors.test.ts b/apps/studio/tests/components/Auth/OverviewErrors.test.ts new file mode 100644 index 00000000000..be2e99e1a84 --- /dev/null +++ b/apps/studio/tests/components/Auth/OverviewErrors.test.ts @@ -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([]) + }) +}) diff --git a/apps/studio/tests/components/Auth/OverviewMetrics.utils.test.ts b/apps/studio/tests/components/Auth/OverviewMetrics.utils.test.ts new file mode 100644 index 00000000000..2c07f9c0707 --- /dev/null +++ b/apps/studio/tests/components/Auth/OverviewMetrics.utils.test.ts @@ -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%') + }) +})