mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): add Multigres log type to unified logs FE-3785 (#47560)
## Problem The Multigres log type is available in the legacy logs collections but was missing from the new unified logs, so Multigres logs could not be selected or viewed there. ## Fix Wire the `multigres_logs` source into unified logs the same way the other single-source types (Realtime, Supavisor, PgBouncer) are: a display label, a filter condition, the derived `log_type` expression, a display-casing entry, and a sidebar icon. ## How to test - Open a project with Multigres logs and go to the new unified logs view - Open the Log Type filter and confirm "Multigres" appears as an option - Select "Multigres" and confirm rows from the `multigres_logs` source are returned and labeled "Multigres" with the network icon - Expected result: Multigres logs are filterable and display correctly, matching the legacy logs behavior ## Notes Level/severity uses the shared `severity_text` fallback that all non-HTTP sources rely on. If Multigres rows come back always classified as success, the OTEL pipeline may not populate `severity_text` for this source (legacy logs read the level from a JSON `event_message`), which would need a source-specific level branch. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for the **Multigres** log type in Unified Logs (labels, icon, and derived filtering/grouping/counting). * Unified Logs now renders Multigres **event_message** by extracting the `msg` field from valid JSON, with correct capitalization. * Unified Logs row click telemetry now recognizes **Multigres**. * The **Multigres** log type option is hidden when the selected project is not high-availability. * **Tests** * Added/updated unit tests for Multigres event-message parsing and shared event-message display behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
79b2eeb8b2
commit
17ee3e6977
10 files changed
+170
-55
No files matched your search
@@ -26,6 +26,7 @@ export const LOG_TYPES_LABELS = {
|
||||
realtime: 'Realtime',
|
||||
supavisor: 'Supavisor',
|
||||
pgbouncer: 'PgBouncer',
|
||||
multigres: 'Multigres',
|
||||
}
|
||||
|
||||
type LogType = keyof typeof LOG_TYPES_LABELS
|
||||
|
||||
@@ -64,6 +64,7 @@ const LOG_TYPE_CONDITION: Record<string, SafeLogSqlFragment> = {
|
||||
realtime: safeSql`source = 'realtime_logs'`,
|
||||
supavisor: safeSql`source = 'supavisor_logs'`,
|
||||
pgbouncer: safeSql`source = 'pgbouncer_logs'`,
|
||||
multigres: safeSql`source = 'multigres_logs'`,
|
||||
}
|
||||
|
||||
// Derived `log_type` column for SELECT / GROUP BY / countIf use.
|
||||
@@ -79,6 +80,7 @@ const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE
|
||||
WHEN source = 'realtime_logs' THEN 'realtime'
|
||||
WHEN source = 'supavisor_logs' THEN 'supavisor'
|
||||
WHEN source = 'pgbouncer_logs' THEN 'pgbouncer'
|
||||
WHEN source = 'multigres_logs' THEN 'multigres'
|
||||
ELSE source
|
||||
END`
|
||||
|
||||
|
||||
@@ -45,7 +45,11 @@ import {
|
||||
import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks'
|
||||
import { ColumnSchema } from './UnifiedLogs.schema'
|
||||
import { QuerySearchParamsType } from './UnifiedLogs.types'
|
||||
import { getFacetedUniqueValues, getLevelRowClassName } from './UnifiedLogs.utils'
|
||||
import {
|
||||
gateMultigresLogType,
|
||||
getFacetedUniqueValues,
|
||||
getLevelRowClassName,
|
||||
} from './UnifiedLogs.utils'
|
||||
import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
|
||||
import { Option } from '@/components/ui/DataTable/DataTable.types'
|
||||
import { arrSome, inDateRange } from '@/components/ui/DataTable/DataTable.utils'
|
||||
@@ -62,6 +66,7 @@ import { useUnifiedLogsChartQuery } from '@/data/logs/unified-logs-chart-query'
|
||||
import { useUnifiedLogsCountQuery } from '@/data/logs/unified-logs-count-query'
|
||||
import { useUnifiedLogsInfiniteQuery } from '@/data/logs/unified-logs-infinite-query'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { useShowMultigresLogs } from '@/hooks/misc/useShowMultigresLogs'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
@@ -106,6 +111,8 @@ export const UnifiedLogs = () => {
|
||||
return () => observer.unobserve(topBar)
|
||||
}, [])
|
||||
|
||||
const showMultigresLogs = useShowMultigresLogs()
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultColumnSorting)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(defaultColumnFilters)
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
@@ -266,7 +273,9 @@ export const UnifiedLogs = () => {
|
||||
// Will need to refactor this bit
|
||||
// - Each facet just handles its own state, rather than getting passed down like this
|
||||
const filterFields = useMemo(() => {
|
||||
return defaultFilterFields.map((field) => {
|
||||
const gatedFields = gateMultigresLogType(defaultFilterFields, showMultigresLogs)
|
||||
|
||||
return gatedFields.map((field) => {
|
||||
const facetsField = facets?.[field.value]
|
||||
|
||||
// If no facets data available, use the predefined field
|
||||
@@ -292,7 +301,7 @@ export const UnifiedLogs = () => {
|
||||
|
||||
return { ...field, options }
|
||||
})
|
||||
}, [facets])
|
||||
}, [facets, showMultigresLogs])
|
||||
|
||||
const applyFilterSearch = () => {
|
||||
setSearch(buildFilterSearchUpdate(columnFilters, filterFields))
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildUnifiedLogsUrl } from './UnifiedLogs.utils'
|
||||
import {
|
||||
buildUnifiedLogsUrl,
|
||||
gateMultigresLogType,
|
||||
getEventMessageDisplay,
|
||||
parseMultigresEventMessage,
|
||||
} from './UnifiedLogs.utils'
|
||||
|
||||
describe('buildUnifiedLogsUrl', () => {
|
||||
const parse = (url: string) => {
|
||||
@@ -45,3 +50,75 @@ describe('buildUnifiedLogsUrl', () => {
|
||||
expect(params.has('date')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMultigresEventMessage', () => {
|
||||
it('extracts the msg field from a stringified JSON payload', () => {
|
||||
const value = JSON.stringify({
|
||||
time: '2026-07-03T09:42:12.344925698Z',
|
||||
level: 'INFO',
|
||||
msg: 'user pool capacity updated',
|
||||
user: 'supabase_admin',
|
||||
})
|
||||
expect(parseMultigresEventMessage(value)).toBe('user pool capacity updated')
|
||||
})
|
||||
|
||||
it('returns the raw string when it is not JSON', () => {
|
||||
expect(parseMultigresEventMessage('plain text message')).toBe('plain text message')
|
||||
})
|
||||
|
||||
it('returns the raw string when msg is missing or empty', () => {
|
||||
expect(parseMultigresEventMessage(JSON.stringify({ level: 'INFO' }))).toBe('{"level":"INFO"}')
|
||||
expect(parseMultigresEventMessage(JSON.stringify({ msg: ' ' }))).toBe('{"msg":" "}')
|
||||
})
|
||||
|
||||
it('passes empty values through unchanged', () => {
|
||||
expect(parseMultigresEventMessage(undefined)).toBeUndefined()
|
||||
expect(parseMultigresEventMessage('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventMessageDisplay', () => {
|
||||
it('parses multigres rows into their msg field and capitalizes them', () => {
|
||||
const value = JSON.stringify({ level: 'INFO', msg: 'Configuring synchronous replication' })
|
||||
expect(getEventMessageDisplay('multigres', value)).toEqual({
|
||||
message: 'Configuring synchronous replication',
|
||||
capitalize: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves non-parsed log types untouched and uncapitalized', () => {
|
||||
expect(getEventMessageDisplay('postgres', 'relation does not exist')).toEqual({
|
||||
message: 'relation does not exist',
|
||||
capitalize: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('gateMultigresLogType', () => {
|
||||
const fields = [
|
||||
{ value: 'date' },
|
||||
{
|
||||
value: 'log_type',
|
||||
options: [
|
||||
{ label: 'Postgres', value: 'postgres' },
|
||||
{ label: 'Multigres', value: 'multigres' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it('drops the multigres log_type option when the flag is disabled', () => {
|
||||
const gated = gateMultigresLogType(fields, false)
|
||||
const logType = gated.find((field) => field.value === 'log_type')
|
||||
expect(logType?.options?.map((option) => option.value)).toEqual(['postgres'])
|
||||
})
|
||||
|
||||
it('keeps the multigres option when the flag is enabled', () => {
|
||||
const gated = gateMultigresLogType(fields, true)
|
||||
expect(gated).toBe(fields)
|
||||
})
|
||||
|
||||
it('leaves non log_type fields untouched', () => {
|
||||
const gated = gateMultigresLogType(fields, false)
|
||||
expect(gated.find((field) => field.value === 'date')).toEqual({ value: 'date' })
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { cn } from 'ui'
|
||||
import { LOG_TYPES_LABELS } from './UnifiedLogs.constants'
|
||||
import { FacetMetadataSchema } from './UnifiedLogs.schema'
|
||||
import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
|
||||
import { Option } from '@/components/ui/DataTable/DataTable.types'
|
||||
|
||||
export type UnifiedLogType = keyof typeof LOG_TYPES_LABELS
|
||||
|
||||
@@ -120,6 +121,7 @@ export function formatServiceTypeForDisplay(serviceType: string): string {
|
||||
realtime: 'Realtime',
|
||||
supavisor: 'Supavisor',
|
||||
pgbouncer: 'PgBouncer',
|
||||
multigres: 'Multigres',
|
||||
}
|
||||
|
||||
return specialCases[serviceType.toLowerCase()] || serviceType
|
||||
@@ -156,3 +158,62 @@ export function parseAuthLogEventMessage(value: string | undefined): string | un
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Multigres logs are gated behind the `showMultigresLogs` flag, so the multigres
|
||||
* log_type option is removed from the filter fields when the flag is disabled.
|
||||
*/
|
||||
export function gateMultigresLogType<T extends { value: string; options?: Option[] }>(
|
||||
fields: T[],
|
||||
showMultigresLogs: boolean
|
||||
): T[] {
|
||||
if (showMultigresLogs) return fields
|
||||
|
||||
return fields.map((field) =>
|
||||
field.value === 'log_type' && field.options
|
||||
? ({ ...field, options: field.options.filter((option) => option.value !== 'multigres') } as T)
|
||||
: field
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Checkbox, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { STATUS_CODE_LABELS } from '../UnifiedLogs.constants'
|
||||
import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema'
|
||||
import { parseAuthLogEventMessage } from '../UnifiedLogs.utils'
|
||||
import { getEventMessageDisplay } from '../UnifiedLogs.utils'
|
||||
import { HoverCardTimestamp } from './HoverCardTimestamp'
|
||||
import { LogTypeIcon } from './LogTypeIcon'
|
||||
import { DataTableColumnLevelIndicator } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator'
|
||||
@@ -234,7 +234,10 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): {
|
||||
const value = row.getValue<ColumnSchema['event_message']>('event_message')
|
||||
const logType = row.original.log_type
|
||||
const logCount = row.original.log_count
|
||||
const displayMessage = logType === 'auth' ? parseAuthLogEventMessage(value) : value
|
||||
const { message: displayMessage, capitalize: capitalizeMessage } = getEventMessageDisplay(
|
||||
logType,
|
||||
value
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
@@ -252,7 +255,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): {
|
||||
)}
|
||||
{displayMessage && (
|
||||
<span
|
||||
className={cn('text-muted-foreground', logType === 'auth' && 'capitalize-sentence')}
|
||||
className={cn('text-muted-foreground', capitalizeMessage && 'capitalize-sentence')}
|
||||
>
|
||||
{displayMessage}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Auth, EdgeFunctions, Realtime, Storage } from 'icons'
|
||||
import { Box, Cable, Code2, Database } from 'lucide-react'
|
||||
import { Box, Cable, Code2, Database, Network } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { type LOG_TYPES } from '../UnifiedLogs.constants'
|
||||
@@ -28,6 +28,7 @@ const ICON_MAP: Partial<Record<(typeof LOG_TYPES)[number], IconComponent>> = {
|
||||
realtime: Realtime,
|
||||
supavisor: Cable,
|
||||
pgbouncer: Cable,
|
||||
multigres: Network,
|
||||
}
|
||||
|
||||
export const LogTypeIcon = ({
|
||||
|
||||
@@ -4,54 +4,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useShowMultigresLogs } from '../useShowMultigresLogs'
|
||||
|
||||
const mockUseFlag = vi.fn()
|
||||
const mockUseIsHighAvailability = vi.fn()
|
||||
|
||||
vi.mock('common', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('common')>()),
|
||||
useFlag: (name: string) => mockUseFlag(name),
|
||||
}))
|
||||
|
||||
vi.mock('../useSelectedProject', () => ({
|
||||
useIsHighAvailability: () => mockUseIsHighAvailability(),
|
||||
}))
|
||||
|
||||
describe('useShowMultigresLogs', () => {
|
||||
beforeEach(() => {
|
||||
mockUseFlag.mockReset()
|
||||
mockUseIsHighAvailability.mockReset()
|
||||
})
|
||||
|
||||
it('returns true only when the multigresLogs flag and high availability are both enabled', () => {
|
||||
it('returns true when the showMultigresLogs flag is enabled', () => {
|
||||
mockUseFlag.mockReturnValue(true)
|
||||
mockUseIsHighAvailability.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useShowMultigresLogs())
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
expect(mockUseFlag).toHaveBeenCalledWith('multigresLogs')
|
||||
expect(mockUseFlag).toHaveBeenCalledWith('showMultigresLogs')
|
||||
})
|
||||
|
||||
it('returns false when the flag is off, even on a high availability project', () => {
|
||||
it('returns false when the showMultigresLogs flag is disabled', () => {
|
||||
mockUseFlag.mockReturnValue(false)
|
||||
mockUseIsHighAvailability.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useShowMultigresLogs())
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the project is not high availability, even with the flag on', () => {
|
||||
mockUseFlag.mockReturnValue(true)
|
||||
mockUseIsHighAvailability.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useShowMultigresLogs())
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when both the flag and high availability are off', () => {
|
||||
mockUseFlag.mockReturnValue(false)
|
||||
mockUseIsHighAvailability.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useShowMultigresLogs())
|
||||
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import { useFlag } from 'common'
|
||||
|
||||
import { useIsHighAvailability } from './useSelectedProject'
|
||||
|
||||
/**
|
||||
* Whether to surface the Multigres logs collection (sidebar, page, and Field
|
||||
* Reference source).
|
||||
*
|
||||
* Gated on both:
|
||||
* - the `multigresLogs` feature flag, so rollout is decoupled from HA status
|
||||
* and the feature ships dark until explicitly enabled, and
|
||||
* - the project's `high_availability` flag, since Multigres only runs on HA
|
||||
* projects.
|
||||
*
|
||||
* Note: `high_availability` is an existing product feature that predates
|
||||
* Multigres, so the flag is required to avoid showing a broken collection to
|
||||
* existing HA projects that don't have a `multigres_logs` table.
|
||||
* Whether to surface the Multigres logs collection (legacy logs sidebar, page,
|
||||
* Field Reference source, and the unified logs filter). Gated on the
|
||||
* `showMultigresLogs` feature flag so rollout is controlled centrally.
|
||||
*/
|
||||
export const useShowMultigresLogs = () => {
|
||||
const multigresLogsEnabled = useFlag('multigresLogs')
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
|
||||
return multigresLogsEnabled && isHighAvailability
|
||||
return useFlag('showMultigresLogs')
|
||||
}
|
||||
@@ -3370,6 +3370,7 @@ export interface UnifiedLogsRowClickedEvent {
|
||||
| 'realtime'
|
||||
| 'supavisor'
|
||||
| 'pgbouncer'
|
||||
| 'multigres'
|
||||
}
|
||||
groups: TelemetryGroups
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user