Files
supabase/apps/studio/data/reports/v2/auth.config.otel.test.ts
Jordi Enric a28214c236 feat(studio): add ClickHouse OTEL SQL for the Auth report's v2 metrics (#48750)
## Problem

Part of DEBUG-73 (migrate reports queries to the OTEL/ClickHouse
endpoint).

The Auth report's v2 metrics (ActiveUsers, SignInAttempts,
PasswordResetRequests, TotalSignUps, sign-in/sign-up processing time,
error breakdowns) currently only query the BigQuery-backed logs.all
endpoint.

## Fix

Adds `AUTH_REPORT_SQL_OTEL`, a ClickHouse-dialect mirror of the existing
`AUTH_REPORT_SQL`, covering all 10 metrics. Threads a `useOtel`
parameter through `fetchLogs` and the three report config creators
(`createUsageReportConfig`, `createErrorsReportConfig`,
`createLatencyReportConfig`), defaulting to `false` everywhere.

This PR is inert on its own: nothing yet passes `useOtel: true`, so it
changes no runtime behavior. The follow-up PR (stacked on this one)
wires the `otelReports` feature flag through the Auth report page to
actually select the OTEL SQL.

Also includes: one dataProvider now validates its raw rows with a Zod
schema instead of casting to `any`, and removal of a few functions in
this file that had zero callers (`AUTH_ERROR_CODE_VALUES`,
`createAuthReportConfig`, an exact duplicate of a status-code color map,
an unused hook).

## How to test

- Unit: `cd apps/studio && npx vitest run
data/reports/v2/auth.config.otel.test.ts`
- No manual testing needed for this PR alone since it changes no runtime
behavior (useOtel defaults to false, unwired). The follow-up PR covers
manual testing of the actual flag-gated behavior.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added OpenTelemetry-backed authentication reports.
* Authentication reports now include usage, errors, latency, sign-ins,
sign-ups, and edge-log metrics.
  * Added filtering by provider, status code, action, and time range.
  * Added selectable telemetry sources for retrieving report logs.
* **Bug Fixes**
  * Improved validation of authentication error codes.
* Improved handling of missing report data with consistent empty
results.
  * Improved report formatting for more consistent attribute display.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-05 18:25:41 +02:00

156 lines
6.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { AUTH_REPORT_SQL_OTEL } from './auth.config'
const sql = (fragment: { toString(): string }) => String(fragment)
describe('AUTH_REPORT_SQL_OTEL', () => {
it('queries the single OTEL logs table by source, never a per-service table', () => {
const out = sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h'))
expect(out).toContain('from logs')
expect(out).toContain("where source = 'auth_logs'")
expect(out).not.toContain('from auth_logs')
expect(out).not.toContain('cross join unnest')
})
it('emits 16-digit unix-microsecond timestamps bucketed by granularity', () => {
expect(sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h'))).toContain(
'toUnixTimestamp(toStartOfHour(timestamp)) * 1000000 as timestamp'
)
expect(sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1d'))).toContain(
'toUnixTimestamp(toStartOfDay(timestamp)) * 1000000 as timestamp'
)
expect(sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('5m'))).toContain(
'toUnixTimestamp(toStartOfMinute(timestamp)) * 1000000 as timestamp'
)
})
it('groups and orders by the full bucket expression, not the timestamp alias', () => {
const out = sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h'))
expect(out).toContain('group by toUnixTimestamp(toStartOfHour(timestamp)) * 1000000')
expect(out).toContain('order by toUnixTimestamp(toStartOfHour(timestamp)) * 1000000 desc')
expect(out).not.toContain('group by timestamp')
expect(out).not.toContain('order by timestamp desc')
})
it('reads auth_logs fields from the raw JSON event_message, not BigQuery json_value', () => {
const out = sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h'))
expect(out).toContain("JSONExtractString(event_message, 'auth_event', 'action')")
expect(out).toContain(
"count(distinct JSONExtractString(event_message, 'auth_event', 'actor_id')) as count"
)
expect(out).not.toContain('json_value')
expect(out).not.toContain('timestamp_trunc')
expect(out).not.toContain('count(*)')
})
it('groups by provider only when a provider filter is set', () => {
const withProvider = sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h', { provider: ['google'] }))
expect(withProvider).toContain(
"coalesce(nullIf(JSONExtractString(event_message, 'provider'), ''), 'unknown') as provider"
)
expect(withProvider).toContain("JSONExtractString(event_message, 'provider') IN ('google')")
const withoutProvider = sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h'))
expect(withoutProvider).not.toContain('as provider')
})
it('translates the sign-in metering predicate and login_type_provider concat', () => {
const out = sql(AUTH_REPORT_SQL_OTEL.SignInAttempts('1h'))
expect(out).toContain("JSONExtractString(event_message, 'action') = 'login'")
expect(out).toContain("JSONExtractString(event_message, 'metering') = 'true'")
expect(out).toContain('as login_type_provider')
expect(out).toContain('concat(')
})
it('uses ClickHouse quantile() for percentiles over the duration field', () => {
const out = sql(AUTH_REPORT_SQL_OTEL.SignInProcessingTimePercentiles('1h'))
expect(out).toContain(
"round(quantile(0.5)(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as p50_processing_time_ms"
)
expect(out).toContain('quantile(0.95)')
expect(out).toContain('quantile(0.99)')
expect(out).not.toContain('approx_quantiles')
})
it('filters TotalSignUps/PasswordResetRequests to their respective auth_event actions', () => {
expect(sql(AUTH_REPORT_SQL_OTEL.TotalSignUps('1h'))).toContain(
"JSONExtractString(event_message, 'auth_event', 'action') = 'user_signedup'"
)
expect(sql(AUTH_REPORT_SQL_OTEL.PasswordResetRequests('1h'))).toContain(
"JSONExtractString(event_message, 'auth_event', 'action') = 'user_recovery_requested'"
)
})
it('uses ClickHouse quantile() for sign-up percentiles over the duration field', () => {
const out = sql(AUTH_REPORT_SQL_OTEL.SignUpProcessingTimePercentiles('1h'))
expect(out).toContain(
"round(quantile(0.5)(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as p50_processing_time_ms"
)
expect(out).toContain('quantile(0.95)')
expect(out).toContain('quantile(0.99)')
expect(out).not.toContain('approx_quantiles')
})
it('averages sign-in processing time without percentiles for the basic variant', () => {
const out = sql(AUTH_REPORT_SQL_OTEL.SignInProcessingTimeBasic('1h'))
expect(out).toContain("JSONExtractString(event_message, 'auth_event', 'action') = 'login'")
expect(out).toContain(
"round(avg(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as avg_processing_time_ms"
)
expect(out).not.toContain('quantile(')
})
it('averages sign-up processing time without percentiles for the basic variant', () => {
const out = sql(AUTH_REPORT_SQL_OTEL.SignUpProcessingTimeBasic('1h'))
expect(out).toContain(
"round(avg(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as avg_processing_time_ms"
)
expect(out).not.toContain('quantile(')
})
it('reads edge_logs error fields from log_attributes', () => {
const out = sql(AUTH_REPORT_SQL_OTEL.ErrorsByStatus('1h'))
expect(out).toContain("where source = 'edge_logs'")
expect(out).toContain("log_attributes['request.path'] like '%auth/v1%'")
expect(out).toContain(
"toInt32OrZero(log_attributes['response.status_code']) between 400 and 599"
)
expect(out).toContain("toInt32OrZero(log_attributes['response.status_code']) as status_code")
})
it('selects the x_sb_error_code attribute for the by-code breakdown', () => {
expect(sql(AUTH_REPORT_SQL_OTEL.ErrorsByAuthCode('1h'))).toContain(
"log_attributes['response.headers.x_sb_error_code'] as error_code"
)
})
it('applies the numeric status_code filter to edge_logs error queries', () => {
const out = sql(
AUTH_REPORT_SQL_OTEL.ErrorsByStatus('1h', { status_code: { operator: '>=', value: 500 } })
)
expect(out).toContain("AND toInt32OrZero(log_attributes['response.status_code']) >= 500")
})
it('ignores a status_code filter on auth_logs-sourced queries (no HTTP response fields there)', () => {
const out = sql(
AUTH_REPORT_SQL_OTEL.ActiveUsers('1h', { status_code: { operator: '>=', value: 500 } })
)
expect(out).not.toContain("toInt32OrZero(log_attributes['response.status_code'])")
})
it('ignores a provider filter on edge_logs-sourced queries (no auth provider attribute there)', () => {
const out = sql(AUTH_REPORT_SQL_OTEL.ErrorsByStatus('1h', { provider: ['google'] }))
expect(out).not.toContain("JSONExtractString(event_message, 'provider')")
})
})