diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.test.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.test.tsx index a632e8d152334..582ed9cdc7e1c 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.test.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { ComponentProps } from 'react' +import { toast } from 'sonner' import { beforeEach, describe, expect, it, vi } from 'vitest' import { LogDrainDestinationSheetForm } from './LogDrainDestinationSheetForm' @@ -110,6 +111,37 @@ describe('LogDrainDestinationSheetForm', () => { expect(screen.getByRole('button', { name: 'Save destination' })).toBeInTheDocument() }) + it('blocks submission when the destination name matches an existing drain', async () => { + const user = userEvent.setup() + const { onSubmit } = renderForm({ existingDrainNames: ['existing-drain'] }) + + await screen.findByRole('dialog') + + await user.type(screen.getByPlaceholderText('My Destination'), 'existing-drain') + submitForm() + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Log drain name already exists')) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('invokes onSaveClick with the destination type when saving', async () => { + const user = userEvent.setup() + const onSaveClick = vi.fn() + const { onSubmit } = renderForm({ onSaveClick }) + + await screen.findByRole('dialog') + + await user.type(screen.getByPlaceholderText('My Destination'), 'Webhook sink') + await user.type( + screen.getByPlaceholderText('https://example.com/log-drain'), + 'https://logs.example.com/ingest' + ) + submitForm() + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) + expect(onSaveClick).toHaveBeenCalledWith('webhook') + }) + it('shows the protobuf content type header for OTLP create mode', async () => { renderForm({ defaultValues: { type: 'otlp' }, diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx index 7ea07ca59ee67..4b974b2c727a6 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { IS_PLATFORM, useFlag, useParams } from 'common' +import { IS_PLATFORM, useFlag } from 'common' import Link from 'next/link' import { ReactNode, useEffect, useMemo, useRef } from 'react' import { useForm } from 'react-hook-form' @@ -53,9 +53,8 @@ import { } from './LogDrains.utils' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' import { Shortcut } from '@/components/ui/Shortcut' -import { LogDrainData, useLogDrainsQuery } from '@/data/log-drains/log-drains-query' +import { LogDrainData } from '@/data/log-drains/log-drains-query' import { DOCS_URL } from '@/lib/constants' -import { useTrack } from '@/lib/telemetry/track' import { httpEndpointUrlSchema } from '@/lib/validation/http-url' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' @@ -263,7 +262,9 @@ type LogDrainDestinationSubmitValues = z.infer const HEADER_ENABLED_TYPES = ['webhook', 'loki', 'otlp'] as const -function toSubmitValues(values: LogDrainDestinationFormValues): LogDrainDestinationSubmitValues { +export function toSubmitValues( + values: LogDrainDestinationFormValues +): LogDrainDestinationSubmitValues { if (!HEADER_ENABLED_TYPES.includes(values.type as (typeof HEADER_ENABLED_TYPES)[number])) { return submitSchema.parse(values) } @@ -321,6 +322,8 @@ export function LogDrainDestinationSheetForm({ onSubmit, isLoading, mode, + existingDrainNames = [], + onSaveClick, }: { open: boolean onOpenChange: (v: boolean) => void @@ -328,6 +331,8 @@ export function LogDrainDestinationSheetForm({ isLoading?: boolean onSubmit: (values: LogDrainDestinationSubmitValues) => void mode: 'create' | 'update' + existingDrainNames?: string[] + onSaveClick?: (type: LogDrainType) => void }) { // NOTE(kamil): This used to be `any` for a long long time, but after moving to Zod, // it produces a correct union type of all possible configs. Unfortunately, this type was not designed correctly @@ -349,12 +354,6 @@ export function LogDrainDestinationSheetForm({ const last9Enabled = useFlag('Last9LogDrain') const syslogEnabled = useFlag('syslogLogDrain') - const { ref } = useParams() - const { data: logDrains } = useLogDrainsQuery({ - ref, - }) - - const track = useTrack() const formRef = useRef(null) const formValues = useMemo(() => { @@ -435,17 +434,16 @@ export function LogDrainDestinationSheetForm({ // Temp check to make sure the name is unique const logDrainName = form.getValues('name') - const logDrainExists = - !!logDrains?.length && logDrains?.find((drain) => drain.name === logDrainName) + const logDrainExists = existingDrainNames.includes(logDrainName) if (logDrainExists && mode === 'create') { toast.error('Log drain name already exists') return } - form.handleSubmit((values) => onSubmit(toSubmitValues(values)))(e) - track('log_drain_save_button_clicked', { - destination: form.getValues('type'), - }) + form.handleSubmit((values) => { + onSubmit(toSubmitValues(values)) + onSaveClick?.(values.type) + })(e) }} >
diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx index 0c773fc5b50b9..a4bb37db5f2a6 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx @@ -1,30 +1,10 @@ -import { IS_PLATFORM, useFlag, useParams } from 'common' -import { MoreHorizontal, TrashIcon } from 'lucide-react' -import { cloneElement, useState } from 'react' +import { useParams } from 'common' import { toast } from 'sonner' -import { - Button, - Card, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants' -import { LogDrainsCard } from './LogDrainsCard' +import { LogDrainType } from './LogDrains.constants' import { LogDrainsEmpty } from './LogDrainsEmpty' -import { VoteLink } from './VoteLink' -import AlertError from '@/components/ui/AlertError' +import { LogDrainsList } from './LogDrainsList' import { useDeleteLogDrainMutation } from '@/data/log-drains/delete-log-drain-mutation' import { LogDrainData, useLogDrainsQuery } from '@/data/log-drains/log-drains-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' @@ -37,44 +17,25 @@ export function LogDrains({ onNewDrainClick: (src: LogDrainType) => void onUpdateDrainClick: (drain: LogDrainData) => void }) { + const { ref } = useParams() + const track = useTrack() const { hasAccess: hasAccessToLogDrains, isLoading: isLoadingEntitlement } = useCheckEntitlements('log_drains') - const track = useTrack() - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) - const [selectedLogDrain, setSelectedLogDrain] = useState(null) - const { ref } = useParams() + const { data: logDrains, isPending: isLoading, error, isError, - } = useLogDrainsQuery( - { ref }, - { - enabled: hasAccessToLogDrains, - } - ) - const sentryEnabled = useFlag('SentryLogDrain') - const s3Enabled = useFlag('S3logdrain') - const axiomEnabled = useFlag('axiomLogDrain') - const otlpEnabled = useFlag('otlpLogDrain') - const last9Enabled = useFlag('Last9LogDrain') - const syslogEnabled = useFlag('syslogLogDrain') - const hasLogDrains = !!logDrains?.length + } = useLogDrainsQuery({ ref }, { enabled: hasAccessToLogDrains }) - const { mutate: deleteLogDrain } = useDeleteLogDrainMutation({ - onSuccess: () => { - setIsDeleteModalOpen(false) - setSelectedLogDrain(null) - }, + const { mutate: deleteLogDrain, isPending: isDeleting } = useDeleteLogDrainMutation({ onError: () => { - setIsDeleteModalOpen(false) - setSelectedLogDrain(null) toast.error('Failed to delete log drain') }, }) - if (isLoading || isLoadingEntitlement) { + if (isLoadingEntitlement) { return (
@@ -82,151 +43,24 @@ export function LogDrains({ ) } - if (!isLoadingEntitlement && !hasAccessToLogDrains) { + if (!hasAccessToLogDrains) { return } - if (isError) { - return - } - - if (!isLoading && !hasLogDrains) { - return ( - <> -
- {LOG_DRAIN_TYPES.filter((t) => { - if (t.value === 'sentry') return sentryEnabled - if (t.value === 's3') return s3Enabled - if (t.value === 'axiom') return axiomEnabled - if (t.value === 'otlp') return otlpEnabled - if (t.value === 'last9') return last9Enabled - if (t.value === 'syslog') return syslogEnabled - return true - }).map((src) => ( - { - onNewDrainClick(src.value) - }} - /> - ))} -
- - - ) - } - return ( - <> - - - - - Name - Description - Destination - -
Actions
-
-
-
- - {logDrains - ?.slice() - .sort((a, b) => b.id - a.id) - .map((drain) => ( - - - {drain.name} - - - {drain.description || '-'} - - -
- {LOG_DRAIN_TYPES.find((t) => t.value === drain.type)?.icon && ( - - {cloneElement(LOG_DRAIN_TYPES.find((t) => t.value === drain.type)!.icon, { - height: 16, - width: 16, - })} - - )} - - {LOG_DRAIN_TYPES.find((t) => t.value === drain.type)?.name ?? drain.type} - -
-
- - - -
-
- + { + if (ref) { + deleteLogDrain({ token: drain.token, projectRef: ref }) + track('log_drain_removed', { destination: drain.type }) + } + }} + /> ) } diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainsList.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainsList.tsx new file mode 100644 index 0000000000000..10946a807493b --- /dev/null +++ b/apps/studio/components/interfaces/LogDrains/LogDrainsList.tsx @@ -0,0 +1,202 @@ +import { IS_PLATFORM } from 'common' +import { MoreHorizontal, PlugZap, TrashIcon } from 'lucide-react' +import { cloneElement, useEffect, useRef, useState } from 'react' +import { + Button, + Card, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants' +import { LogDrainsCard } from './LogDrainsCard' +import { useEnabledLogDrainTypes } from './useEnabledLogDrainTypes' +import { VoteLink } from './VoteLink' +import AlertError from '@/components/ui/AlertError' +import { LogDrainData } from '@/data/log-drains/log-drains-query' +import type { ResponseError } from '@/types' + +export function LogDrainsList({ + logDrains, + isLoading, + isError, + error, + isDeleting, + onNewDrainClick, + onDeleteDrain, + onTestDrain, +}: { + logDrains: LogDrainData[] | undefined + isLoading: boolean + isError: boolean + error: ResponseError | null + isDeleting?: boolean + onNewDrainClick: (src: LogDrainType) => void + onDeleteDrain: (drain: LogDrainData) => void + onTestDrain?: (drain: LogDrainData) => void +}) { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [selectedLogDrain, setSelectedLogDrain] = useState(null) + + const enabledDrainTypes = useEnabledLogDrainTypes() + const hasLogDrains = !!logDrains?.length + + const wasDeleting = useRef(false) + useEffect(() => { + if (wasDeleting.current && !isDeleting) { + setIsDeleteModalOpen(false) + setSelectedLogDrain(null) + } + wasDeleting.current = !!isDeleting + }, [isDeleting]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError) { + return + } + + if (!hasLogDrains) { + return ( + <> +
+ {enabledDrainTypes.map((src) => ( + { + onNewDrainClick(src.value) + }} + /> + ))} +
+ + + ) + } + + return ( + <> + + + + + Name + Description + Destination + +
Actions
+
+
+
+ + {logDrains + ?.slice() + .sort((a, b) => b.id - a.id) + .map((drain) => ( + + + {drain.name} + + + {drain.description || '-'} + + +
+ {LOG_DRAIN_TYPES.find((t) => t.value === drain.type)?.icon && ( + + {cloneElement(LOG_DRAIN_TYPES.find((t) => t.value === drain.type)!.icon, { + height: 16, + width: 16, + })} + + )} + + {LOG_DRAIN_TYPES.find((t) => t.value === drain.type)?.name ?? drain.type} + +
+
+ + + +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx b/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx new file mode 100644 index 0000000000000..ef1a849ce07a5 --- /dev/null +++ b/apps/studio/components/interfaces/LogDrains/OrgAuditLogDrains.tsx @@ -0,0 +1,285 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { IS_PLATFORM, useParams } from 'common' +import { ChevronDown } from 'lucide-react' +import { cloneElement, useState } from 'react' +import { toast } from 'sonner' +import { + Alert, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + +import { LogDrainDestinationSheetForm } from './LogDrainDestinationSheetForm' +import { LogDrainType } from './LogDrains.constants' +import { LogDrainsList } from './LogDrainsList' +import { useEnabledLogDrainTypes } from './useEnabledLogDrainTypes' +import { Shortcut } from '@/components/ui/Shortcut' +import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' +import { useAuditLogDrainsQuery } from '@/data/log-drains/audit-log-drains-query' +import { + AuditLogDrainConfig, + AuditLogDrainCreateVariables, + useCreateAuditLogDrainMutation, +} from '@/data/log-drains/create-audit-log-drain-mutation' +import { useDeleteAuditLogDrainMutation } from '@/data/log-drains/delete-audit-log-drain-mutation' +import { LogDrainData } from '@/data/log-drains/log-drains-query' +import { useTestAuditLogDrainMutation } from '@/data/log-drains/test-audit-log-drain-mutation' +import { useUpdateAuditLogDrainMutation } from '@/data/log-drains/update-audit-log-drain-mutation' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useTrack } from '@/lib/telemetry/track' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' + +export function OrgAuditLogDrains() { + const { slug } = useParams() as { slug: string } + const track = useTrack() + + const { can: canManageLogDrains, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.ANALYTICS_ADMIN_WRITE, + 'logflare' + ) + + const { hasAccess: hasAccessToLogDrains, isLoading: isLoadingEntitlement } = useCheckEntitlements( + 'audit_log_drains', + slug + ) + + const [open, setOpen] = useState(false) + const [mode, setMode] = useState<'create' | 'update'>('create') + const [selectedLogDrain, setSelectedLogDrain] = useState | null>(null) + const [isCreateConfirmModalOpen, setIsCreateConfirmModalOpen] = useState(false) + const [pendingLogDrainValues, setPendingLogDrainValues] = + useState(null) + + const enabledDrainTypes = useEnabledLogDrainTypes() + + const { + data: logDrains, + isPending: isLoadingDrains, + error, + isError, + } = useAuditLogDrainsQuery({ slug }, { enabled: !isLoadingEntitlement && hasAccessToLogDrains }) + + const { mutate: createLogDrain, isPending: createLoading } = useCreateAuditLogDrainMutation({ + onSuccess: () => { + toast.success('Audit log drain destination created') + setPendingLogDrainValues(null) + setIsCreateConfirmModalOpen(false) + setOpen(false) + }, + onError: () => { + toast.error('Failed to create audit log drain') + }, + }) + + const { mutate: updateLogDrain, isPending: updateLoading } = useUpdateAuditLogDrainMutation({ + onSuccess: () => { + toast.success('Audit log drain updated') + setOpen(false) + }, + onError: () => { + setOpen(false) + toast.error('Failed to update audit log drain') + }, + }) + + const { mutate: deleteLogDrain, isPending: isDeleting } = useDeleteAuditLogDrainMutation({ + onError: () => { + toast.error('Failed to delete audit log drain') + }, + }) + + const { mutate: testLogDrain } = useTestAuditLogDrainMutation({ + onSuccess: () => { + toast.success('Audit log drain connection test succeeded') + }, + }) + + const isLoading = createLoading || updateLoading + + function handleNewClick(src: LogDrainType) { + setSelectedLogDrain({ type: src }) + setMode('create') + setOpen(true) + } + + function handleAddDestinationClick() { + setSelectedLogDrain(null) + setMode('create') + setOpen(true) + } + + if (isLoadingPermissions || isLoadingEntitlement) { + return + } + + if (!hasAccessToLogDrains) { + return ( +
+
+

Audit log drains are not available on your plan

+

+ Upgrade to a Team or Enterprise Plan to export your organization audit logs to your + preferred destination. +

+
+ +
+ ) + } + + if (!canManageLogDrains) { + return You do not have permission to manage audit log drains + } + + return ( +
+ {!!logDrains?.length && ( +
+
+ + + + + +
+
+ )} + + { + if (!v) { + setSelectedLogDrain(null) + } + setOpen(v) + }} + defaultValues={{ + ...selectedLogDrain, + type: selectedLogDrain?.type ? selectedLogDrain.type : 'webhook', + }} + isLoading={isLoading} + existingDrainNames={(logDrains ?? []).map((drain) => drain.name)} + onSaveClick={(type) => { + track('log_drain_save_button_clicked', { destination: type }) + }} + onSubmit={({ name, description, type, ...values }) => { + const logDrainValues = { + name, + description: description || '', + type, + config: values as AuditLogDrainConfig, + id: selectedLogDrain?.id, + slug, + token: selectedLogDrain?.token, + } + + if (mode === 'create') { + setPendingLogDrainValues(logDrainValues) + setIsCreateConfirmModalOpen(true) + } else { + if (!selectedLogDrain?.token) { + toast.error('Audit log drain token is required') + return + } + updateLogDrain(logDrainValues) + } + }} + /> + + { + deleteLogDrain({ token: drain.token, slug }) + track('log_drain_removed', { destination: drain.type }) + }} + onTestDrain={(drain) => testLogDrain({ token: drain.token, slug })} + /> + + { + if (pendingLogDrainValues && !createLoading) { + createLogDrain(pendingLogDrainValues) + } + }} + onCancel={() => { + setIsCreateConfirmModalOpen(false) + setPendingLogDrainValues(null) + }} + > +
+

+ You are about to create a new audit log drain destination:{' '} + {pendingLogDrainValues?.name} +

+ {IS_PLATFORM && ( +

+ This will incur an additional $60 per month{' '} + charge to your subscription. +

+ )} +

Are you sure you want to proceed?

+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/LogDrains/useEnabledLogDrainTypes.ts b/apps/studio/components/interfaces/LogDrains/useEnabledLogDrainTypes.ts new file mode 100644 index 0000000000000..a722f15187c6a --- /dev/null +++ b/apps/studio/components/interfaces/LogDrains/useEnabledLogDrainTypes.ts @@ -0,0 +1,22 @@ +import { useFlag } from 'common' + +import { LOG_DRAIN_TYPES } from './LogDrains.constants' + +export function useEnabledLogDrainTypes() { + const sentryEnabled = useFlag('SentryLogDrain') + const s3Enabled = useFlag('S3logdrain') + const axiomEnabled = useFlag('axiomLogDrain') + const otlpEnabled = useFlag('otlpLogDrain') + const last9Enabled = useFlag('Last9LogDrain') + const syslogEnabled = useFlag('syslogLogDrain') + + return LOG_DRAIN_TYPES.filter((t) => { + if (t.value === 'sentry') return sentryEnabled + if (t.value === 's3') return s3Enabled + if (t.value === 'axiom') return axiomEnabled + if (t.value === 'otlp') return otlpEnabled + if (t.value === 'last9') return last9Enabled + if (t.value === 'syslog') return syslogEnabled + return true + }) +} diff --git a/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx b/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx index 0c2a1cf5a14ea..ca4ef9ef2ccdf 100644 --- a/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx @@ -18,6 +18,7 @@ interface OrganizationSettingsMenuItemsProps { showLegalDocuments?: boolean showPlatformWebhooks?: boolean showPrivateApps?: boolean + showAuditLogDrains?: boolean } interface OrganizationSettingsSectionsProps extends OrganizationSettingsMenuItemsProps { @@ -33,6 +34,7 @@ export const generateOrganizationSettingsMenuItems = ({ showLegalDocuments = true, showPlatformWebhooks = true, showPrivateApps: _showPrivateApps = false, + showAuditLogDrains = false, }: OrganizationSettingsMenuItemsProps) => [ { key: 'general', @@ -76,6 +78,15 @@ export const generateOrganizationSettingsMenuItems = ({ label: 'Audit Logs', href: `/org/${slug}/audit`, }, + ...(showAuditLogDrains + ? [ + { + key: 'audit-log-drains', + label: 'Audit Log Drains', + href: `/org/${slug}/audit-log-drains`, + }, + ] + : []), ...(showLegalDocuments ? [ { @@ -95,6 +106,7 @@ export const generateOrganizationSettingsSections = ({ showLegalDocuments = true, showPlatformWebhooks = true, showPrivateApps = false, + showAuditLogDrains = false, }: OrganizationSettingsSectionsProps): SidebarSection[] => { const isLinkActive = (key: string, href: string) => key === 'webhooks' @@ -166,6 +178,16 @@ export const generateOrganizationSettingsSections = ({ href: `/org/${slug}/audit`, shortcutId: SHORTCUT_IDS.NAV_ORG_SETTINGS_AUDIT, }, + ...(showAuditLogDrains + ? [ + { + key: 'audit-log-drains', + label: 'Audit Log Drains', + href: `/org/${slug}/audit-log-drains`, + shortcutId: SHORTCUT_IDS.NAV_ORG_SETTINGS_AUDIT_LOG_DRAINS, + }, + ] + : []), ...(showLegalDocuments ? [ { @@ -210,6 +232,7 @@ export function OrganizationSettingsLayout({ children }: PropsWithChildren) { const { slug } = useParams() const showPlatformWebhooks = useIsPlatformWebhooksEnabled() const showPrivateApps = useFlag('privateApps') + const showAuditLogDrains = useFlag('auditLogsLogDrain') const fullCurrentPath = useCurrentPath() const currentPath = normalizeOrganizationSettingsPath(fullCurrentPath) @@ -231,6 +254,7 @@ export function OrganizationSettingsLayout({ children }: PropsWithChildren) { showLegalDocuments, showPlatformWebhooks, showPrivateApps, + showAuditLogDrains, }) const orgSettingsMenu = useMemo( diff --git a/apps/studio/data/log-drains/audit-log-drains-query.ts b/apps/studio/data/log-drains/audit-log-drains-query.ts new file mode 100644 index 0000000000000..9e65a789cde85 --- /dev/null +++ b/apps/studio/data/log-drains/audit-log-drains-query.ts @@ -0,0 +1,51 @@ +import { useQuery } from '@tanstack/react-query' + +import { logDrainsKeys } from './keys' +import { LogDrainData } from './log-drains-query' +import { get, handleError } from '@/data/fetchers' +import { MAX_RETRY_FAILURE_COUNT } from '@/data/query-client' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type AuditLogDrainsVariables = { + slug?: string +} + +export async function getAuditLogDrains({ slug }: AuditLogDrainsVariables, signal?: AbortSignal) { + if (!slug) { + throw new Error('slug is required') + } + + const { data, error } = await get('/platform/organizations/{slug}/analytics/audit-log-drains', { + params: { path: { slug } }, + signal, + }) + + if (error) handleError(error) + + return (data ?? []) as LogDrainData[] +} + +export type AuditLogDrainsData = LogDrainData[] +export type AuditLogDrainsError = ResponseError + +export const useAuditLogDrainsQuery = ( + { slug }: AuditLogDrainsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: logDrainsKeys.auditList(slug), + queryFn: ({ signal }) => getAuditLogDrains({ slug }, signal), + enabled: enabled && !!slug, + refetchOnMount: false, + retry: (failureCount, error) => { + if (error.code === 500 || error.message.includes('API error happened')) return false + if (failureCount < MAX_RETRY_FAILURE_COUNT) { + return true + } + return false + }, + ...options, + }) diff --git a/apps/studio/data/log-drains/audit-log-drains.test.tsx b/apps/studio/data/log-drains/audit-log-drains.test.tsx new file mode 100644 index 0000000000000..b1e3d300272a3 --- /dev/null +++ b/apps/studio/data/log-drains/audit-log-drains.test.tsx @@ -0,0 +1,74 @@ +import { waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' + +import { useAuditLogDrainsQuery } from './audit-log-drains-query' +import { useCreateAuditLogDrainMutation } from './create-audit-log-drain-mutation' +import { useDeleteAuditLogDrainMutation } from './delete-audit-log-drain-mutation' +import { API_URL } from '@/lib/constants' +import { customRenderHook } from '@/tests/lib/custom-render' +import { mswServer } from '@/tests/lib/msw' + +const SLUG = 'my-org' +const BASE = `${API_URL}/platform/organizations/:slug/analytics/audit-log-drains` + +const DRAIN = { + id: 1, + token: 'tok-1', + name: 'Audit drain', + description: '', + type: 'webhook', + config: { url: 'https://example.com' }, +} + +describe('org audit log drain hooks', () => { + it('lists audit log drains for an organization', async () => { + mswServer.use(http.get(BASE, () => HttpResponse.json([DRAIN]))) + + const { result } = customRenderHook(() => useAuditLogDrainsQuery({ slug: SLUG })) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toHaveLength(1) + expect(result.current.data?.[0].name).toBe('Audit drain') + }) + + it('creates an audit log drain at the org-scoped path', async () => { + let receivedSlug: string | undefined + mswServer.use( + http.post(BASE, ({ params }) => { + receivedSlug = params.slug as string + return HttpResponse.json(DRAIN) + }) + ) + + const { result } = customRenderHook(() => useCreateAuditLogDrainMutation()) + + result.current.mutate({ + slug: SLUG, + name: 'Audit drain', + description: '', + type: 'webhook', + config: {} as any, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(receivedSlug).toBe(SLUG) + }) + + it('deletes an audit log drain by token', async () => { + let receivedToken: string | undefined + mswServer.use( + http.delete(`${BASE}/:token`, ({ params }) => { + receivedToken = params.token as string + return new HttpResponse(null, { status: 204 }) + }) + ) + + const { result } = customRenderHook(() => useDeleteAuditLogDrainMutation()) + + result.current.mutate({ slug: SLUG, token: 'tok-1' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(receivedToken).toBe('tok-1') + }) +}) diff --git a/apps/studio/data/log-drains/create-audit-log-drain-mutation.ts b/apps/studio/data/log-drains/create-audit-log-drain-mutation.ts new file mode 100644 index 0000000000000..de5518bbb2674 --- /dev/null +++ b/apps/studio/data/log-drains/create-audit-log-drain-mutation.ts @@ -0,0 +1,65 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { logDrainsKeys } from './keys' +import { LogDrainType } from '@/components/interfaces/LogDrains/LogDrains.constants' +import type { components } from '@/data/api' +import { handleError, post } from '@/data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type AuditLogDrainConfig = components['schemas']['CreateBackendParamsOpenapi']['config'] + +export type AuditLogDrainCreateVariables = { + slug: string + name: string + description: string + config: AuditLogDrainConfig + type: LogDrainType +} + +export async function createAuditLogDrain(payload: AuditLogDrainCreateVariables) { + const { data, error } = await post('/platform/organizations/{slug}/analytics/audit-log-drains', { + params: { path: { slug: payload.slug } }, + body: { + name: payload.name, + description: payload.description, + type: payload.type, + config: payload.config, + }, + }) + + if (error) handleError(error) + return data +} + +type AuditLogDrainCreateData = Awaited> + +export const useCreateAuditLogDrainMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => createAuditLogDrain(vars), + async onSuccess(data, variables, context) { + const { slug } = variables + + await queryClient.invalidateQueries({ queryKey: logDrainsKeys.auditList(slug) }) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to mutate: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/log-drains/delete-audit-log-drain-mutation.ts b/apps/studio/data/log-drains/delete-audit-log-drain-mutation.ts new file mode 100644 index 0000000000000..67897d1fc300d --- /dev/null +++ b/apps/studio/data/log-drains/delete-audit-log-drain-mutation.ts @@ -0,0 +1,55 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { logDrainsKeys } from './keys' +import { del, handleError } from '@/data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type AuditLogDrainDeleteVariables = { + slug: string + token: string +} + +export async function deleteAuditLogDrain({ slug, token }: AuditLogDrainDeleteVariables) { + const { data, error } = await del( + '/platform/organizations/{slug}/analytics/audit-log-drains/{token}', + { + params: { path: { slug, token } }, + } + ) + + if (error) handleError(error) + return data +} + +type AuditLogDrainDeleteData = Awaited> + +export const useDeleteAuditLogDrainMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deleteAuditLogDrain(vars), + async onSuccess(data, variables, context) { + const { slug } = variables + + await queryClient.invalidateQueries({ queryKey: logDrainsKeys.auditList(slug) }) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to mutate: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/log-drains/keys.ts b/apps/studio/data/log-drains/keys.ts index afc0219b7eece..288a0b892d69a 100644 --- a/apps/studio/data/log-drains/keys.ts +++ b/apps/studio/data/log-drains/keys.ts @@ -1,3 +1,4 @@ export const logDrainsKeys = { list: (projectRef: string | undefined) => ['projects', projectRef, 'log-drains'] as const, + auditList: (slug: string | undefined) => ['organizations', slug, 'audit-log-drains'] as const, } diff --git a/apps/studio/data/log-drains/test-audit-log-drain-mutation.ts b/apps/studio/data/log-drains/test-audit-log-drain-mutation.ts new file mode 100644 index 0000000000000..2ced95d2d59ae --- /dev/null +++ b/apps/studio/data/log-drains/test-audit-log-drain-mutation.ts @@ -0,0 +1,48 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from '@/data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type AuditLogDrainTestVariables = { + slug: string + token: string +} + +export async function testAuditLogDrain({ slug, token }: AuditLogDrainTestVariables) { + const { data, error } = await post( + '/platform/organizations/{slug}/analytics/audit-log-drains/{token}/test', + { + params: { path: { slug, token } }, + } + ) + + if (error) handleError(error) + return data +} + +type AuditLogDrainTestData = Awaited> + +export const useTestAuditLogDrainMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation({ + mutationFn: (vars) => testAuditLogDrain(vars), + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to test log drain: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/log-drains/update-audit-log-drain-mutation.ts b/apps/studio/data/log-drains/update-audit-log-drain-mutation.ts new file mode 100644 index 0000000000000..eb97f751d7f54 --- /dev/null +++ b/apps/studio/data/log-drains/update-audit-log-drain-mutation.ts @@ -0,0 +1,71 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { AuditLogDrainConfig } from './create-audit-log-drain-mutation' +import { logDrainsKeys } from './keys' +import { LogDrainType } from '@/components/interfaces/LogDrains/LogDrains.constants' +import { handleError, put } from '@/data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type AuditLogDrainUpdateVariables = { + slug: string + token?: string + name: string + description?: string + type: LogDrainType + config: AuditLogDrainConfig +} + +export async function updateAuditLogDrain(payload: AuditLogDrainUpdateVariables) { + if (!payload.token) { + throw new Error('Token is required') + } + + const { data, error } = await put( + '/platform/organizations/{slug}/analytics/audit-log-drains/{token}', + { + params: { path: { slug: payload.slug, token: payload.token } }, + body: { + name: payload.name, + description: payload.description, + type: payload.type, + config: payload.config, + }, + } + ) + + if (error) handleError(error) + return data +} + +type AuditLogDrainUpdateData = Awaited> + +export const useUpdateAuditLogDrainMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => updateAuditLogDrain(vars), + async onSuccess(data, variables, context) { + const { slug } = variables + + await queryClient.invalidateQueries({ queryKey: logDrainsKeys.auditList(slug) }) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to mutate: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/pages/org/[slug]/audit-log-drains.tsx b/apps/studio/pages/org/[slug]/audit-log-drains.tsx new file mode 100644 index 0000000000000..30dac6633616a --- /dev/null +++ b/apps/studio/pages/org/[slug]/audit-log-drains.tsx @@ -0,0 +1,53 @@ +import { IS_PLATFORM, useFlag, useParams } from 'common' +import { PageContainer } from 'ui-patterns/PageContainer' +import { + PageHeader, + PageHeaderDescription, + PageHeaderMeta, + PageHeaderSummary, + PageHeaderTitle, +} from 'ui-patterns/PageHeader' + +import { OrgAuditLogDrains } from '@/components/interfaces/LogDrains/OrgAuditLogDrains' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import OrganizationLayout from '@/components/layouts/OrganizationLayout' +import { OrganizationSettingsLayout } from '@/components/layouts/ProjectLayout/OrganizationSettingsLayout' +import { UnknownInterface } from '@/components/ui/UnknownInterface' +import type { NextPageWithLayout } from '@/types' + +const OrgAuditLogDrainsPage: NextPageWithLayout = () => { + const { slug } = useParams() + const showAuditLogDrains = useFlag('auditLogsLogDrain') + + if (!IS_PLATFORM || !showAuditLogDrains) { + return + } + + return ( + <> + + + + Audit Log Drains + + Export your organization audit logs to third party destinations + + + + + + + + + ) +} + +OrgAuditLogDrainsPage.getLayout = (page) => ( + + + {page} + + +) + +export default OrgAuditLogDrainsPage diff --git a/apps/studio/pages/project/[ref]/settings/log-drains.tsx b/apps/studio/pages/project/[ref]/settings/log-drains.tsx index 1db356abac728..f2ede5591303d 100644 --- a/apps/studio/pages/project/[ref]/settings/log-drains.tsx +++ b/apps/studio/pages/project/[ref]/settings/log-drains.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { IS_PLATFORM, useFlag, useParams } from 'common' +import { IS_PLATFORM, useParams } from 'common' import { ChevronDown } from 'lucide-react' import { cloneElement, useState, type ReactElement } from 'react' import { toast } from 'sonner' @@ -16,10 +16,8 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { LogDrainDestinationSheetForm } from '@/components/interfaces/LogDrains/LogDrainDestinationSheetForm' import { LogDrains } from '@/components/interfaces/LogDrains/LogDrains' -import { - LOG_DRAIN_TYPES, - LogDrainType, -} from '@/components/interfaces/LogDrains/LogDrains.constants' +import { LogDrainType } from '@/components/interfaces/LogDrains/LogDrains.constants' +import { useEnabledLogDrainTypes } from '@/components/interfaces/LogDrains/useEnabledLogDrainTypes' import DefaultLayout from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' @@ -35,6 +33,7 @@ import { useUpdateLogDrainMutation } from '@/data/log-drains/update-log-drain-mu import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { DOCS_URL } from '@/lib/constants' +import { useTrack } from '@/lib/telemetry/track' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import type { NextPageWithLayout } from '@/types' @@ -44,6 +43,7 @@ const LogDrainsSettings: NextPageWithLayout = () => { 'logflare' ) + const track = useTrack() const [open, setOpen] = useState(false) const { ref } = useParams() as { ref: string } const [selectedLogDrain, setSelectedLogDrain] = useState | null>(null) @@ -55,12 +55,7 @@ const LogDrainsSettings: NextPageWithLayout = () => { const { hasAccess: hasAccessToLogDrains, isLoading: isLoadingEntitlement } = useCheckEntitlements('log_drains') - const sentryEnabled = useFlag('SentryLogDrain') - const s3Enabled = useFlag('S3logdrain') - const axiomEnabled = useFlag('axiomLogDrain') - const otlpEnabled = useFlag('otlpLogDrain') - const last9Enabled = useFlag('Last9LogDrain') - const syslogEnabled = useFlag('syslogLogDrain') + const enabledDrainTypes = useEnabledLogDrainTypes() const { data: logDrains } = useLogDrainsQuery( { ref }, @@ -128,6 +123,10 @@ const LogDrainsSettings: NextPageWithLayout = () => { type: selectedLogDrain?.type ? selectedLogDrain.type : 'webhook', }} isLoading={isLoading} + existingDrainNames={(logDrains ?? []).map((drain) => drain.name)} + onSaveClick={(type) => { + track('log_drain_save_button_clicked', { destination: type }) + }} onSubmit={({ name, description, type, ...values }) => { const logDrainValues = { name, @@ -144,10 +143,10 @@ const LogDrainsSettings: NextPageWithLayout = () => { setIsCreateConfirmModalOpen(true) } else { if (!logDrainValues.id || !selectedLogDrain?.token) { - throw new Error('Log drain ID and token is required') - } else { - updateLogDrain(logDrainValues) + toast.error('Unable to update log drain: missing ID or token') + return } + updateLogDrain(logDrainValues) } }} /> @@ -231,15 +230,7 @@ const LogDrainsSettings: NextPageWithLayout = () => { /> - {LOG_DRAIN_TYPES.filter((t) => { - if (t.value === 'sentry') return sentryEnabled - if (t.value === 's3') return s3Enabled - if (t.value === 'axiom') return axiomEnabled - if (t.value === 'otlp') return otlpEnabled - if (t.value === 'last9') return last9Enabled - if (t.value === 'syslog') return syslogEnabled - return true - }).map((drainType) => ( + {enabledDrainTypes.map((drainType) => ( handleNewClick(drainType.value)} diff --git a/apps/studio/state/shortcuts/registry/org-settings-nav.ts b/apps/studio/state/shortcuts/registry/org-settings-nav.ts index df31254acb38b..0669ffb57ecf2 100644 --- a/apps/studio/state/shortcuts/registry/org-settings-nav.ts +++ b/apps/studio/state/shortcuts/registry/org-settings-nav.ts @@ -16,6 +16,7 @@ export const ORG_SETTINGS_NAV_SHORTCUT_IDS = { NAV_ORG_SETTINGS_PRIVATE_APPS: 'nav.org-settings-private-apps', NAV_ORG_SETTINGS_WEBHOOKS: 'nav.org-settings-webhooks', NAV_ORG_SETTINGS_AUDIT: 'nav.org-settings-audit', + NAV_ORG_SETTINGS_AUDIT_LOG_DRAINS: 'nav.org-settings-audit-log-drains', NAV_ORG_SETTINGS_DOCUMENTS: 'nav.org-settings-documents', } as const @@ -79,6 +80,14 @@ export const orgSettingsNavRegistry: RegistryDefinations { SHORTCUT_IDS.NAV_ORG_SETTINGS_PRIVATE_APPS, SHORTCUT_IDS.NAV_ORG_SETTINGS_WEBHOOKS, SHORTCUT_IDS.NAV_ORG_SETTINGS_AUDIT, + SHORTCUT_IDS.NAV_ORG_SETTINGS_AUDIT_LOG_DRAINS, SHORTCUT_IDS.NAV_ORG_SETTINGS_DOCUMENTS, ] diff --git a/apps/www/_blog/2026-06-04-multigres-v0-1-alpha.mdx b/apps/www/_blog/2026-06-04-multigres-v0-1-alpha.mdx new file mode 100644 index 0000000000000..cbb130de63cb7 --- /dev/null +++ b/apps/www/_blog/2026-06-04-multigres-v0-1-alpha.mdx @@ -0,0 +1,81 @@ +--- +title: 'Multigres v0.1 Alpha: an operating system for Postgres' +description: "Today we're releasing Multigres v0.1 alpha to the open source community, bringing Vitess-grade horizontal scaling, high availability, and operational simplicity to Postgres." +author: sugu_sougoumarane +imgSocial: '2026-06-01-multigres-v0-1-alpha/og.jpg' +imgThumb: '2026-06-01-multigres-v0-1-alpha/thumb.jpg' +categories: + - product + - postgres +tags: + - postgres +date: '2026-06-04' +toc_depth: 3 +--- + +Today we're releasing [Multigres v0.1 alpha](https://github.com/multigres/multigres/releases/tag/v0.1.0) to the open source community, the first public milestone of our mission to bring Vitess-grade horizontal scaling, high availability, and operational simplicity to Postgres. It's early, it's alpha, and we're excited to get it into your hands. + +This is an open-source-only release. Multigres for Supabase is coming soon. + +## What is Multigres? + +Multigres is a scalable operating system for Postgres: it holistically manages your Postgres instances and gives you sharding, connection pooling, automatic failover, and backup orchestration. + +Postgres at scale is operationally complex: You need to manage read replicas, failovers, connection limits, backups, and more. Multigres handles these chores as a single cohesive system. And when the time comes to scale, Multigres will help you shard your database and scale it horizontally. + +The v0.1 alpha introduces advanced connection pooling, automatic failovers, and a Kubernetes operator for deployment. + +## The Multigres Operator + +The Kubernetes Multigres Operator allows you to deploy and manage Multigres clusters on Kubernetes. To [get started](https://multigres.com/blog/deploying-the-multigres-operator), you need a Kubernetes cluster along with a location for backups configured. The backups can be a shared file system or a cloud storage bucket like AWS S3. It is also possible to run Multigres on a local Kind cluster. + +All the necessary images for running Multigres are publicly available. + +## High Availability + +Multigres treats HA as a consensus problem, and is capable of resolving split-brain scenarios without losing commits that have succeeded. The protocol implemented by Multigres is based on generalized consensus, a model that gives you flexibilities that do not exist in traditional consensus-based systems: + +- **Built on top of Postgres replication**: The protocol uses unmodified Postgres replication, and yet satisfies all the strict consistency requirements of a consensus-based system. +- **User-defined durability policies**: You can define arbitrarily complex durability policies. This flexibility allows you to specify the failures you are willing to tolerate without being constrained by restrictive rules like a majority quorum. For example, if you'd like your data to survive the failure of a single AZ, you can set your durability policy to be cross-zone, but still have standbys deployed in more than three zones. +- **Add or remove replicas without affecting performance**: You can safely scale replicas up and down while the cluster is running. Multigres will continue to honor the durability policy you've configured without affecting performance, while also maintaining correctness. + +For more information on HA, you may read the following posts: + +- [High availability from first principles](https://multigres.com/blog/high-availability-from-first-principles) +- [Handling edge cases using consensus](https://multigres.com/blog/handling-edge-cases-using-consensus) + +## Connection Pooling + +Multigres ships its own connection pooling solution using a two-service architecture. It consists of a **multigateway** that accepts client connections and routes queries, and a **multipooler** that manages backend connections. This architecture provides some distinct advantages over a single-process pooler. + +- **Traffic routing**: The integration with the HA system allows multigateway to transparently route connections to the current primary. During a failover, multigateway can hold requests until a new primary is promoted, thereby minimizing errors. Additionally, you can balance read load across multiple replicas. In the future, multigateway will handle routing of traffic to different shards. +- **Context-aware pooling**: Multigres does not require you to [choose a pooling mode](https://multigres.com/blog/pooling-without-choosing-a-mode) like transaction, session, etc. This is because it has a built-in parser and understands the effects of each request. This allows it to track connection state and reuse them wherever applicable. If a request requires a stateful connection, like a transaction, the connection gets pinned to that client until the stateful requirement is satisfied. +- **Per-user pools**: Multigres maintains a separate [connection pool per user](https://multigres.com/blog/per-user-pools-that-share-fairly) with no shared pool and no `SET ROLE` impersonation. A fair-share algorithm splits a fixed connection budget across users, and pool routing is kept fast. +- **Prepared statement consolidation**: Multigres deduplicates prepared statements across gateways. Postgres parses, plans, and caches a given statement once, no matter how many gateways are forwarding it. + +For more information on connection pooling, you may read [Two jobs, two processes: why Multigres has its own connection pooler](https://multigres.com/blog/two-jobs-two-processes). + +## Backups + +Multigres uses pgBackRest for backups. Backups are taken from replicas to avoid overloading the primary. + +- **Three backup types**: full backups copy the entire data directory at a checkpoint; incremental backups copy only files changed since the previous backup; differential backups copy changes since the last full backup. A typical schedule runs full backups periodically with more frequent incrementals or differentials in between. +- **On-demand and scheduled**: the CLI lets you list backups, trigger a manual backup, and initiate a restore. We'll soon add support for scheduled backups through the cluster spec. +- **Bootstrap**: During bootstrap, Multigres automatically identifies a primary, performs a backup, and uses it to initialize the other replicas. This workflow brings up a ready-to-run cluster without any manual intervention. + +To learn more about how a cluster bootstraps, you may read [How a Multigres Cluster Bootstraps](https://multigres.com/blog/multigres-cluster-bootstrap). + +## What alpha means + +v0.1 is stable enough to experiment with and give feedback on. It is not yet ready for production workloads. Specific caveats: + +- We have [known issues](https://github.com/multigres/multigres/issues) that still need to be addressed. +- Sharding (the eventual flagship feature) is not in this release. v0.1 is a single-shard cluster with HA and pooling. +- Future releases are not guaranteed to be backward compatible. +- The CR API is not yet stable. Fields may change before v1.0. +- Performance benchmarks are in progress. We'll publish numbers in a follow-up post. + +## Try Multigres + +1. **[Deploy your first cluster](https://multigres.com/blog/deploying-the-multigres-operator)**: a minimal CR that stands up a 3-node HA cluster. +2. **Join the community**: file issues and request features at [github.com/multigres/multigres](https://github.com/multigres/multigres). Post your thoughts in [github.com/multigres/multigres/discussions](https://github.com/multigres/multigres/discussions) to discuss features and get help. diff --git a/apps/www/_blog/2026-06-04-supabase-series-f.mdx b/apps/www/_blog/2026-06-04-supabase-series-f.mdx new file mode 100644 index 0000000000000..8ed1c52c87708 --- /dev/null +++ b/apps/www/_blog/2026-06-04-supabase-series-f.mdx @@ -0,0 +1,57 @@ +--- +title: 'Supabase Series F' +description: 'Supabase has raised a $500M Series F at a $10B pre-money valuation, led by GIC.' +author: paul_copplestone +imgSocial: series-f/supabase-series-f.png +imgThumb: series-f/supabase-series-f.png +categories: + - company +tags: + - supabase +date: '2026-06-04T12:00:00' +toc_depth: 2 +--- + +Supabase has raised a $500M Series F at a $10B pre-money valuation. It’s led by GIC. All existing investors joined the round, Stripe made a second investment. Georgian and Salesforce Ventures joined as new investors. + +The round is for three things: + +- Accelerate development of open source / Postgres tools. +- To support our growth. +- Liquidity for the employees. + +## Open source development + +Today we're releasing [Multigres v0.1 alpha](/blog/multigres-v0-1-alpha). It’s open source and self-hostable. + +Postgres is hard to run at scale. Multigres is a scalable operating system for Postgres that provides high availability and operational simplicity. In a future release it will provide Vitess-grade horizontal scaling. + +The alpha status is to signal that it’s ready to try, but not yet production-ready. We’ll get there in a few months. If you’re running a large Postgres workload you can [apply to be a Multigres partner](https://supabase.link/mg-partner) and help shape the direction of Multigres. + +Multigres will be available on the platform when it moves out of alpha. Together with our work on OrioleDB (targeting production-readiness this year), we believe that we will have a complete Postgres solution that solves many hairy Postgres problems, including bloat, connection pooling, and Jepsen-level high availability. Sharding is also on the menu - we'll share more details later. + +## Supabase growth + +In the past year, database launches on Supabase have grown 600%. More than 60% of new databases are launched by some sort of AI tool. + +Nearly 10 million developers build on Supabase today, more than doubling since our last fundraising announcement eight months ago. Growth is accelerating since January as Claude Code and Codex expand the number of people who can build. + +![Cumulative Supabase users from 2020 to 2026, growing to nearly 10 million](/images/blog/series-f/supabase-users-growth.png) + +A lot of our growth is also thanks to our partners, platforms that build on top of Supabase. [Supabase for Platforms](/docs/guides/integrations/supabase-for-platforms) has been the fastest growing product over the last year. + +Managing infrastructure at our scale brings a unique set of challenges. We’re using this funding to invest in the not-so-visible platform features: performance, reliability, and support teams to help our customers. Postgres can be hard to manage, even for experienced DBAs, and we're investing in tooling that helps builders run their databases autonomously. + +## Employee secondaries + +Supabase has employees in over 50 countries now. We are always looking for the “best person for the job”, no matter where they live. We don’t do geo-adjusted salaries - people get paid for the result of their work, not the city they live in. + +We approach stock options with a similar mindset. Employees have 10 years to exercise, even if they decide to leave. In every round, we offer our employees to sell up to 25% of their vested stock. This provides liquidity long before we reach the public market. + +Check out our [careers page](/careers) if you’d like to join our global remote and async team. + +## To the community + +I work at Supabase because we’re open source. Building for a community of builders is the thing I care most about. One of Supabase’s principles is to [support existing tools](/docs/guides/getting-started/architecture#support-existing-tools), working collaboratively with the open source community. + +Thank you to everyone who has helped along the way, especially those who have contributed directly or indirectly to the tools that make up the Supabase ecosystem. diff --git a/apps/www/public/images/blog/2026-06-01-multigres-v0-1-alpha/og.jpg b/apps/www/public/images/blog/2026-06-01-multigres-v0-1-alpha/og.jpg new file mode 100644 index 0000000000000..a836c8762391a Binary files /dev/null and b/apps/www/public/images/blog/2026-06-01-multigres-v0-1-alpha/og.jpg differ diff --git a/apps/www/public/images/blog/2026-06-01-multigres-v0-1-alpha/thumb.jpg b/apps/www/public/images/blog/2026-06-01-multigres-v0-1-alpha/thumb.jpg new file mode 100644 index 0000000000000..ceba1715e5c83 Binary files /dev/null and b/apps/www/public/images/blog/2026-06-01-multigres-v0-1-alpha/thumb.jpg differ diff --git a/apps/www/public/images/blog/series-f/supabase-series-f.png b/apps/www/public/images/blog/series-f/supabase-series-f.png new file mode 100644 index 0000000000000..31b7fe5d914dc Binary files /dev/null and b/apps/www/public/images/blog/series-f/supabase-series-f.png differ diff --git a/apps/www/public/images/blog/series-f/supabase-users-growth.png b/apps/www/public/images/blog/series-f/supabase-users-growth.png new file mode 100644 index 0000000000000..37a76b7d39b5c Binary files /dev/null and b/apps/www/public/images/blog/series-f/supabase-users-growth.png differ