Files
supabase/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts
Jordi Enric be030229bf feat(studio): add workers to unified logs FE-4281 (#49682)
## Problem

Unified Logs does not expose Workers logs, so users cannot search
Workers ingress, runtime, or build events alongside other services.

## Fix

Add a Workers log type that classifies all three Workers OTEL streams.
Gate the option and any persisted Workers filters with the existing
Workers feature flag.

## How to test

- Enable the Workers feature flag and open Unified Logs.
- Select Workers from the Log Type filter.
- Expected result: Unified Logs shows ingress, runtime, and build events
with the Workers icon.
- Disable the Workers feature flag and load a URL containing
`log_type:eq:workers`.
- Expected result: the Workers option and filter are removed, and
Workers logs are not queried.
- Run `./node_modules/.bin/vitest --run
components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts
components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts
data/workers/worker-logs-query.test.ts` from `apps/studio`.

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

* **New Features**
  * Added Workers as a selectable log type in Unified Logs.
* Unified Logs now combines worker ingress, guest, and API streams under
the Workers category.
  * Added a dedicated Workers icon and worker log filtering.

* **Improvements**
  * Worker filters and URL parameters respect feature availability.
* Worker details show relevant metadata while omitting unavailable HTTP
fields.
  * Improved handling of worker log levels, statuses, and raw data.
  * Added stronger validation for unified log data.

* **Tests**
* Added coverage for worker routing, filtering, feature visibility,
parsing, and metadata redaction.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-07 15:53:36 +02:00

283 lines
8.8 KiB
TypeScript

import { type Table as TTable } from '@tanstack/react-table'
import { cn } from 'ui'
import { LOG_TYPES_LABELS } from './UnifiedLogs.constants'
import { parseLogsFilterUrlParams } from './UnifiedLogs.filters'
import { ColumnSchema, FacetMetadataSchema } from './UnifiedLogs.schema'
import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
import { Option } from '@/components/ui/DataTable/DataTable.types'
import type { UnifiedLogInspectionEntry } from '@/data/logs/unified-log-inspection-query'
export type UnifiedLogType = keyof typeof LOG_TYPES_LABELS
export function getWorkersLogsAvailability({
isPlatform,
flagsLoaded,
workersEnabled,
}: {
isPlatform: boolean
flagsLoaded?: boolean
workersEnabled: boolean
}) {
const flagsReady = flagsLoaded === true
return {
canQueryWorkers: isPlatform && flagsReady && workersEnabled,
preserveWorkersFilter: isPlatform && (!flagsReady || workersEnabled),
readyToSyncFilters: !isPlatform || flagsReady,
}
}
export const buildUnifiedLogsUrl = ({
projectRef,
logType,
user,
start,
end,
}: {
projectRef: string
logType?: UnifiedLogType
/** Pre-applies the cross-cutting "filter by user" (?user=) — an id or email. */
user?: string
start?: string | Date
end?: string | Date
}) => {
const params = new URLSearchParams()
if (logType) params.append('filter', `log_type:eq:${logType}`)
if (user) params.set('user', user)
if (start && end) {
params.set('date', `${new Date(start).valueOf()}-${new Date(end).valueOf()}`)
}
return `/project/${projectRef}/logs?${params.toString()}`
}
export const getFacetedUniqueValues = <TData>(facets?: Record<string, FacetMetadataSchema>) => {
return (_table: TTable<TData>, columnId: string) => {
return new Map(facets?.[columnId]?.rows?.map(({ value, total }) => [value, total]) || [])
}
}
export const getFacetedMinMaxValues = <TData>(facets?: Record<string, FacetMetadataSchema>) => {
return (_table: TTable<TData>, columnId: string) => {
const min = facets?.[columnId]?.min
const max = facets?.[columnId]?.max
if (typeof min === 'number' && typeof max === 'number') return [min, max]
if (typeof min === 'number') return [min, min]
if (typeof max === 'number') return [max, max]
return undefined
}
}
/**
* Returns a unified-logs row's timestamp in epoch milliseconds.
*
* The row mapper attaches a pre-parsed `date` (works for both BigQuery
* microsecond timestamps and OTEL ISO strings); fall back to the raw
* `timestamp` value when it's a number (older BQ-style microseconds).
*/
export function getRowTimestampMs(
row: { date?: Date | null; timestamp?: number | string | null } | null | undefined
): number | null {
if (row?.date instanceof Date) return row.date.getTime()
if (typeof row?.timestamp === 'number') return row.timestamp / 1000
return null
}
type WorkersRawLogData = Pick<ColumnSchema, 'id' | 'timestamp' | 'event_message' | 'metadata'>
export function getRawLogData(
row: ColumnSchema | UnifiedLogInspectionEntry
): ColumnSchema | UnifiedLogInspectionEntry | WorkersRawLogData {
if (!('log_type' in row) || row.log_type !== 'workers') return row
return {
id: row.id,
timestamp: row.timestamp,
event_message: row.event_message,
metadata: row.metadata,
}
}
export const getLevelLabel = (value: (typeof LEVELS)[number]): string => {
switch (value) {
case 'success':
return '2xx'
case 'warning':
return '4xx'
case 'error':
return '5xx'
}
}
// Helper function to determine level from HTTP status code
export const getStatusLevel = (status?: number | string): string => {
if (!status) return 'success'
const statusNum = Number(status)
if (statusNum >= 500) return 'error'
if (statusNum >= 400) return 'warning'
if (statusNum >= 300) return 'info' // 3xx redirects are informational
if (statusNum >= 200) return 'success'
if (statusNum >= 100) return 'info'
return 'success'
}
export function getLevelRowClassName(value: (typeof LEVELS)[number] | null | undefined): string {
switch (value) {
case 'success':
return ''
case 'warning':
return cn(
'bg-warning/5 hover:bg-warning/10',
'data-[state=selected]:bg-warning/20 focus-visible:bg-warning/10',
'dark:bg-warning/10 dark:hover:bg-warning/20 dark:data-[state=selected]:bg-warning/30 dark:focus-visible:bg-warning/20'
)
case 'error':
return cn(
'bg-destructive/5 hover:bg-destructive/10',
'data-[state=selected]:bg-destructive/20 focus-visible:bg-destructive/10',
'dark:bg-error/10 dark:hover:bg-destructive/20 dark:data-[state=selected]:bg-destructive/30 dark:focus-visible:bg-destructive/20'
)
default:
return ''
}
}
/**
* Formats service type strings for display purposes
* Handles special cases like "edge function" -> "Edge Function"
* and applies proper capitalization to other service types
*/
export function formatServiceTypeForDisplay(serviceType: string): string {
if (!serviceType) return ''
// Handle special cases
const specialCases: Record<string, string> = {
'edge function': 'Edge Function',
postgrest: 'PostgREST',
postgres: 'Postgres',
auth: 'Auth',
storage: 'Storage',
realtime: 'Realtime',
supavisor: 'Supavisor',
pgbouncer: 'PgBouncer',
multigres: 'Multigres',
}
return specialCases[serviceType.toLowerCase()] || serviceType
}
/**
* Parses an auth log event_message that may be a stringified JSON object.
* Auth log entries store metadata as JSON in event_message (e.g. {"msg":"...","level":"info"}).
* Extracts the human-readable msg field, falling back to error, then the raw string.
* The fallback ensures self-hosted versions with different formats still render correctly.
*/
export function parseAuthLogEventMessage(value: string | undefined): string | undefined {
if (!value) return value
try {
const parsed = JSON.parse(value)
if (parsed && typeof parsed === 'object') {
const err = parsed.error || parsed.error_code
if (typeof err === 'string' && err.trim()) {
return !/^\d{3}:/.test(err) ? err.replaceAll('_', ' ') : err
}
const msg = parsed.msg
if (typeof msg === 'string' && msg.trim()) {
const action = parsed.action ?? parsed.auth_event?.action
const authEvent = typeof action === 'string' ? action.replaceAll('_', ' ') : undefined
return `${authEvent ? `${authEvent}: ` : ''}${msg}`
}
}
return value
} catch (error) {
return value
}
}
/**
* Parses a Multigres log event_message, which is a stringified JSON object
* (e.g. {"time":"...","level":"INFO","msg":"user pool capacity updated",...}).
* Extracts the human-readable msg field, falling back to the raw string so
* unexpected formats still render.
*/
export function parseMultigresEventMessage(value: string | undefined): string | undefined {
if (!value) return value
try {
const parsed = JSON.parse(value)
if (
parsed &&
typeof parsed === 'object' &&
typeof parsed.msg === 'string' &&
parsed.msg.trim()
) {
return parsed.msg
}
return value
} catch (error) {
return value
}
}
/**
* Returns the display text for a log row's event_message alongside whether it
* should be rendered as a capitalized sentence. Keeps the per-service parsing
* and its capitalization rule in one place so callers don't re-derive the list.
*/
export function getEventMessageDisplay(
logType: string,
value: string | undefined
): { message: string | undefined; capitalize: boolean } {
if (logType === 'auth') return { message: parseAuthLogEventMessage(value), capitalize: true }
if (logType === 'multigres')
return { message: parseMultigresEventMessage(value), capitalize: true }
return { message: value, capitalize: false }
}
export function gateLogTypeOptions<T extends { value: string; options?: Option[] }>(
fields: T[],
visibility: Partial<Record<UnifiedLogType, boolean>>
): T[] {
const hiddenLogTypes = new Set(
Object.entries(visibility)
.filter(([, visible]) => !visible)
.map(([logType]) => logType)
)
if (hiddenLogTypes.size === 0) return fields
return fields.map((field) => {
if (field.value !== 'log_type' || !field.options) return field
return {
...field,
options: field.options.filter((option) => !hiddenLogTypes.has(option.value)),
}
})
}
export function gateLogTypeFilters(
filters: string[] | null | undefined,
visibility: Partial<Record<UnifiedLogType, boolean>>
): string[] | null | undefined {
if (!filters) return filters
const hiddenLogTypes = new Set(
Object.entries(visibility)
.filter(([, visible]) => !visible)
.map(([logType]) => logType)
)
if (hiddenLogTypes.size === 0) return filters
return filters.filter((filter) => {
const parsed = parseLogsFilterUrlParams([filter])[0]
return parsed?.column !== 'log_type' || !hiddenLogTypes.has(parsed.value)
})
}