diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx index 36dd514026a..5e5493b7b23 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -26,6 +26,7 @@ export const LOG_TYPES_LABELS = { realtime: 'Realtime', supavisor: 'Supavisor', pgbouncer: 'PgBouncer', + multigres: 'Multigres', } type LogType = keyof typeof LOG_TYPES_LABELS diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts index 2968e77238b..5a5d6d7e5ab 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -64,6 +64,7 @@ const LOG_TYPE_CONDITION: Record = { 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` diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index c927d36fe83..9a034beb7a0 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -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(defaultColumnSorting) const [columnFilters, setColumnFilters] = useState(defaultColumnFilters) const [rowSelection, setRowSelection] = useState({}) @@ -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)) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts index 431ce1bb0b7..c31bb69b9b2 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts @@ -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' }) + }) +}) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts index 21421486332..ad43604bf36 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts @@ -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( + 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 + ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx index 20af0e5e7e3..4f43c6c660e 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -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('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 (
@@ -252,7 +255,7 @@ export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { )} {displayMessage && ( {displayMessage} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx index 73134ae74fe..d482821ee1a 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx @@ -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> = { realtime: Realtime, supavisor: Cable, pgbouncer: Cable, + multigres: Network, } export const LogTypeIcon = ({ diff --git a/apps/studio/hooks/misc/__tests__/useShowMultigresLogs.test.ts b/apps/studio/hooks/misc/__tests__/useShowMultigresLogs.test.ts index 07ebd250298..0dac534c708 100644 --- a/apps/studio/hooks/misc/__tests__/useShowMultigresLogs.test.ts +++ b/apps/studio/hooks/misc/__tests__/useShowMultigresLogs.test.ts @@ -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()), 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()) diff --git a/apps/studio/hooks/misc/useShowMultigresLogs.ts b/apps/studio/hooks/misc/useShowMultigresLogs.ts index 7975cafb22e..8f0fc0438e0 100644 --- a/apps/studio/hooks/misc/useShowMultigresLogs.ts +++ b/apps/studio/hooks/misc/useShowMultigresLogs.ts @@ -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') } diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 22cfaba8675..e7e463d84da 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3370,6 +3370,7 @@ export interface UnifiedLogsRowClickedEvent { | 'realtime' | 'supavisor' | 'pgbouncer' + | 'multigres' } groups: TelemetryGroups }