diff --git a/apps/docs/content/guides/telemetry/log-drains.mdx b/apps/docs/content/guides/telemetry/log-drains.mdx index b1eee214ef994..bbf6f5f876d16 100644 --- a/apps/docs/content/guides/telemetry/log-drains.mdx +++ b/apps/docs/content/guides/telemetry/log-drains.mdx @@ -12,12 +12,13 @@ You can read about the initial announcement [here](/blog/log-drains) and vote fo The following table lists the supported destinations and the required setup configuration: -| Destination | Transport Method | Configuration | -| --------------------- | ---------------- | ------------------------------------------------- | -| Generic HTTP endpoint | HTTP | URL
HTTP Version
Gzip
Headers | -| DataDog | HTTP | API Key
Region | -| Loki | HTTP | URL
Headers | -| Sentry | HTTP | DSN | +| Destination | Transport Method | Configuration | +| --------------------- | ---------------- | -------------------------------------------------------------------------------------- | +| Generic HTTP endpoint | HTTP | URL
HTTP Version
Gzip
Headers | +| Datadog | HTTP | API Key
Region | +| Loki | HTTP | URL
Headers | +| Sentry | HTTP | DSN | +| Amazon S3 | AWS SDK | S3 Bucket
Region
Access Key ID
Secret Access Key
Batch Timeout | HTTP requests are batched with a max of 250 logs or 1 second intervals, whichever happens first. Logs are compressed via Gzip if the destination supports it. @@ -136,13 +137,13 @@ Deno.serve(async (req) => { -## DataDog logs +## Datadog logs -Logs sent to DataDog have the name of the log source set on the `service` field of the event and the source set to `Supabase`. Logs are gzipped before they are sent to DataDog. +Logs sent to Datadog have the name of the log source set on the `service` field of the event and the source set to `Supabase`. Logs are gzipped before they are sent to Datadog. The payload message is a JSON string of the raw log event, prefixed with the event timestamp. -To setup DataDog log drain, generate a DataDog API key [here](https://app.datadoghq.com/organization-settings/api-keys) and the location of your DataDog site. +To setup Datadog log drain, generate a Datadog API key [here](https://app.datadoghq.com/organization-settings/api-keys) and the location of your Datadog site. - 1. Generate API Key in [DataDog dashboard](https://app.datadoghq.com/organization-settings/api-keys) + 1. Generate API Key in [Datadog dashboard](https://app.datadoghq.com/organization-settings/api-keys) 2. Create log drain in [Supabase dashboard](/dashboard/project/_/settings/log-drains) - 3. Watch for events in the [DataDog Logs page](https://app.datadoghq.com/logs) + 3. Watch for events in the [Datadog Logs page](https://app.datadoghq.com/logs) @@ -211,6 +212,24 @@ All fields from the log event are attached as attributes to the Sentry log, whic If you are self-hosting Sentry, Sentry Logs are only supported in self-hosted version [25.9.0](https://github.com/getsentry/self-hosted/releases/tag/25.9.0) and later. +## Amazon S3 + +Logs are written to an existing S3 bucket that you own. + +Required configuration when creating an S3 Log Drain: + +- S3 Bucket: the name of an existing S3 bucket. +- Region: the AWS region where the bucket is located. +- Access Key ID: used for authentication. +- Secret Access Key: used for authentication. +- Batch Timeout (ms): maximum time to wait before flushing a batch. Recommended 2000–5000ms. + + + +Ensure the AWS account tied to the Access Key ID has permissions to write to the specified S3 bucket. + + + ## Pricing For a detailed breakdown of how charges are calculated, refer to [Manage Log Drain usage](/docs/guides/platform/manage-your-usage/log-drains). diff --git a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx index 1d4bcacf00d16..af3a5bb586bdd 100644 --- a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx @@ -1,34 +1,53 @@ +import { zodResolver } from '@hookform/resolvers/zod' import type { PostgresExtension } from '@supabase/postgres-meta' import { DocsButton } from 'components/ui/DocsButton' import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation' import { useSchemasQuery } from 'data/database/schemas-query' -import { executeSql } from 'data/sql/execute-sql-query' import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import { DOCS_URL } from 'lib/constants' -import { Database, ExternalLinkIcon, Plus } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, + Badge, Button, - Form, - Input, - Listbox, - Modal, - WarningIcon, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + FormControl_Shadcn_, + FormField_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectSeparator_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, } from 'ui' import { Admonition } from 'ui-patterns' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' + +import { extensionsWithRecommendedSchemas } from './Extensions.constants' +import { useDatabaseExtensionDefaultSchemaQuery } from '@/data/database-extensions/database-extension-schema-query' const orioleExtCallOuts = ['vector', 'postgis'] -// Extensions that have recommended schemas (rather than required schemas) -const extensionsWithRecommendedSchemas: Record = { - wrappers: 'extensions', -} +const FormSchema = z.object({ name: z.string(), schema: z.string() }).superRefine((val, ctx) => { + if (val.schema === 'custom' && val.name.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['name'], + message: 'Please provide a name for the schema', + }) + } +}) interface EnableExtensionModalProps { visible: boolean @@ -36,20 +55,44 @@ interface EnableExtensionModalProps { onCancel: () => void } -const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionModalProps) => { - const { data: project } = useSelectedProjectQuery() +export const EnableExtensionModal = ({ + visible, + extension, + onCancel, +}: EnableExtensionModalProps) => { const isOrioleDb = useIsOrioleDb() - const [defaultSchema, setDefaultSchema] = useState() - const [fetchingSchemaInfo, setFetchingSchemaInfo] = useState(false) + const { data: project } = useSelectedProjectQuery() + const { data: protectedSchemas } = useProtectedSchemas({ excludeSchemas: ['extensions'] }) + + const recommendedSchema = extensionsWithRecommendedSchemas[extension.name] - const { data: schemas, isPending: isSchemasLoading } = useSchemasQuery( + const { data: schemas = [], isPending: isSchemasLoading } = useSchemasQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, { enabled: visible } ) - const { data: protectedSchemas } = useProtectedSchemas({ excludeSchemas: ['extensions'] }) + const availableSchemas = schemas.filter( + (schema) => + schema.name === recommendedSchema || + !protectedSchemas.some((protectedSchema) => protectedSchema.name === schema.name) + ) + + const { data: extensionMeta, isPending: fetchingSchemaInfo } = + useDatabaseExtensionDefaultSchemaQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + extension: extension.name, + }, + { enabled: visible } + ) + // [Joshen] Hard-coding pg_cron here as this is enforced on our end (Not via pg_available_extension_versions) + const defaultSchema = extension.name === 'pg_cron' ? 'pg_catalog' : extensionMeta?.schema + + const isLoading = fetchingSchemaInfo || isSchemasLoading + const { mutate: enableExtension, isPending: isEnabling } = useDatabaseExtensionEnableMutation({ onSuccess: () => { toast.success(`Extension "${extension.name}" is now enabled`) @@ -60,62 +103,16 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM }, }) - // [Joshen] Worth checking in with users - whether having this schema selection - // might be confusing, and if we should have a tooltip to explain that schemas - // are just concepts of namespace, you can use that extension no matter where it's - // installed in - - useEffect(() => { - let cancel = false - - if (visible) { - const checkExtensionSchema = async () => { - if (!cancel) { - setFetchingSchemaInfo(true) - setDefaultSchema(undefined) - } - try { - const res = await executeSql({ - projectRef: project?.ref, - connectionString: project?.connectionString, - sql: `select * from pg_available_extension_versions where name = '${extension.name}'`, - }) - if (!cancel) setDefaultSchema(res.result[0].schema) - } catch (error) {} - - setFetchingSchemaInfo(false) - } - checkExtensionSchema() - } - - return () => { - cancel = true - } - }, [visible, extension.name]) - - const getSchemaDescriptionText = (extensionName: string, schema: string | null | undefined) => { - // Prioritize defaultSchema (required/forced) over recommended schema - if (schema) { - return `Extension must be installed in the “${schema}” schema.` - } - - const recommendedSchema = extensionsWithRecommendedSchemas[extensionName] - if (recommendedSchema) { - return `Use the “${recommendedSchema}” schema for full compatibility with related features.` - } - - return undefined - } - - const isLoading = fetchingSchemaInfo || isSchemasLoading - - const validate = (values: any) => { - const errors: any = {} - if (values.schema === 'custom' && !values.name) errors.name = 'Required field' - return errors - } + const defaultValues = { name: extension.name, schema: recommendedSchema ?? 'extensions' } + const form = useForm>({ + mode: 'onBlur', + reValidateMode: 'onBlur', + resolver: zodResolver(FormSchema), + defaultValues, + }) + const { schema } = form.watch() - const onSubmit = async (values: any) => { + const onSubmit = async (values: z.infer) => { if (project === undefined) return console.error('Project is required') const schema = @@ -137,149 +134,160 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM } return ( - -
Enable
- {extension.name} - - } + { + if (!open) onCancel() + }} > -
- {({ values }: any) => { - return ( - <> - - {isOrioleDb && orioleExtCallOuts.includes(extension.name) && ( - - - {extension.name} cannot be accelerated by indexes on tables that are using the - OrioleDB access method - - - - )} + + + Enable {extension.name} + + + - {isLoading ? ( -
+ {isOrioleDb && orioleExtCallOuts.includes(extension.name) && ( + + + {extension.name} cannot be accelerated by indexes on tables that are using the + OrioleDB access method + + + + )} + + {extension.name === 'pg_cron' && project?.cloud_provider === 'FLY' && ( + +

+ You can still enable the extension, but pg_cron jobs may not run due to the behavior + of Fly projects. +

+ +
+ )} + + + + + {isLoading ? ( +
+ +
-
- -
- ) : defaultSchema ? ( - + ) : !!defaultSchema ? ( +
+ - ) : ( - - } - > - Create a new schema "{extension.name}" - - - {schemas - ?.filter( - (schema) => - !protectedSchemas.some( - (protectedSchema) => protectedSchema.name === schema.name - ) - ) - .map((schema) => { - return ( - } + + +

+ Extension must be installed in the “{defaultSchema}” schema. +

+
+ ) : ( +
+ ( + + + - {schema.name} - - ) - })} - - )} - - - {values.schema === 'custom' && ( - - - - )} - - {extension.name === 'pg_cron' && project?.cloud_provider === 'FLY' && ( - - - - - The pg_cron extension is not fully supported for Fly projects - + + + + + + Create a new schema{' '} + {extension.name} + + + {availableSchemas.map((schema) => { + return ( + + {schema.name} + {schema.name === recommendedSchema ? ( + + Recommended + + ) : !defaultSchema && schema.name === 'extensions' ? ( + Default + ) : null} + + ) + })} + + + + + )} + /> - - You can still enable the extension, but pg_cron jobs may not run due to the - behavior of Fly projects. - + {!!recommendedSchema && ( +

+ Use the "{recommendedSchema}" schema for full compatibility with related + features. +

+ )} - - - - - + {schema === 'custom' && ( + ( + + + + + + )} + /> + )} +
)} - - - - - - - ) - }} - - + + + + + + + + + +
) } - -export default EnableExtensionModal diff --git a/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx b/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx index 883c350167e7a..56a7e86390223 100644 --- a/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx @@ -1,20 +1,20 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { AlertTriangle, Book, Github, Loader2 } from 'lucide-react' -import Link from 'next/link' -import { useState } from 'react' -import { toast } from 'sonner' - import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useDatabaseExtensionDisableMutation } from 'data/database-extensions/database-extension-disable-mutation' import { DatabaseExtension } from 'data/database-extensions/database-extensions-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' +import { AlertTriangle, Book, Github, Loader2 } from 'lucide-react' +import Link from 'next/link' +import { useState } from 'react' import { extensions } from 'shared-data' +import { toast } from 'sonner' import { Button, Switch, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Admonition } from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import EnableExtensionModal from './EnableExtensionModal' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' + +import { EnableExtensionModal } from './EnableExtensionModal' import { EXTENSION_DISABLE_WARNINGS } from './Extensions.constants' interface ExtensionRowProps { diff --git a/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts b/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts index d22f90d7b8621..065adf1b7dc84 100644 --- a/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts +++ b/apps/studio/components/interfaces/Database/Extensions/Extensions.constants.ts @@ -24,3 +24,8 @@ export const SEARCH_TERMS: Record = { export const EXTENSION_DISABLE_WARNINGS: Record = { pg_cron: 'Disabling this extension will delete all scheduled jobs. This cannot be undone.', } + +// Extensions that have recommended schemas (rather than required schemas) +export const extensionsWithRecommendedSchemas: Record = { + wrappers: 'extensions', +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx index 53e4232800d16..a836fbc2d25a3 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx @@ -1,13 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { parseAsString, useQueryState } from 'nuqs' -import { useEffect, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' - import { useWatch } from '@ui/components/shadcn/ui/form' import { useParams } from 'common' -import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' +import { EnableExtensionModal } from 'components/interfaces/Database/Extensions/EnableExtensionModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { getDatabaseCronJob } from 'data/database-cron-jobs/database-cron-job-query' import { useDatabaseCronJobCreateMutation } from 'data/database-cron-jobs/database-cron-jobs-create-mutation' @@ -17,11 +12,15 @@ import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { parseAsString, useQueryState } from 'nuqs' +import { useEffect, useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' import { Button, - Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, + Form_Shadcn_, Input_Shadcn_, RadioGroupStacked, RadioGroupStackedItem, @@ -34,6 +33,7 @@ import { } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + import { CRONJOB_DEFINITIONS } from '../CronJobs.constants' import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from '../CronJobs.utils' import { EdgeFunctionSection } from '../EdgeFunctionSection' @@ -43,9 +43,9 @@ import { HttpRequestSection } from '../HttpRequestSection' import { SqlFunctionSection } from '../SqlFunctionSection' import { SqlSnippetSection } from '../SqlSnippetSection' import { - FormSchema, type CreateCronJobForm, type CronJobType, + FormSchema, } from './CreateCronJobSheet.constants' import { CronJobScheduleSection } from './CronJobScheduleSection' diff --git a/apps/studio/components/interfaces/Integrations/Integration/MissingExtensionAlert.tsx b/apps/studio/components/interfaces/Integrations/Integration/MissingExtensionAlert.tsx index 54526a943fde1..d8dbc1312a15f 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/MissingExtensionAlert.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/MissingExtensionAlert.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react' - -import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' +import { EnableExtensionModal } from 'components/interfaces/Database/Extensions/EnableExtensionModal' import { DatabaseExtension } from 'data/database-extensions/database-extensions-query' +import { useState } from 'react' import { Button } from 'ui' export const MissingExtensionAlert = ({ extension }: { extension: DatabaseExtension }) => { diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx index f612b62df4f2f..e3fa362be1845 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx @@ -91,6 +91,14 @@ const formUnion = z.discriminatedUnion('type', [ }), z.object({ type: z.literal('s3'), + s3_bucket: z.string().min(1, { message: 'Bucket name is required' }), + storage_region: z.string().min(1, { message: 'Region is required' }), + access_key_id: z.string().min(1, { message: 'Access Key ID is required' }), + secret_access_key: z.string().min(1, { message: 'Secret Access Key is required' }), + batch_timeout: z.coerce + .number() + .int({ message: 'Batch timeout must be an integer' }) + .min(1, { message: 'Batch timeout must be a positive integer' }), }), z.object({ type: z.literal('axiom'), @@ -120,7 +128,6 @@ function LogDrainFormItem({ formControl, placeholder, type, - defaultValue, }: { value: string label: string @@ -128,7 +135,6 @@ function LogDrainFormItem({ placeholder?: string description?: ReactNode type?: string - defaultValue?: string }) { return ( ( - + )} @@ -178,6 +179,7 @@ export function LogDrainDestinationSheetForm({ const DEFAULT_HEADERS = mode === 'create' ? CREATE_DEFAULT_HEADERS : defaultConfig?.headers || {} const sentryEnabled = useFlag('SentryLogDrain') + const s3Enabled = useFlag('S3logdrain') const { ref } = useParams() const { data: logDrains } = useLogDrainsQuery({ @@ -203,6 +205,11 @@ export function LogDrainDestinationSheetForm({ username: defaultConfig?.username || '', password: defaultConfig?.password || '', dsn: defaultConfig?.dsn || '', + s3_bucket: defaultConfig?.s3_bucket || '', + storage_region: defaultConfig?.storage_region || '', + access_key_id: defaultConfig?.access_key_id || '', + secret_access_key: defaultConfig?.secret_access_key || '', + batch_timeout: defaultConfig?.batch_timeout ?? 3000, }, }) @@ -290,10 +297,7 @@ export function LogDrainDestinationSheetForm({ form.handleSubmit(onSubmit)(e) track('log_drain_save_button_clicked', { - destination: form.getValues('type') as Exclude< - LogDrainType, - 'elastic' | 'postgres' | 'bigquery' | 'clickhouse' | 's3' | 'axiom' - >, + destination: form.getValues('type'), }) }} > @@ -327,18 +331,20 @@ export function LogDrainDestinationSheetForm({ {LOG_DRAIN_TYPES.find((t) => t.value === type)?.name} - {LOG_DRAIN_TYPES.filter((t) => t.value !== 'sentry' || sentryEnabled).map( - (type) => ( - - {type.name} - - ) - )} + {LOG_DRAIN_TYPES.filter( + (t) => + (t.value !== 'sentry' || sentryEnabled) && + (t.value !== 's3' || s3Enabled) + ).map((type) => ( + + {type.name} + + ))} @@ -517,6 +523,49 @@ export function LogDrainDestinationSheetForm({ /> )} + {type === 's3' && ( +
+ + + + + +

+ Ensure the account tied to the Access Key ID can write to the specified + bucket. +

+
+ )} diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx index ea38250a16876..49d11a4981240 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx @@ -1,6 +1,6 @@ import { components } from 'api-types' import { Datadog, Grafana, Sentry } from 'icons' -import { BracesIcon } from 'lucide-react' +import { BracesIcon, Cloud } from 'lucide-react' const iconProps = { height: 24, @@ -30,6 +30,12 @@ export const LOG_DRAIN_TYPES = [ 'Loki is an open-source log aggregation system designed to store and query logs from multiple sources', icon: , }, + { + value: 's3', + name: 'Amazon S3', + description: 'Forward logs to an S3 bucket', + icon: , + }, { value: 'sentry', name: 'Sentry', diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx index 6bc48cfaa3507..8eba92aba9c96 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx @@ -56,6 +56,7 @@ export function LogDrains({ } ) const sentryEnabled = useFlag('SentryLogDrain') + const s3Enabled = useFlag('S3logdrain') const hasLogDrains = !!logDrains?.length const { mutate: deleteLogDrain } = useDeleteLogDrainMutation({ @@ -90,7 +91,9 @@ export function LogDrains({ return ( <>
- {LOG_DRAIN_TYPES.filter((t) => t.value !== 'sentry' || sentryEnabled).map((src) => ( + {LOG_DRAIN_TYPES.filter( + (t) => (t.value !== 'sentry' || sentryEnabled) && (t.value !== 's3' || s3Enabled) + ).map((src) => ( , + destination: selectedLogDrain.type, }) } }} diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts index d2e25ceb26614..a9daa97a7b269 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts @@ -32,7 +32,7 @@ export const FormSchema = z dbPassStrengthWarning: z.string().default(''), instanceSize: z.string().optional(), dataApi: z.boolean(), - useApiSchema: z.boolean(), + enableRlsEventTrigger: z.boolean(), postgresVersionSelection: z.string(), useOrioleDb: z.boolean(), }) diff --git a/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx b/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx index 19a556777a501..7e41b66222c04 100644 --- a/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx @@ -1,18 +1,16 @@ -import { ChevronRight } from 'lucide-react' import { UseFormReturn } from 'react-hook-form' import Panel from 'components/ui/Panel' +import { usePHFlag } from 'hooks/ui/useFlag' +import Link from 'next/link' import { - Badge, - cn, - Collapsible_Shadcn_, - CollapsibleContent_Shadcn_, - CollapsibleTrigger_Shadcn_, + Checkbox_Shadcn_, FormControl_Shadcn_, + FormDescription_Shadcn_, FormField_Shadcn_, FormItem_Shadcn_, - RadioGroupStacked, - RadioGroupStackedItem, + FormLabel_Shadcn_, + useWatch_Shadcn_, } from 'ui' import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -21,159 +19,87 @@ import { CreateProjectForm } from './ProjectCreation.schema' interface SecurityOptionsProps { form: UseFormReturn layout?: 'vertical' | 'horizontal' - collapsible?: boolean } -export const SecurityOptions = ({ - form, - layout = 'horizontal', - collapsible = true, -}: SecurityOptionsProps) => { - const content = ( - <> - ( - <> - - - field.onChange(value === 'true')} - defaultValue={field.value.toString()} - > - - - - - - - - div>div>p]:text-left [&>div>div>p]:text-xs' - )} - onClick={() => form.setValue('useApiSchema', false)} - /> - - - - - {!form.getValues('dataApi') && ( - - PostgREST which powers the Data API will have no schemas available to it. - - )} - - - )} - /> +export const SecurityOptions = ({ form, layout = 'horizontal' }: SecurityOptionsProps) => { + const rlsExperimentVariant = usePHFlag<'control' | 'test' | false | undefined>( + 'projectCreationEnableRlsEventTrigger' + ) + const shouldShowEnableRlsEventTrigger = rlsExperimentVariant === 'test' + const dataApi = useWatch_Shadcn_({ control: form.control, name: 'dataApi' }) - {form.getValues('dataApi') && ( - ( - <> - + return ( + + +
+ ( + - field.onChange(value === 'true')} - > - - - - Use public schema for Data API - Default -
- } - // @ts-ignore - description={ - <> - Query all tables in the{' '} - public schema - - } - className="[&>div>div>p]:text-left [&>div>div>p]:text-xs" - /> - - - - - - Query allowlisted tables in a dedicated{' '} - api schema - - } - className="[&>div>div>p]:text-left [&>div>div>p]:text-xs" - /> - - - + field.onChange(value === true)} + /> -
- - )} - /> - )} -

- These two security options can be changed after your project is created -

- - ) +
+ + Enable Data API + + + Autogenerate a RESTful API for your public schema. Recommended if using a client + library like{' '} + + supabase-js + + . + +
+ + )} + /> - const collapsibleContent = ( - - - Security options - - - - {content} - - - ) + {shouldShowEnableRlsEventTrigger && ( + ( + + + field.onChange(value === true)} + /> + +
+ + Enable automatic RLS + + + Create an event trigger that automatically enables Row Level Security on all + new tables in the public schema. + +
+
+ )} + /> + )} - return ( - - {collapsible ? collapsibleContent : content} + {!dataApi && ( + + You will not be able to query or mutate data via Supabase client libraries like + supabase-js. + + )} +
+ ) } diff --git a/apps/studio/data/database-extensions/database-extension-schema-query.ts b/apps/studio/data/database-extensions/database-extension-schema-query.ts new file mode 100644 index 0000000000000..ef7b2d6ca020a --- /dev/null +++ b/apps/studio/data/database-extensions/database-extension-schema-query.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query' +import { UseCustomQueryOptions } from 'types' + +import { ExecuteSqlError, executeSql } from '../sql/execute-sql-query' +import { getDatabaseExtensionDefaultSchemaSQL } from '../sql/queries/get-extension-default-schema' +import { databaseExtensionsKeys } from './keys' + +type DatabaseExtensionDefaultSchemaVariables = { + extension?: string + projectRef?: string + connectionString?: string | null +} + +async function getDatabaseExtensionDefaultSchema( + { extension, projectRef, connectionString }: DatabaseExtensionDefaultSchemaVariables, + signal?: AbortSignal +) { + if (!extension) { + throw new Error('extension is required') + } + + const sql = getDatabaseExtensionDefaultSchemaSQL({ extension }) + + const { result } = await executeSql( + { + projectRef, + connectionString, + sql, + queryKey: ['database-extension-default-schema', extension], + }, + signal + ) + + return result[0] as { name: string; version: string; schema: string | null } +} + +type DatabaseExtensionDefaultSchemaData = Awaited< + ReturnType +> +type DatabaseExtensionDefaultSchemaError = ExecuteSqlError + +export const useDatabaseExtensionDefaultSchemaQuery = ( + { projectRef, connectionString, extension }: DatabaseExtensionDefaultSchemaVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + DatabaseExtensionDefaultSchemaData, + DatabaseExtensionDefaultSchemaError, + TData + > = {} +) => + useQuery({ + queryKey: databaseExtensionsKeys.defaultSchema(projectRef, extension), + queryFn: ({ signal }) => + getDatabaseExtensionDefaultSchema({ projectRef, connectionString, extension }, signal), + enabled: enabled && typeof projectRef !== 'undefined' && typeof extension !== 'undefined', + ...options, + }) diff --git a/apps/studio/data/database-extensions/keys.ts b/apps/studio/data/database-extensions/keys.ts index 930b7eaefc775..b139610ffd431 100644 --- a/apps/studio/data/database-extensions/keys.ts +++ b/apps/studio/data/database-extensions/keys.ts @@ -1,4 +1,6 @@ export const databaseExtensionsKeys = { list: (projectRef: string | undefined) => ['projects', projectRef, 'database-extensions'] as const, + defaultSchema: (projectRef: string | undefined, extension: string | undefined) => + ['projects', projectRef, 'database-extensions', extension, 'default-schema'] as const, } diff --git a/apps/studio/data/sql/queries/get-extension-default-schema.ts b/apps/studio/data/sql/queries/get-extension-default-schema.ts new file mode 100644 index 0000000000000..82e670ff8bda4 --- /dev/null +++ b/apps/studio/data/sql/queries/get-extension-default-schema.ts @@ -0,0 +1,9 @@ +import { literal } from '@supabase/pg-meta/src/pg-format' + +export const getDatabaseExtensionDefaultSchemaSQL = ({ extension }: { extension: string }) => { + const sql = /* SQL */ ` +select name, version, schema from pg_available_extension_versions where name = ${literal(extension)} limit 1; +`.trim() + + return sql +} diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index 9f85f5d08e636..dab30244317e7 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -1,6 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' +import { AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL } from 'components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants' import { AdvancedConfiguration } from 'components/interfaces/ProjectCreation/AdvancedConfiguration' import { CloudProviderSelector } from 'components/interfaces/ProjectCreation/CloudProviderSelector' import { ComputeSizeSelector } from 'components/interfaces/ProjectCreation/ComputeSizeSelector' @@ -46,10 +47,11 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { withAuth } from 'hooks/misc/withAuth' import { usePHFlag } from 'hooks/ui/useFlag' import { DOCS_URL, PROJECT_STATUS, PROVIDERS, useDefaultProvider } from 'lib/constants' +import { useProfile } from 'lib/profile' import { useTrack } from 'lib/telemetry/track' import Link from 'next/link' import { useRouter } from 'next/router' -import { PropsWithChildren, useEffect, useMemo, useState } from 'react' +import { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { AWS_REGIONS, type CloudProvider } from 'shared-data' import { toast } from 'sonner' @@ -66,8 +68,13 @@ const Wizard: NextPageWithLayout = () => { const router = useRouter() const { slug, projectName } = useParams() const defaultProvider = useDefaultProvider() + const { profile } = useProfile() const { data: currentOrg } = useSelectedOrganizationQuery() + const rlsExperimentVariant = usePHFlag<'control' | 'test' | false | undefined>( + 'projectCreationEnableRlsEventTrigger' + ) + const shouldShowEnableRlsEventTrigger = rlsExperimentVariant === 'test' const isFreePlan = currentOrg?.plan?.id === 'free' const canChooseInstanceSize = !isFreePlan @@ -86,6 +93,7 @@ const Wizard: NextPageWithLayout = () => { const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '') + const hasTrackedRlsExposure = useRef(false) // This is to make the database.new redirect work correctly. The database.new redirect should be set to supabase.com/dashboard/new/last-visited-org if (slug === 'last-visited-org') { @@ -114,7 +122,7 @@ const Wizard: NextPageWithLayout = () => { dbRegion: undefined, instanceSize: canChooseInstanceSize ? sizes[0] : undefined, dataApi: true, - useApiSchema: false, + enableRlsEventTrigger: false, postgresVersionSelection: '', useOrioleDb: false, }, @@ -223,7 +231,13 @@ const Wizard: NextPageWithLayout = () => { { enabled: currentOrg !== null } ) - const shouldShowFreeProjectInfo = !!currentOrg && !isFreePlan + const userPrimaryEmail = profile?.primary_email?.toLowerCase() + const isUserAtFreeProjectLimit = userPrimaryEmail + ? membersExceededLimit.some( + (member) => member.primary_email?.toLowerCase() === userPrimaryEmail + ) + : false + const shouldShowFreeProjectInfo = !!currentOrg && !isFreePlan && !isUserAtFreeProjectLimit const { mutate: createProject, @@ -235,8 +249,11 @@ const Wizard: NextPageWithLayout = () => { 'project_creation_simple_version_submitted', { instanceSize: form.getValues('instanceSize'), + enableRlsEventTrigger: form.getValues('enableRlsEventTrigger'), + ...((rlsExperimentVariant === 'control' || rlsExperimentVariant === 'test') && { + rlsOptionVariant: rlsExperimentVariant, + }), dataApiEnabled: form.getValues('dataApi'), - useApiSchema: form.getValues('useApiSchema'), useOrioleDb: form.getValues('useOrioleDb'), }, { @@ -274,7 +291,7 @@ const Wizard: NextPageWithLayout = () => { postgresVersion, instanceSize, dataApi, - useApiSchema, + enableRlsEventTrigger, postgresVersionSelection, useOrioleDb, } = values @@ -301,10 +318,11 @@ const Wizard: NextPageWithLayout = () => { // only set the compute size on pro+ plans. Free plans always use micro (nano in the future) size. dbInstanceSize: isFreePlan ? undefined : (instanceSize as DesiredInstanceSize), dataApiExposedSchemas: !dataApi ? [] : undefined, - dataApiUseApiSchema: !dataApi ? false : useApiSchema, + dataApiUseApiSchema: false, postgresEngine: useOrioleDb ? availableOrioleVersion?.postgres_engine : postgresEngine, releaseChannel: useOrioleDb ? availableOrioleVersion?.release_channel : releaseChannel, ...(smartRegionEnabled ? { regionSelection: selectedRegion } : { dbRegion }), + ...(enableRlsEventTrigger ? { dbSql: AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL } : {}), } if (postgresVersion) { @@ -371,6 +389,20 @@ const Wizard: NextPageWithLayout = () => { } }, [instanceSize, watchedInstanceSize, form]) + // Track exposure to RLS option experiment (only when explicitly assigned to a variant) + useEffect(() => { + if ( + !hasTrackedRlsExposure.current && + currentOrg?.slug && + (rlsExperimentVariant === 'control' || rlsExperimentVariant === 'test') + ) { + hasTrackedRlsExposure.current = true + track('project_creation_rls_option_experiment_exposed', { + variant: rlsExperimentVariant, + }) + } + }, [currentOrg?.slug, rlsExperimentVariant, track]) + return (
@@ -447,7 +479,7 @@ const Wizard: NextPageWithLayout = () => { {shouldShowFreeProjectInfo ? ( +} + /** * Existing project creation form was submitted and the project was created. * @@ -278,6 +296,14 @@ export interface ProjectCreationSimpleVersionSubmittedEvent { */ properties: { instanceSize?: string + /** + * Whether the automatic RLS event trigger option was enabled + */ + enableRlsEventTrigger?: boolean + /** + * Experiment variant: 'control' (checkbox not shown) or 'test' (checkbox shown) + */ + rlsOptionVariant?: 'control' | 'test' /** * Whether Data API is enabled. * true = "Data API + Connection String" (default) @@ -2467,7 +2493,17 @@ export interface LogDrainSaveButtonClickedEvent { /** * Type of the destination saved */ - destination: 'webhook' | 'datadog' | 'loki' | 'sentry' + destination: + | 'postgres' + | 'bigquery' + | 'clickhouse' + | 'webhook' + | 'datadog' + | 'elastic' + | 'loki' + | 'sentry' + | 's3' + | 'axiom' } groups: TelemetryGroups } @@ -2485,7 +2521,17 @@ export interface LogDrainConfirmButtonSubmittedEvent { /** * Type of the destination confirmed */ - destination: 'webhook' | 'datadog' | 'loki' | 'sentry' + destination: + | 'postgres' + | 'bigquery' + | 'clickhouse' + | 'webhook' + | 'datadog' + | 'elastic' + | 'loki' + | 'sentry' + | 's3' + | 'axiom' } groups: TelemetryGroups } @@ -2756,6 +2802,7 @@ export type TelemetryEvent = | CronJobHistoryClickedEvent | FeaturePreviewEnabledEvent | FeaturePreviewDisabledEvent + | ProjectCreationRlsOptionExperimentExposedEvent | ProjectCreationSimpleVersionSubmittedEvent | ProjectCreationSimpleVersionConfirmModalOpenedEvent | ProjectCreationInitialStepPromptIntendedEvent diff --git a/packages/ui-patterns/src/form/Layout/FormLayout.tsx b/packages/ui-patterns/src/form/Layout/FormLayout.tsx index a92a60f449da5..13319daf8ae8a 100644 --- a/packages/ui-patterns/src/form/Layout/FormLayout.tsx +++ b/packages/ui-patterns/src/form/Layout/FormLayout.tsx @@ -299,23 +299,16 @@ export const FormLayout = React.forwardRef< ) => { const flex = layout === 'flex' || layout === 'flex-row-reverse' const hasLabel = Boolean(label || beforeLabel || afterLabel) - const renderError = isReactForm && !hideMessage && ( - + const renderError = + isReactForm && !hideMessage ? ( - - ) + ) : null const renderDescription = description && isReactForm ? (