Files
supabase/apps/studio/components/interfaces/Reports/Reports.utils.tsx
Jordi Enric 7d3e8fa5a3 feat(studio): migrate Storage report to OTEL (#50519)
## Problem

The Storage observability report still sends legacy BigQuery SQL to the
`logs.all` analytics endpoint, so it cannot use the ClickHouse-backed
OTEL logs path.

## Fix

Add ClickHouse query variants for the API and cache metrics used by the
Storage report, and switch SQL plus endpoint together behind
`otelReports`. Keep legacy behavior when the flag is disabled and
prevent requests until feature flags have resolved on platform.

Tested by checking the preview AND running all queries in log explorer
one by one ✅

## How to test

- Run `CI=1 pnpm --filter studio exec vitest run
components/interfaces/Reports/Reports.constants.otel.test.ts
data/reports/storage-report-query.test.tsx`
- Enable `otelReports`, open Project > Observability > Storage, and
verify all charts load.
- Expected result: Storage analytics requests use `logs.all.otel` with
ClickHouse SQL; no legacy request is sent during flag hydration.

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

* **New Features**
* Reports now support OpenTelemetry-based querying for API and storage
metrics.
* Report filters provide safer numeric comparisons and improved route,
status, timing, traffic, and cache metrics.

* **Bug Fixes**
* Reports now wait for reporting configuration and the project reference
to be ready before querying.
* Invalid numeric filters are safely ignored, and cache-status
calculations are more reliable.

* **Tests**
* Added coverage for OpenTelemetry report queries, filtering,
aggregations, caching, and project readiness.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-21 16:45:34 +02:00

224 lines
6.3 KiB
TypeScript

import dayjs from 'dayjs'
import {
type BaseQueries,
type PresetConfig,
type ReportFilterItem,
type ReportQuery,
} from './Reports.types'
import {
isUnixMicro,
unixMicroToIsoTimestamp,
} from '@/components/interfaces/Settings/Logs/Logs.utils'
import type { SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql'
import { REPORT_STATUS_CODE_COLORS } from '@/data/reports/report.utils'
import useDbQuery, { DbQueryHook } from '@/hooks/analytics/useDbQuery'
import { useLogsQuery, type LogsQueryHook } from '@/hooks/analytics/useLogsQuery'
import { getHttpStatusCodeInfo } from '@/lib/http-status-codes'
/**
* Converts a query params string to an object
*/
export const queryParamsToObject = (params: string) => {
return Object.fromEntries(new URLSearchParams(params))
}
/**
* Decodes a URI component, returning the original string if it is malformed.
*
* `decodeURIComponent` throws a `URIError` on invalid percent-encoding (e.g. a
* literal `%` such as `?discount=100%`). Request paths are user-controlled, so
* decoding inline during render can crash the page.
*/
export const safeDecodeURIComponent = (value: string) => {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
export type PresetHookResult = LogsQueryHook | DbQueryHook
type PresetHooks = Record<keyof PresetConfig['queries'], () => PresetHookResult>
/**
* @deprecated
* Queries are hooks, avoid generating hooks dynamically
* Generate fetch functions instead, and pass it to a hook inside the component
*/
export const queriesFactory = <T extends string>(
queries: BaseQueries<T>,
projectRef?: string,
useOtel = false,
enabled = true
): PresetHooks => {
const hooks: PresetHooks = Object.entries<ReportQuery>(queries).reduce((acc, [k, query]) => {
if (query.queryType === 'db') {
return {
...acc,
[k]: () => useDbQuery({ sql: query.safeSql }),
}
} else {
return {
...acc,
[k]: () => useLogsQuery({ projectRef, enabled, options: { useOtel } }),
}
}
}, {})
return hooks
}
export function getLogsSql(
query: ReportQuery,
filters: ReportFilterItem[],
useOtel = false
): SafeLogSqlFragment {
if (query.queryType !== 'logs') {
throw new Error(`Expected logs query, got ${query.queryType}`)
}
if (useOtel) {
if (query.safeSqlOtel === undefined) {
throw new Error('Expected an OTEL logs query')
}
return query.safeSqlOtel(filters)
}
return query.safeSql(filters)
}
/**
* Formats a timestamp to a human readable format in UTC
*
* @param timestamp - The timestamp to format
* @param returnUtc - Whether to return the timestamp in UTC
* @param format - The format to use for the timestamp
* @returns The formatted timestamp string
*/
export const formatTimestamp = (
timestamp: number | string,
{ returnUtc = false, format = 'MMM D, h:mma' }: { returnUtc?: boolean; format?: string } = {}
) => {
try {
const isSeconds = String(timestamp).length === 10
const isMicroseconds = String(timestamp).length === 16
const timestampInMs = isSeconds
? Number(timestamp) * 1000
: isMicroseconds
? Number(timestamp) / 1000
: Number(timestamp)
if (returnUtc) {
return dayjs.utc(timestampInMs).format(format)
} else {
return dayjs(timestampInMs).format(format)
}
} catch (error) {
console.error(error)
return 'Invalid Date'
}
}
/**
* Extracts distinct status codes from log data rows
*/
export function extractStatusCodesFromData(data: any[]): string[] {
const statusCodes = new Set<string>()
data.forEach((item: any) => {
if (item.status_code !== undefined && item.status_code !== null) {
statusCodes.add(String(item.status_code))
}
})
return Array.from(statusCodes).sort()
}
/**
* Generates chart attributes for status codes with labels and colors
*/
export function generateStatusCodeAttributes(statusCodes: string[]) {
return statusCodes.map((code) => ({
attribute: code,
label: `${code} ${getHttpStatusCodeInfo(parseInt(code, 10)).label}`,
color: REPORT_STATUS_CODE_COLORS[code] || REPORT_STATUS_CODE_COLORS.default,
}))
}
/**
* Pivots rows of { timestamp, status_code, count } into { timestamp, [status_code]: count }
* and normalizes timestamps to ISO strings (UTC), filling missing codes with 0 per timestamp
*/
export function transformStatusCodeData(data: any[], statusCodes: string[]) {
const pivotedData = data.reduce((acc: Record<string, any>, d: any) => {
const timestamp = isUnixMicro(d.timestamp)
? unixMicroToIsoTimestamp(d.timestamp)
: dayjs.utc(d.timestamp).toISOString()
if (!acc[timestamp]) {
acc[timestamp] = { timestamp }
statusCodes.forEach((code) => {
acc[timestamp][code] = 0
})
}
const codeKey = String(d.status_code)
if (codeKey in acc[timestamp]) {
acc[timestamp][codeKey] = d.count
}
return acc
}, {})
return Object.values(pivotedData)
}
/**
* Extract distinct string values for a given field from data rows
*/
export function extractDistinctValuesFromData(data: any[], field: string): string[] {
const values = new Set<string>()
data.forEach((item: any) => {
if (item[field] !== undefined && item[field] !== null) {
values.add(String(item[field]))
}
})
return Array.from(values).sort()
}
/**
* Generates chart attributes from a list of category values
*/
export function generateCategoryAttributes(
values: string[],
labelResolver?: (v: string) => string
) {
return values.map((v) => ({
attribute: v,
label: labelResolver ? labelResolver(v) : v,
}))
}
/**
* Pivot rows of { timestamp, [categoryField], count } into { timestamp, [category]: count }
*/
export function transformCategoricalCountData(
data: any[],
categoryField: string,
categories: string[]
) {
const pivotedData = data.reduce((acc: Record<string, any>, d: any) => {
const timestamp = isUnixMicro(d.timestamp)
? unixMicroToIsoTimestamp(d.timestamp)
: dayjs.utc(d.timestamp).toISOString()
if (!acc[timestamp]) {
acc[timestamp] = { timestamp }
categories.forEach((c) => {
acc[timestamp][c] = 0
})
}
const key = String(d[categoryField])
if (key in acc[timestamp]) {
acc[timestamp][key] = d.count
}
return acc
}, {})
return Object.values(pivotedData)
}