import { zodResolver } from '@hookform/resolvers/zod' import { IS_PLATFORM, useFlag, useParams } from 'common' import Link from 'next/link' import { ReactNode, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, cn, Form, FormControl, FormField, FormItem, FormLabel, Input, RadioGroupCard, RadioGroupCardItem, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, Sheet, SheetContent, SheetFooter, SheetHeader, SheetSection, SheetTitle, Switch, TextArea, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { z } from 'zod' import { DATADOG_REGIONS, LAST9_REGIONS, LOG_DRAIN_TYPES, LogDrainType, OTLP_PROTOCOLS, } from './LogDrains.constants' import { getDefaultHeadersByType, getHeadersSectionDescription as getHeadersDescription, headerRecordToRows, headerRowsToRecord, logDrainHeaderEntriesSchema, type LogDrainHeaderRow, } from './LogDrains.utils' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' import { LogDrainData, useLogDrainsQuery } 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' const FORM_ID = 'log-drain-destination-form' const headerRecordSchema = z.record(z.string(), z.string()) const webhookFields = { type: z.literal('webhook'), url: httpEndpointUrlSchema({ requiredMessage: 'Endpoint URL is required', invalidMessage: 'Endpoint URL must be a valid URL', prefixMessage: 'Endpoint URL must start with http:// or https://', }), http: z.enum(['http1', 'http2']), gzip: z.boolean(), } const webhookFormSchema = z.object({ ...webhookFields, headerEntries: logDrainHeaderEntriesSchema.optional(), }) const webhookSubmitSchema = z.object({ ...webhookFields, headers: headerRecordSchema.optional(), }) const datadogSchema = z.object({ type: z.literal('datadog'), api_key: z.string().min(1, { message: 'API key is required' }), region: z.string().min(1, { message: 'Region is required' }), }) const lokiFields = { type: z.literal('loki'), url: httpEndpointUrlSchema({ requiredMessage: 'Loki URL is required', invalidMessage: 'Loki URL must be a valid URL', prefixMessage: 'Loki URL must start with http:// or https://', }), username: z.string().optional(), password: z.string().optional(), } const lokiFormSchema = z.object({ ...lokiFields, headerEntries: logDrainHeaderEntriesSchema.optional(), }) const lokiSubmitSchema = z.object({ ...lokiFields, headers: headerRecordSchema, }) const elasticSchema = z.object({ type: z.literal('elastic'), }) const postgresSchema = z.object({ type: z.literal('postgres'), }) const bigquerySchema = z.object({ type: z.literal('bigquery'), }) const clickhouseSchema = z.object({ type: z.literal('clickhouse'), }) const s3Schema = 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' }), }) const sentrySchema = z.object({ type: z.literal('sentry'), dsn: z .string() .min(1, { message: 'Sentry DSN is required' }) .refine((dsn) => dsn.startsWith('https://'), 'Sentry DSN must start with https://'), }) const axiomSchema = z.object({ type: z.literal('axiom'), api_token: z.string().min(1, { message: 'API token is required' }), dataset_name: z.string().min(1, { message: 'Dataset name is required' }), }) const last9Schema = z.object({ type: z.literal('last9'), region: z.string().min(1, { message: 'Region is required' }), username: z.string().min(1, { message: 'Username is required' }), password: z.string().min(1, { message: 'Password is required' }), }) const otlpFields = { type: z.literal('otlp'), endpoint: httpEndpointUrlSchema({ requiredMessage: 'OTLP endpoint is required', invalidMessage: 'OTLP endpoint must be a valid URL', prefixMessage: 'OTLP endpoint must start with http:// or https://', }), protocol: z.string().optional().default('http/protobuf'), gzip: z.boolean().optional().default(true), } const otlpFormSchema = z.object({ ...otlpFields, headerEntries: logDrainHeaderEntriesSchema.optional(), }) const otlpSubmitSchema = z.object({ ...otlpFields, headers: headerRecordSchema.optional(), }) const syslogSchema = z.object({ type: z.literal('syslog'), host: z.string().min(1, { message: 'Host is required' }), port: z.coerce .number() .int({ message: 'Port must be an integer' }) .min(0, { message: 'Port must be between 0 and 65535' }) .max(65535, { message: 'Port must be between 0 and 65535' }), tls: z.boolean().optional().default(false), structured_data: z.string().optional(), cipher_key: z.string().optional(), ca_cert: z.string().optional(), client_cert: z.string().optional(), client_key: z.string().optional(), }) const formUnion = z.discriminatedUnion('type', [ webhookFormSchema, datadogSchema, lokiFormSchema, // [Joshen] To fix API types, not supported in the UI elasticSchema, postgresSchema, bigquerySchema, clickhouseSchema, s3Schema, sentrySchema, axiomSchema, last9Schema, otlpFormSchema, syslogSchema, ]) const submitUnion = z.discriminatedUnion('type', [ webhookSubmitSchema, datadogSchema, lokiSubmitSchema, elasticSchema, postgresSchema, bigquerySchema, clickhouseSchema, s3Schema, sentrySchema, axiomSchema, last9Schema, otlpSubmitSchema, syslogSchema, ]) const formSchema = z .object({ name: z.string().min(1, { message: 'Destination name is required', }), description: z.string().optional(), }) .and(formUnion) .superRefine((data, ctx) => { if (data.type !== 'syslog') return if (data.client_cert && !data.client_key) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Client key is required when a client certificate is provided', path: ['client_key'], }) } if (data.client_key && !data.client_cert) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Client certificate is required when a client key is provided', path: ['client_cert'], }) } }) const submitSchema = z .object({ name: z.string().min(1, { message: 'Destination name is required', }), description: z.string().optional(), }) .and(submitUnion) type LogDrainDestinationFormValues = z.infer type LogDrainDestinationSubmitValues = z.infer const HEADER_ENABLED_TYPES = ['webhook', 'loki', 'otlp'] as const function toSubmitValues(values: LogDrainDestinationFormValues): LogDrainDestinationSubmitValues { if (!HEADER_ENABLED_TYPES.includes(values.type as (typeof HEADER_ENABLED_TYPES)[number])) { return submitSchema.parse(values) } const { headerEntries = [], ...rest } = values as LogDrainDestinationFormValues & { headerEntries?: LogDrainHeaderRow[] } const headers = headerRowsToRecord(headerEntries) const transformedValues = rest.type === 'loki' ? { ...rest, headers } : Object.keys(headers).length > 0 ? { ...rest, headers } : rest return submitSchema.parse(transformedValues) } function LogDrainFormItem({ value, label, description, formControl, placeholder, type, }: { value: string label: string formControl: any placeholder?: string description?: ReactNode type?: string }) { return ( ( )} /> ) } type DefaultValues = { type: LogDrainType } & Partial export function LogDrainDestinationSheetForm({ open, onOpenChange, defaultValues, onSubmit, isLoading, mode, }: { open: boolean onOpenChange: (v: boolean) => void defaultValues?: DefaultValues isLoading?: boolean onSubmit: (values: LogDrainDestinationSubmitValues) => void mode: 'create' | 'update' }) { // 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 // and it does not include `type` inside the config itself, so it's not trivial to create `discriminatedUnion` // out of it, therefore for an ease of use now, we bail to `any` until the better time come. const defaultType = defaultValues?.type || 'webhook' const defaultHeaderEntries = useMemo(() => { const config = (defaultValues?.config || {}) as any const type = defaultValues?.type || 'webhook' return headerRecordToRows( mode === 'create' ? getDefaultHeadersByType(type) : config?.headers || {} ) }, [defaultValues, mode]) 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 { ref } = useParams() const { data: logDrains } = useLogDrainsQuery({ ref, }) const track = useTrack() const formValues = useMemo(() => { const config = (defaultValues?.config || {}) as any const type = defaultValues?.type || 'webhook' return { name: defaultValues?.name || '', description: defaultValues?.description || '', type, http: config?.http || 'http2', gzip: mode === 'create' ? true : config?.gzip || false, headerEntries: defaultHeaderEntries, url: config?.url || '', api_key: config?.api_key || '', region: config?.region || '', username: config?.username || '', password: config?.password || '', dsn: config?.dsn || '', s3_bucket: config?.s3_bucket || '', storage_region: config?.storage_region || '', access_key_id: config?.access_key_id || '', secret_access_key: config?.secret_access_key || '', batch_timeout: config?.batch_timeout ?? 3000, dataset_name: config?.dataset_name || '', api_token: config?.api_token || '', endpoint: config?.endpoint || '', protocol: config?.protocol || 'http/protobuf', host: config?.host || '', port: (config?.port ?? '') as number, tls: config?.tls ?? false, structured_data: config?.structured_data || '', cipher_key: config?.cipher_key || '', ca_cert: config?.ca_cert || '', client_cert: config?.client_cert || '', client_key: config?.client_key || '', } }, [defaultValues, mode, defaultHeaderEntries]) const form = useForm({ resolver: zodResolver(formSchema as any), values: formValues, }) const type = form.watch('type') const tls = form.watch('tls') useEffect(() => { if (mode === 'create' && !open) { form.reset() } }, [mode, open, form]) useEffect(() => { if (!open || mode !== 'create') return form.setValue('headerEntries', headerRecordToRows(getDefaultHeadersByType(type))) form.clearErrors('headerEntries') }, [form, mode, open, type]) return ( Add destination
{ e.preventDefault() // Temp check to make sure the name is unique const logDrainName = form.getValues('name') const logDrainExists = !!logDrains?.length && logDrains?.find((drain) => drain.name === 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'), }) }} >
{mode === 'create' && ( t.value === type)?.description || ''} > )}
{type === 'webhook' && ( <>
( )} />
(
Gzip Gzip compresses logs before sending it to the destination.
)} /> )} {type === 'datadog' && (
The API Key obtained from the Datadog dashboard{' '} here } /> ( The Datadog region to send logs to. Read more about Datadog regions{' '} here .

} >
)} />
)} {type === 'loki' && (
)} {type === 'sentry' && (
The DSN obtained from the Sentry dashboard. Read more about DSNs{' '} here . } />
)} {type === 's3' && (

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

)} {type === 'axiom' && (
)} {type === 'otlp' && ( <>
( )} />
(
Gzip Compression Enable gzip compression for log data sent to the OTLP endpoint.
)} /> )} {type === 'last9' && (
( The Last9 region to send logs to. Credentials can be obtained from the Last9 OTEL integration panel.

} >
)} />
)} {type === 'syslog' && ( <>
(
TLS Connect via SSL/TLS instead of plain TCP.
)} /> {tls && (
(