Auth Report tests and improvements (#38550)

* move to new ReportConfig

* separate types

* add auth report config

* add auth report transofmration test

* add fetchlogs util for reports

* rm auth-charts

* rm auth-report-query

* rm dupped code

* simplify upsell compo

* rm unnecessary hook

* use upsell in database report instead of old component

* rip ReportChart you wont be missed

* more tests

* rm unnecessary import

* Fix SQL in active users

* support hide inside report chart component

* add max bar size to avoid having big ass bars when theres little data

* fix incorrect timestamp key passed on to fillTimeseries

* add tooltip to auth report

* fix bug with timestamp key inside tooltips

* add report settings

* rm debugging check

* Update apps/studio/data/reports/v2/auth.config.ts

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* safely access result

* fix sign in latency query

* add toggleable attributes

* handle toggleable attributes

* pass on hideHighlightedValue

* fix signup latency

* pass hideHighlighted

* report chart pass on hide highlighted value

* cleanup finalChartData

* simplify tests, use timestamp for everything

* fix test

* rm dupped attribute

* add tooltip to auth errors

* Update apps/studio/data/reports/v2/auth.config.ts

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* Update apps/studio/components/ui/Charts/ComposedChart.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* prettify

---------

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>
This commit is contained in:
Jordi Enric
2025-09-17 17:41:10 +02:00
committed by GitHub
parent 3a9c377ac3
commit c75bb1b60d
15 changed files with 742 additions and 852 deletions

View File

@@ -1,96 +0,0 @@
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
import LogChartHandler from 'components/ui/Charts/LogChartHandler'
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useChartData } from 'hooks/useChartData'
import type { UpdateDateRange } from 'pages/project/[ref]/reports/database'
import { ReportChartUpsell } from './v2/ReportChartUpsell'
interface ReportChartProps {
chart: any
startDate: string
endDate: string
interval: string
updateDateRange: UpdateDateRange
functionIds?: string[]
isLoading?: boolean
}
/**
* A wrapper component that uses the useChartData hook to fetch data for a chart
* and then passes the data and loading state to the ComposedChartHandler.
*
* This component acts as a bridge between the data-fetching logic and the
* presentational chart component.
*/
export const ReportChart = ({
chart,
startDate,
endDate,
interval,
updateDateRange,
functionIds,
isLoading,
}: ReportChartProps) => {
const { data: org } = useSelectedOrganizationQuery()
const { plan: orgPlan } = useCurrentOrgPlan()
const orgPlanId = orgPlan?.id
const isAvailable =
chart.availableIn === undefined || (orgPlanId && chart.availableIn.includes(orgPlanId))
const canFetch = orgPlanId !== undefined
const {
data,
isLoading: isLoadingChart,
highlightedValue,
} = useChartData({
attributes: chart.attributes,
startDate,
endDate,
interval,
functionIds,
data: undefined,
enabled: canFetch,
highlightedValue:
chart.id === 'client-connections' || chart.id === 'pgbouncer-connections'
? true
: chart.showMaxValue,
})
const isTopListChart = chart.id === 'top-api-routes' || chart.id === 'top-rpc-functions'
const chartDataArray = Array.isArray(data) ? data : []
const { data: filledData, isError: isFillError } = useFillTimeseriesSorted(
chartDataArray,
'period_start',
chart.attributes.map((attr: any) => attr.attribute),
0,
startDate,
endDate,
undefined,
interval
)
const finalData =
chartDataArray.length > 0 && chartDataArray.length < 20 && !isFillError && !isTopListChart
? filledData
: chartDataArray
if (!isAvailable && !isLoading) {
return <ReportChartUpsell report={chart} orgSlug={org?.slug ?? ''} />
}
return (
<LogChartHandler
{...chart}
attributes={chart.attributes as MultiAttribute[]}
data={finalData}
isLoading={isLoadingChart || isLoading}
highlightedValue={highlightedValue as any}
updateDateRange={updateDateRange}
/>
)
}

View File

@@ -5,7 +5,16 @@ import { LogChartHandler } from 'components/ui/Charts/LogChartHandler'
import { ReportConfig } from 'data/reports/v2/reports.types'
import { Button, Card, cn } from 'ui'
export function ReportChartUpsell({ report, orgSlug }: { report: ReportConfig; orgSlug: string }) {
export function ReportChartUpsell({
report,
orgSlug,
}: {
report: {
label: string
availableIn: string[]
}
orgSlug: string
}) {
const [isHoveringUpgrade, setIsHoveringUpgrade] = useState(false)
const startDate = '2025-01-01'

View File

@@ -58,7 +58,7 @@ export const ReportChartV2 = ({
return await report.dataProvider(projectRef, startDate, endDate, interval, filters)
},
{
enabled: Boolean(projectRef && canFetch && isAvailable),
enabled: Boolean(projectRef && canFetch && isAvailable && !report.hide),
refetchOnWindowFocus: false,
staleTime: 0,
}
@@ -67,9 +67,15 @@ export const ReportChartV2 = ({
const chartData = queryResult?.data || []
const dynamicAttributes = queryResult?.attributes || []
/**
* Depending on the source the timestamp key could be 'timestamp' or 'period_start'
*/
const firstItem = chartData[0]
const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start'
const { data: filledChartData, isError: isFillError } = useFillTimeseriesSorted(
chartData,
'timestamp',
timestampKey,
(dynamicAttributes as any[]).map((attr: any) => attr.attribute),
0,
startDate,
@@ -78,9 +84,6 @@ export const ReportChartV2 = ({
interval
)
const finalChartData =
filledChartData && filledChartData.length > 0 && !isFillError ? filledChartData : chartData
const [chartStyle, setChartStyle] = useState<string>(report.defaultChartStyle)
if (!isAvailable) {
@@ -88,7 +91,8 @@ export const ReportChartV2 = ({
}
const isErrorState = error && !isLoadingChart
const showEmptyState = (!finalChartData || finalChartData.length === 0) && !isLoadingChart
if (report.hide) return null
return (
<Card id={report.id} className={cn('relative w-full overflow-hidden scroll-mt-16', className)}>
@@ -100,10 +104,6 @@ export const ReportChartV2 = ({
>
{isLoadingChart ? (
<Loader2 className="size-5 animate-spin text-foreground-light" />
) : showEmptyState ? (
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
No data available for the selected time range and filters
</p>
) : isErrorState ? (
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
Error loading chart data
@@ -112,10 +112,11 @@ export const ReportChartV2 = ({
<div className="w-full">
<ComposedChart
attributes={dynamicAttributes}
data={finalChartData}
data={filledChartData}
format={report.format ?? undefined}
xAxisKey={report.xAxisKey ?? 'timestamp'}
yAxisKey={report.yAxisKey ?? dynamicAttributes[0]?.attribute}
hideHighlightedValue={report.hideHighlightedValue}
highlightedValue={0}
title={report.label}
customDateFormat={undefined}

View File

@@ -111,11 +111,12 @@ export function ComposedChart({
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
const [hoveredLabel, setHoveredLabel] = useState<string | null>(null)
const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false)
const [hiddenAttributes, setHiddenAttributes] = useState<Set<string>>(new Set())
const isDarkMode = resolvedTheme?.includes('dark')
useEffect(() => {
updateStackedChartColors(isDarkMode ?? false)
}, [resolvedTheme])
}, [isDarkMode])
const { Container } = useChartSize(size)
@@ -174,6 +175,7 @@ export function ComposedChart({
...attributesToIgnore,
...(referenceLines?.map((a: MultiAttribute) => a.attribute) ?? []),
...(maxAttribute?.attribute ? [maxAttribute?.attribute] : []),
...Array.from(hiddenAttributes),
]
const lastDataPoint = data[data.length - 1]
@@ -265,6 +267,7 @@ export function ComposedChart({
const attribute = attributes.find((attr) => attr.attribute === att.name)
return !attribute?.isMaxValue
})
const visibleAttributes = stackedAttributes.filter((att) => !hiddenAttributes.has(att.name))
const isPercentage = format === '%'
const isRamChart =
!chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') &&
@@ -282,10 +285,11 @@ export function ComposedChart({
// to the highest value in the chart data for percentage charts
// to vertically zoom in on the data
// */
const yDomain = [
const yMaxFromVisible = Math.max(
0,
Math.max(...chartData.map((att) => (typeof att.value === 'number' ? att.value : 0))),
]
...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0))
)
const yDomain = [0, yMaxFromVisible]
if (data.length === 0) {
return (
@@ -303,6 +307,7 @@ export function ComposedChart({
return (
<div className={cn('flex flex-col gap-y-3', className)}>
<ChartHeader
hideHighlightedValue={hideHighlightedValue}
title={title}
format={format}
customDateFormat={customDateFormat}
@@ -314,7 +319,6 @@ export function ComposedChart({
onChartStyleChange={onChartStyleChange}
showMaxValue={_showMaxValue}
setShowMaxValue={maxAttribute ? setShowMaxValue : undefined}
hideHighlightedValue={hideHighlightedValue}
docsUrl={docsUrl}
syncId={syncId}
data={data}
@@ -403,29 +407,28 @@ export function ComposedChart({
}
/>
{chartStyle === 'bar'
? stackedAttributes.map((attribute) => (
? visibleAttributes.map((attribute) => (
<Bar
key={attribute.name}
dataKey={attribute.name}
stackId={attributes?.find((a) => a.attribute === attribute?.name)?.stackId ?? '1'}
fill={attribute.color}
fillOpacity={hoveredLabel && hoveredLabel !== attribute?.name ? 0.25 : 1}
radius={0.75}
opacity={hoveredLabel && hoveredLabel !== attribute?.name ? 0.5 : 1}
opacity={1}
name={
attributes?.find((a) => a.attribute === attribute?.name)?.label ||
attribute?.name
}
maxBarSize={24}
/>
))
: stackedAttributes.map((attribute, i) => (
: visibleAttributes.map((attribute, i) => (
<Area
key={attribute.name}
type="step"
dataKey={attribute.name}
stackId={attributes?.find((a) => a.attribute === attribute.name)?.stackId ?? '1'}
fill={attribute.color}
strokeOpacity={hoveredLabel && hoveredLabel !== attribute.name ? 0.4 : 1}
stroke={attribute.color}
radius={20}
animationDuration={375}
@@ -509,6 +512,28 @@ export function ComposedChart({
attributes={attributes}
showMaxValue={_showMaxValue}
onLabelHover={setHoveredLabel}
onToggleAttribute={(attribute, options) => {
setHiddenAttributes((prev) => {
if (options?.exclusive) {
const next = new Set<string>()
// Hide every attribute except the selected one. If all but one are hidden, clicking again will reset to all visible.
const allNames = chartData.map((c) => c.name)
const allHiddenExcept = allNames.filter((n) => n !== attribute)
const isAlreadyExclusive =
allHiddenExcept.every((n) => prev.has(n)) && !prev.has(attribute)
return isAlreadyExclusive ? new Set() : new Set(allHiddenExcept)
}
const next = new Set(prev)
if (next.has(attribute)) {
next.delete(attribute)
} else {
next.add(attribute)
}
return next
})
}}
hiddenAttributes={hiddenAttributes}
/>
)}
</div>

View File

@@ -141,7 +141,12 @@ const CustomTooltip = ({
isActiveHoveredChart,
}: TooltipProps) => {
if (active && payload && payload.length) {
const timestamp = payload[0].payload.timestamp
/**
* Depending on the data source, the timestamp key could be 'timestamp' or 'period_start'
*/
const firstItem = payload[0].payload
const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start'
const timestamp = payload[0].payload[timestampKey]
const maxValueAttribute = isMaxAttribute(attributes)
const maxValueData =
maxValueAttribute && payload?.find((p: any) => p.dataKey === maxValueAttribute.attribute)
@@ -250,9 +255,18 @@ interface CustomLabelProps {
attributes?: MultiAttribute[]
showMaxValue?: boolean
onLabelHover?: (label: string | null) => void
onToggleAttribute?: (attribute: string, options?: { exclusive?: boolean }) => void
hiddenAttributes?: Set<string>
}
const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: CustomLabelProps) => {
const CustomLabel = ({
payload,
attributes,
showMaxValue,
onLabelHover,
onToggleAttribute,
hiddenAttributes,
}: CustomLabelProps) => {
const items = payload ?? []
const maxValueAttribute = isMaxAttribute(attributes)
const [hoveredLabel, setHoveredLabel] = useState<string | null>(null)
@@ -280,16 +294,12 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom
const attribute = attributes?.find((a) => a.attribute === entry.name)
const isMax = entry.name === maxValueAttribute?.attribute
const isHovered = hoveredLabel === entry.name
const isHidden = hiddenAttributes?.has(entry.name)
const Label = () => (
<div className="flex items-center gap-1 p-1">
<div className="flex items-center gap-1">
{getIcon(entry.name, entry.color)}
<span
className={cn(
'text-nowrap text-foreground-lighter cursor-default select-none',
hoveredLabel && !isHovered && 'opacity-50'
)}
>
<span className={cn('text-nowrap text-foreground-lighter', isHidden && 'opacity-50')}>
{attribute?.label || entry.name}
</span>
</div>
@@ -298,11 +308,12 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom
if (!showMaxValue && isMax) return null
return (
<div
<button
key={entry.name}
className="inline-flex md:flex-col gap-1 md:gap-0 w-fit text-foreground"
className="flex md:flex-col gap-1 md:gap-0 w-fit text-foreground rounded-lg p-1.5 hover:bg-background-overlay-hover"
onMouseOver={() => handleMouseEnter(entry.name)}
onMouseOutCapture={handleMouseLeave}
onClick={(e) => onToggleAttribute?.(entry.name, { exclusive: e.metaKey || e.ctrlKey })}
>
{!!attribute?.tooltip ? (
<Tooltip>
@@ -316,12 +327,12 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom
) : (
<Label />
)}
</div>
</button>
)
}
return (
<div className="relative z-0 mx-auto flex flex-col items-center gap-1 text-xs w-full">
<div className="relative z-10 mx-auto flex flex-col items-center gap-1 text-xs w-full">
<div className="flex flex-wrap items-center justify-center gap-2">
{items?.map((entry, index) => <LabelItem key={`${entry.name}-${index}`} entry={entry} />)}
</div>

View File

@@ -1,200 +0,0 @@
export const getAuthReportAttributes = () => [
{
id: 'active-users',
label: 'Active Users',
valuePrecision: 0,
hide: false,
showTooltip: false,
showLegend: false,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'bar',
attributes: [
{ attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true },
],
},
{
id: 'sign-in-attempts',
label: 'Sign In Attempts by Type',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'bar',
titleTooltip: 'The total number of sign in attempts by grant type.',
attributes: [
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'Password',
login_type_provider: 'password',
enabled: true,
},
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'PKCE',
login_type_provider: 'pkce',
enabled: true,
},
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'Refresh Token',
login_type_provider: 'token',
enabled: true,
},
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'ID Token',
login_type_provider: 'id_token',
enabled: true,
},
],
},
{
id: 'signups',
label: 'Sign Ups',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: false,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'bar',
titleTooltip: 'The total number of sign ups.',
attributes: [
{
attribute: 'TotalSignUps',
provider: 'logs',
label: 'Sign Ups',
enabled: true,
},
],
},
{
id: 'auth-errors',
label: 'Auth Errors',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'bar',
titleTooltip: 'The total number of auth errors by status code.',
attributes: [
{
attribute: 'ErrorsByStatus',
provider: 'logs',
label: 'Auth Errors',
},
],
},
{
id: 'password-reset-requests',
label: 'Password Reset Requests',
valuePrecision: 0,
hide: false,
showTooltip: false,
showLegend: false,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'bar',
attributes: [
{
attribute: 'PasswordResetRequests',
provider: 'logs',
label: 'Password Reset Requests',
enabled: true,
},
],
},
{
id: 'sign-in-latency',
label: 'Sign In Latency',
valuePrecision: 2,
hide: true, // Jordi: Hidden until we can fix the query
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'Average latency for sign in operations by grant type.',
attributes: [
{
attribute: 'SignInLatency',
provider: 'logs',
label: 'Password',
grantType: 'password',
enabled: true,
},
{
attribute: 'SignInLatency',
provider: 'logs',
label: 'PKCE',
grantType: 'pkce',
enabled: true,
},
{
attribute: 'SignInLatency',
provider: 'logs',
label: 'Refresh Token',
grantType: 'refresh_token',
enabled: true,
},
{
attribute: 'SignInLatency',
provider: 'logs',
label: 'ID Token',
grantType: 'id_token',
enabled: true,
},
],
},
{
id: 'sign-up-latency',
label: 'Sign Up Latency',
valuePrecision: 2,
hide: true, // Jordi: Hidden until we can fix the query
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'Average latency for sign up operations by provider.',
attributes: [
{
attribute: 'SignUpLatency',
provider: 'logs',
label: 'Email',
providerType: 'email',
enabled: true,
},
{
attribute: 'SignUpLatency',
provider: 'logs',
label: 'Google',
providerType: 'google',
enabled: true,
},
{
attribute: 'SignUpLatency',
provider: 'logs',
label: 'GitHub',
providerType: 'github',
enabled: true,
},
{
attribute: 'SignUpLatency',
provider: 'logs',
label: 'Apple',
providerType: 'apple',
enabled: true,
},
],
},
]

View File

@@ -1,345 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { get } from 'data/fetchers'
import { AnalyticsInterval } from 'data/analytics/constants'
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
import { getHttpStatusCodeInfo } from 'lib/http-status-codes'
import { analyticsIntervalToGranularity } from './report.utils'
/**
* METRICS
* Each chart in the UI has a corresponding metric key.
*/
const METRIC_KEYS = [
'ActiveUsers',
'SignInAttempts',
'PasswordResetRequests',
'TotalSignUps',
'SignInLatency',
'SignUpLatency',
'ErrorsByStatus',
]
const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = {
'400': { light: '#FFD54F', dark: '#FFF176' },
'401': { light: '#FF8A65', dark: '#FFAB91' },
'403': { light: '#FFB74D', dark: '#FFCC80' },
'404': { light: '#90A4AE', dark: '#B0BEC5' },
'409': { light: '#BA68C8', dark: '#CE93D8' },
'410': { light: '#A1887F', dark: '#BCAAA4' },
'422': { light: '#FF9800', dark: '#FFB74D' },
'429': { light: '#E65100', dark: '#F57C00' },
'500': { light: '#B71C1C', dark: '#D32F2F' },
'502': { light: '#9575CD', dark: '#B39DDB' },
'503': { light: '#0097A7', dark: '#4DD0E1' },
'504': { light: '#C0CA33', dark: '#D4E157' },
default: { light: '#757575', dark: '#9E9E9E' },
}
type MetricKey = (typeof METRIC_KEYS)[number]
/**
* SQL
* Each metric has a corresponding SQL query.
*/
const METRIC_SQL: Record<MetricKey, (interval: AnalyticsInterval) => string> = {
ActiveUsers: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--active-users
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count
from auth_logs f
where json_value(f.event_message, "$.auth_event.action") in (
'login', 'user_signedup', 'token_refreshed', 'user_modified',
'user_recovery_requested', 'user_reauthenticate_requested'
)
group by timestamp
order by timestamp desc
`
},
SignInAttempts: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--sign-in-attempts
SELECT
timestamp_trunc(timestamp, ${granularity}) as timestamp,
CASE
WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL
AND JSON_VALUE(event_message, "$.provider") != ''
THEN CONCAT(
JSON_VALUE(event_message, "$.login_method"),
' (',
JSON_VALUE(event_message, "$.provider"),
')'
)
ELSE JSON_VALUE(event_message, "$.login_method")
END as login_type_provider,
COUNT(*) as count
FROM
auth_logs
WHERE
JSON_VALUE(event_message, "$.action") = 'login'
AND JSON_VALUE(event_message, "$.metering") = "true"
GROUP BY
timestamp, login_type_provider
ORDER BY
timestamp desc, login_type_provider
`
},
PasswordResetRequests: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--password-reset-requests
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
count(*) as count
from auth_logs f
where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested'
group by timestamp
order by timestamp desc
`
},
TotalSignUps: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--total-signups
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
count(*) as count
from auth_logs
where json_value(event_message, "$.auth_event.action") = 'user_signedup'
group by timestamp
order by timestamp desc
`
},
SignInLatency: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--signin-latency
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
json_value(event_message, "$.grant_type") as grant_type,
count(*) as request_count,
round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms,
round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms,
round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms
from auth_logs
where json_value(event_message, "$.path") = '/token'
group by timestamp, grant_type
order by timestamp desc, grant_type
`
},
SignUpLatency: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--signup-latency
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
json_value(event_message, "$.auth_event.traits.provider") as provider,
round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms,
round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms,
round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms
from auth_logs
where json_value(event_message, "$.auth_event.action") = 'user_signedup'
and json_value(event_message, "$.status") = '200'
group by timestamp, provider
order by timestamp desc, provider
`
},
ErrorsByStatus: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--auth-errors-by-status
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
count(*) as count,
response.status_code
from edge_logs
cross join unnest(metadata) as m
cross join unnest(m.request) as request
cross join unnest(m.response) as response
where path like '%/auth%'
and response.status_code >= 400 and response.status_code <= 599
group by timestamp, status_code
order by timestamp desc
`
},
}
/**
* FORMATTERS.
* Metrics need to be formatted before being passed on to the UI charts.
*/
function defaultFormatter(rawData: any, attributes: MultiAttribute[]) {
const chartAttributes = attributes
if (!rawData) return { data: undefined, chartAttributes }
const result = rawData.result || []
const timestamps = new Set<string>(result.map((p: any) => p.timestamp))
const data = Array.from(timestamps)
.sort()
.map((timestamp) => {
const point: any = { period_start: timestamp }
chartAttributes.forEach((attr) => {
point[attr.attribute] = 0
})
const matchingPoints = result.filter((p: any) => p.timestamp === timestamp)
matchingPoints.forEach((p: any) => {
point[attributes[0].attribute] = p.count
})
return point
})
return { data, chartAttributes }
}
const METRIC_FORMATTER: Record<
MetricKey,
(
rawData: any,
attributes: MultiAttribute[],
logsMetric: string
) => { data: any; chartAttributes: any }
> = {
ActiveUsers: (rawData, attributes) => defaultFormatter(rawData, attributes),
SignInAttempts: (rawData, attributes) => {
const chartAttributes = attributes.map((attr) => {
if (attr.attribute === 'SignInAttempts' && attr.login_type_provider) {
return { ...attr, attribute: `${attr.attribute}_${attr.login_type_provider}` }
}
return attr
})
if (!rawData) return { data: undefined, chartAttributes }
const result = rawData.result || []
const timestamps = new Set<string>(result.map((p: any) => p.timestamp))
const data = Array.from(timestamps)
.sort()
.map((timestamp) => {
const point: any = { period_start: timestamp }
chartAttributes.forEach((attr) => {
point[attr.attribute] = 0
})
const matchingPoints = result.filter((p: any) => p.timestamp === timestamp)
matchingPoints.forEach((p: any) => {
point[`SignInAttempts_${p.login_type_provider}`] = p.count
})
return point
})
return { data, chartAttributes }
},
PasswordResetRequests: (rawData, attributes) => defaultFormatter(rawData, attributes),
TotalSignUps: (rawData, attributes) => defaultFormatter(rawData, attributes),
SignInLatency: (rawData, attributes) => defaultFormatter(rawData, attributes),
SignUpLatency: (rawData, attributes) => defaultFormatter(rawData, attributes),
ErrorsByStatus: (rawData, attributes) => {
if (!rawData) return { data: undefined, chartAttributes: attributes }
const result = rawData.result || []
const statusCodes = Array.from(new Set(result.map((p: any) => p.status_code)))
const chartAttributes = statusCodes.map((statusCode) => {
const statusCodeInfo = getHttpStatusCodeInfo(Number(statusCode))
const color = STATUS_CODE_COLORS[String(statusCode)] || STATUS_CODE_COLORS.default
return {
attribute: `status_${statusCode}`,
label: `${statusCode} ${statusCodeInfo.label}`,
provider: 'logs',
enabled: true,
color: color,
statusCode: String(statusCode),
}
})
const timestamps = new Set<string>(result.map((p: any) => p.timestamp))
const data = Array.from(timestamps)
.sort()
.map((timestamp) => {
const point: any = { period_start: timestamp }
chartAttributes.forEach((attr) => {
point[attr.attribute] = 0
})
const matchingPoints = result.filter((p: any) => p.timestamp === timestamp)
matchingPoints.forEach((p: any) => {
point[`status_${p.status_code}`] = p.count
})
return point
})
return { data, chartAttributes }
},
}
/**
* REPORT QUERY.
* Fetching and state management for the report.
*/
export function useAuthLogsReport({
projectRef,
attributes,
startDate,
endDate,
interval,
enabled = true,
}: {
projectRef: string
attributes: MultiAttribute[]
startDate: string
endDate: string
interval: AnalyticsInterval
enabled?: boolean
}) {
const logsMetric = attributes.length > 0 ? attributes[0].attribute : ''
const isAuthMetric = METRIC_KEYS.includes(logsMetric)
const sql = isAuthMetric ? METRIC_SQL[logsMetric](interval) : ''
const {
data: rawData,
error,
isLoading,
isFetching,
} = useQuery(
['auth-logs-report', projectRef, logsMetric, startDate, endDate, interval, sql],
async () => {
const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, {
params: {
path: { ref: projectRef },
query: {
sql,
iso_timestamp_start: startDate,
iso_timestamp_end: endDate,
},
},
})
if (error) throw error
return data
},
{
enabled: Boolean(projectRef && sql && enabled && isAuthMetric),
refetchOnWindowFocus: false,
}
)
// Use formatter if available
const formatter =
(isAuthMetric ? METRIC_FORMATTER[logsMetric as MetricKey] : undefined) || defaultFormatter
const { data, chartAttributes } = formatter(rawData, attributes, logsMetric)
return {
data,
attributes: chartAttributes,
isLoading,
error,
isFetching,
}
}

View File

@@ -1,5 +1,6 @@
import { AnalyticsInterval } from 'data/analytics/constants'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { get } from 'data/fetchers'
export type Granularity = 'minute' | 'hour' | 'day'
export function analyticsIntervalToGranularity(interval: AnalyticsInterval): Granularity {
@@ -51,3 +52,39 @@ export const useEdgeFnIdToName = ({ projectRef }: { projectRef: string }) => {
isLoading,
}
}
export async function fetchLogs(
projectRef: string,
sql: string,
startDate: string,
endDate: string
) {
const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, {
params: {
path: { ref: projectRef },
query: {
sql,
iso_timestamp_start: startDate,
iso_timestamp_end: endDate,
},
},
})
if (error) throw error
return data
}
export const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = {
'400': { light: '#FFD54F', dark: '#FFF176' },
'401': { light: '#FF8A65', dark: '#FFAB91' },
'403': { light: '#FFB74D', dark: '#FFCC80' },
'404': { light: '#90A4AE', dark: '#B0BEC5' },
'409': { light: '#BA68C8', dark: '#CE93D8' },
'410': { light: '#A1887F', dark: '#BCAAA4' },
'422': { light: '#FF9800', dark: '#FFB74D' },
'429': { light: '#E65100', dark: '#F57C00' },
'500': { light: '#B71C1C', dark: '#D32F2F' },
'502': { light: '#9575CD', dark: '#B39DDB' },
'503': { light: '#0097A7', dark: '#4DD0E1' },
'504': { light: '#C0CA33', dark: '#D4E157' },
default: { light: '#757575', dark: '#9E9E9E' },
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { defaultAuthReportFormatter } from './auth.config'
describe('defaultAuthReportFormatter', () => {
const timestamp = new Date('2021-01-01').getTime()
it('should format the data correctly', () => {
const data = { result: [{ timestamp: String(timestamp), count: 1 }] }
const attributes = [
{ attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true },
]
const result = defaultAuthReportFormatter(data, attributes)
expect(result).toEqual({
data: [{ timestamp: String(timestamp), ActiveUsers: 1 }],
chartAttributes: attributes,
})
})
it('should format the data correctly with multiple attributes', () => {
const data = {
result: [
{ timestamp: String(timestamp), active_users: 1 },
{ timestamp: String(timestamp + 1), sign_in_attempts: 2 },
],
}
const attributes = [
{ attribute: 'active_users', label: 'Active Users' },
{ attribute: 'sign_in_attempts', label: 'Sign In Attempts' },
]
const result = defaultAuthReportFormatter(data, attributes)
expect(result).toEqual({
data: [
{ timestamp: String(timestamp), active_users: 1, sign_in_attempts: 0 },
{ timestamp: String(timestamp + 1), active_users: 0, sign_in_attempts: 2 },
],
chartAttributes: attributes,
})
})
it('should handle empty data', () => {
const data = { result: [] }
const attributes = [
{ attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true },
]
const result = defaultAuthReportFormatter(data, attributes)
expect(result).toEqual({
data: [],
chartAttributes: attributes,
})
})
})

View File

@@ -0,0 +1,514 @@
import type { AnalyticsInterval } from 'data/analytics/constants'
import { analyticsIntervalToGranularity } from 'data/reports/report.utils'
import { ReportConfig, ReportDataProviderAttribute } from './reports.types'
import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter'
import { fetchLogs } from 'data/reports/report.utils'
import z from 'zod'
const METRIC_KEYS = [
'ActiveUsers',
'SignInAttempts',
'PasswordResetRequests',
'TotalSignUps',
'SignInLatency',
'SignUpLatency',
'ErrorsByStatus',
]
type MetricKey = (typeof METRIC_KEYS)[number]
const AUTH_REPORT_SQL: Record<
MetricKey,
(interval: AnalyticsInterval, filters?: AuthReportFilters) => string
> = {
ActiveUsers: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--active-users
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count
from auth_logs f
where json_value(f.event_message, "$.auth_event.action") in (
'login', 'user_signedup', 'token_refreshed', 'user_modified',
'user_recovery_requested', 'user_reauthenticate_requested'
)
group by timestamp
order by timestamp desc
`
},
SignInAttempts: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--sign-in-attempts
SELECT
timestamp_trunc(timestamp, ${granularity}) as timestamp,
CASE
WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL
AND JSON_VALUE(event_message, "$.provider") != ''
THEN CONCAT(
JSON_VALUE(event_message, "$.login_method"),
' (',
JSON_VALUE(event_message, "$.provider"),
')'
)
ELSE JSON_VALUE(event_message, "$.login_method")
END as login_type_provider,
COUNT(*) as count
FROM
auth_logs
WHERE
JSON_VALUE(event_message, "$.action") = 'login'
AND JSON_VALUE(event_message, "$.metering") = "true"
GROUP BY
timestamp, login_type_provider
ORDER BY
timestamp desc, login_type_provider
`
},
PasswordResetRequests: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--password-reset-requests
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
count(*) as count
from auth_logs f
where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested'
group by timestamp
order by timestamp desc
`
},
TotalSignUps: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--total-signups
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
count(*) as count
from auth_logs
where json_value(event_message, "$.auth_event.action") = 'user_signedup'
group by timestamp
order by timestamp desc
`
},
SignInLatency: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--signin-latency
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
count(*) as count,
round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms,
round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms,
round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms
from auth_logs
where json_value(event_message, "$.auth_event.action") = 'login'
group by timestamp
order by timestamp desc
`
},
SignUpLatency: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--signup-latency
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
count(*) as count,
round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms,
round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms,
round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms,
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms
from auth_logs
where json_value(event_message, "$.auth_event.action") = 'user_signedup'
group by timestamp
order by timestamp desc
`
},
ErrorsByStatus: (interval) => {
const granularity = analyticsIntervalToGranularity(interval)
return `
--auth-errors-by-status
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
count(*) as count,
response.status_code
from edge_logs
cross join unnest(metadata) as m
cross join unnest(m.request) as request
cross join unnest(m.response) as response
where path like '%/auth%'
and response.status_code >= 400 and response.status_code <= 599
group by timestamp, status_code
order by timestamp desc
`
},
}
type AuthReportFilters = {
status_code: NumericFilter | null
}
function filterToWhereClause(filters?: AuthReportFilters): string {
if (!filters) return ''
return ``
}
/**
* Transforms raw analytics data into a chart-ready format by ensuring data consistency and completeness.
*
* This function addresses several key requirements for chart rendering:
* 1. Fills missing timestamps with zero values to prevent gaps in charts
* 2. Creates a consistent data structure with `period_start` as the time axis
* 3. Initializes all chart attributes to 0, then populates actual values
* 4. Sorts timestamps chronologically for proper chart ordering
*
* @param rawData - Raw analytics data from backend queries containing timestamp and count fields
* @param attributes - Chart attribute configuration defining what metrics to display
* @returns Formatted data object with consistent time series data and chart attributes
*/
export function defaultAuthReportFormatter(
rawData: unknown,
attributes: ReportDataProviderAttribute[]
) {
const chartAttributes = attributes
const rawDataSchema = z.object({
result: z.array(
z
.object({
timestamp: z.coerce.number(),
})
.catchall(z.any())
),
})
const parsedRawData = rawDataSchema.parse(rawData)
const result = parsedRawData.result
if (!result) return { data: undefined, chartAttributes }
const timestamps = new Set<string>(result.map((p: any) => String(p.timestamp)))
const data = Array.from(timestamps)
.sort()
.map((timestamp) => {
const point: any = { timestamp }
chartAttributes.forEach((attr) => {
point[attr.attribute] = 0
})
const matchingPoints = result.filter((p: any) => String(p.timestamp) === timestamp)
matchingPoints.forEach((p: any) => {
chartAttributes.forEach((attr) => {
// Optional dimension filters used by some reports
if ('login_type_provider' in (attr as any)) {
if (p.login_type_provider !== (attr as any).login_type_provider) return
}
if ('providerType' in (attr as any)) {
if (p.provider !== (attr as any).providerType) return
}
const valueFromField =
typeof p[attr.attribute] === 'number'
? p[attr.attribute]
: typeof p.count === 'number'
? p.count
: undefined
if (typeof valueFromField === 'number') {
point[attr.attribute] = (point[attr.attribute] ?? 0) + valueFromField
}
})
})
return point
})
return { data, chartAttributes }
}
export const createAuthReportConfig = ({
projectRef,
startDate,
endDate,
interval,
filters,
}: {
projectRef: string
startDate: string
endDate: string
interval: AnalyticsInterval
filters: AuthReportFilters
}): ReportConfig<AuthReportFilters>[] => [
{
id: 'active-user',
label: 'Active Users',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: false,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'The total number of active users over time.',
availableIn: ['free', 'pro', 'team', 'enterprise'],
dataProvider: async () => {
const attributes = [
{ attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true },
]
const sql = AUTH_REPORT_SQL.ActiveUsers(interval, filters)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
const transformedData = defaultAuthReportFormatter(rawData, attributes)
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
},
},
{
id: 'sign-in-attempts',
label: 'Sign In Attempts by Type',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'The total number of sign in attempts by type.',
availableIn: ['free', 'pro', 'team', 'enterprise'],
dataProvider: async () => {
const attributes = [
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'Password',
login_type_provider: 'password',
enabled: true,
},
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'PKCE',
login_type_provider: 'pkce',
enabled: true,
},
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'Refresh Token',
login_type_provider: 'token',
enabled: true,
},
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'ID Token',
login_type_provider: 'id_token',
enabled: true,
},
]
const sql = AUTH_REPORT_SQL.SignInAttempts(interval, filters)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
const transformedData = defaultAuthReportFormatter(rawData, attributes)
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
},
},
{
id: 'signups',
label: 'Sign Ups',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'The total number of sign ups.',
availableIn: ['free', 'pro', 'team', 'enterprise'],
dataProvider: async () => {
const attributes = [
{
attribute: 'TotalSignUps',
provider: 'logs',
label: 'Sign Ups',
enabled: true,
},
]
const sql = AUTH_REPORT_SQL.TotalSignUps(interval, filters)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
const transformedData = defaultAuthReportFormatter(rawData, attributes)
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
},
},
{
id: 'auth-errors',
label: 'Auth Errors',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: false,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'The total number of auth errors by status code.',
availableIn: ['free', 'pro', 'team', 'enterprise'],
dataProvider: async () => {
const attributes = [
{
attribute: 'ErrorsByStatus',
provider: 'logs',
label: 'Auth Errors',
},
]
const sql = AUTH_REPORT_SQL.ErrorsByStatus(interval, filters)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
const transformedData = defaultAuthReportFormatter(rawData, attributes)
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
},
},
{
id: 'password-reset-requests',
label: 'Password Reset Requests',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'The total number of password reset requests.',
availableIn: ['free', 'pro', 'team', 'enterprise'],
dataProvider: async () => {
const attributes = [
{
attribute: 'PasswordResetRequests',
provider: 'logs',
label: 'Password Reset Requests',
enabled: true,
},
]
const sql = AUTH_REPORT_SQL.PasswordResetRequests(interval, filters)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
const transformedData = defaultAuthReportFormatter(rawData, attributes)
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
},
},
{
id: 'sign-in-latency',
label: 'Sign In Latency',
valuePrecision: 2,
hide: false,
hideHighlightedValue: true,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'The average latency for sign in operations by grant type.',
availableIn: ['pro', 'team', 'enterprise'],
dataProvider: async () => {
const attributes = [
{
attribute: 'avg_latency_ms',
label: 'Avg. Latency (ms)',
},
{
attribute: 'max_latency_ms',
label: 'Max. Latency (ms)',
},
{
attribute: 'min_latency_ms',
label: 'Min. Latency (ms)',
},
{
attribute: 'p50_latency_ms',
label: 'P50 Latency (ms)',
},
{
attribute: 'p95_latency_ms',
label: 'P95 Latency (ms)',
},
{
attribute: 'p99_latency_ms',
label: 'P99 Latency (ms)',
},
{
attribute: 'request_count',
label: 'Request Count',
},
]
const sql = AUTH_REPORT_SQL.SignInLatency(interval, filters)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
const transformedData = defaultAuthReportFormatter(rawData, attributes)
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
},
},
{
id: 'sign-up-latency',
label: 'Sign Up Latency',
valuePrecision: 2,
hide: false,
hideHighlightedValue: true,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'The average latency for sign up operations by provider.',
availableIn: ['pro', 'team', 'enterprise'],
dataProvider: async () => {
const attributes = [
{
attribute: 'avg_latency_ms',
label: 'Avg. Latency (ms)',
},
{
attribute: 'max_latency_ms',
label: 'Max. Latency (ms)',
},
{
attribute: 'min_latency_ms',
label: 'Min. Latency (ms)',
},
{
attribute: 'p50_latency_ms',
label: 'P50 Latency (ms)',
},
{
attribute: 'p95_latency_ms',
label: 'P95 Latency (ms)',
},
{
attribute: 'p99_latency_ms',
label: 'P99 Latency (ms)',
},
{
attribute: 'request_count',
label: 'Request Count',
},
]
const sql = AUTH_REPORT_SQL.SignUpLatency(interval, filters)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
const transformedData = defaultAuthReportFormatter(rawData, attributes)
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
},
},
]

View File

@@ -14,6 +14,7 @@ import { getHttpStatusCodeInfo } from 'lib/http-status-codes'
import { ReportConfig } from './reports.types'
import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter'
import { SelectFilters } from 'components/interfaces/Reports/v2/ReportsSelectFilter'
import { fetchLogs } from 'data/reports/report.utils'
type EdgeFunctionReportFilters = {
status_code: NumericFilter | null
@@ -151,21 +152,6 @@ order by
},
}
async function runQuery(projectRef: string, sql: string, startDate: string, endDate: string) {
const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, {
params: {
path: { ref: projectRef },
query: {
sql,
iso_timestamp_start: startDate,
iso_timestamp_end: endDate,
},
},
})
if (error) throw error
return data
}
export function extractStatusCodesFromData(data: any[]): string[] {
const statusCodes = new Set<string>()
@@ -272,7 +258,7 @@ export const edgeFunctionReports = ({
availableIn: ['free', 'pro', 'team', 'enterprise'],
dataProvider: async () => {
const sql = METRIC_SQL.TotalInvocations(interval, filters)
const response = await runQuery(projectRef, sql, startDate, endDate)
const response = await fetchLogs(projectRef, sql, startDate, endDate)
if (!response?.result) return { data: [] }
@@ -304,7 +290,7 @@ export const edgeFunctionReports = ({
availableIn: ['free', 'pro', 'team', 'enterprise'],
dataProvider: async () => {
const sql = METRIC_SQL.ExecutionStatusCodes(interval, filters)
const rawData = await runQuery(projectRef, sql, startDate, endDate)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
if (!rawData?.result) return { data: [] }
@@ -341,7 +327,7 @@ export const edgeFunctionReports = ({
format: (value: unknown) => `${Number(value).toFixed(0)}ms`,
dataProvider: async () => {
const sql = METRIC_SQL.ExecutionTime(interval, filters)
const rawData = await runQuery(projectRef, sql, startDate, endDate)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
if (!rawData?.result) return { data: [] }
@@ -398,7 +384,7 @@ export const edgeFunctionReports = ({
availableIn: ['pro', 'team', 'enterprise'],
dataProvider: async () => {
const sql = METRIC_SQL.InvocationsByRegion(interval, filters)
const rawData = await runQuery(projectRef, sql, startDate, endDate)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
const data = rawData.result?.map((point: any) => ({
...point,
timestamp: isUnixMicro(point.timestamp)

View File

@@ -1,6 +1,12 @@
import { AnalyticsInterval } from 'data/analytics/constants'
import { YAxisProps } from 'recharts'
export type ReportDataProviderAttribute = {
attribute: string
label: string
color?: { light: string; dark: string }
}
export interface ReportDataProvider<FiltersType> {
(
projectRef: string,
@@ -10,13 +16,9 @@ export interface ReportDataProvider<FiltersType> {
filters?: FiltersType
): Promise<{
data: any
attributes?: {
attribute: string
label: string
color?: { light: string; dark: string }
}[]
attributes?: ReportDataProviderAttribute[]
query?: string // The SQL used to fetch the data if any
}> // [jordi] would be cool to have a type that forces data keys to match the attributes
}>
}
export interface ReportConfig<FiltersType = any> {
@@ -25,6 +27,7 @@ export interface ReportConfig<FiltersType = any> {
dataProvider: ReportDataProvider<FiltersType>
valuePrecision: number
hide: boolean
hideHighlightedValue?: boolean
showTooltip: boolean
showLegend: boolean
showMaxValue: boolean

View File

@@ -1,123 +0,0 @@
/**
* useChartData
*
* A hook for fetching and processing data for a chart.
* This hook is responsible for all the data fetching, combining, and state management logic
* that was previously inside ComposedChartHandler.
*
* It takes all necessary parameters like project reference, date range, and attributes,
* and returns the final chart data, loading state, and derived attributes.
*/
import { useMemo } from 'react'
import { useRouter } from 'next/router'
import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants'
import { useAuthLogsReport } from 'data/reports/auth-report-query'
import type { ChartData } from 'components/ui/Charts/Charts.types'
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
export const useChartData = ({
attributes,
startDate,
endDate,
interval,
data,
highlightedValue,
functionIds,
enabled = true,
}: {
attributes: MultiAttribute[]
startDate: string
endDate: string
interval: string
data?: ChartData
highlightedValue?: string | number
functionIds?: string[]
enabled?: boolean
}) => {
const router = useRouter()
const { ref } = router.query
const logsAttributes = attributes.filter((attr) => attr.provider === 'logs')
const isEdgeFunctionRoute = router.asPath.includes('/reports/edge-functions')
const {
data: authData,
attributes: authChartAttributes,
isLoading: isAuthLoading,
} = useAuthLogsReport({
projectRef: ref as string,
attributes: logsAttributes,
startDate,
endDate,
interval: interval as AnalyticsInterval,
enabled: enabled && logsAttributes.length > 0 && !isEdgeFunctionRoute,
})
const logsData = authData
const logsChartAttributes = authChartAttributes
const isLogsLoading = isAuthLoading
const combinedData = useMemo(() => {
if (data) return data
// Get all unique timestamps from all datasets
const timestamps = new Set<string>()
if (logsData) {
logsData.forEach((point: any) => {
if (point?.period_start) {
timestamps.add(point.period_start)
}
})
}
// Combine data points for each timestamp
const combined = Array.from(timestamps)
.sort()
.map((timestamp) => {
const point: any = { period_start: timestamp }
const logPoint = logsData?.find((p: any) => p.period_start === timestamp) || {}
Object.assign(point, logPoint)
return point as DataPoint
})
return combined as DataPoint[]
}, [data, attributes, isLogsLoading, logsData, logsAttributes])
const loading = logsAttributes.length > 0 && isLogsLoading
// Calculate highlighted value based on the first attribute's data
const _highlightedValue = useMemo(() => {
if (highlightedValue !== undefined) return highlightedValue
const firstAttr = attributes[0]
const firstData = logsChartAttributes.find((p: any) => p.attribute === firstAttr.attribute)
if (!firstData) return undefined
const shouldHighlightMaxValue =
firstAttr.provider === 'daily-stats' &&
!firstAttr.attribute.includes('ingress') &&
!firstAttr.attribute.includes('egress') &&
'maximum' in firstData
const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData
return shouldHighlightMaxValue
? firstData.maximum
: firstAttr.provider === 'daily-stats'
? firstData.total
: shouldHighlightTotalGroupedValue
? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped]
: (firstData.data?.[firstData.data?.length - 1] as any)?.[firstAttr.attribute]
}, [highlightedValue, attributes])
return {
data: combinedData,
isLoading: loading,
highlightedValue: _highlightedValue,
}
}

View File

@@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import { ArrowRight, RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { ReportChart } from 'components/interfaces/Reports/ReportChart'
import { ReportChartV2 } from 'components/interfaces/Reports/v2/ReportChartV2'
import ReportHeader from 'components/interfaces/Reports/ReportHeader'
import ReportPadding from 'components/interfaces/Reports/ReportPadding'
import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav'
@@ -18,9 +18,10 @@ import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Re
import { SharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport'
import { useSharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants'
import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
import { getAuthReportAttributes } from 'data/reports/auth-charts'
import { useReportDateRange } from 'hooks/misc/useReportDateRange'
import type { NextPageWithLayout } from 'types'
import { createAuthReportConfig } from 'data/reports/v2/auth.config'
import { ReportSettings } from 'components/ui/Charts/ReportSettings'
const AuthReport: NextPageWithLayout = () => {
return (
@@ -41,6 +42,7 @@ export default AuthReport
const AuthUsage = () => {
const { ref } = useParams()
const chartSyncId = `auth-report`
const {
selectedDateRange,
@@ -71,17 +73,19 @@ const AuthUsage = () => {
const queryClient = useQueryClient()
const [isRefreshing, setIsRefreshing] = useState(false)
const AUTH_REPORT_ATTRIBUTES = getAuthReportAttributes()
const authReportConfig = createAuthReportConfig({
projectRef: ref || '',
startDate: selectedDateRange?.period_start?.date,
endDate: selectedDateRange?.period_end?.date,
interval: selectedDateRange?.interval,
filters: { status_code: null },
})
const onRefreshReport = async () => {
if (!selectedDateRange) return
setIsRefreshing(true)
AUTH_REPORT_ATTRIBUTES.forEach((attr) => {
attr.attributes.forEach((subAttr) => {
queryClient.invalidateQueries(['auth-logs-report', 'auth-metrics'])
})
})
refetch()
setTimeout(() => setIsRefreshing(false), 1000)
}
@@ -100,6 +104,7 @@ const AuthUsage = () => {
tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
onClick={onRefreshReport}
/>
<ReportSettings chartId={chartSyncId} />
<div className="flex items-center gap-3">
<LogsDatePicker
onSubmit={handleDatePickerChange}
@@ -130,18 +135,21 @@ const AuthUsage = () => {
</>
}
>
{selectedDateRange &&
AUTH_REPORT_ATTRIBUTES.filter((attr) => !attr.hide).map((attr, i) => (
<ReportChart
key={`${attr.id}-${i}`}
chart={attr}
interval={selectedDateRange.interval}
startDate={selectedDateRange?.period_start?.date}
endDate={selectedDateRange?.period_end?.date}
updateDateRange={updateDateRange}
isLoading={isRefreshing}
/>
))}
{authReportConfig.map((metric, i) => (
<ReportChartV2
key={`${metric.id}`}
report={metric}
projectRef={ref!}
interval={selectedDateRange.interval}
startDate={selectedDateRange?.period_start?.date}
endDate={selectedDateRange?.period_end?.date}
updateDateRange={updateDateRange}
syncId={chartSyncId}
filters={{
status_code: null,
}}
/>
))}
<div>
<div className="mb-4">
<h5 className="text-foreground mb-2">Auth API Gateway</h5>

View File

@@ -7,7 +7,6 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useFlag, useParams } from 'common'
import { ReportChart } from 'components/interfaces/Reports/ReportChart'
import ReportHeader from 'components/interfaces/Reports/ReportHeader'
import ReportPadding from 'components/interfaces/Reports/ReportPadding'
import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants'
@@ -42,6 +41,7 @@ import { formatBytes } from 'lib/helpers'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import type { NextPageWithLayout } from 'types'
import { AlertDescription_Shadcn_, Alert_Shadcn_, Button } from 'ui'
import { ReportChartUpsell } from 'components/interfaces/Reports/v2/ReportChartUpsell'
const DatabaseReport: NextPageWithLayout = () => {
return (
@@ -307,13 +307,13 @@ const DatabaseUsage = () => {
}
/>
) : (
<ReportChart
key={`${chart.id}-${i}`}
chart={chart}
interval={selectedDateRange.interval}
startDate={selectedDateRange?.period_start?.date}
endDate={selectedDateRange?.period_end?.date}
updateDateRange={updateDateRange}
<ReportChartUpsell
key={chart.id}
report={{
label: chart.label,
availableIn: chart.availableIn ?? [],
}}
orgSlug={org?.slug ?? ''}
/>
)
))}