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%') + }) +})