import { zodResolver } from '@hookform/resolvers/zod' import { useTrack } from 'lib/telemetry/track' import { TrashIcon } from 'lucide-react' import Link from 'next/link' import { ReactNode, useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' import { IS_PLATFORM, useFlag, useParams } from 'common' import { LogDrainData, useLogDrainsQuery } from 'data/log-drains/log-drains-query' import { DOCS_URL } from 'lib/constants' import { Button, cn, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, FormItem_Shadcn_, FormLabel_Shadcn_, FormMessage_Shadcn_, Input_Shadcn_, RadioGroupCard, RadioGroupCardItem, Select_Shadcn_, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectLabel_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, Sheet, SheetContent, SheetFooter, SheetHeader, SheetSection, SheetTitle, Switch, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { urlRegex } from '../Auth/Auth.constants' import { DATADOG_REGIONS, LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants' const FORM_ID = 'log-drain-destination-form' const formUnion = z.discriminatedUnion('type', [ z.object({ type: z.literal('webhook'), url: z .string() .regex(urlRegex(), 'Endpoint URL is required and must be a valid URL') .refine( (url) => url.startsWith('http://') || url.startsWith('https://'), 'Endpoint URL must start with http:// or https://' ), http: z.enum(['http1', 'http2']), gzip: z.boolean(), headers: z.record(z.string(), z.string()).optional(), }), 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' }), }), z.object({ type: z.literal('loki'), url: z .string() .min(1, { message: 'Loki URL is required' }) .refine( (url) => url.startsWith('http://') || url.startsWith('https://'), 'Loki URL must start with http:// or https://' ), headers: z.record(z.string(), z.string()), username: z.string().optional(), password: z.string().optional(), }), // [Joshen] To fix API types, not supported in the UI z.object({ type: z.literal('elastic'), }), z.object({ type: z.literal('postgres'), }), z.object({ type: z.literal('bigquery'), }), z.object({ type: z.literal('clickhouse'), }), z.object({ type: z.literal('s3'), }), z.object({ type: z.literal('axiom'), }), 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 formSchema = z .object({ name: z.string().min(1, { message: 'Destination name is required', }), description: z.string().optional(), }) .and(formUnion) function LogDrainFormItem({ value, label, description, formControl, placeholder, type, defaultValue, }: { value: string label: string formControl: any placeholder?: string description?: ReactNode type?: string defaultValue?: 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: z.infer) => 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 defaultConfig = (defaultValues?.config || {}) as any const CREATE_DEFAULT_HEADERS = { 'Content-Type': 'application/json', } const DEFAULT_HEADERS = mode === 'create' ? CREATE_DEFAULT_HEADERS : defaultConfig?.headers || {} const sentryEnabled = useFlag('SentryLogDrain') const { ref } = useParams() const { data: logDrains } = useLogDrainsQuery({ ref, }) const defaultType = defaultValues?.type || 'webhook' const [newCustomHeader, setNewCustomHeader] = useState({ name: '', value: '' }) const track = useTrack() const form = useForm>({ resolver: zodResolver(formSchema), values: { name: defaultValues?.name || '', description: defaultValues?.description || '', type: defaultType, http: defaultConfig?.http || 'http2', gzip: mode === 'create' ? true : defaultConfig?.gzip || false, headers: DEFAULT_HEADERS, url: defaultConfig?.url || '', api_key: defaultConfig?.api_key || '', region: defaultConfig?.region || '', username: defaultConfig?.username || '', password: defaultConfig?.password || '', dsn: defaultConfig?.dsn || '', }, }) const headers = form.watch('headers') const type = form.watch('type') function removeHeader(key: string) { const newHeaders = { ...headers, } delete newHeaders[key] form.setValue('headers', newHeaders) } function addHeader() { const formHeaders = form.getValues('headers') if (!formHeaders) return const headerKeys = Object.keys(formHeaders) if (headerKeys?.length === 20) { toast.error('You can only have 20 custom headers') return } if (headerKeys?.includes(newCustomHeader.name)) { toast.error('Header name already exists') return } if (!newCustomHeader.name || !newCustomHeader.value) { toast.error('Header name and value are required') return } form.setValue('headers', { ...formHeaders, [newCustomHeader.name]: newCustomHeader.value }) setNewCustomHeader({ name: '', value: '' }) } const hasHeaders = Object.keys(headers || {})?.length > 0 useEffect(() => { if (mode === 'create' && !open) { form.reset() } }, [mode, open, form]) function getHeadersSectionDescription() { if (type === 'webhook') { return 'Set custom headers when draining logs to the Endpoint URL' } if (type === 'loki') { return 'Set custom headers when draining logs to the Loki HTTP(S) endpoint' } return '' } return ( { setNewCustomHeader({ name: '', value: '' }) onOpenChange(v) }} > 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(onSubmit)(e) track('log_drain_save_button_clicked', { destination: form.getValues('type') as Exclude< LogDrainType, 'elastic' | 'postgres' | 'bigquery' | 'clickhouse' | 's3' | 'axiom' >, }) }} >
{mode === 'create' && ( t.value === type)?.description || ''} > ) => form.setValue('type', v) } > {LOG_DRAIN_TYPES.find((t) => t.value === type)?.name} {LOG_DRAIN_TYPES.filter((t) => t.value !== 'sentry' || sentryEnabled).map( (type) => ( {type.name} ) )} )}
{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 .

} > Region {DATADOG_REGIONS.map((reg) => ( {reg.label} ))}
)} />
)} {type === 'loki' && (
)} {type === 'sentry' && (
The DSN obtained from the Sentry dashboard. Read more about DSNs{' '} here . } />
)}
{/* This form needs to be outside the */} {(type === 'webhook' || type === 'loki') && ( <>

Custom Headers

{getHeadersSectionDescription()}

{hasHeaders && Object.keys(headers || {})?.map((headerKey) => (
{headerKey}
{headers?.[headerKey]}
))}
{ e.preventDefault() e.stopPropagation() addHeader() }} className="flex border-t py-4 gap-4 items-center px-content" > setNewCustomHeader({ ...newCustomHeader, name: e.target.value })} /> setNewCustomHeader({ ...newCustomHeader, value: e.target.value }) } /> )}
  • Additional drain cost $60 per month
  • Per million events +$0.20
  • Per GB egress +$0.09
See full pricing breakdown{' '} here
) }