LogDrainDestinationSheetForm.tsx 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { IS_PLATFORM, useFlag, useParams } from 'common'
  3. import Link from 'next/link'
  4. import { ReactNode, useEffect, useMemo } from 'react'
  5. import { useForm } from 'react-hook-form'
  6. import { toast } from 'sonner'
  7. import {
  8. Button,
  9. cn,
  10. Form,
  11. FormControl,
  12. FormField,
  13. FormItem,
  14. FormLabel,
  15. Input,
  16. RadioGroupCard,
  17. RadioGroupCardItem,
  18. Select,
  19. SelectContent,
  20. SelectGroup,
  21. SelectItem,
  22. SelectLabel,
  23. SelectTrigger,
  24. SelectValue,
  25. Sheet,
  26. SheetContent,
  27. SheetFooter,
  28. SheetHeader,
  29. SheetSection,
  30. SheetTitle,
  31. Switch,
  32. TextArea,
  33. } from 'ui'
  34. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  35. import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
  36. import { InfoTooltip } from 'ui-patterns/info-tooltip'
  37. import { z } from 'zod'
  38. import {
  39. DATADOG_REGIONS,
  40. LAST9_REGIONS,
  41. LOG_DRAIN_TYPES,
  42. LogDrainType,
  43. OTLP_PROTOCOLS,
  44. } from './LogDrains.constants'
  45. import {
  46. getDefaultHeadersByType,
  47. getHeadersSectionDescription as getHeadersDescription,
  48. headerRecordToRows,
  49. headerRowsToRecord,
  50. logDrainHeaderEntriesSchema,
  51. type LogDrainHeaderRow,
  52. } from './LogDrains.utils'
  53. import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
  54. import { LogDrainData, useLogDrainsQuery } from '@/data/log-drains/log-drains-query'
  55. import { DOCS_URL } from '@/lib/constants'
  56. import { useTrack } from '@/lib/telemetry/track'
  57. import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
  58. const FORM_ID = 'log-drain-destination-form'
  59. const headerRecordSchema = z.record(z.string(), z.string())
  60. const webhookFields = {
  61. type: z.literal('webhook'),
  62. url: httpEndpointUrlSchema({
  63. requiredMessage: 'Endpoint URL is required',
  64. invalidMessage: 'Endpoint URL must be a valid URL',
  65. prefixMessage: 'Endpoint URL must start with http:// or https://',
  66. }),
  67. http: z.enum(['http1', 'http2']),
  68. gzip: z.boolean(),
  69. }
  70. const webhookFormSchema = z.object({
  71. ...webhookFields,
  72. headerEntries: logDrainHeaderEntriesSchema.optional(),
  73. })
  74. const webhookSubmitSchema = z.object({
  75. ...webhookFields,
  76. headers: headerRecordSchema.optional(),
  77. })
  78. const datadogSchema = z.object({
  79. type: z.literal('datadog'),
  80. api_key: z.string().min(1, { message: 'API key is required' }),
  81. region: z.string().min(1, { message: 'Region is required' }),
  82. })
  83. const lokiFields = {
  84. type: z.literal('loki'),
  85. url: httpEndpointUrlSchema({
  86. requiredMessage: 'Loki URL is required',
  87. invalidMessage: 'Loki URL must be a valid URL',
  88. prefixMessage: 'Loki URL must start with http:// or https://',
  89. }),
  90. username: z.string().optional(),
  91. password: z.string().optional(),
  92. }
  93. const lokiFormSchema = z.object({
  94. ...lokiFields,
  95. headerEntries: logDrainHeaderEntriesSchema.optional(),
  96. })
  97. const lokiSubmitSchema = z.object({
  98. ...lokiFields,
  99. headers: headerRecordSchema,
  100. })
  101. const elasticSchema = z.object({
  102. type: z.literal('elastic'),
  103. })
  104. const postgresSchema = z.object({
  105. type: z.literal('postgres'),
  106. })
  107. const bigquerySchema = z.object({
  108. type: z.literal('bigquery'),
  109. })
  110. const clickhouseSchema = z.object({
  111. type: z.literal('clickhouse'),
  112. })
  113. const s3Schema = z.object({
  114. type: z.literal('s3'),
  115. s3_bucket: z.string().min(1, { message: 'Bucket name is required' }),
  116. storage_region: z.string().min(1, { message: 'Region is required' }),
  117. access_key_id: z.string().min(1, { message: 'Access Key ID is required' }),
  118. secret_access_key: z.string().min(1, { message: 'Secret Access Key is required' }),
  119. batch_timeout: z.coerce
  120. .number()
  121. .int({ message: 'Batch timeout must be an integer' })
  122. .min(1, { message: 'Batch timeout must be a positive integer' }),
  123. })
  124. const sentrySchema = z.object({
  125. type: z.literal('sentry'),
  126. dsn: z
  127. .string()
  128. .min(1, { message: 'Sentry DSN is required' })
  129. .refine((dsn) => dsn.startsWith('https://'), 'Sentry DSN must start with https://'),
  130. })
  131. const axiomSchema = z.object({
  132. type: z.literal('axiom'),
  133. api_token: z.string().min(1, { message: 'API token is required' }),
  134. dataset_name: z.string().min(1, { message: 'Dataset name is required' }),
  135. })
  136. const last9Schema = z.object({
  137. type: z.literal('last9'),
  138. region: z.string().min(1, { message: 'Region is required' }),
  139. username: z.string().min(1, { message: 'Username is required' }),
  140. password: z.string().min(1, { message: 'Password is required' }),
  141. })
  142. const otlpFields = {
  143. type: z.literal('otlp'),
  144. endpoint: httpEndpointUrlSchema({
  145. requiredMessage: 'OTLP endpoint is required',
  146. invalidMessage: 'OTLP endpoint must be a valid URL',
  147. prefixMessage: 'OTLP endpoint must start with http:// or https://',
  148. }),
  149. protocol: z.string().optional().default('http/protobuf'),
  150. gzip: z.boolean().optional().default(true),
  151. }
  152. const otlpFormSchema = z.object({
  153. ...otlpFields,
  154. headerEntries: logDrainHeaderEntriesSchema.optional(),
  155. })
  156. const otlpSubmitSchema = z.object({
  157. ...otlpFields,
  158. headers: headerRecordSchema.optional(),
  159. })
  160. const syslogSchema = z.object({
  161. type: z.literal('syslog'),
  162. host: z.string().min(1, { message: 'Host is required' }),
  163. port: z.coerce
  164. .number()
  165. .int({ message: 'Port must be an integer' })
  166. .min(0, { message: 'Port must be between 0 and 65535' })
  167. .max(65535, { message: 'Port must be between 0 and 65535' }),
  168. tls: z.boolean().optional().default(false),
  169. structured_data: z.string().optional(),
  170. cipher_key: z.string().optional(),
  171. ca_cert: z.string().optional(),
  172. client_cert: z.string().optional(),
  173. client_key: z.string().optional(),
  174. })
  175. const formUnion = z.discriminatedUnion('type', [
  176. webhookFormSchema,
  177. datadogSchema,
  178. lokiFormSchema,
  179. // [Joshen] To fix API types, not supported in the UI
  180. elasticSchema,
  181. postgresSchema,
  182. bigquerySchema,
  183. clickhouseSchema,
  184. s3Schema,
  185. sentrySchema,
  186. axiomSchema,
  187. last9Schema,
  188. otlpFormSchema,
  189. syslogSchema,
  190. ])
  191. const submitUnion = z.discriminatedUnion('type', [
  192. webhookSubmitSchema,
  193. datadogSchema,
  194. lokiSubmitSchema,
  195. elasticSchema,
  196. postgresSchema,
  197. bigquerySchema,
  198. clickhouseSchema,
  199. s3Schema,
  200. sentrySchema,
  201. axiomSchema,
  202. last9Schema,
  203. otlpSubmitSchema,
  204. syslogSchema,
  205. ])
  206. const formSchema = z
  207. .object({
  208. name: z.string().min(1, {
  209. message: 'Destination name is required',
  210. }),
  211. description: z.string().optional(),
  212. })
  213. .and(formUnion)
  214. .superRefine((data, ctx) => {
  215. if (data.type !== 'syslog') return
  216. if (data.client_cert && !data.client_key) {
  217. ctx.addIssue({
  218. code: z.ZodIssueCode.custom,
  219. message: 'Client key is required when a client certificate is provided',
  220. path: ['client_key'],
  221. })
  222. }
  223. if (data.client_key && !data.client_cert) {
  224. ctx.addIssue({
  225. code: z.ZodIssueCode.custom,
  226. message: 'Client certificate is required when a client key is provided',
  227. path: ['client_cert'],
  228. })
  229. }
  230. })
  231. const submitSchema = z
  232. .object({
  233. name: z.string().min(1, {
  234. message: 'Destination name is required',
  235. }),
  236. description: z.string().optional(),
  237. })
  238. .and(submitUnion)
  239. type LogDrainDestinationFormValues = z.infer<typeof formSchema>
  240. type LogDrainDestinationSubmitValues = z.infer<typeof submitSchema>
  241. const HEADER_ENABLED_TYPES = ['webhook', 'loki', 'otlp'] as const
  242. function toSubmitValues(values: LogDrainDestinationFormValues): LogDrainDestinationSubmitValues {
  243. if (!HEADER_ENABLED_TYPES.includes(values.type as (typeof HEADER_ENABLED_TYPES)[number])) {
  244. return submitSchema.parse(values)
  245. }
  246. const { headerEntries = [], ...rest } = values as LogDrainDestinationFormValues & {
  247. headerEntries?: LogDrainHeaderRow[]
  248. }
  249. const headers = headerRowsToRecord(headerEntries)
  250. const transformedValues =
  251. rest.type === 'loki'
  252. ? { ...rest, headers }
  253. : Object.keys(headers).length > 0
  254. ? { ...rest, headers }
  255. : rest
  256. return submitSchema.parse(transformedValues)
  257. }
  258. function LogDrainFormItem({
  259. value,
  260. label,
  261. description,
  262. formControl,
  263. placeholder,
  264. type,
  265. }: {
  266. value: string
  267. label: string
  268. formControl: any
  269. placeholder?: string
  270. description?: ReactNode
  271. type?: string
  272. }) {
  273. return (
  274. <FormField
  275. name={value}
  276. control={formControl}
  277. render={({ field }) => (
  278. <FormItemLayout layout="horizontal" label={label} description={description || ''}>
  279. <FormControl>
  280. <Input type={type || 'text'} placeholder={placeholder} {...field} />
  281. </FormControl>
  282. </FormItemLayout>
  283. )}
  284. />
  285. )
  286. }
  287. type DefaultValues = { type: LogDrainType } & Partial<LogDrainData>
  288. export function LogDrainDestinationSheetForm({
  289. open,
  290. onOpenChange,
  291. defaultValues,
  292. onSubmit,
  293. isLoading,
  294. mode,
  295. }: {
  296. open: boolean
  297. onOpenChange: (v: boolean) => void
  298. defaultValues?: DefaultValues
  299. isLoading?: boolean
  300. onSubmit: (values: LogDrainDestinationSubmitValues) => void
  301. mode: 'create' | 'update'
  302. }) {
  303. // NOTE(kamil): This used to be `any` for a long long time, but after moving to Zod,
  304. // it produces a correct union type of all possible configs. Unfortunately, this type was not designed correctly
  305. // and it does not include `type` inside the config itself, so it's not trivial to create `discriminatedUnion`
  306. // out of it, therefore for an ease of use now, we bail to `any` until the better time come.
  307. const defaultType = defaultValues?.type || 'webhook'
  308. const defaultHeaderEntries = useMemo(() => {
  309. const config = (defaultValues?.config || {}) as any
  310. const type = defaultValues?.type || 'webhook'
  311. return headerRecordToRows(
  312. mode === 'create' ? getDefaultHeadersByType(type) : config?.headers || {}
  313. )
  314. }, [defaultValues, mode])
  315. const sentryEnabled = useFlag('SentryLogDrain')
  316. const s3Enabled = useFlag('S3logdrain')
  317. const axiomEnabled = useFlag('axiomLogDrain')
  318. const otlpEnabled = useFlag('otlpLogDrain')
  319. const last9Enabled = useFlag('Last9LogDrain')
  320. const syslogEnabled = useFlag('syslogLogDrain')
  321. const { ref } = useParams()
  322. const { data: logDrains } = useLogDrainsQuery({
  323. ref,
  324. })
  325. const track = useTrack()
  326. const formValues = useMemo(() => {
  327. const config = (defaultValues?.config || {}) as any
  328. const type = defaultValues?.type || 'webhook'
  329. return {
  330. name: defaultValues?.name || '',
  331. description: defaultValues?.description || '',
  332. type,
  333. http: config?.http || 'http2',
  334. gzip: mode === 'create' ? true : config?.gzip || false,
  335. headerEntries: defaultHeaderEntries,
  336. url: config?.url || '',
  337. api_key: config?.api_key || '',
  338. region: config?.region || '',
  339. username: config?.username || '',
  340. password: config?.password || '',
  341. dsn: config?.dsn || '',
  342. s3_bucket: config?.s3_bucket || '',
  343. storage_region: config?.storage_region || '',
  344. access_key_id: config?.access_key_id || '',
  345. secret_access_key: config?.secret_access_key || '',
  346. batch_timeout: config?.batch_timeout ?? 3000,
  347. dataset_name: config?.dataset_name || '',
  348. api_token: config?.api_token || '',
  349. endpoint: config?.endpoint || '',
  350. protocol: config?.protocol || 'http/protobuf',
  351. host: config?.host || '',
  352. port: (config?.port ?? '') as number,
  353. tls: config?.tls ?? false,
  354. structured_data: config?.structured_data || '',
  355. cipher_key: config?.cipher_key || '',
  356. ca_cert: config?.ca_cert || '',
  357. client_cert: config?.client_cert || '',
  358. client_key: config?.client_key || '',
  359. }
  360. }, [defaultValues, mode, defaultHeaderEntries])
  361. const form = useForm<LogDrainDestinationFormValues>({
  362. resolver: zodResolver(formSchema as any),
  363. values: formValues,
  364. })
  365. const type = form.watch('type')
  366. const tls = form.watch('tls')
  367. useEffect(() => {
  368. if (mode === 'create' && !open) {
  369. form.reset()
  370. }
  371. }, [mode, open, form])
  372. useEffect(() => {
  373. if (!open || mode !== 'create') return
  374. form.setValue('headerEntries', headerRecordToRows(getDefaultHeadersByType(type)))
  375. form.clearErrors('headerEntries')
  376. }, [form, mode, open, type])
  377. return (
  378. <Sheet open={open} onOpenChange={onOpenChange}>
  379. <SheetContent
  380. tabIndex={undefined}
  381. showClose={false}
  382. size="lg"
  383. className="overflow-y-auto flex flex-col"
  384. >
  385. <SheetHeader>
  386. <SheetTitle>Add destination</SheetTitle>
  387. </SheetHeader>
  388. <SheetSection className="px-0! pb-0!">
  389. <Form {...form}>
  390. <form
  391. id={FORM_ID}
  392. onSubmit={(e) => {
  393. e.preventDefault()
  394. // Temp check to make sure the name is unique
  395. const logDrainName = form.getValues('name')
  396. const logDrainExists =
  397. !!logDrains?.length && logDrains?.find((drain) => drain.name === logDrainName)
  398. if (logDrainExists && mode === 'create') {
  399. toast.error('Log drain name already exists')
  400. return
  401. }
  402. form.handleSubmit((values) => onSubmit(toSubmitValues(values)))(e)
  403. track('log_drain_save_button_clicked', {
  404. destination: form.getValues('type'),
  405. })
  406. }}
  407. >
  408. <div className="space-y-8 px-content">
  409. <LogDrainFormItem
  410. value="name"
  411. placeholder="My Destination"
  412. label="Name"
  413. formControl={form.control}
  414. />
  415. <LogDrainFormItem
  416. value="description"
  417. placeholder="Optional description"
  418. label="Description"
  419. formControl={form.control}
  420. />
  421. {mode === 'create' && (
  422. <FormItemLayout
  423. layout="horizontal"
  424. label="Type"
  425. description={LOG_DRAIN_TYPES.find((t) => t.value === type)?.description || ''}
  426. >
  427. <Select
  428. defaultValue={defaultType}
  429. value={form.getValues('type')}
  430. onValueChange={(v: LogDrainType) => form.setValue('type', v)}
  431. >
  432. <SelectTrigger>
  433. {LOG_DRAIN_TYPES.find((t) => t.value === type)?.name}
  434. </SelectTrigger>
  435. <SelectContent>
  436. {LOG_DRAIN_TYPES.filter((t) => {
  437. if (t.value === 'sentry') return sentryEnabled
  438. if (t.value === 's3') return s3Enabled
  439. if (t.value === 'axiom') return axiomEnabled
  440. if (t.value === 'otlp') return otlpEnabled
  441. if (t.value === 'last9') return last9Enabled
  442. if (t.value === 'syslog') return syslogEnabled
  443. return true
  444. }).map((type) => (
  445. <SelectItem
  446. value={type.value}
  447. key={type.value}
  448. id={type.value}
  449. className="text-left"
  450. >
  451. {type.name}
  452. </SelectItem>
  453. ))}
  454. </SelectContent>
  455. </Select>
  456. </FormItemLayout>
  457. )}
  458. </div>
  459. <div className="space-y-8 mt-4">
  460. {type === 'webhook' && (
  461. <>
  462. <div className="px-content space-y-8">
  463. <LogDrainFormItem
  464. value="url"
  465. label="Endpoint URL"
  466. formControl={form.control}
  467. placeholder="https://example.com/log-drain"
  468. />
  469. <FormField
  470. control={form.control}
  471. name="http"
  472. render={({ field }) => (
  473. <FormItemLayout layout="horizontal" label="HTTP Version">
  474. <FormControl>
  475. <RadioGroupCard
  476. className="flex gap-2"
  477. onValueChange={field.onChange}
  478. value={field.value}
  479. >
  480. <FormItem asChild>
  481. <FormControl>
  482. <RadioGroupCardItem value="http1" label="HTTP/1" />
  483. </FormControl>
  484. </FormItem>
  485. <FormItem asChild>
  486. <FormControl>
  487. <RadioGroupCardItem value="http2" label="HTTP/2" />
  488. </FormControl>
  489. </FormItem>
  490. </RadioGroupCard>
  491. </FormControl>
  492. </FormItemLayout>
  493. )}
  494. />
  495. </div>
  496. <FormField
  497. control={form.control}
  498. name="gzip"
  499. render={({ field }) => (
  500. <FormItem className="space-y-2 px-4">
  501. <div className="flex gap-2 items-center">
  502. <FormControl>
  503. <Switch checked={field.value} onCheckedChange={field.onChange} />
  504. </FormControl>
  505. <FormLabel className="text-base">Gzip</FormLabel>
  506. <InfoTooltip align="start">
  507. Gzip compresses logs before sending it to the destination.
  508. </InfoTooltip>
  509. </div>
  510. </FormItem>
  511. )}
  512. />
  513. </>
  514. )}
  515. {type === 'datadog' && (
  516. <div className="grid gap-4 px-content">
  517. <LogDrainFormItem
  518. type="password"
  519. value="api_key"
  520. label="API Key"
  521. formControl={form.control}
  522. description={
  523. <>
  524. The API Key obtained from the Datadog dashboard{' '}
  525. <a
  526. target="_blank"
  527. rel="noopener noreferrer"
  528. className="text-sm underline transition hover:text-foreground"
  529. href="https://app.datadoghq.com/organization-settings/api-keys"
  530. >
  531. here
  532. </a>
  533. </>
  534. }
  535. />
  536. <FormField
  537. name="region"
  538. control={form.control}
  539. render={({ field }) => (
  540. <FormItemLayout
  541. layout="horizontal"
  542. label={'Region'}
  543. description={
  544. <p>
  545. The Datadog region to send logs to. Read more about Datadog regions{' '}
  546. <a
  547. target="_blank"
  548. rel="noopener noreferrer"
  549. className="underline hover:text-foreground transition"
  550. href="https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site"
  551. >
  552. here
  553. </a>
  554. .
  555. </p>
  556. }
  557. >
  558. <FormControl>
  559. <Select value={field.value} onValueChange={field.onChange}>
  560. <SelectTrigger className="col-span-3">
  561. <SelectValue placeholder="Select a region" />
  562. </SelectTrigger>
  563. <SelectContent>
  564. <SelectGroup>
  565. <SelectLabel>Region</SelectLabel>
  566. {DATADOG_REGIONS.map((reg) => (
  567. <SelectItem key={reg.value} value={reg.value}>
  568. {reg.label}
  569. </SelectItem>
  570. ))}
  571. </SelectGroup>
  572. </SelectContent>
  573. </Select>
  574. </FormControl>
  575. </FormItemLayout>
  576. )}
  577. />
  578. </div>
  579. )}
  580. {type === 'loki' && (
  581. <div className="grid gap-4 px-content">
  582. <LogDrainFormItem
  583. type="url"
  584. value="url"
  585. placeholder="https://my-logs-endpoint.grafana.net/loki/api/v1/push"
  586. label="Loki URL"
  587. formControl={form.control}
  588. description="The Loki HTTP(S) endpoint to send events."
  589. />
  590. <LogDrainFormItem
  591. value="username"
  592. label="Username"
  593. placeholder="123456789"
  594. formControl={form.control}
  595. />
  596. <LogDrainFormItem
  597. type="password"
  598. value="password"
  599. label="Password"
  600. placeholder="glc_ABCD1234567890"
  601. formControl={form.control}
  602. />
  603. </div>
  604. )}
  605. {type === 'sentry' && (
  606. <div className="grid gap-4 px-content">
  607. <LogDrainFormItem
  608. type="text"
  609. value="dsn"
  610. label="DSN"
  611. placeholder="https://<project_id>@o<organization_id>.ingest.sentry.io/<project_id>"
  612. formControl={form.control}
  613. description={
  614. <>
  615. The DSN obtained from the Sentry dashboard. Read more about DSNs{' '}
  616. <a
  617. target="_blank"
  618. rel="noopener noreferrer"
  619. className="text-sm underline transition hover:text-foreground"
  620. href="https://docs.sentry.io/concepts/key-terms/dsn-explainer/"
  621. >
  622. here
  623. </a>
  624. .
  625. </>
  626. }
  627. />
  628. </div>
  629. )}
  630. {type === 's3' && (
  631. <div className="grid gap-4 px-content">
  632. <LogDrainFormItem
  633. value="s3_bucket"
  634. label="S3 Bucket"
  635. placeholder="my-log-bucket"
  636. formControl={form.control}
  637. description="The name of an existing S3 bucket."
  638. />
  639. <LogDrainFormItem
  640. value="storage_region"
  641. label="Region"
  642. placeholder="us-east-1"
  643. formControl={form.control}
  644. description="AWS region where the bucket is located."
  645. />
  646. <LogDrainFormItem
  647. value="access_key_id"
  648. label="Access Key ID"
  649. placeholder="AKIA..."
  650. formControl={form.control}
  651. />
  652. <LogDrainFormItem
  653. type="password"
  654. value="secret_access_key"
  655. label="Secret Access Key"
  656. placeholder="••••••••••••••••"
  657. formControl={form.control}
  658. />
  659. <LogDrainFormItem
  660. type="number"
  661. value="batch_timeout"
  662. label="Batch Timeout (ms)"
  663. placeholder="3000"
  664. formControl={form.control}
  665. description="Recommended 2000–5000ms."
  666. />
  667. <p className="text-xs text-foreground-lighter">
  668. Ensure the account tied to the Access Key ID can write to the specified
  669. bucket.
  670. </p>
  671. </div>
  672. )}
  673. {type === 'axiom' && (
  674. <div className="grid gap-4 px-content">
  675. <LogDrainFormItem
  676. type="text"
  677. value="dataset_name"
  678. label="Dataset name"
  679. placeholder="dataset"
  680. formControl={form.control}
  681. description="Name of the dataset in Axiom where the logs will be sent."
  682. />
  683. <LogDrainFormItem
  684. type="text"
  685. value="api_token"
  686. label="API Token"
  687. placeholder="xaat-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
  688. formControl={form.control}
  689. description="Token allowing ingest access to the specified dataset"
  690. />
  691. </div>
  692. )}
  693. {type === 'otlp' && (
  694. <>
  695. <div className="grid gap-4 px-content">
  696. <LogDrainFormItem
  697. type="url"
  698. value="endpoint"
  699. label="OTLP Endpoint"
  700. placeholder="https://otlp.example.com:4318/v1/logs"
  701. formControl={form.control}
  702. description="The HTTP endpoint for OTLP log ingestion (typically ends with /v1/logs)"
  703. />
  704. <FormField
  705. name="protocol"
  706. control={form.control}
  707. render={({ field }) => (
  708. <FormItemLayout
  709. layout="horizontal"
  710. label="Protocol"
  711. description="Only HTTP with Protocol Buffers is currently supported"
  712. >
  713. <FormControl>
  714. <Select value={field.value} onValueChange={field.onChange}>
  715. <SelectTrigger className="col-span-3">
  716. <SelectValue placeholder="Select protocol" />
  717. </SelectTrigger>
  718. <SelectContent>
  719. <SelectGroup>
  720. <SelectLabel>Protocol</SelectLabel>
  721. {OTLP_PROTOCOLS.map((proto) => (
  722. <SelectItem key={proto.value} value={proto.value}>
  723. {proto.label}
  724. </SelectItem>
  725. ))}
  726. </SelectGroup>
  727. </SelectContent>
  728. </Select>
  729. </FormControl>
  730. </FormItemLayout>
  731. )}
  732. />
  733. </div>
  734. <FormField
  735. control={form.control}
  736. name="gzip"
  737. render={({ field }) => (
  738. <FormItem className="space-y-2 px-4">
  739. <div className="flex gap-2 items-center">
  740. <FormControl>
  741. <Switch checked={field.value} onCheckedChange={field.onChange} />
  742. </FormControl>
  743. <FormLabel className="text-base">Gzip Compression</FormLabel>
  744. <InfoTooltip align="start">
  745. Enable gzip compression for log data sent to the OTLP endpoint.
  746. </InfoTooltip>
  747. </div>
  748. </FormItem>
  749. )}
  750. />
  751. </>
  752. )}
  753. {type === 'last9' && (
  754. <div className="grid gap-4 px-content">
  755. <FormField
  756. name="region"
  757. control={form.control}
  758. render={({ field }) => (
  759. <FormItemLayout
  760. layout="horizontal"
  761. label={'Region'}
  762. description={
  763. <p>
  764. The Last9 region to send logs to. Credentials can be obtained from the
  765. Last9 OTEL integration panel.
  766. </p>
  767. }
  768. >
  769. <FormControl>
  770. <Select value={field.value} onValueChange={field.onChange}>
  771. <SelectTrigger className="col-span-3">
  772. <SelectValue placeholder="Select a region" />
  773. </SelectTrigger>
  774. <SelectContent>
  775. <SelectGroup>
  776. <SelectLabel>Region</SelectLabel>
  777. {LAST9_REGIONS.map((reg) => (
  778. <SelectItem key={reg.value} value={reg.value}>
  779. {reg.label}
  780. </SelectItem>
  781. ))}
  782. </SelectGroup>
  783. </SelectContent>
  784. </Select>
  785. </FormControl>
  786. </FormItemLayout>
  787. )}
  788. />
  789. <LogDrainFormItem
  790. type="text"
  791. value="username"
  792. label="Username"
  793. placeholder="username"
  794. formControl={form.control}
  795. description="Username for authentication from Last9 OTEL integration."
  796. />
  797. <LogDrainFormItem
  798. type="password"
  799. value="password"
  800. label="Password"
  801. placeholder="••••••••••••••••"
  802. formControl={form.control}
  803. description="Password for authentication from Last9 OTEL integration."
  804. />
  805. </div>
  806. )}
  807. {type === 'syslog' && (
  808. <>
  809. <div className="grid gap-4 px-content">
  810. <LogDrainFormItem
  811. value="host"
  812. label="Host"
  813. placeholder="logs.example.com"
  814. formControl={form.control}
  815. description="Hostname or IP address of the syslog receiver."
  816. />
  817. <LogDrainFormItem
  818. type="number"
  819. value="port"
  820. label="Port"
  821. placeholder="514"
  822. formControl={form.control}
  823. description="Port of the syslog receiver (0–65535)."
  824. />
  825. <LogDrainFormItem
  826. value="structured_data"
  827. label="Structured Data"
  828. placeholder='[exampleSDID@32473 iut="3" eventSource="Application"]'
  829. formControl={form.control}
  830. description="Static RFC 5424 Structured Data included in every log frame."
  831. />
  832. <LogDrainFormItem
  833. type="password"
  834. value="cipher_key"
  835. label="Cipher Key"
  836. placeholder="••••••••••••••••"
  837. formControl={form.control}
  838. description="Base64-encoded 32-byte key for AES-256-GCM encryption of the log body."
  839. />
  840. </div>
  841. <FormField
  842. control={form.control}
  843. name="tls"
  844. render={({ field }) => (
  845. <FormItem className="space-y-2 px-4">
  846. <div className="flex gap-2 items-center">
  847. <FormControl>
  848. <Switch checked={field.value} onCheckedChange={field.onChange} />
  849. </FormControl>
  850. <FormLabel className="text-base">TLS</FormLabel>
  851. <InfoTooltip align="start">
  852. Connect via SSL/TLS instead of plain TCP.
  853. </InfoTooltip>
  854. </div>
  855. </FormItem>
  856. )}
  857. />
  858. {tls && (
  859. <div className="grid gap-4 px-content">
  860. <FormField
  861. name="ca_cert"
  862. control={form.control}
  863. render={({ field }) => (
  864. <FormItemLayout
  865. layout="horizontal"
  866. label="CA Certificate"
  867. description="PEM encoded CA certificate for verifying the server. Falls back to the system CA bundle if omitted."
  868. >
  869. <FormControl>
  870. <TextArea
  871. className="font-mono text-xs"
  872. placeholder="-----BEGIN CERTIFICATE-----"
  873. rows={4}
  874. {...field}
  875. />
  876. </FormControl>
  877. </FormItemLayout>
  878. )}
  879. />
  880. <FormField
  881. name="client_cert"
  882. control={form.control}
  883. render={({ field }) => (
  884. <FormItemLayout
  885. layout="horizontal"
  886. label="Client Certificate"
  887. description="PEM encoded client certificate for mTLS."
  888. >
  889. <FormControl>
  890. <TextArea
  891. className="font-mono text-xs"
  892. placeholder="-----BEGIN CERTIFICATE-----"
  893. rows={4}
  894. {...field}
  895. />
  896. </FormControl>
  897. </FormItemLayout>
  898. )}
  899. />
  900. <FormField
  901. name="client_key"
  902. control={form.control}
  903. render={({ field }) => (
  904. <FormItemLayout
  905. layout="horizontal"
  906. label="Client Key"
  907. description="PEM encoded client private key for mTLS. Required when a client certificate is provided."
  908. >
  909. <FormControl>
  910. <TextArea
  911. className="font-mono text-xs"
  912. placeholder="-----BEGIN PRIVATE KEY-----"
  913. rows={4}
  914. {...field}
  915. />
  916. </FormControl>
  917. </FormItemLayout>
  918. )}
  919. />
  920. </div>
  921. )}
  922. </>
  923. )}
  924. {HEADER_ENABLED_TYPES.includes(type as (typeof HEADER_ENABLED_TYPES)[number]) && (
  925. <div className="px-content">
  926. <FormField
  927. control={form.control}
  928. name="headerEntries"
  929. render={({ fieldState }) => (
  930. <FormItemLayout
  931. layout="horizontal"
  932. label="Custom Headers"
  933. description={getHeadersDescription(type)}
  934. hideMessage={!fieldState.error?.message}
  935. >
  936. <KeyValueFieldArray
  937. control={form.control}
  938. name="headerEntries"
  939. keyFieldName="key"
  940. valueFieldName="value"
  941. createEmptyRow={() => ({ key: '', value: '' })}
  942. keyPlaceholder="Header name"
  943. valuePlaceholder="Header value"
  944. addLabel="Add a new header"
  945. removeLabel="Remove header"
  946. />
  947. </FormItemLayout>
  948. )}
  949. />
  950. </div>
  951. )}
  952. </div>
  953. </form>
  954. </Form>
  955. </SheetSection>
  956. <div className="mt-auto">
  957. <SheetSection
  958. className={cn(
  959. `border-t bg-background-alternative-200 mt-auto py-1.5 ${!IS_PLATFORM && 'hidden'}`
  960. )}
  961. >
  962. <ul className="text-right text-foreground-light divide-y divide-dashed text-sm">
  963. <li className="flex items-center justify-between gap-2 py-2" translate="no">
  964. <span className="text-foreground-lighter">Additional drain cost</span>
  965. <span className="text-foreground">$60 per month</span>
  966. </li>
  967. <li className="flex items-center justify-between gap-2 py-2" translate="no">
  968. <span className="text-foreground-lighter">Per million events</span>
  969. <span>+$0.20</span>
  970. </li>
  971. <li className="flex items-center justify-between gap-2 py-2" translate="no">
  972. <span className="text-foreground-lighter">Per GB egress</span>
  973. <span>+$0.09</span>
  974. </li>
  975. </ul>
  976. </SheetSection>
  977. <SheetFooter className="p-content mt-0! justify-between! flex-row! w-full items-center">
  978. <div className="flex flex-col gap-0.5">
  979. <span className="text-sm text-foreground-light">
  980. <span>See full pricing breakdown</span>{' '}
  981. <Link
  982. href={`${DOCS_URL}/guides/platform/manage-your-usage/log-drains`}
  983. target="_blank"
  984. className="text-foreground underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground transition-all"
  985. >
  986. here
  987. </Link>
  988. </span>
  989. <TaxDisclaimer />
  990. </div>
  991. <Button form={FORM_ID} loading={isLoading} htmlType="submit" type="primary">
  992. Save destination
  993. </Button>
  994. </SheetFooter>
  995. </div>
  996. </SheetContent>
  997. </Sheet>
  998. )
  999. }