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:
Jordi EnricandClaude Opus 4.8 authored and GitHub committed 2026-07-10 16:38:41 +00:00
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())
+4 -18
View File
@@ -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')
}
+1
View File
@@ -3370,6 +3370,7 @@ export interface UnifiedLogsRowClickedEvent {
| 'realtime'
| 'supavisor'
| 'pgbouncer'
| 'multigres'
}
groups: TelemetryGroups
}