new home: refactor charts to use old sources (#42245)

- refactors new charts in homepage to use stable analytics endpoints
- changes are behind newHomepageUsageV2 flag

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

* **New Features**
* Centralized per-service health metrics hook with per-service data,
loading/error states and refresh.

* **Improvements**
  * Time-series normalization into fixed buckets aligned to an end time.
* Updated UI: success-rate formatting, per-service loading/error
surfaced, refreshed click/refresh behavior; removed delta display.

* **Removals**
  * Legacy project-metrics query and mapping utilities removed.

* **Tests**
* Extensive unit tests added for date ranges, bucket normalization, and
health metric calculations; some obsolete tests removed.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jordi Enric authored and GitHub committed 2026-01-28 17:02:00 +00:00
1 parent ba5538576b
commit 88ed2aad97
12 files changed
+1055 -403

No files matched your search

@@ -0,0 +1,221 @@
import dayjs from 'dayjs'
import { describe, expect, it } from 'vitest'
import { normalizeChartBuckets } from './ChartDataTransform.utils'
import type { LogsBarChartDatum } from './ProjectUsage.metrics'
describe('normalizeChartBuckets', () => {
const now = dayjs('2024-01-28T12:00:00.000Z')
describe('1hr interval', () => {
it('should create exactly 30 buckets with 2-minute intervals', () => {
const result = normalizeChartBuckets([], '1hr', now.toDate())
expect(result).toHaveLength(30)
// Check first bucket
expect(result[0].timestamp).toBe(now.subtract(60, 'minute').toISOString())
// Check last bucket
expect(result[29].timestamp).toBe(now.subtract(2, 'minute').toISOString())
// Check all buckets are 2 minutes apart
for (let i = 0; i < result.length - 1; i++) {
const diff = dayjs(result[i + 1].timestamp).diff(dayjs(result[i].timestamp), 'minute')
expect(diff).toBe(2)
}
})
it('should aggregate data points into correct 2-minute buckets', () => {
const data: LogsBarChartDatum[] = [
{
// First bucket starts at -60 minutes, so -60 to -59 minutes is in bucket 0
timestamp: now.subtract(60, 'minute').toISOString(),
ok_count: 10,
warning_count: 1,
error_count: 2,
},
{
timestamp: now.subtract(59, 'minute').add(30, 'second').toISOString(),
ok_count: 5,
warning_count: 0,
error_count: 1,
},
{
timestamp: now.subtract(30, 'minute').toISOString(),
ok_count: 20,
warning_count: 2,
error_count: 0,
},
]
const result = normalizeChartBuckets(data, '1hr', now.toDate())
// First bucket (60-58 minutes ago) should contain aggregated data
const firstBucket = result[0]
expect(firstBucket.ok_count).toBe(15) // 10 + 5
expect(firstBucket.warning_count).toBe(1) // 1 + 0
expect(firstBucket.error_count).toBe(3) // 2 + 1
// Bucket at 30 minutes ago
const bucket15 = result[15] // 30 minutes / 2 minutes per bucket = bucket 15
expect(bucket15.ok_count).toBe(20)
expect(bucket15.warning_count).toBe(2)
expect(bucket15.error_count).toBe(0)
})
it('should return empty buckets when no data provided', () => {
const result = normalizeChartBuckets([], '1hr', now.toDate())
expect(result).toHaveLength(30)
result.forEach((bucket) => {
expect(bucket.ok_count).toBe(0)
expect(bucket.warning_count).toBe(0)
expect(bucket.error_count).toBe(0)
})
})
})
describe('1day interval', () => {
it('should create exactly 24 buckets with 1-hour intervals', () => {
const result = normalizeChartBuckets([], '1day', now.toDate())
expect(result).toHaveLength(24)
// Check first bucket
expect(result[0].timestamp).toBe(now.subtract(24, 'hour').toISOString())
// Check last bucket
expect(result[23].timestamp).toBe(now.subtract(1, 'hour').toISOString())
// Check all buckets are 1 hour apart
for (let i = 0; i < result.length - 1; i++) {
const diff = dayjs(result[i + 1].timestamp).diff(dayjs(result[i].timestamp), 'hour')
expect(diff).toBe(1)
}
})
it('should aggregate multiple data points into hourly buckets', () => {
const data: LogsBarChartDatum[] = [
{
timestamp: now.subtract(23, 'hour').subtract(30, 'minute').toISOString(),
ok_count: 100,
warning_count: 5,
error_count: 3,
},
{
timestamp: now.subtract(23, 'hour').subtract(15, 'minute').toISOString(),
ok_count: 50,
warning_count: 2,
error_count: 1,
},
]
const result = normalizeChartBuckets(data, '1day', now.toDate())
// First bucket should contain aggregated data
expect(result[0].ok_count).toBe(150)
expect(result[0].warning_count).toBe(7)
expect(result[0].error_count).toBe(4)
})
})
describe('7day interval', () => {
it('should create exactly 28 buckets with 6-hour intervals', () => {
const result = normalizeChartBuckets([], '7day', now.toDate())
expect(result).toHaveLength(28)
// Check first bucket (7 days = 168 hours ago)
expect(result[0].timestamp).toBe(now.subtract(168, 'hour').toISOString())
// Check last bucket
expect(result[27].timestamp).toBe(now.subtract(6, 'hour').toISOString())
// Check all buckets are 6 hours apart
for (let i = 0; i < result.length - 1; i++) {
const diff = dayjs(result[i + 1].timestamp).diff(dayjs(result[i].timestamp), 'hour')
expect(diff).toBe(6)
}
})
it('should aggregate data points into 6-hour buckets', () => {
const data: LogsBarChartDatum[] = [
{
timestamp: now.subtract(167, 'hour').toISOString(),
ok_count: 1000,
warning_count: 10,
error_count: 5,
},
{
timestamp: now.subtract(165, 'hour').toISOString(),
ok_count: 500,
warning_count: 5,
error_count: 2,
},
]
const result = normalizeChartBuckets(data, '7day', now.toDate())
// First bucket should contain aggregated data
expect(result[0].ok_count).toBe(1500)
expect(result[0].warning_count).toBe(15)
expect(result[0].error_count).toBe(7)
})
})
describe('edge cases', () => {
it('should handle data points outside the time range', () => {
const data: LogsBarChartDatum[] = [
{
timestamp: now.subtract(120, 'minute').toISOString(), // Outside 1hr range
ok_count: 100,
warning_count: 10,
error_count: 5,
},
{
timestamp: now.add(10, 'minute').toISOString(), // Future data
ok_count: 50,
warning_count: 5,
error_count: 2,
},
{
timestamp: now.subtract(30, 'minute').toISOString(), // Within range
ok_count: 25,
warning_count: 2,
error_count: 1,
},
]
const result = normalizeChartBuckets(data, '1hr', now.toDate())
// Should only include the data within range
const validBucket = result[15] // 30 minutes ago
expect(validBucket.ok_count).toBe(25)
expect(validBucket.warning_count).toBe(2)
expect(validBucket.error_count).toBe(1)
// Other buckets should be empty
expect(result[0].ok_count).toBe(0)
expect(result[29].ok_count).toBe(0)
})
it('should handle undefined/null values in data', () => {
const data: LogsBarChartDatum[] = [
{
timestamp: now.subtract(30, 'minute').toISOString(),
ok_count: undefined as any,
warning_count: null as any,
error_count: 5,
},
]
const result = normalizeChartBuckets(data, '1hr', now.toDate())
const bucket = result[15]
expect(bucket.ok_count).toBe(0)
expect(bucket.warning_count).toBe(0)
expect(bucket.error_count).toBe(5)
})
})
})
@@ -0,0 +1,88 @@
import dayjs from 'dayjs'
import type { LogsBarChartDatum } from './ProjectUsage.metrics'
/**
* Configuration for chart bucket sizes based on time interval
*/
const BUCKET_CONFIG = {
'1hr': {
bucketMinutes: 2, // 2-minute buckets
expectedBuckets: 30, // 60 minutes / 2 = 30 buckets
},
'1day': {
bucketMinutes: 60, // 1-hour buckets
expectedBuckets: 24, // 24 hours
},
'7day': {
bucketMinutes: 360, // 6-hour buckets
expectedBuckets: 28, // 168 hours / 6 = 28 buckets
},
} as const
type IntervalKey = keyof typeof BUCKET_CONFIG
/**
* Normalizes chart data to consistent bucket sizes regardless of backend data density.
*
* For 1hr interval: Creates 30 buckets of 2 minutes each
* For 1day interval: Creates 24 buckets of 1 hour each
* For 7day interval: Creates 28 buckets of 6 hours each
*
* This ensures consistent bar width in charts and proper data aggregation.
*
* @param data - Raw chart data from backend
* @param interval - Time interval key ('1hr', '1day', '7day')
* @param endDate - End date for the chart (defaults to now)
* @returns Array of exactly the expected number of buckets with aggregated data
*/
export function normalizeChartBuckets(
data: LogsBarChartDatum[],
interval: IntervalKey,
endDate: Date = new Date()
): LogsBarChartDatum[] {
const config = BUCKET_CONFIG[interval]
const { bucketMinutes, expectedBuckets } = config
// Calculate start time based on expected buckets
const end = dayjs(endDate)
const start = end.subtract(expectedBuckets * bucketMinutes, 'minute')
// Create empty buckets
const buckets: LogsBarChartDatum[] = []
let currentBucketStart = start
for (let i = 0; i < expectedBuckets; i++) {
buckets.push({
timestamp: currentBucketStart.toISOString(),
ok_count: 0,
warning_count: 0,
error_count: 0,
})
currentBucketStart = currentBucketStart.add(bucketMinutes, 'minute')
}
// If no data, return empty buckets
if (!data || data.length === 0) {
return buckets
}
// Aggregate data into buckets
for (const datum of data) {
const datumTime = dayjs(datum.timestamp)
// Find which bucket this datum belongs to
const bucketIndex = Math.floor(datumTime.diff(start, 'minute') / bucketMinutes)
// Skip data points outside our time range
if (bucketIndex < 0 || bucketIndex >= expectedBuckets) {
continue
}
// Aggregate counts into the appropriate bucket
buckets[bucketIndex].ok_count += datum.ok_count || 0
buckets[bucketIndex].warning_count += datum.warning_count || 0
buckets[bucketIndex].error_count += datum.error_count || 0
}
return buckets
}
@@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest'
import {
computeChangePercent,
computeSuccessAndNonSuccessRates,
sumErrors,
sumTotal,
@@ -43,14 +42,4 @@ describe('ProjectUsage.metrics', () => {
expect(successRate).toBeCloseTo(87.5)
expect(nonSuccessRate).toBeCloseTo(12.5)
})
it('computeChangePercent handles zero previous safely', () => {
expect(computeChangePercent(10, 0)).toBe(100)
expect(computeChangePercent(0, 0)).toBe(0)
})
it('computeChangePercent returns standard percentage delta', () => {
expect(computeChangePercent(120, 100)).toBe(20)
expect(computeChangePercent(80, 100)).toBe(-20)
})
})
@@ -35,10 +35,3 @@ export const computeSuccessAndNonSuccessRates = (
const successRate = 100 - nonSuccessRate
return { successRate, nonSuccessRate }
}
export const computeChangePercent = (current: number, previous: number): number => {
if (previous === 0) return current > 0 ? 100 : 0
return ((current - previous) / previous) * 100
}
export const formatDelta = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`
@@ -1,16 +1,15 @@
import dayjs from 'dayjs'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder'
import { InlineLink } from 'components/ui/InlineLink'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import dayjs from 'dayjs'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
import type { ChartIntervals } from 'types'
import {
Button,
@@ -31,16 +30,15 @@ import {
} from 'ui'
import { Row } from 'ui-patterns'
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
import { useServiceStats } from './ProjectUsageSection.utils'
import type { StatsLike } from './ProjectUsageSection.utils'
import { useServiceHealthMetrics } from '../Observability/useServiceHealthMetrics'
import { normalizeChartBuckets } from './ChartDataTransform.utils'
import type { LogsBarChartDatum } from './ProjectUsage.metrics'
import {
computeSuccessAndNonSuccessRates,
sumErrors,
sumTotal,
sumWarnings,
sumErrors,
computeSuccessAndNonSuccessRates,
computeChangePercent,
formatDelta,
} from './ProjectUsage.metrics'
const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90, platform: 1 }
@@ -89,7 +87,8 @@ type ServiceComputed = ServiceEntry & {
total: number
warn: number
err: number
stats: StatsLike
isLoading: boolean
error: unknown | null
}
export const ProjectUsageSection = () => {
@@ -105,6 +104,7 @@ export const ProjectUsageSection = () => {
const DEFAULT_INTERVAL: ChartIntervalKey = plan?.id === 'free' ? '1hr' : '1day'
const [interval, setInterval] = useState<ChartIntervalKey>(DEFAULT_INTERVAL)
const [refreshKey, setRefreshKey] = useState(0)
const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1]
@@ -113,7 +113,11 @@ export const ProjectUsageSection = () => {
return { datetimeFormat: format }
}, [selectedInterval])
const statsByService = useServiceStats(projectRef!, interval)
const {
services: healthServices,
isLoading: isHealthLoading,
endDate,
} = useServiceHealthMetrics(projectRef!, interval, refreshKey)
const serviceBase: ServiceEntry[] = useMemo(
() => [
@@ -158,77 +162,71 @@ export const ProjectUsageSection = () => {
const services: ServiceComputed[] = useMemo(
() =>
serviceBase.map((s) => {
const currentStats = statsByService[s.key].current
const data = currentStats.eventChartData
const total = sumTotal(data)
const warn = sumWarnings(data)
const err = sumErrors(data)
return { ...s, stats: currentStats, data, total, warn, err }
const healthData = healthServices[s.key]
// Normalize chart data to consistent bucket sizes using the same endDate from the query
const normalizedData = normalizeChartBuckets(
healthData.eventChartData,
interval,
new Date(endDate)
)
const total = sumTotal(normalizedData)
const warn = sumWarnings(normalizedData)
const err = sumErrors(normalizedData)
return {
...s,
data: normalizedData,
total,
warn,
err,
isLoading: healthData.isLoading,
error: healthData.error,
}
}),
[serviceBase, statsByService]
[serviceBase, healthServices, interval, endDate]
)
const isLoading = services.some((s) => s.stats.isLoading)
const isLoading = isHealthLoading
const handleBarClick = (logRoute: string, serviceKey: ServiceKey) => (datum: any) => {
if (!datum?.timestamp) return
const handleBarClick =
(logRoute: string, serviceKey: ServiceKey) => (datum: LogsBarChartDatum) => {
if (!datum?.timestamp) return
const datumTimestamp = dayjs(datum.timestamp).toISOString()
const start = dayjs(datumTimestamp).subtract(1, 'minute').toISOString()
const end = dayjs(datumTimestamp).add(1, 'minute').toISOString()
const datumTimestamp = dayjs(datum.timestamp).toISOString()
const start = dayjs(datumTimestamp).subtract(1, 'minute').toISOString()
const end = dayjs(datumTimestamp).add(1, 'minute').toISOString()
const queryParams = new URLSearchParams({
iso_timestamp_start: start,
iso_timestamp_end: end,
})
router.push(`/project/${projectRef}${logRoute}?${queryParams.toString()}`)
if (projectRef && organization?.slug) {
sendEvent({
action: 'home_project_usage_chart_clicked',
properties: {
service_type: serviceKey,
bar_timestamp: datum.timestamp,
},
groups: {
project: projectRef,
organization: organization.slug,
},
const queryParams = new URLSearchParams({
iso_timestamp_start: start,
iso_timestamp_end: end,
})
router.push(`/project/${projectRef}${logRoute}?${queryParams.toString()}`)
if (projectRef && organization?.slug) {
sendEvent({
action: 'home_project_usage_chart_clicked',
properties: {
service_type: serviceKey,
bar_timestamp: datum.timestamp,
},
groups: {
project: projectRef,
organization: organization.slug,
},
})
}
}
}
const enabledServices = services.filter((s) => s.enabled)
const totalRequests = enabledServices.reduce((sum, s) => sum + (s.total || 0), 0)
const totalErrors = enabledServices.reduce((sum, s) => sum + (s.err || 0), 0)
const totalWarnings = enabledServices.reduce((sum, s) => sum + (s.warn || 0), 0)
const { successRate, nonSuccessRate } = computeSuccessAndNonSuccessRates(
const { successRate } = computeSuccessAndNonSuccessRates(
totalRequests,
totalWarnings,
totalErrors
)
const prevServiceTotals = useMemo(
() =>
serviceBase.map((s) => {
const previousStats = statsByService[s.key].previous
const data = previousStats.eventChartData
return {
enabled: s.enabled,
total: sumTotal(data),
}
}),
[serviceBase, statsByService]
)
const enabledPrev = prevServiceTotals.filter((s) => s.enabled)
const prevTotalRequests = enabledPrev.reduce((sum, s) => sum + (s.total || 0), 0)
const totalRequestsChangePct = computeChangePercent(totalRequests, prevTotalRequests)
const totalDeltaClass = totalRequestsChangePct >= 0 ? 'text-brand-link' : 'text-destructive'
const nonSuccessClass = nonSuccessRate > 0 ? 'text-destructive' : 'text-brand-link'
return (
<div className="space-y-6">
<div className="flex flex-row justify-between items-center gap-x-2">
@@ -236,14 +234,12 @@ export const ProjectUsageSection = () => {
<div className="flex items-start gap-2 heading-section text-foreground-light">
<span className="text-foreground">{totalRequests.toLocaleString()}</span>
<span>Total Requests</span>
<span className={cn('text-sm', totalDeltaClass)}>
{formatDelta(totalRequestsChangePct)}
</span>
</div>
<div className="flex items-start gap-2 heading-section text-foreground-light">
<span className="text-foreground">{successRate.toFixed(1)}%</span>
<span className="text-foreground">
{successRate === 100 ? '100' : successRate.toFixed(1)}%
</span>
<span>Success Rate</span>
<span className={cn('text-sm', nonSuccessClass)}>{formatDelta(nonSuccessRate)}</span>
</div>
</div>
<DropdownMenu>
@@ -1,70 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { toServiceStatsMap } from './ProjectUsageSection.utils'
import type { ProjectMetricsRow } from 'data/analytics/project-metrics-query'
const mkRow = (
n: number,
service: ProjectMetricsRow['service'],
time_window: ProjectMetricsRow['time_window']
): ProjectMetricsRow => ({
timestamp: (1700000000000 + n * 60000) * 1000, // microseconds
service,
time_window,
ok_count: n,
warning_count: 0,
error_count: 0,
})
const emptyRows: ProjectMetricsRow[] = []
describe('toServiceStatsMap', () => {
it('returns empty arrays when no data', () => {
const onRefresh = vi.fn()
const map = toServiceStatsMap({
data: emptyRows,
isLoading: false,
error: undefined,
onRefresh,
})
expect(map.db.current.eventChartData).toEqual([])
expect(map.functions.previous.eventChartData).toEqual([])
expect(map.auth.current.isLoading).toBe(false)
expect(map.storage.current.error).toBeNull()
map.realtime.current.refresh()
expect(onRefresh).toHaveBeenCalledTimes(1)
})
it('maps data rows through for each service', () => {
const rows: ProjectMetricsRow[] = [
mkRow(1, 'db', 'current'),
mkRow(2, 'db', 'current'),
mkRow(0, 'db', 'previous'),
]
const map = toServiceStatsMap({
data: rows,
isLoading: true,
error: undefined,
onRefresh: () => {},
})
expect(map.db.current.eventChartData.length).toBe(2)
expect(map.db.previous.eventChartData.length).toBe(1)
expect(map.db.current.isLoading).toBe(true)
})
it('propagates errors to all services', () => {
const err = new Error('boom')
const map = toServiceStatsMap({
data: emptyRows,
isLoading: false,
error: err,
onRefresh: () => {},
})
expect(map.db.current.error).toBe(err)
expect(map.functions.previous.error).toBe(err)
})
})
@@ -1,126 +0,0 @@
import type { ProjectMetricsRow } from 'data/analytics/project-metrics-query'
import { useProjectMetricsQuery } from 'data/analytics/project-metrics-query'
type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime'
export type StatsLike = {
error: unknown | null
isLoading: boolean
eventChartData: Array<{
timestamp: string
ok_count: number
warning_count: number
error_count: number
}>
refresh: () => void
}
type ServiceStatsMap = Record<
ServiceKey,
{
current: StatsLike
previous: StatsLike
}
>
/**
* Transform backend project metrics into a UI-friendly structure with consistent
* loading/error/refresh state per service.
*
* Why this exists
* - Backend returns flat rows: one record per (time_window, service, bucket_ts).
* - UI needs per-service objects with two series (current/previous) to drive 5 cards and compute per-service totals.
* - Charts expect ISO string timestamps; backend gives TIMESTAMP (coming to client as microseconds). We convert to ISO.
* - We also need stable sorting and consistent empty arrays when a series has no points.
* - We attach loading/error/refresh per service to keep UI simple.
*/
export const toServiceStatsMap = (args: {
data?: ProjectMetricsRow[]
isLoading: boolean
error?: unknown
onRefresh: () => void
}): ServiceStatsMap => {
const { data, isLoading, error, onRefresh } = args
const base = {
error: error ?? null,
isLoading,
refresh: () => {
onRefresh()
},
}
const empty: StatsLike = { ...base, eventChartData: [] }
const grouped: Record<
ServiceKey,
{ current: StatsLike['eventChartData']; previous: StatsLike['eventChartData'] }
> = {
db: { current: [], previous: [] },
functions: { current: [], previous: [] },
auth: { current: [], previous: [] },
storage: { current: [], previous: [] },
realtime: { current: [], previous: [] },
}
const toIso = (microseconds: number) => new Date(microseconds / 1000).toISOString()
for (const r of data ?? []) {
const bucket = grouped[r.service as ServiceKey]
const target = r.time_window === 'current' ? bucket.current : bucket.previous
target.push({
timestamp: toIso(r.timestamp),
ok_count: r.ok_count,
warning_count: r.warning_count,
error_count: r.error_count,
})
}
const byTime = (a: { timestamp: string }, b: { timestamp: string }) =>
Date.parse(a.timestamp) - Date.parse(b.timestamp)
for (const key of Object.keys(grouped) as ServiceKey[]) {
grouped[key].current.sort(byTime)
grouped[key].previous.sort(byTime)
}
const toStats = (rows: StatsLike['eventChartData'] | undefined): StatsLike =>
rows ? { ...base, eventChartData: rows } : empty
return {
db: { current: toStats(grouped.db.current), previous: toStats(grouped.db.previous) },
functions: {
current: toStats(grouped.functions.current),
previous: toStats(grouped.functions.previous),
},
auth: { current: toStats(grouped.auth.current), previous: toStats(grouped.auth.previous) },
storage: {
current: toStats(grouped.storage.current),
previous: toStats(grouped.storage.previous),
},
realtime: {
current: toStats(grouped.realtime.current),
previous: toStats(grouped.realtime.previous),
},
}
}
export const useServiceStats = (
projectRef: string,
interval: '1hr' | '1day' | '7day'
): ServiceStatsMap => {
const {
data,
isPending: isLoading,
error,
refetch,
} = useProjectMetricsQuery({ projectRef, interval })
return toServiceStatsMap({
data,
isLoading,
error,
onRefresh: () => {
void refetch()
},
})
}
@@ -0,0 +1,225 @@
import { useQuery } from '@tanstack/react-query'
import { get } from 'data/fetchers'
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
import useTimeseriesUnixToIso from 'hooks/analytics/useTimeseriesUnixToIso'
import { useMemo } from 'react'
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
import { LogsTableName } from '../Settings/Logs/Logs.constants'
import { genChartQuery } from '../Settings/Logs/Logs.utils'
import {
calculateAggregatedMetrics,
calculateDateRange,
calculateHealthMetrics,
transformToBarChartData,
} from './useServiceHealthMetrics.utils'
export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest'
export type ServiceHealthData = {
total: number
errorRate: number
successRate: number
errorCount: number
warningCount: number
okCount: number
eventChartData: LogsBarChartDatum[]
isLoading: boolean
error: unknown | null
refresh: () => void
}
type ServiceConfig = {
table: LogsTableName
enabled: boolean
}
const SERVICE_CONFIG: Record<ServiceKey, ServiceConfig> = {
db: { table: LogsTableName.POSTGRES, enabled: true },
auth: { table: LogsTableName.AUTH, enabled: true },
functions: { table: LogsTableName.FN_EDGE, enabled: true },
storage: { table: LogsTableName.STORAGE, enabled: true },
realtime: { table: LogsTableName.REALTIME, enabled: true },
postgrest: { table: LogsTableName.POSTGREST, enabled: true },
}
type ChartQueryResult = {
timestamp: string | number
ok_count: number
warning_count: number
error_count: number
}
/**
* Fetches service health metrics using the same logic as the logs pages
*/
const fetchServiceHealthMetrics = async (
projectRef: string,
table: LogsTableName,
startDate: string,
endDate: string,
signal?: AbortSignal
): Promise<ChartQueryResult[]> => {
const sql = genChartQuery(
table,
{
iso_timestamp_start: startDate,
iso_timestamp_end: endDate,
},
{}
)
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,
},
},
signal,
})
if (error || data?.error) {
throw error || data?.error
}
return (data?.result || []) as ChartQueryResult[]
}
/**
* Hook to fetch health metrics for a single service
*/
const useServiceHealthQuery = ({
projectRef,
serviceKey,
startDate,
endDate,
enabled,
}: {
projectRef: string
serviceKey: ServiceKey
startDate: string
endDate: string
enabled: boolean
}) => {
const config = SERVICE_CONFIG[serviceKey]
const table = config.table
const queryResult = useQuery({
queryKey: ['service-health-metrics', projectRef, serviceKey, startDate, endDate, table],
queryFn: ({ signal }) =>
fetchServiceHealthMetrics(projectRef, table, startDate, endDate, signal),
enabled: enabled && config.enabled && Boolean(projectRef),
staleTime: 1000 * 60, // 1 minute
})
// Convert unix microseconds to ISO timestamps
const normalizedData = useTimeseriesUnixToIso(queryResult.data ?? [], 'timestamp')
// Fill gaps in timeseries
const { data: filledData } = useFillTimeseriesSorted(
normalizedData,
'timestamp',
'ok_count',
0,
startDate,
endDate
)
// Transform to LogsBarChartDatum format
const eventChartData: LogsBarChartDatum[] = useMemo(
() => transformToBarChartData(filledData),
[filledData]
)
// Calculate metrics
const metrics = useMemo(() => calculateHealthMetrics(eventChartData), [eventChartData])
return {
...metrics,
eventChartData,
isLoading: queryResult.isLoading,
error: queryResult.error,
refresh: queryResult.refetch,
}
}
/**
* Hook to fetch observability overview data for all services using logs page queries
*/
export const useServiceHealthMetrics = (
projectRef: string,
interval: '1hr' | '1day' | '7day',
refreshKey: number
) => {
// Calculate date range based on interval
// refreshKey is intentionally included to force recalculation when user refreshes
// eslint-disable-next-line react-hooks/exhaustive-deps
const { startDate, endDate } = useMemo(() => calculateDateRange(interval), [interval, refreshKey])
const enabled = Boolean(projectRef)
// Fetch metrics for each service
const db = useServiceHealthQuery({ projectRef, serviceKey: 'db', startDate, endDate, enabled })
const auth = useServiceHealthQuery({
projectRef,
serviceKey: 'auth',
startDate,
endDate,
enabled,
})
const functions = useServiceHealthQuery({
projectRef,
serviceKey: 'functions',
startDate,
endDate,
enabled,
})
const storage = useServiceHealthQuery({
projectRef,
serviceKey: 'storage',
startDate,
endDate,
enabled,
})
const realtime = useServiceHealthQuery({
projectRef,
serviceKey: 'realtime',
startDate,
endDate,
enabled,
})
const postgrest = useServiceHealthQuery({
projectRef,
serviceKey: 'postgrest',
startDate,
endDate,
enabled,
})
const services: Record<ServiceKey, ServiceHealthData> = useMemo(
() => ({
db,
auth,
functions,
storage,
realtime,
postgrest,
}),
[db, auth, functions, storage, realtime, postgrest]
)
// Calculate aggregated metrics
const aggregated = useMemo(() => calculateAggregatedMetrics(Object.values(services)), [services])
const isLoading = Object.values(services).some((s) => s.isLoading)
return {
services,
aggregated,
isLoading,
endDate,
}
}
@@ -0,0 +1,350 @@
import dayjs from 'dayjs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
import {
calculateAggregatedMetrics,
calculateDateRange,
calculateHealthMetrics,
transformToBarChartData,
} from './useServiceHealthMetrics.utils'
describe('calculateDateRange', () => {
beforeEach(() => {
// Mock the current time to ensure consistent test results
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-15T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('calculates correct date range for 1hr interval', () => {
const result = calculateDateRange('1hr')
expect(result.startDate).toBe('2024-01-15T11:00:00.000Z')
expect(result.endDate).toBe('2024-01-15T12:00:00.000Z')
})
it('calculates correct date range for 1day interval', () => {
const result = calculateDateRange('1day')
expect(result.startDate).toBe('2024-01-14T12:00:00.000Z')
expect(result.endDate).toBe('2024-01-15T12:00:00.000Z')
})
it('calculates correct date range for 7day interval', () => {
const result = calculateDateRange('7day')
expect(result.startDate).toBe('2024-01-08T12:00:00.000Z')
expect(result.endDate).toBe('2024-01-15T12:00:00.000Z')
})
it('returns ISO format strings', () => {
const result = calculateDateRange('1hr')
expect(dayjs(result.startDate).isValid()).toBe(true)
expect(dayjs(result.endDate).isValid()).toBe(true)
})
it('end date is always after start date', () => {
const intervals: Array<'1hr' | '1day' | '7day'> = ['1hr', '1day', '7day']
intervals.forEach((interval) => {
const result = calculateDateRange(interval)
expect(dayjs(result.endDate).isAfter(dayjs(result.startDate))).toBe(true)
})
})
})
describe('transformToBarChartData', () => {
it('transforms raw data to LogsBarChartDatum format', () => {
const rawData = [
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 2, error_count: 1 },
{ timestamp: '2024-01-15T12:01:00Z', ok_count: 15, warning_count: 0, error_count: 0 },
]
const result = transformToBarChartData(rawData)
expect(result).toEqual([
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 2, error_count: 1 },
{ timestamp: '2024-01-15T12:01:00Z', ok_count: 15, warning_count: 0, error_count: 0 },
])
})
it('handles missing count fields by setting them to 0', () => {
const rawData = [
{ timestamp: '2024-01-15T12:00:00Z' },
{ timestamp: '2024-01-15T12:01:00Z', ok_count: 5 },
]
const result = transformToBarChartData(rawData)
expect(result).toEqual([
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 },
{ timestamp: '2024-01-15T12:01:00Z', ok_count: 5, warning_count: 0, error_count: 0 },
])
})
it('handles null values by converting to 0', () => {
const rawData = [
{
timestamp: '2024-01-15T12:00:00Z',
ok_count: null,
warning_count: null,
error_count: null,
},
]
const result = transformToBarChartData(rawData)
expect(result).toEqual([
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 },
])
})
it('handles empty array', () => {
const result = transformToBarChartData([])
expect(result).toEqual([])
})
it('preserves timestamp values', () => {
const rawData = [
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 0, error_count: 0 },
]
const result = transformToBarChartData(rawData)
expect(result[0].timestamp).toBe('2024-01-15T12:00:00Z')
})
})
describe('calculateHealthMetrics', () => {
it('calculates metrics correctly with only ok requests', () => {
const eventChartData: LogsBarChartDatum[] = [
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 100, warning_count: 0, error_count: 0 },
{ timestamp: '2024-01-15T12:01:00Z', ok_count: 50, warning_count: 0, error_count: 0 },
]
const result = calculateHealthMetrics(eventChartData)
expect(result).toEqual({
total: 150,
errorRate: 0,
successRate: 100,
errorCount: 0,
warningCount: 0,
okCount: 150,
})
})
it('calculates metrics correctly with errors', () => {
const eventChartData: LogsBarChartDatum[] = [
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 80, warning_count: 10, error_count: 10 },
]
const result = calculateHealthMetrics(eventChartData)
expect(result).toEqual({
total: 100,
errorRate: 10,
successRate: 80,
errorCount: 10,
warningCount: 10,
okCount: 80,
})
})
it('calculates metrics correctly with warnings', () => {
const eventChartData: LogsBarChartDatum[] = [
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 70, warning_count: 30, error_count: 0 },
]
const result = calculateHealthMetrics(eventChartData)
expect(result).toEqual({
total: 100,
errorRate: 0,
successRate: 70,
errorCount: 0,
warningCount: 30,
okCount: 70,
})
})
it('returns 0 error rate when total is 0', () => {
const eventChartData: LogsBarChartDatum[] = [
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 },
]
const result = calculateHealthMetrics(eventChartData)
expect(result).toEqual({
total: 0,
errorRate: 0,
successRate: 0,
errorCount: 0,
warningCount: 0,
okCount: 0,
})
})
it('handles empty array', () => {
const result = calculateHealthMetrics([])
expect(result).toEqual({
total: 0,
errorRate: 0,
successRate: 0,
errorCount: 0,
warningCount: 0,
okCount: 0,
})
})
it('aggregates metrics across multiple time periods', () => {
const eventChartData: LogsBarChartDatum[] = [
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 50, warning_count: 5, error_count: 5 },
{ timestamp: '2024-01-15T12:01:00Z', ok_count: 30, warning_count: 5, error_count: 5 },
{ timestamp: '2024-01-15T12:02:00Z', ok_count: 20, warning_count: 0, error_count: 0 },
]
const result = calculateHealthMetrics(eventChartData)
expect(result).toEqual({
total: 120,
errorRate: (10 / 120) * 100,
successRate: (100 / 120) * 100,
errorCount: 10,
warningCount: 10,
okCount: 100,
})
})
it('calculates correct error rate for high error scenario', () => {
const eventChartData: LogsBarChartDatum[] = [
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 10, error_count: 80 },
]
const result = calculateHealthMetrics(eventChartData)
expect(result.errorRate).toBe(80)
expect(result.successRate).toBe(10)
})
})
describe('calculateAggregatedMetrics', () => {
it('aggregates metrics from multiple services', () => {
const services = [
{ total: 100, errorCount: 10, warningCount: 5 },
{ total: 200, errorCount: 20, warningCount: 10 },
{ total: 50, errorCount: 5, warningCount: 2 },
]
const result = calculateAggregatedMetrics(services)
expect(result).toEqual({
totalRequests: 350,
totalErrors: 35,
totalWarnings: 17,
overallErrorRate: ((35 + 17) / 350) * 100,
overallSuccessRate: ((350 - 35 - 17) / 350) * 100,
})
})
it('handles empty services array', () => {
const result = calculateAggregatedMetrics([])
expect(result).toEqual({
totalRequests: 0,
totalErrors: 0,
totalWarnings: 0,
overallErrorRate: 0,
overallSuccessRate: 0,
})
})
it('handles single service', () => {
const services = [{ total: 100, errorCount: 10, warningCount: 5 }]
const result = calculateAggregatedMetrics(services)
expect(result).toEqual({
totalRequests: 100,
totalErrors: 10,
totalWarnings: 5,
overallErrorRate: 15,
overallSuccessRate: 85,
})
})
it('handles services with zero metrics', () => {
const services = [
{ total: 0, errorCount: 0, warningCount: 0 },
{ total: 100, errorCount: 10, warningCount: 5 },
]
const result = calculateAggregatedMetrics(services)
expect(result).toEqual({
totalRequests: 100,
totalErrors: 10,
totalWarnings: 5,
overallErrorRate: 15,
overallSuccessRate: 85,
})
})
it('calculates correct rates when all requests are successful', () => {
const services = [
{ total: 100, errorCount: 0, warningCount: 0 },
{ total: 200, errorCount: 0, warningCount: 0 },
]
const result = calculateAggregatedMetrics(services)
expect(result).toEqual({
totalRequests: 300,
totalErrors: 0,
totalWarnings: 0,
overallErrorRate: 0,
overallSuccessRate: 100,
})
})
it('calculates correct rates when all requests fail', () => {
const services = [
{ total: 100, errorCount: 100, warningCount: 0 },
{ total: 200, errorCount: 200, warningCount: 0 },
]
const result = calculateAggregatedMetrics(services)
expect(result).toEqual({
totalRequests: 300,
totalErrors: 300,
totalWarnings: 0,
overallErrorRate: 100,
overallSuccessRate: 0,
})
})
it('handles mixed success/warning/error scenarios', () => {
const services = [
{ total: 100, errorCount: 20, warningCount: 30 }, // 50% success
{ total: 100, errorCount: 10, warningCount: 10 }, // 80% success
{ total: 100, errorCount: 0, warningCount: 0 }, // 100% success
]
const result = calculateAggregatedMetrics(services)
expect(result.totalRequests).toBe(300)
expect(result.totalErrors).toBe(30)
expect(result.totalWarnings).toBe(40)
// Overall: 230 success, 40 warnings, 30 errors out of 300
expect(result.overallSuccessRate).toBeCloseTo((230 / 300) * 100, 2)
expect(result.overallErrorRate).toBeCloseTo((70 / 300) * 100, 2)
})
})
@@ -0,0 +1,103 @@
import dayjs from 'dayjs'
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
import {
computeSuccessAndNonSuccessRates,
sumErrors,
sumTotal,
sumWarnings,
} from '../HomeNew/ProjectUsage.metrics'
/**
* Calculates the date range for fetching service health metrics
* based on the selected interval
*/
export const calculateDateRange = (
interval: '1hr' | '1day' | '7day'
): { startDate: string; endDate: string } => {
const now = dayjs()
const end = now.toISOString()
let start: string
switch (interval) {
case '1hr':
start = now.subtract(1, 'hour').toISOString()
break
case '1day':
start = now.subtract(1, 'day').toISOString()
break
case '7day':
start = now.subtract(7, 'day').toISOString()
break
default:
start = now.subtract(1, 'hour').toISOString()
}
return { startDate: start, endDate: end }
}
type RawChartData = {
timestamp: string | number
ok_count?: number | null
warning_count?: number | null
error_count?: number | null
}
/**
* Transforms raw chart query results to LogsBarChartDatum format
*/
export const transformToBarChartData = (data: RawChartData[]): LogsBarChartDatum[] => {
return data.map((row) => ({
timestamp: typeof row.timestamp === 'string' ? row.timestamp : String(row.timestamp),
ok_count: row.ok_count || 0,
warning_count: row.warning_count || 0,
error_count: row.error_count || 0,
}))
}
/**
* Calculates health metrics from bar chart data
*/
export const calculateHealthMetrics = (eventChartData: LogsBarChartDatum[]) => {
const total = sumTotal(eventChartData)
const errorCount = sumErrors(eventChartData)
const warningCount = sumWarnings(eventChartData)
const okCount = total - errorCount - warningCount
const errorRate = total > 0 ? (errorCount / total) * 100 : 0
const { successRate } = computeSuccessAndNonSuccessRates(total, warningCount, errorCount)
return {
total,
errorRate,
successRate,
errorCount,
warningCount,
okCount,
}
}
/**
* Calculates aggregated metrics across all services
*/
export const calculateAggregatedMetrics = (
services: {
total: number
errorCount: number
warningCount: number
}[]
) => {
const totalRequests = services.reduce((sum, s) => sum + s.total, 0)
const totalErrors = services.reduce((sum, s) => sum + s.errorCount, 0)
const totalWarnings = services.reduce((sum, s) => sum + s.warningCount, 0)
const { successRate: overallSuccessRate, nonSuccessRate: overallErrorRate } =
computeSuccessAndNonSuccessRates(totalRequests, totalWarnings, totalErrors)
return {
totalRequests,
totalErrors,
totalWarnings,
overallErrorRate,
overallSuccessRate,
}
}
-2
View File
@@ -150,8 +150,6 @@ export const analyticsKeys = {
databaseIdentifier,
},
] as const,
projectMetrics: (projectRef: string | undefined, { interval }: { interval?: string }) =>
['projects', projectRef, 'project.metrics', { interval }] as const,
usageApiCounts: (projectRef: string | undefined, interval: string | undefined) =>
['projects', projectRef, 'usage.api-counts', interval] as const,
@@ -1,115 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { fetchGet } from 'data/fetchers'
import { API_URL, IS_PLATFORM } from 'lib/constants'
import { analyticsKeys } from './keys'
import { UseCustomQueryOptions } from 'types'
export type ProjectMetricsVariables = {
projectRef?: string
interval?: '1hr' | '1day' | '7day'
}
const MetricsRow = z.object({
timestamp: z
.number({
required_error: 'Timestamp is required',
invalid_type_error: 'Timestamp must be a number (microseconds since epoch)',
})
.int('Timestamp must be an integer')
.positive('Timestamp must be positive'),
service: z.enum(['auth', 'db', 'functions', 'realtime', 'storage'], {
required_error: 'Service field is required',
invalid_type_error: 'Service must be one of: auth, db, functions, realtime, storage',
}),
time_window: z.enum(['current', 'previous'], {
required_error: 'Time window field is required',
invalid_type_error: 'Time window must be either "current" or "previous"',
}),
ok_count: z
.number({
required_error: 'ok_count is required',
invalid_type_error: 'ok_count must be a number',
})
.int('ok_count must be an integer')
.nonnegative('ok_count cannot be negative'),
warning_count: z
.number({
required_error: 'warning_count is required',
invalid_type_error: 'warning_count must be a number',
})
.int('warning_count must be an integer')
.nonnegative('warning_count cannot be negative'),
error_count: z
.number({
required_error: 'error_count is required',
invalid_type_error: 'error_count must be a number',
})
.int('error_count must be an integer')
.nonnegative('error_count cannot be negative'),
})
const MetricsRows = z.array(MetricsRow, {
required_error: 'Metrics response must be an array',
invalid_type_error: 'Metrics response must be an array of metric rows',
})
export type ProjectMetricsRow = z.infer<typeof MetricsRow>
export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime'
export async function getProjectMetrics(
{ projectRef, interval }: ProjectMetricsVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const search = new URLSearchParams()
if (interval) search.set('interval', interval)
const url = IS_PLATFORM
? `${API_URL}/projects/${projectRef}/analytics/endpoints/project.metrics?${search.toString()}`
: `/api/platform/projects/${projectRef}/analytics/endpoints/project.metrics?${search.toString()}`
const response = await fetchGet<ProjectMetricsRow[] | { result: ProjectMetricsRow[] }>(url, {
abortSignal: signal,
})
if (response instanceof Error || (response as any)?.error) {
// normalize to throw
throw (response as any).error ?? response
}
const payload = Array.isArray(response) ? response : (response as any)?.result
const parsed = MetricsRows.safeParse(payload)
if (!parsed.success) {
const firstError = parsed.error.errors[0]
const errorPath = firstError.path.length > 0 ? ` at path: ${firstError.path.join('.')}` : ''
throw new Error(
`Invalid metrics response${errorPath}: ${firstError.message}. Received: ${JSON.stringify(payload?.slice(0, 2))}`
)
}
return parsed.data
}
export type ProjectMetricsData = Awaited<ReturnType<typeof getProjectMetrics>>
export type ProjectMetricsError = unknown
export const useProjectMetricsQuery = <TData = ProjectMetricsData>(
vars: ProjectMetricsVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<ProjectMetricsData, ProjectMetricsError, TData> = {}
) => {
const { projectRef, interval } = vars
return useQuery<ProjectMetricsData, ProjectMetricsError, TData>({
queryKey: analyticsKeys.projectMetrics(projectRef, { interval }),
queryFn: ({ signal }) => getProjectMetrics({ projectRef, interval }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
refetchOnWindowFocus: false,
...options,
})
}