mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## Problem Auth observability charts always queried the legacy logs.all endpoint, even when the OTEL reports rollout was enabled. The existing OTEL SQL also had ClickHouse correctness and parity gaps around timestamp aliasing, JSON types, provider paths, missing values, and error-code attributes. ## Fix Route the ten Auth-specific charts through the OTEL query builders and logs.all.otel endpoint when otelReports is enabled. Preserve the BigQuery fallback, partition React Query caches by backend, and leave the shared API gateway charts on the legacy endpoint. Correct the OTEL queries by qualifying source timestamps, using typed and nullable JSON extraction, preserving missing actor and duration semantics, selecting the right provider path for each event shape, preferring the canonical Auth error-code attribute with a legacy fallback, and applying bounded result limits. Two-minute report intervals now use minute-level SQL buckets instead of falling through to hourly buckets. ## How to test - Run `CI=1 pnpm --filter studio exec vitest run data/reports/v2/auth.config.otel.test.ts hooks/misc/__tests__/useReportDateRange.test.ts` - Run `pnpm --filter studio run lint:ratchet` - Run `pnpm --filter studio run typecheck` - Expected result: all checks pass and generated OTEL SQL preserves legacy report semantics. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Auth observability charts can now use OpenTelemetry data when enabled, while retaining the existing reporting source otherwise. - Switching the data source automatically refreshes the relevant charts. - **Bug Fixes** - Improved Auth observability accuracy for provider, duration, actor, and error-code reporting. - Added safeguards to keep report queries within the supported result limit. - Corrected minute-level grouping for two-minute analytics intervals and three-hour date ranges. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
193 lines
8.7 KiB
TypeScript
193 lines
8.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.each(Object.entries(AUTH_REPORT_SQL_OTEL))('%s has a high fixed result limit', (_, query) => {
|
|
expect(sql(query('1h'))).toContain('limit 50000')
|
|
})
|
|
|
|
it('emits 16-digit unix-microsecond timestamps bucketed by granularity', () => {
|
|
expect(sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h'))).toContain(
|
|
'toUnixTimestamp(toStartOfHour(logs.timestamp)) * 1000000 as timestamp'
|
|
)
|
|
expect(sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1d'))).toContain(
|
|
'toUnixTimestamp(toStartOfDay(logs.timestamp)) * 1000000 as timestamp'
|
|
)
|
|
expect(sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('5m'))).toContain(
|
|
'toUnixTimestamp(toStartOfMinute(logs.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(logs.timestamp)) * 1000000')
|
|
expect(out).toContain('order by toUnixTimestamp(toStartOfHour(logs.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(
|
|
"countDistinct(nullIf(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.each([
|
|
['active users', AUTH_REPORT_SQL_OTEL.ActiveUsers],
|
|
['password reset requests', AUTH_REPORT_SQL_OTEL.PasswordResetRequests],
|
|
['total sign-ups', AUTH_REPORT_SQL_OTEL.TotalSignUps],
|
|
['sign-in processing time', AUTH_REPORT_SQL_OTEL.SignInProcessingTimeBasic],
|
|
['sign-in processing percentiles', AUTH_REPORT_SQL_OTEL.SignInProcessingTimePercentiles],
|
|
['sign-up processing time', AUTH_REPORT_SQL_OTEL.SignUpProcessingTimeBasic],
|
|
['sign-up processing percentiles', AUTH_REPORT_SQL_OTEL.SignUpProcessingTimePercentiles],
|
|
] as const)('uses the nested audit-event provider for %s', (_, query) => {
|
|
const withProvider = sql(query('1h', { provider: ['google'] }))
|
|
expect(withProvider).toContain(
|
|
"coalesce(nullIf(JSONExtractString(event_message, 'auth_event', 'traits', 'provider'), ''), 'unknown') as provider"
|
|
)
|
|
expect(withProvider).toContain(
|
|
"JSONExtractString(event_message, 'auth_event', 'traits', 'provider') IN ('google')"
|
|
)
|
|
expect(withProvider).not.toContain("JSONExtractString(event_message, 'provider') IN")
|
|
|
|
const withoutProvider = sql(query('1h'))
|
|
expect(withoutProvider).not.toContain('as provider')
|
|
})
|
|
|
|
it('uses the top-level provider and boolean metering field for sign-in attempt queries', () => {
|
|
const out = sql(AUTH_REPORT_SQL_OTEL.SignInAttempts('1h', { provider: ['google', 'github'] }))
|
|
|
|
expect(out).toContain("JSONExtractString(event_message, 'action') = 'login'")
|
|
expect(out).toContain("JSONExtractBool(event_message, 'metering') = 1")
|
|
expect(out).not.toContain("JSONExtractString(event_message, 'metering')")
|
|
expect(out).toContain(
|
|
"coalesce(nullIf(JSONExtractString(event_message, 'provider'), ''), 'unknown') as provider"
|
|
)
|
|
expect(out).toContain("JSONExtractString(event_message, 'provider') IN ('google', 'github')")
|
|
expect(out).not.toContain("'auth_event', 'traits', 'provider'")
|
|
expect(out).toContain('as login_type_provider')
|
|
expect(out).toContain('concat(')
|
|
})
|
|
|
|
it.each([
|
|
['sign-in basic', AUTH_REPORT_SQL_OTEL.SignInProcessingTimeBasic],
|
|
['sign-in percentiles', AUTH_REPORT_SQL_OTEL.SignInProcessingTimePercentiles],
|
|
['sign-up basic', AUTH_REPORT_SQL_OTEL.SignUpProcessingTimeBasic],
|
|
['sign-up percentiles', AUTH_REPORT_SQL_OTEL.SignUpProcessingTimePercentiles],
|
|
] as const)('extracts numeric duration as nullable for %s', (_, query) => {
|
|
const out = sql(query('1h'))
|
|
|
|
expect(out).toContain("JSONExtract(event_message, 'duration', 'Nullable(Int64)')")
|
|
expect(out).not.toContain("JSONExtractString(event_message, 'duration')")
|
|
expect(out).not.toContain('toInt64OrZero')
|
|
})
|
|
|
|
it('uses ClickHouse quantile() for sign-in duration percentiles', () => {
|
|
const out = sql(AUTH_REPORT_SQL_OTEL.SignInProcessingTimePercentiles('1h'))
|
|
|
|
expect(out).toContain(
|
|
"round(quantile(0.5)(JSONExtract(event_message, 'duration', 'Nullable(Int64)')) / 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)(JSONExtract(event_message, 'duration', 'Nullable(Int64)')) / 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(JSONExtract(event_message, 'duration', 'Nullable(Int64)')) / 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(JSONExtract(event_message, 'duration', 'Nullable(Int64)')) / 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('prefers the canonical error code and falls back to the legacy header', () => {
|
|
const out = sql(AUTH_REPORT_SQL_OTEL.ErrorsByAuthCode('1h'))
|
|
|
|
expect(out).toContain(
|
|
"coalesce(nullIf(log_attributes['response.headers.sb_error_code'], ''), nullIf(log_attributes['response.headers.x_sb_error_code'], '')) as error_code"
|
|
)
|
|
expect(out).not.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')")
|
|
})
|
|
})
|