diff --git a/apps/docs/content/guides/telemetry/log-drains.mdx b/apps/docs/content/guides/telemetry/log-drains.mdx index cee83707ee817..b1eee214ef994 100644 --- a/apps/docs/content/guides/telemetry/log-drains.mdx +++ b/apps/docs/content/guides/telemetry/log-drains.mdx @@ -17,6 +17,7 @@ The following table lists the supported destinations and the required setup conf | Generic HTTP endpoint | HTTP | URL
HTTP Version
Gzip
Headers | | DataDog | HTTP | API Key
Region | | Loki | HTTP | URL
Headers | +| Sentry | HTTP | DSN | 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. @@ -196,6 +197,20 @@ The `event_message` and `timestamp` fields will be dropped from the events to av Loki must be configured to accept **structured metadata**, and it is advised to increase the default maximum number of structured metadata fields to at least 500 to accommodate large log event payloads of different products. +## Sentry + +Logs are sent to Sentry as part of [Sentry's Logging Product](https://docs.sentry.io/product/explore/logs/). Ingesting Supabase logs as Sentry errors is currently not supported. + +To setup the Sentry log drain, you need to do the following: + +1. Grab your DSN from your [Sentry project settings](https://docs.sentry.io/concepts/key-terms/dsn-explainer/). It should be of the format `{PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}{PATH}/{PROJECT_ID}`. +2. Create log drain in [Supabase dashboard](/dashboard/project/_/settings/log-drains) +3. Watch for events in the [Sentry Logs page](https://sentry.io/explore/logs/) + +All fields from the log event are attached as attributes to the Sentry log, which can be used for filtering and grouping in the Sentry UI. There are no limits to cardinality or the number of attributes that can be attached to a log. + +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. + ## 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/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx index 22f464b6bfe92..ae019d3d25456 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx @@ -1,6 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { debounce } from 'lodash' -import { useRef, useState } from 'react' +import { useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' @@ -10,7 +9,7 @@ import { useProjectCloneMutation } from 'data/projects/clone-mutation' import { useCloneBackupsQuery } from 'data/projects/clone-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { passwordStrength } from 'lib/helpers' +import { passwordStrength } from 'lib/password-strength' import { generateStrongPassword } from 'lib/project' import { Button, @@ -86,10 +85,6 @@ export const CreateNewProjectDialog = ({ }, }) - const delayedCheckPasswordStrength = useRef( - debounce((value: string) => checkPasswordStrength(value), 300) - ).current - async function checkPasswordStrength(value: string) { const { message, strength } = await passwordStrength(value) setPasswordStrengthScore(strength) @@ -99,7 +94,7 @@ export const CreateNewProjectDialog = ({ const generatePassword = () => { const password = generateStrongPassword() form.setValue('password', password) - delayedCheckPasswordStrength(password) + checkPasswordStrength(password) } return ( @@ -173,7 +168,7 @@ export const CreateNewProjectDialog = ({ if (value == '') { setPasswordStrengthScore(-1) setPasswordStrengthMessage('') - } else delayedCheckPasswordStrength(value) + } else checkPasswordStrength(value) }} descriptionText={ >({ resolver: zodResolver(formSchema), values: { @@ -178,6 +186,7 @@ export function LogDrainDestinationSheetForm({ region: defaultConfig?.region || '', username: defaultConfig?.username || '', password: defaultConfig?.password || '', + dsn: defaultConfig?.dsn || '', }, }) @@ -274,7 +283,7 @@ export function LogDrainDestinationSheetForm({ /> @@ -293,16 +302,18 @@ export function LogDrainDestinationSheetForm({ {LOG_DRAIN_TYPES.find((t) => t.value === type)?.name} - {LOG_DRAIN_TYPES.map((type) => ( - - {type.name} - - ))} + {LOG_DRAIN_TYPES.filter((t) => t.value !== 'sentry' || sentryEnabled).map( + (type) => ( + + {type.name} + + ) + )} @@ -456,6 +467,31 @@ export function LogDrainDestinationSheetForm({ /> )} + {type === 'sentry' && ( +
+ + The DSN obtained from the Sentry dashboard. Read more about DSNs{' '} + + here + + . + + } + /> +
+ )} diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx index bb4308170bd62..8a6801bc82a32 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx @@ -1,5 +1,5 @@ +import { Datadog, Grafana, Sentry } from 'icons' import { components } from 'api-types' -import { Datadog, Grafana } from 'icons' import { BracesIcon } from 'lucide-react' const iconProps = { @@ -28,6 +28,13 @@ export const LOG_DRAIN_TYPES = [ 'Loki is an open-source log aggregation system designed to store and query logs from multiple sources', icon: , }, + { + value: 'sentry', + name: 'Sentry', + description: + 'Sentry is an application monitoring service that helps developers identify and debug performance issues and errors', + icon: , + }, ] as const export const LOG_DRAIN_SOURCE_VALUES = LOG_DRAIN_TYPES.map((source) => source.value) diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx index 04c42ba4b819e..dd360683085a2 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' -import { useParams } from 'common' +import { useFlag, useParams } from 'common' import AlertError from 'components/ui/AlertError' import CardButton from 'components/ui/CardButton' import Panel from 'components/ui/Panel' @@ -55,6 +55,8 @@ export function LogDrains({ enabled: logDrainsEnabled, } ) + const sentryEnabled = useFlag('SentryLogDrain') + const { mutate: deleteLogDrain } = useDeleteLogDrainMutation({ onSuccess: () => { setIsDeleteModalOpen(false) @@ -91,7 +93,7 @@ export function LogDrains({ if (!isLoading && logDrains?.length === 0) { return (
- {LOG_DRAIN_TYPES.map((src) => ( + {LOG_DRAIN_TYPES.filter((t) => t.value !== 'sentry' || sentryEnabled).map((src) => ( checkPasswordStrength(value), 300) - ).current - // [Refactor] DB Password could be a common component used in multiple pages with repeated logic function generatePassword() { const password = generateStrongPassword() form.setValue('dbPass', password) - delayedCheckPasswordStrength(password) + checkPasswordStrength(password) } return ( @@ -88,7 +82,7 @@ export const DatabasePasswordInput = ({ await form.setValue('dbPassStrength', 0) await form.trigger('dbPass') } else { - await delayedCheckPasswordStrength(value) + await checkPasswordStrength(value) } }} /> diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index 17e30ace69036..0642690718dc7 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -1,6 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { debounce } from 'lodash' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' @@ -12,7 +11,7 @@ import { useDatabasePasswordResetMutation } from 'data/database/database-passwor import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DEFAULT_MINIMUM_PASSWORD_STRENGTH } from 'lib/constants' -import passwordStrength from 'lib/password-strength' +import { passwordStrength } from 'lib/password-strength' import { generateStrongPassword } from 'lib/project' import { Button, Input, Modal } from 'ui' @@ -62,17 +61,13 @@ const ResetDbPassword = ({ disabled = false }) => { setPasswordStrengthMessage(message) } - const delayedCheckPasswordStrength = useRef( - debounce((value) => checkPasswordStrength(value), 300) - ).current - const onDbPassChange = (e: any) => { const value = e.target.value setPassword(value) if (value == '') { setPasswordStrengthScore(-1) setPasswordStrengthMessage('') - } else delayedCheckPasswordStrength(value) + } else checkPasswordStrength(value) } const confirmResetDbPass = async () => { @@ -86,7 +81,7 @@ const ResetDbPassword = ({ disabled = false }) => { function generatePassword() { const password = generateStrongPassword() setPassword(password) - delayedCheckPasswordStrength(password) + checkPasswordStrength(password) } return ( diff --git a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx index ddfb6dd7f72fa..1ccb9a81ad2ef 100644 --- a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx @@ -93,7 +93,7 @@ export function useDatabaseGotoCommands(options?: CommandOptions) { id: 'nav-database-hooks', name: 'Webhooks', value: 'Database: Webhooks', - route: `/project/${ref}/integrations/hooks`, + route: `/project/${ref}/integrations/webhooks`, defaultHidden: true, }, { diff --git a/apps/studio/components/ui/PasswordStrengthBar.tsx b/apps/studio/components/ui/PasswordStrengthBar.tsx index 90a5ae9c937e7..15e140735607b 100644 --- a/apps/studio/components/ui/PasswordStrengthBar.tsx +++ b/apps/studio/components/ui/PasswordStrengthBar.tsx @@ -35,9 +35,10 @@ const PasswordStrengthBar = ({
)}

- {passwordStrengthMessage + {(passwordStrengthMessage ? passwordStrengthMessage - : 'This is the password to your Postgres database, so it must be strong and hard to guess.'}{' '} + : 'This is the password to your Postgres database, so it must be strong and hard to guess.') + + ' '} matchFn(error.requestPathname!)) + SKIP_RETRY_PATHNAME_MATCHERS.some((matchFn) => matchFn(error.requestPathname!)) && + error.code !== 429 ) { return false } diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index d5abad608a4bf..7c4d38c1b0b50 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -1,4 +1,3 @@ -export { default as passwordStrength } from './password-strength' export { default as uuidv4 } from './uuid' import { UIEvent } from 'react' import type { TablesData } from '../data/tables/tables-query' diff --git a/apps/studio/lib/password-strength.test.ts b/apps/studio/lib/password-strength.test.ts index 41f09655ad1c8..9699204014497 100644 --- a/apps/studio/lib/password-strength.test.ts +++ b/apps/studio/lib/password-strength.test.ts @@ -1,27 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import passwordStrength from './password-strength' -import { toast } from 'sonner' - -// Hoist the post_ mock so it's available before the module is loaded -const postMock = vi.hoisted(() => vi.fn()) - -vi.mock('data/fetchers', () => ({ - post: postMock, -})) - -vi.mock('sonner', () => ({ - toast: { error: vi.fn() }, -})) +import { passwordStrength } from './password-strength' describe('passwordStrength', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - it('returns empty values for message, warning and strength for empty input', async () => { const result = await passwordStrength('') expect(result).toEqual({ message: '', warning: '', strength: 0 }) - expect(postMock).not.toHaveBeenCalled() }) it('returns max length message, warning, and strength 0 for password longer than 99 characters', async () => { @@ -30,51 +13,21 @@ describe('passwordStrength', () => { expect(result.message).toMatch(/maximum length/i) expect(result.warning).toMatch(/less than 100 characters/i) expect(result.strength).toBe(0) - expect(postMock).not.toHaveBeenCalled() }) it('returns strong score, suggestion, and empty warning for strong password', async () => { - postMock.mockResolvedValue({ - data: { - result: { - score: 4, - feedback: { suggestions: ['Successfully updated database password'] }, - }, - }, - error: null, - }) - const result = await passwordStrength('StrongPassword123!') + const result = await passwordStrength('ActuallyAStrongPassword123!') expect(result.message).toMatch(/strong/i) - expect(result.message).toContain('Successfully updated database password') + expect(result.message).toContain('This password is strong') expect(result.warning).toBe('') expect(result.strength).toBe(4) }) it('returns weak score, suggestion, and warning for weak password', async () => { - postMock.mockResolvedValue({ - data: { - result: { - score: 2, - feedback: { - suggestions: ['Try a longer password'], - warning: 'Too short', - }, - }, - }, - error: null, - }) const result = await passwordStrength('weak') expect(result.message).toMatch(/not secure/i) - expect(result.message).toContain('Try a longer password') - expect(result.warning).toMatch(/too short/i) + expect(result.message).toContain('This password is not secure enough') expect(result.warning).toMatch(/you need a stronger password/i) - expect(result.strength).toBe(2) - }) - - it('returns empty values and shows toast error on server error', async () => { - postMock.mockResolvedValue({ data: null, error: { message: 'Server error' } }) - const result = await passwordStrength('any') - expect(result).toEqual({ message: '', warning: '', strength: 0 }) - expect(toast.error).toHaveBeenCalledTimes(1) + expect(result.strength).toBe(1) }) }) diff --git a/apps/studio/lib/password-strength.ts b/apps/studio/lib/password-strength.ts index cf813807e547c..5dd1502e5ce5a 100644 --- a/apps/studio/lib/password-strength.ts +++ b/apps/studio/lib/password-strength.ts @@ -1,9 +1,9 @@ -import { post as post_ } from 'data/fetchers' import { DEFAULT_MINIMUM_PASSWORD_STRENGTH, PASSWORD_STRENGTH } from 'lib/constants' -import { toast } from 'sonner' -import { ResponseError } from 'types' -export default async function passwordStrength(value: string) { +export async function passwordStrength(value: string) { + // [Alaister]: Lazy load zxcvbn to avoid bundling it with the main app (it's pretty chunky) + const zxcvbn = await import('zxcvbn').then((module) => module.default) + let message: string = '' let warning: string = '' let strength: number = 0 @@ -13,29 +13,20 @@ export default async function passwordStrength(value: string) { message = `${PASSWORD_STRENGTH[0]} Maximum length of password exceeded` warning = `Password should be less than 100 characters` } else { - const { data, error } = await post_('/platform/profile/password-check', { - body: { password: value }, - }) - if (!error) { - const { result } = data - const resultScore = result?.score ?? 0 + const result = zxcvbn(value) + const resultScore = result?.score ?? 0 - const score = (PASSWORD_STRENGTH as any)[resultScore] - const suggestions = result.feedback?.suggestions - ? result.feedback.suggestions.join(' ') - : '' + const score = (PASSWORD_STRENGTH as any)[resultScore] + const suggestions = result.feedback?.suggestions?.join(' ') ?? '' - message = `${score} ${suggestions}` - strength = resultScore + message = `${score} ${suggestions}` + strength = resultScore - // warning message for anything below 4 strength :string - if (resultScore < DEFAULT_MINIMUM_PASSWORD_STRENGTH) { - warning = `${ - result?.feedback?.warning ? result?.feedback?.warning + '.' : '' - } You need a stronger password.` - } - } else { - toast.error(`Failed to check password strength: ${(error as ResponseError).message}`) + // warning message for anything below 4 strength :string + if (resultScore < DEFAULT_MINIMUM_PASSWORD_STRENGTH) { + warning = `${ + result?.feedback?.warning ? result?.feedback?.warning + '.' : '' + } You need a stronger password.` } } } diff --git a/apps/studio/pages/api/platform/profile/password-check.ts b/apps/studio/pages/api/platform/profile/password-check.ts deleted file mode 100644 index 46c2e932f4984..0000000000000 --- a/apps/studio/pages/api/platform/profile/password-check.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next' -import zxcvbn from 'zxcvbn' -import apiWrapper from 'lib/api/apiWrapper' - -export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { method } = req - - switch (method) { - case 'POST': - return handlePost(req, res) - default: - res.setHeader('Allow', ['POST']) - res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) - } -} - -const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { - const { body } = req - const result = zxcvbn(body.password) - return res.status(200).json({ result }) -} diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 52e49b7aa04e8..d96812b48ada5 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -1,6 +1,5 @@ import { useParams } from 'common' -import { debounce } from 'lodash' -import { ChangeEvent, useRef, useState } from 'react' +import { ChangeEvent, useState } from 'react' import { toast } from 'sonner' import { Alert, Button, Checkbox, Input, Listbox } from 'ui' @@ -18,7 +17,7 @@ import { useProjectCreateMutation } from 'data/projects/project-create-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, PROVIDERS } from 'lib/constants' import { getInitialMigrationSQLFromGitHubRepo } from 'lib/integration-utils' -import passwordStrength from 'lib/password-strength' +import { passwordStrength } from 'lib/password-strength' import { generateStrongPassword } from 'lib/project' import { AWS_REGIONS } from 'shared-data' import { useIntegrationInstallationSnapshot } from 'state/integration-installation' @@ -63,9 +62,11 @@ const CreateProject = () => { const snapshot = useIntegrationInstallationSnapshot() - const delayedCheckPasswordStrength = useRef( - debounce((value: string) => checkPasswordStrength(value), 300) - ).current + async function checkPasswordStrength(value: string) { + const { message, strength } = await passwordStrength(value) + setPasswordStrengthScore(strength) + setPasswordStrengthMessage(message) + } const { slug, next, currentProjectId: foreignProjectId, externalId } = useParams() @@ -105,19 +106,13 @@ const CreateProject = () => { if (value == '') { setPasswordStrengthScore(-1) setPasswordStrengthMessage('') - } else delayedCheckPasswordStrength(value) - } - - async function checkPasswordStrength(value: string) { - const { message, strength } = await passwordStrength(value) - setPasswordStrengthScore(strength) - setPasswordStrengthMessage(message) + } else checkPasswordStrength(value) } function generatePassword() { const password = generateStrongPassword() setDbPass(password) - delayedCheckPasswordStrength(password) + checkPasswordStrength(password) } const [newProjectRef, setNewProjectRef] = useState(undefined) diff --git a/packages/icons/src/icons/index.ts b/packages/icons/src/icons/index.ts index 9dc947c868175..ec3ff0dbf770f 100644 --- a/packages/icons/src/icons/index.ts +++ b/packages/icons/src/icons/index.ts @@ -17,6 +17,7 @@ export { default as Realtime } from './realtime'; export { default as ReplaceCode } from './replace-code'; export { default as Reports } from './reports'; export { default as Settings } from './settings'; +export { default as Sentry } from './sentry'; export { default as SqlEditor } from './sql-editor'; export { default as Storage } from './storage'; export { default as TableEditor } from './table-editor'; diff --git a/packages/icons/src/icons/sentry.ts b/packages/icons/src/icons/sentry.ts new file mode 100644 index 0000000000000..0692dade98064 --- /dev/null +++ b/packages/icons/src/icons/sentry.ts @@ -0,0 +1,23 @@ +import createSupabaseIcon from '../createSupabaseIcon'; + +/** + * @component @name Sentry + * @description Supabase SVG icon component, renders SVG Element with children. + * + * @preview ![img](data:image/svg+xml;base64,PHN2ZyBzdHJva2U9ImN1cnJlbnRDb2xvciIgZmlsbD0iY3VycmVudENvbG9yIiBzdHJva2Utd2lkdGg9IjAiIHJvbGU9ImltZyIgdmlld0JveD0iMCAwIDI0IDI0IiBoZWlnaHQ9IjFlbSIgd2lkdGg9IjFlbSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTMuOTEgMi41MDVjLS44NzMtMS40NDgtMi45NzItMS40NDgtMy44NDQgMEw2LjkwNCA3LjkyYTE1LjQ3OCAxNS40NzggMCAwIDEgOC41MyAxMi44MTFoLTIuMjIxQTEzLjMwMSAxMy4zMDEgMCAwIDAgNS43ODQgOS44MTRsLTIuOTI2IDUuMDZhNy42NSA3LjY1IDAgMCAxIDQuNDM1IDUuODQ4SDIuMTk0YS4zNjUuMzY1IDAgMCAxLS4yOTgtLjUzNGwxLjQxMy0yLjQwMmE1LjE2IDUuMTYgMCAwIDAtMS42MTQtLjkxM0wuMjk2IDE5LjI3NWEyLjE4MiAyLjE4MiAwIDAgMCAuODEyIDIuOTk5IDIuMjQgMi4yNCAwIDAgMCAxLjA4Ni4yODhoNi45ODNhOS4zMjIgOS4zMjIgMCAwIDAtMy44NDUtOC4zMThsMS4xMS0xLjkyMmExMS40NyAxMS40NyAwIDAgMSA0Ljk1IDEwLjI0aDUuOTE1YTE3LjI0MiAxNy4yNDIgMCAwIDAtNy44ODUtMTUuMjhsMi4yNDQtMy44NDVhLjM3LjM3IDAgMCAxIC41MDQtLjEzYy4yNTUuMTQgOS43NSAxNi43MDggOS45MjggMTYuOWEuMzY1LjM2NSAwIDAgMS0uMzI3LjU0M2gtMi4yODdjLjAyOS42MTIuMDI5IDEuMjIzIDAgMS44MzFoMi4yOTdhMi4yMDYgMi4yMDYgMCAwIDAgMS45MjItMy4zMXoiPjwvcGF0aD48L3N2Zz4=) + * + * @param {Object} props - Supabase icons props and any valid SVG attribute + * @returns {JSX.Element} JSX Element + * + */ +const Sentry = createSupabaseIcon('Sentry', [ + [ + 'path', + { + d: 'M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z', + key: 'hopart', + }, + ], +]); + +export default Sentry;