diff --git a/frontend/app/PrivateRoutes.tsx b/frontend/app/PrivateRoutes.tsx index ee5250c07..7afdbce42 100644 --- a/frontend/app/PrivateRoutes.tsx +++ b/frontend/app/PrivateRoutes.tsx @@ -106,7 +106,7 @@ const SPOT_PATH = routes.spot(); const SCOPE_SETUP = routes.scopeSetup(); function PrivateRoutes() { - const { projectsStore, userStore } = useStore(); + const { projectsStore, userStore, integrationsStore } = useStore(); const onboarding = userStore.onboarding; const scope = userStore.scopeState; const tenantId = userStore.account.tenantId; @@ -118,6 +118,11 @@ function PrivateRoutes() { !onboarding && (localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' || (sites.length > 0 && !hasRecordings)) && scope > 0; const siteIdList: any = sites.map(({ id }) => id); + React.useEffect(() => { + if (integrationsStore.integrations.list.length === 0 && siteId) { + void integrationsStore.integrations.fetchIntegrations(siteId); + } + }, [siteId]) return ( }> diff --git a/frontend/app/api_client.ts b/frontend/app/api_client.ts index 515f393b3..431cea014 100644 --- a/frontend/app/api_client.ts +++ b/frontend/app/api_client.ts @@ -166,7 +166,7 @@ export default class APIClient { let fetch = window.fetch; let edp = window.env.API_EDP || window.location.origin + '/api'; - const noChalice = path.includes('/spot') && !path.includes('/login') + const noChalice = path.includes('v1/integrations') || path.includes('/spot') && !path.includes('/login') if (noChalice && !edp.includes('api.openreplay.com')) { edp = edp.replace('/api', '') } diff --git a/frontend/app/components/Client/Integrations/Backend/DatadogForm/DatadogFormModal.tsx b/frontend/app/components/Client/Integrations/Backend/DatadogForm/DatadogFormModal.tsx new file mode 100644 index 000000000..64dde0560 --- /dev/null +++ b/frontend/app/components/Client/Integrations/Backend/DatadogForm/DatadogFormModal.tsx @@ -0,0 +1,145 @@ +import { Button } from 'antd'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +import FormField from 'App/components/Client/Integrations/FormField'; +import { useIntegration } from 'App/components/Client/Integrations/apiMethods'; +import useForm from 'App/hooks/useForm'; +import { useStore } from 'App/mstore'; +import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; +import { Loader } from 'UI'; + +import DocLink from 'Shared/DocLink/DocLink'; +import { toast } from ".store/react-toastify-virtual-9dd0f3eae1/package"; + +interface DatadogConfig { + site: string; + api_key: string; + app_key: string; +} + +const initialValues = { + site: '', + api_key: '', + app_key: '', +}; + +const DatadogFormModal = ({ + onClose, + integrated, +}: { + onClose: () => void; + integrated: boolean; +}) => { + const { integrationsStore } = useStore(); + const siteId = integrationsStore.integrations.siteId; + + const { + data = initialValues, + isPending, + saveMutation, + removeMutation, + } = useIntegration('datadog', siteId, initialValues); + const { values, errors, handleChange, hasErrors, checkErrors } = useForm(data, { + site: { + required: true, + }, + api_key: { + required: true, + }, + app_key: { + required: true, + }, + }); + const exists = Boolean(data.api_key); + + const save = async () => { + if (checkErrors()) { + return; + } + try { + await saveMutation.mutateAsync({ values, siteId, exists }); + } catch (e) { + console.error(e) + } + onClose(); + }; + + const remove = async () => { + try { + await removeMutation.mutateAsync({ siteId }); + } catch (e) { + console.error(e) + } + onClose(); + }; + return ( +
+ +
+
How it works?
+
    +
  1. Generate Datadog API Key & Application Key
  2. +
  3. Enter the API key below
  4. +
  5. Propagate openReplaySessionToken
  6. +
+ + + + + +
+ + + {integrated && ( + + )} +
+
+
+
+ ); +}; + +DatadogFormModal.displayName = 'DatadogForm'; + +export default observer(DatadogFormModal); diff --git a/frontend/app/components/Client/Integrations/Backend/DynatraceForm/DynatraceFormModal.tsx b/frontend/app/components/Client/Integrations/Backend/DynatraceForm/DynatraceFormModal.tsx new file mode 100644 index 000000000..40bfe2e8a --- /dev/null +++ b/frontend/app/components/Client/Integrations/Backend/DynatraceForm/DynatraceFormModal.tsx @@ -0,0 +1,164 @@ +import { Button } from 'antd'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +import FormField from 'App/components/Client/Integrations/FormField'; +import { useIntegration } from 'App/components/Client/Integrations/apiMethods'; +import useForm from 'App/hooks/useForm'; +import { useStore } from 'App/mstore'; +import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; +import { Loader } from 'UI'; + +import DocLink from 'Shared/DocLink/DocLink'; +import { toast } from ".store/react-toastify-virtual-9dd0f3eae1/package"; + +interface DynatraceConfig { + environment: string; + client_id: string; + client_secret: string; + resource: string; +} + +const initialValues = { + environment: '', + client_id: '', + client_secret: '', + resource: '', +}; +const DynatraceFormModal = ({ + onClose, + integrated, +}: { + onClose: () => void; + integrated: boolean; +}) => { + const { integrationsStore } = useStore(); + const siteId = integrationsStore.integrations.siteId; + const { + data = initialValues, + isPending, + saveMutation, + removeMutation, + } = useIntegration('dynatrace', siteId, initialValues); + const { values, errors, handleChange, hasErrors, checkErrors } = useForm(data, { + environment: { + required: true, + }, + client_id: { + required: true, + }, + client_secret: { + required: true, + }, + resource: { + required: true, + }, + }); + const exists = Boolean(data.client_id); + + const save = async () => { + if (checkErrors()) { + return; + } + try { + await saveMutation.mutateAsync({ values, siteId, exists }); + } catch (e) { + console.error(e) + } + onClose(); + }; + + const remove = async () => { + try { + await removeMutation.mutateAsync({ siteId }); + } catch (e) { + console.error(e) + } + onClose(); + }; + return ( +
+ +
+
How it works?
+
    +
  1. + Enter your Environment ID, Client ID, Client Secret, and Account URN + in the form below. +
  2. +
  3. + Create a custom Log attribute openReplaySessionToken in Dynatrace. +
  4. +
  5. + Propagate openReplaySessionToken in your application's backend logs. +
  6. +
+ + + + + + + +
+ + + {integrated && ( + + )} +
+
+
+
+ ); +}; + +DynatraceFormModal.displayName = 'DynatraceFormModal'; + +export default observer(DynatraceFormModal); diff --git a/frontend/app/components/Client/Integrations/Backend/ElasticForm/ElasticFormModal.tsx b/frontend/app/components/Client/Integrations/Backend/ElasticForm/ElasticFormModal.tsx new file mode 100644 index 000000000..94cd2f0e0 --- /dev/null +++ b/frontend/app/components/Client/Integrations/Backend/ElasticForm/ElasticFormModal.tsx @@ -0,0 +1,152 @@ +import { Button } from 'antd'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +import FormField from 'App/components/Client/Integrations/FormField'; +import { useIntegration } from 'App/components/Client/Integrations/apiMethods'; +import useForm from 'App/hooks/useForm'; +import { useStore } from 'App/mstore'; +import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; +import { Loader } from 'UI'; + +import DocLink from 'Shared/DocLink/DocLink'; +import { toast } from ".store/react-toastify-virtual-9dd0f3eae1/package"; + +interface ElasticConfig { + url: string; + api_key_id: string; + api_key: string; + indexes: string; +} + +const initialValues = { + url: '', + api_key_id: '', + api_key: '', + indexes: '', +}; + +function ElasticsearchForm({ + onClose, + integrated, +}: { + onClose: () => void; + integrated: boolean; +}) { + const { integrationsStore } = useStore(); + const siteId = integrationsStore.integrations.siteId; + const { + data = initialValues, + isPending, + saveMutation, + removeMutation, + } = useIntegration('elasticsearch', siteId, initialValues); + const { values, errors, handleChange, hasErrors, checkErrors } = useForm(data, { + url: { + required: true, + }, + api_key_id: { + required: true, + }, + api_key: { + required: true, + }, + }); + const exists = Boolean(data.api_key_id); + + const save = async () => { + if (checkErrors()) { + return; + } + try { + await saveMutation.mutateAsync({ values, siteId, exists }); + } catch (e) { + console.error(e) + } + onClose(); + }; + + const remove = async () => { + try { + await removeMutation.mutateAsync({ siteId }); + } catch (e) { + console.error(e) + } + onClose(); + }; + return ( +
+ + +
+
How it works?
+
    +
  1. Create a new Elastic API key
  2. +
  3. Enter the API key below
  4. +
  5. Propagate openReplaySessionToken
  6. +
+ + + + + + +
+ + + {integrated && ( + + )} +
+
+
+
+ ); +} + +export default observer(ElasticsearchForm); diff --git a/frontend/app/components/Client/Integrations/Backend/SentryForm/SentryFormModal.tsx b/frontend/app/components/Client/Integrations/Backend/SentryForm/SentryFormModal.tsx new file mode 100644 index 000000000..cd43acfea --- /dev/null +++ b/frontend/app/components/Client/Integrations/Backend/SentryForm/SentryFormModal.tsx @@ -0,0 +1,143 @@ +import { Button } from 'antd'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +import FormField from 'App/components/Client/Integrations/FormField'; +import { useIntegration } from 'App/components/Client/Integrations/apiMethods'; +import useForm from 'App/hooks/useForm'; +import { useStore } from 'App/mstore'; +import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; +import { Loader } from 'UI'; +import { toast } from 'react-toastify'; +import DocLink from 'Shared/DocLink/DocLink'; + +interface SentryConfig { + organization_slug: string; + project_slug: string; + token: string; +} + +const initialValues = { + organization_slug: '', + project_slug: '', + token: '', +}; + +function SentryForm({ + onClose, + integrated, +}: { + onClose: () => void; + integrated: boolean; +}) { + const { integrationsStore } = useStore(); + const siteId = integrationsStore.integrations.siteId; + const { + data = initialValues, + isPending, + saveMutation, + removeMutation, + } = useIntegration('sentry', siteId, initialValues); + const { values, errors, handleChange, hasErrors, checkErrors, } = useForm(data, { + organization_slug: { + required: true, + }, + project_slug: { + required: true, + }, + token: { + required: true, + }, + }); + const exists = Boolean(data.token); + + const save = async () => { + if (checkErrors()) { + return; + } + try { + await saveMutation.mutateAsync({ values, siteId, exists }); + } catch (e) { + console.error(e) + } + onClose(); + }; + + const remove = async () => { + try { + await removeMutation.mutateAsync({ siteId }); + } catch (e) { + console.error(e) + } + onClose(); + }; + return ( +
+ +
+
How it works?
+
    +
  1. Generate Sentry Auth Token
  2. +
  3. Enter the token below
  4. +
  5. Propagate openReplaySessionToken
  6. +
+ + + + + + + +
+ + + {integrated && ( + + )} +
+
+
+
+ ); +} + +export default observer(SentryForm); diff --git a/frontend/app/components/Client/Integrations/BugsnagForm/BugsnagForm.js b/frontend/app/components/Client/Integrations/BugsnagForm/BugsnagForm.js deleted file mode 100644 index 780067755..000000000 --- a/frontend/app/components/Client/Integrations/BugsnagForm/BugsnagForm.js +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import { tokenRE } from 'Types/integrations/bugsnagConfig'; -import IntegrationForm from '../IntegrationForm'; -// import ProjectListDropdown from './ProjectListDropdown'; -import DocLink from 'Shared/DocLink/DocLink'; -import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; - -const BugsnagForm = (props) => ( -
- - -
-
How it works?
-
    -
  1. Generate Bugsnag Auth Token
  2. -
  3. Enter the token below
  4. -
  5. Propagate openReplaySessionToken
  6. -
- -
- tokenRE.test(config.authorizationToken), - // component: ProjectListDropdown - } - ]} - /> -
-); - -BugsnagForm.displayName = 'BugsnagForm'; - -export default BugsnagForm; diff --git a/frontend/app/components/Client/Integrations/BugsnagForm/ProjectListDropdown.js b/frontend/app/components/Client/Integrations/BugsnagForm/ProjectListDropdown.js deleted file mode 100644 index 85dc8cf92..000000000 --- a/frontend/app/components/Client/Integrations/BugsnagForm/ProjectListDropdown.js +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { tokenRE } from 'Types/integrations/bugsnagConfig'; -import Select from 'Shared/Select'; -import { withRequest } from 'HOCs'; - -function ProjectListDropdown(props) { - -} - -@connect(state => ({ - token: state.getIn([ 'bugsnag', 'instance', 'authorizationToken' ]) -})) -@withRequest({ - dataName: "projects", - initialData: [], - dataWrapper: (data = []) => { - if (!Array.isArray(data)) throw new Error('Wrong responce format.'); - const withOrgName = data.length > 1; - return data.reduce((accum, { name: orgName, projects }) => { - if (!Array.isArray(projects)) throw new Error('Wrong responce format.'); - if (withOrgName) projects = projects.map(p => ({ ...p, name: `${ p.name } (${ orgName })` })) - return accum.concat(projects); - }, []); - }, - resetBeforeRequest: true, - requestName: "fetchProjectList", - endpoint: '/integrations/bugsnag/list_projects', - method: 'POST', -}) -export default class ProjectListDropdown extends React.PureComponent { - constructor(props) { - super(props); - this.fetchProjectList() - } - fetchProjectList() { - const { token } = this.props; - if (!tokenRE.test(token)) return; - this.props.fetchProjectList({ - authorizationToken: token, - }) - } - componentDidUpdate(prevProps) { - if (prevProps.token !== this.props.token) { - this.fetchProjectList(); - } - } - onChange = (target) => { - if (typeof this.props.onChange === 'function') { - this.props.onChange({ target }); - } - } - render() { - const { - projects, - name, - value, - placeholder, - loading, - } = this.props; - const options = projects.map(({ name, id }) => ({ text: name, value: id })); - return ( - o.value === value)} - placeholder={placeholder} - onChange={handleChange} - loading={loading} - /> - ); -}; - -export default observer(LogGroupDropdown); diff --git a/frontend/app/components/Client/Integrations/CloudwatchForm/RegionDropdown.js b/frontend/app/components/Client/Integrations/CloudwatchForm/RegionDropdown.js deleted file mode 100644 index e96aff591..000000000 --- a/frontend/app/components/Client/Integrations/CloudwatchForm/RegionDropdown.js +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { regionLabels as labels } from 'Types/integrations/cloudwatchConfig'; -import Select from 'Shared/Select'; - -const options = Object.keys(labels).map(key => ({ text: labels[ key ], label: key })); - -const RegionDropdown = props => ( - + {errors &&
{errors}
} + + ); +} +export default FormField; \ No newline at end of file diff --git a/frontend/app/components/Client/Integrations/IntegrationForm.tsx b/frontend/app/components/Client/Integrations/IntegrationForm.tsx index 0aae508bf..250d5fc08 100644 --- a/frontend/app/components/Client/Integrations/IntegrationForm.tsx +++ b/frontend/app/components/Client/Integrations/IntegrationForm.tsx @@ -8,8 +8,8 @@ import { toast } from 'react-toastify'; function IntegrationForm(props: any) { const { formFields, name, integrated } = props; - const { integrationsStore, projectsStore } = useStore(); - const initialSiteId = projectsStore.siteId; + const { integrationsStore } = useStore(); + const initialSiteId = integrationsStore.integrations.siteId; const integrationStore = integrationsStore[name as unknown as namedStore]; const config = integrationStore.instance; const loading = integrationStore.loading; diff --git a/frontend/app/components/Client/Integrations/IntegrationItem.tsx b/frontend/app/components/Client/Integrations/IntegrationItem.tsx index decdea8ed..4c4e4db5e 100644 --- a/frontend/app/components/Client/Integrations/IntegrationItem.tsx +++ b/frontend/app/components/Client/Integrations/IntegrationItem.tsx @@ -9,11 +9,12 @@ interface Props { onClick?: (e: React.MouseEvent) => void; integrated?: boolean; hide?: boolean; + useIcon?: boolean; } const IntegrationItem = (props: Props) => { - const { integration, integrated, hide = false } = props; - return hide ? <> : ( + const { integration, integrated, hide = false, useIcon } = props; + return hide ? null : (
props.onClick(e)} @@ -21,7 +22,7 @@ const IntegrationItem = (props: Props) => { >
- integration + {useIcon ? : integration}

{integration.title}

diff --git a/frontend/app/components/Client/Integrations/IntegrationModalCard.tsx b/frontend/app/components/Client/Integrations/IntegrationModalCard.tsx index 4864b092b..37ff588f0 100644 --- a/frontend/app/components/Client/Integrations/IntegrationModalCard.tsx +++ b/frontend/app/components/Client/Integrations/IntegrationModalCard.tsx @@ -1,19 +1,19 @@ import React from 'react'; import { Icon } from 'UI'; -import DocLink from 'Shared/DocLink'; interface Props { title: string; icon: string; description: string; + useIcon?: boolean; } function IntegrationModalCard(props: Props) { - const { title, icon, description } = props; + const { title, icon, description, useIcon } = props; return (
- integration + {useIcon ? : integration}

{title}

diff --git a/frontend/app/components/Client/Integrations/Integrations.tsx b/frontend/app/components/Client/Integrations/Integrations.tsx index a39a533d5..b00e5a0b7 100644 --- a/frontend/app/components/Client/Integrations/Integrations.tsx +++ b/frontend/app/components/Client/Integrations/Integrations.tsx @@ -1,5 +1,4 @@ import withPageTitle from 'HOCs/withPageTitle'; -import cn from 'classnames'; import { observer } from 'mobx-react-lite'; import React, { useEffect, useState } from 'react'; @@ -9,30 +8,26 @@ import IntegrationFilters from 'Components/Client/Integrations/IntegrationFilter import { PageTitle } from 'UI'; import DocCard from 'Shared/DocCard/DocCard'; +import SiteDropdown from 'Shared/SiteDropdown'; -import AssistDoc from './AssistDoc'; -import BugsnagForm from './BugsnagForm'; -import CloudwatchForm from './CloudwatchForm'; -import DatadogForm from './DatadogForm'; -import ElasticsearchForm from './ElasticsearchForm'; +import DatadogForm from './Backend/DatadogForm/DatadogFormModal'; +import DynatraceFormModal from './Backend/DynatraceForm/DynatraceFormModal'; +import ElasticsearchForm from './Backend/ElasticForm/ElasticFormModal'; +import SentryForm from './Backend/SentryForm/SentryFormModal'; import GithubForm from './GithubForm'; -import GraphQLDoc from './GraphQLDoc'; import IntegrationItem from './IntegrationItem'; import JiraForm from './JiraForm'; -import MobxDoc from './MobxDoc'; -import NewrelicForm from './NewrelicForm'; -import NgRxDoc from './NgRxDoc'; -import PiniaDoc from './PiniaDoc'; import ProfilerDoc from './ProfilerDoc'; -import ReduxDoc from './ReduxDoc'; -import RollbarForm from './RollbarForm'; -import SentryForm from './SentryForm'; import SlackForm from './SlackForm'; -import StackdriverForm from './StackdriverForm'; -import SumoLogicForm from './SumoLogicForm'; import MSTeams from './Teams'; -import VueDoc from './VueDoc'; -import ZustandDoc from './ZustandDoc'; +import AssistDoc from './Tracker/AssistDoc'; +import GraphQLDoc from './Tracker/GraphQLDoc'; +import MobxDoc from './Tracker/MobxDoc'; +import NgRxDoc from './Tracker/NgRxDoc'; +import PiniaDoc from './Tracker/PiniaDoc'; +import ReduxDoc from './Tracker/ReduxDoc'; +import VueDoc from './Tracker/VueDoc'; +import ZustandDoc from './Tracker/ZustandDoc'; interface Props { siteId: string; @@ -41,23 +36,27 @@ interface Props { function Integrations(props: Props) { const { integrationsStore, projectsStore } = useStore(); - const siteId = projectsStore.siteId; + const initialSiteId = projectsStore.siteId; + const siteId = integrationsStore.integrations.siteId; const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations; const storeIntegratedList = integrationsStore.integrations.list; const { hideHeader = false } = props; - const { showModal } = useModal(); + const { showModal, hideModal } = useModal(); const [integratedList, setIntegratedList] = useState([]); const [activeFilter, setActiveFilter] = useState('all'); useEffect(() => { - const list = storeIntegratedList - .filter((item: any) => item.integrated) + const list = integrationsStore.integrations.integratedServices .map((item: any) => item.name); setIntegratedList(list); }, [storeIntegratedList]); useEffect(() => { - void fetchIntegrationList(siteId); + if (siteId) { + void fetchIntegrationList(siteId); + } else if (initialSiteId) { + integrationsStore.integrations.setSiteId(initialSiteId); + } }, [siteId]); const onClick = (integration: any, width: number) => { @@ -84,6 +83,8 @@ function Integrations(props: Props) { showModal( React.cloneElement(integration.component, { integrated: integratedList.includes(integration.slug), + siteId, + onClose: hideModal, }), { right: true, width } ); @@ -112,15 +113,17 @@ function Integrations(props: Props) { (cat) => cat.integrations ); - console.log( - allIntegrations, - integratedList - ) + const onChangeSelect = ({ value }: any) => { + integrationsStore.integrations.setSiteId(value.value); + }; + return ( <>
- {!hideHeader && Integrations
} />} - +
+ {!hideHeader && Integrations
} />} + +
- {allIntegrations.map((integration: any) => ( - - onClick( - integration, - filteredIntegrations.find((cat) => - cat.integrations.includes(integration) - ).title === 'Plugins' - ? 500 - : 350 - ) - } - hide={ - (integration.slug === 'github' && - integratedList.includes('jira')) || - (integration.slug === 'jira' && integratedList.includes('github')) - } - /> + {allIntegrations.map((integration, i) => ( + + + onClick( + integration, + filteredIntegrations.find((cat) => + cat.integrations.includes(integration) + )?.title === 'Plugins' + ? 500 + : 350 + ) + } + hide={ + (integration.slug === 'github' && + integratedList.includes('jira')) || + (integration.slug === 'jira' && + integratedList.includes('github')) + } + /> + ))}
); } -export default withPageTitle('Integrations - OpenReplay Preferences')(observer(Integrations)) +export default withPageTitle('Integrations - OpenReplay Preferences')( + observer(Integrations) +); const integrations = [ { @@ -219,22 +226,6 @@ const integrations = [ icon: 'integrations/sentry', component: , }, - { - title: 'Bugsnag', - subtitle: - 'Integrate Bugsnag to access the OpenReplay session linked to the JS exception within its interface.', - slug: 'bugsnag', - icon: 'integrations/bugsnag', - component: , - }, - { - title: 'Rollbar', - subtitle: - 'Integrate Rollbar with session replays to seamlessly observe backend errors.', - slug: 'rollbar', - icon: 'integrations/rollbar', - component: , - }, { title: 'Elasticsearch', subtitle: @@ -252,36 +243,13 @@ const integrations = [ component: , }, { - title: 'Sumo Logic', + title: 'Dynatrace', subtitle: - 'Integrate Sumo Logic with session replays to seamlessly observe backend errors.', - slug: 'sumologic', - icon: 'integrations/sumologic', - component: , - }, - { - title: 'Google Cloud', - subtitle: - 'Integrate Google Cloud to view backend logs and errors in conjunction with session replay', - slug: 'stackdriver', - icon: 'integrations/google-cloud', - component: , - }, - { - title: 'CloudWatch', - subtitle: - 'Integrate CloudWatch to see backend logs and errors alongside session replay.', - slug: 'cloudwatch', - icon: 'integrations/aws', - component: , - }, - { - title: 'Newrelic', - subtitle: - 'Integrate NewRelic with session replays to seamlessly observe backend errors.', - slug: 'newrelic', - icon: 'integrations/newrelic', - component: , + 'Integrate Dynatrace with session replays to link backend logs with user sessions for faster issue resolution.', + slug: 'dynatrace', + icon: 'integrations/dynatrace', + useIcon: true, + component: , }, ], }, @@ -409,3 +377,56 @@ const integrations = [ ], }, ]; + +/** + * + * @deprecated + * */ +// { +// title: 'Sumo Logic', +// subtitle: +// 'Integrate Sumo Logic with session replays to seamlessly observe backend errors.', +// slug: 'sumologic', +// icon: 'integrations/sumologic', +// component: , +// }, +// { +// title: 'Bugsnag', +// subtitle: +// 'Integrate Bugsnag to access the OpenReplay session linked to the JS exception within its interface.', +// slug: 'bugsnag', +// icon: 'integrations/bugsnag', +// component: , +// }, +// { +// title: 'Rollbar', +// subtitle: +// 'Integrate Rollbar with session replays to seamlessly observe backend errors.', +// slug: 'rollbar', +// icon: 'integrations/rollbar', +// component: , +// }, +// { +// title: 'Google Cloud', +// subtitle: +// 'Integrate Google Cloud to view backend logs and errors in conjunction with session replay', +// slug: 'stackdriver', +// icon: 'integrations/google-cloud', +// component: , +// }, +// { +// title: 'CloudWatch', +// subtitle: +// 'Integrate CloudWatch to see backend logs and errors alongside session replay.', +// slug: 'cloudwatch', +// icon: 'integrations/aws', +// component: , +// }, +// { +// title: 'Newrelic', +// subtitle: +// 'Integrate NewRelic with session replays to seamlessly observe backend errors.', +// slug: 'newrelic', +// icon: 'integrations/newrelic', +// component: , +// }, diff --git a/frontend/app/components/Client/Integrations/JiraForm/JiraForm.js b/frontend/app/components/Client/Integrations/JiraForm/JiraForm.js index c417f0626..dc8f3e49f 100644 --- a/frontend/app/components/Client/Integrations/JiraForm/JiraForm.js +++ b/frontend/app/components/Client/Integrations/JiraForm/JiraForm.js @@ -2,7 +2,6 @@ import React from 'react'; import IntegrationForm from '../IntegrationForm'; import DocLink from 'Shared/DocLink/DocLink'; import { useModal } from 'App/components/Modal'; -import { Icon } from 'UI'; import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; const JiraForm = (props) => { diff --git a/frontend/app/components/Client/Integrations/NewrelicForm/NewrelicForm.js b/frontend/app/components/Client/Integrations/NewrelicForm/NewrelicForm.js deleted file mode 100644 index 3d3d829b1..000000000 --- a/frontend/app/components/Client/Integrations/NewrelicForm/NewrelicForm.js +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import IntegrationForm from '../IntegrationForm'; -import DocLink from 'Shared/DocLink/DocLink'; -import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; - -const NewrelicForm = (props) => ( -
- -
-
How it works?
-
    -
  1. Create Query Key
  2. -
  3. Enter the details below
  4. -
  5. Propagate openReplaySessionToken
  6. -
- -
- -
-); - -NewrelicForm.displayName = 'NewrelicForm'; - -export default NewrelicForm; diff --git a/frontend/app/components/Client/Integrations/NewrelicForm/index.js b/frontend/app/components/Client/Integrations/NewrelicForm/index.js deleted file mode 100644 index 0d4052873..000000000 --- a/frontend/app/components/Client/Integrations/NewrelicForm/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './NewrelicForm'; diff --git a/frontend/app/components/Client/Integrations/RollbarForm.js b/frontend/app/components/Client/Integrations/RollbarForm.js deleted file mode 100644 index 624ec9bf2..000000000 --- a/frontend/app/components/Client/Integrations/RollbarForm.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import IntegrationForm from './IntegrationForm'; -import DocLink from 'Shared/DocLink/DocLink'; -import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; - -const RollbarForm = (props) => ( -
- -
-
How it works?
-
    -
  1. Create Rollbar Access Tokens
  2. -
  3. Enter the token below
  4. -
  5. Propagate openReplaySessionToken
  6. -
- -
- -
-); - -RollbarForm.displayName = 'RollbarForm'; - -export default RollbarForm; diff --git a/frontend/app/components/Client/Integrations/SentryForm.js b/frontend/app/components/Client/Integrations/SentryForm.js deleted file mode 100644 index 506aec48c..000000000 --- a/frontend/app/components/Client/Integrations/SentryForm.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import IntegrationForm from './IntegrationForm'; -import DocLink from 'Shared/DocLink/DocLink'; -import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; - -const SentryForm = (props) => ( -
- -
-
How it works?
-
    -
  1. Generate Sentry Auth Token
  2. -
  3. Enter the token below
  4. -
  5. Propagate openReplaySessionToken
  6. -
- -
- -
-); - -SentryForm.displayName = 'SentryForm'; - -export default SentryForm; diff --git a/frontend/app/components/Client/Integrations/StackdriverForm.js b/frontend/app/components/Client/Integrations/StackdriverForm.js deleted file mode 100644 index 92935e67b..000000000 --- a/frontend/app/components/Client/Integrations/StackdriverForm.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import IntegrationForm from './IntegrationForm'; -import DocLink from 'Shared/DocLink/DocLink'; -import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; - -const StackdriverForm = (props) => ( -
- -
-
How it works?
-
    -
  1. Create Google Cloud Service Account
  2. -
  3. Enter the details below
  4. -
  5. Propagate openReplaySessionToken
  6. -
- -
- -
-); - -StackdriverForm.displayName = 'StackdriverForm'; - -export default StackdriverForm; diff --git a/frontend/app/components/Client/Integrations/SumoLogicForm/RegionDropdown.js b/frontend/app/components/Client/Integrations/SumoLogicForm/RegionDropdown.js deleted file mode 100644 index 48fa3151d..000000000 --- a/frontend/app/components/Client/Integrations/SumoLogicForm/RegionDropdown.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { regionLabels as labels } from 'Types/integrations/sumoLogicConfig'; -import Select from 'Shared/Select'; - -const options = Object.keys(labels).map(key => ({ text: labels[ key ], label: key })); - -const RegionDropdown = props => ( - +
+ + + + {isPending ? ( + + ) : null} + {isError ? ( + + ) : null} + {isSuccess ? ( + <> + + + {data.map((log, index) => ( + + ))} + + + ) : null} + + + ); +} + +const testLogs = [ + { + key: 1, + timestamp: '2021-09-01 12:00:00', + status: 'INFO', + content: 'This is a test log', + }, + { + key: 2, + timestamp: '2021-09-01 12:00:00', + status: 'WARN', + content: 'This is a test log', + }, + { + key: 3, + timestamp: '2021-09-01 12:00:00', + status: 'ERROR', + content: + 'This is a test log that is very long and should be truncated to fit in the table cell and it will be displayed later in a separate thing when clicked on a row because its so long you never gonna give htem up or alskjhaskfjhqwfhwekfqwfjkqlwhfkjqhflqkwjhefqwklfehqwlkfjhqwlkjfhqwe \n kjhdafskjfhlqkwjhfwelefkhwqlkqehfkqlwehfkqwhefkqhwefkjqwhf', + }, +]; + +export default observer(BackendLogsPanel); diff --git a/frontend/app/components/Session/Player/SharedComponents/BackendLogs/LogsButton.tsx b/frontend/app/components/Session/Player/SharedComponents/BackendLogs/LogsButton.tsx new file mode 100644 index 000000000..79dda3c58 --- /dev/null +++ b/frontend/app/components/Session/Player/SharedComponents/BackendLogs/LogsButton.tsx @@ -0,0 +1,31 @@ +import { Avatar } from 'antd'; +import React from 'react'; + +import ControlButton from 'App/components/Session_/Player/Controls/ControlButton'; +import { Icon } from 'UI'; + +function LogsButton({ + integrated, + onClick, +}: { + integrated: string[]; + onClick: () => void; +}) { + + return ( + + {integrated.map((name) => ( + } /> + )) + } + + } + onClick={onClick} + /> + ); +} + +export default LogsButton; diff --git a/frontend/app/components/Session/Player/SharedComponents/BackendLogs/StatusMessages.tsx b/frontend/app/components/Session/Player/SharedComponents/BackendLogs/StatusMessages.tsx new file mode 100644 index 000000000..ae44651d2 --- /dev/null +++ b/frontend/app/components/Session/Player/SharedComponents/BackendLogs/StatusMessages.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { client as settingsPath, CLIENT_TABS } from 'App/routes'; +import { Icon } from 'UI'; +import { LoadingOutlined } from '@ant-design/icons'; +import { useHistory } from 'react-router-dom'; +import { Button } from 'antd'; + +export function LoadingFetch({ provider }: { provider: string }) { + return ( +
+ +
Fetching logs from {provider}...
+
+ ); +} + +export function FailedFetch({ + provider, + onRetry, +}: { + provider: string; + onRetry: () => void; +}) { + const history = useHistory(); + const intPath = settingsPath(CLIENT_TABS.INTEGRATIONS); + return ( +
+ +
+ Failed to fetch logs from {provider}. +
+ Retry +
+
+
history.push(intPath)}> + Check Configuration +
+
+ ); +} diff --git a/frontend/app/components/Session/Player/SharedComponents/BackendLogs/Table.tsx b/frontend/app/components/Session/Player/SharedComponents/BackendLogs/Table.tsx new file mode 100644 index 000000000..a861123a2 --- /dev/null +++ b/frontend/app/components/Session/Player/SharedComponents/BackendLogs/Table.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Icon } from 'UI'; +import { CopyOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import cn from 'classnames'; +import copy from 'copy-to-clipboard'; +import { getDateFromString } from 'App/date'; + +export function TableHeader({ size }: { size: number }) { + return ( +
+
timestamp
+
status
+
+
content
+
+ {size} Records +
+
+
+ ); +} + +export function LogRow({ + log, +}: { + log: { timestamp: string; status: string; content: string }; +}) { + const [isExpanded, setIsExpanded] = React.useState(false); + const bg = (status: string) => { + //types: warn error info none + if (status === 'WARN') { + return 'bg-yellow'; + } + if (status === 'ERROR') { + return 'bg-red-lightest'; + } + return 'bg-white'; + }; + + const border = (status: string) => { + //types: warn error info none + if (status === 'WARN') { + return 'border-l border-l-4 border-l-amber-500'; + } + if (status === 'ERROR') { + return 'border-l border-l-4 border-l-red'; + } + return 'border-l border-l-4 border-gray-lighter'; + }; + return ( +
+
setIsExpanded((prev) => !prev)} + > +
+
+ +
+ {getDateFromString(log.timestamp)} +
+
+
+
{log.status}
+
+ {log.content} +
+
+ {isExpanded ? ( +
+ {log.content.split('\n').map((line, index) => ( +
+
{index}
+
{line}
+
+ ))} + +
+
+
+ ) : null} +
+ ); +} diff --git a/frontend/app/components/Session/Player/SharedComponents/BackendLogs/utils.ts b/frontend/app/components/Session/Player/SharedComponents/BackendLogs/utils.ts new file mode 100644 index 000000000..766a52a94 --- /dev/null +++ b/frontend/app/components/Session/Player/SharedComponents/BackendLogs/utils.ts @@ -0,0 +1,109 @@ +export interface UnifiedLog { + key: string; + timestamp: string; + content: string; + status: string; +} + +export function processLog(log: any): UnifiedLog[] { + if (isDatadogLog(log)) { + return log.map(processDatadogLog); + } else if (isElasticLog(log)) { + return log.map(processElasticLog); + } else if (isSentryLog(log)) { + return log.map(processSentryLog); + } else if (isDynatraceLog(log)) { + return log.map(processDynatraceLog); + } else { + throw new Error("Unknown log format"); + } +} + +function isDynatraceLog(log: any): boolean { + return ( + log && + log[0].results && + Array.isArray(log[0].results) && + log[0].results.length > 0 && + log[0].results[0].eventType === "LOG" + ); +} + +function isDatadogLog(log: any): boolean { + return log && log[0].attributes && typeof log[0].attributes.message === 'string'; +} + +function isElasticLog(log: any): boolean { + return log && log[0]._source && log[0]._source.message; +} + +function isSentryLog(log: any): boolean { + return log && log[0].id && log[0].message && log[0].title; +} + +function processDynatraceLog(log: any): UnifiedLog { + const result = log.results[0]; + + const key = + result.additionalColumns?.["trace_id"]?.[0] || + result.additionalColumns?.["span_id"]?.[0] || + String(result.timestamp); + + const timestamp = new Date(result.timestamp).toISOString(); + + let message = result.content || ""; + let level = result.status?.toLowerCase() || "info"; + + const contentPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+\|(\w+)\|.*?\| (.*)$/; + const contentMatch = message.match(contentPattern); + + if (contentMatch) { + level = contentMatch[1].toLowerCase(); + message = contentMatch[2]; + } + + return { key, timestamp, content: message, status: level }; +} + +function processDatadogLog(log: any): UnifiedLog { + const key = log.id || ''; + const timestamp = log.timestamp || log.attributes?.timestamp || ''; + const message = log.attributes?.message || ''; + const level = log.attributes?.status || 'info'; + return { key, timestamp, content: message, status: level.toUpperCase() }; +} + +function processElasticLog(log: any): UnifiedLog { + const key = log._id || ''; + const timestamp = log._source['@timestamp'] || ''; + const message = log._source.message || ''; + const level = getLevelFromElasticTags(log._source.level); + return { key, timestamp, content: message, status: level }; +} + +function getLevelFromElasticTags(tags: string[]): string { + const levels = ['error', 'warning', 'info', 'debug']; + for (const level of levels) { + if (tags.includes(level.toLowerCase())) { + return level; + } + } + return 'info'; +} + +function processSentryLog(log: any): UnifiedLog { + const key = log.id || log.eventID || ''; + const timestamp = log.dateCreated || 'N/A'; + const message = `${log.title}: \n ${log.message}`; + const level = log.tags ? getLevelFromSentryTags(log.tags) : 'N/A'; + return { key, timestamp, content: message, status: level }; +} + +function getLevelFromSentryTags(tags: any[]): string { + for (const tag of tags) { + if (tag.key === 'level') { + return tag.value; + } + } + return 'info'; +} diff --git a/frontend/app/components/Session_/Player/Controls/ControlButton.tsx b/frontend/app/components/Session_/Player/Controls/ControlButton.tsx index 2d02136e7..e5244d488 100644 --- a/frontend/app/components/Session_/Player/Controls/ControlButton.tsx +++ b/frontend/app/components/Session_/Player/Controls/ControlButton.tsx @@ -17,6 +17,7 @@ interface IProps { containerClassName?: string; noIcon?: boolean; popover?: React.ReactNode; + customTags?: React.ReactNode; } const ControlButton = ({ @@ -26,6 +27,7 @@ const ControlButton = ({ hasErrors = false, active = false, popover = undefined, + customTags, }: IProps) => (