|
@@ -1,10 +1,14 @@
|
|
|
+import {useEffect, useMemo} from 'react';
|
|
|
import {css} from '@emotion/react';
|
|
|
import styled from '@emotion/styled';
|
|
|
|
|
|
import {openModal} from 'sentry/actionCreators/modal';
|
|
|
-import {promptsCheck, promptsUpdate} from 'sentry/actionCreators/prompts';
|
|
|
-import type {ResponseMeta} from 'sentry/api';
|
|
|
-import AsyncComponent from 'sentry/components/asyncComponent';
|
|
|
+import {
|
|
|
+ makePromptsCheckQueryKey,
|
|
|
+ PromptResponse,
|
|
|
+ promptsUpdate,
|
|
|
+ usePromptsCheck,
|
|
|
+} from 'sentry/actionCreators/prompts';
|
|
|
import Button from 'sentry/components/button';
|
|
|
import ExternalLink from 'sentry/components/links/externalLink';
|
|
|
import Link from 'sentry/components/links/link';
|
|
@@ -21,187 +25,207 @@ import type {
|
|
|
} from 'sentry/types';
|
|
|
import {StacktraceLinkEvents} from 'sentry/utils/analytics/integrations/stacktraceLinkAnalyticsEvents';
|
|
|
import {getAnalyicsDataForEvent} from 'sentry/utils/events';
|
|
|
-import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
|
|
|
import {
|
|
|
getIntegrationIcon,
|
|
|
trackIntegrationAnalytics,
|
|
|
} from 'sentry/utils/integrationUtil';
|
|
|
import {promptIsDismissed} from 'sentry/utils/promptIsDismissed';
|
|
|
-import withOrganization from 'sentry/utils/withOrganization';
|
|
|
-import withProjects from 'sentry/utils/withProjects';
|
|
|
+import {useQuery, useQueryClient} from 'sentry/utils/queryClient';
|
|
|
+import useApi from 'sentry/utils/useApi';
|
|
|
+import useOrganization from 'sentry/utils/useOrganization';
|
|
|
+import useProjects from 'sentry/utils/useProjects';
|
|
|
|
|
|
import {OpenInContainer} from './openInContextLine';
|
|
|
import StacktraceLinkModal from './stacktraceLinkModal';
|
|
|
|
|
|
-type Props = AsyncComponent['props'] & {
|
|
|
+interface StacktraceLinkSetupProps {
|
|
|
event: Event;
|
|
|
- frame: Frame;
|
|
|
- line: string;
|
|
|
- lineNo: number;
|
|
|
organization: Organization;
|
|
|
- projects: Project[];
|
|
|
-};
|
|
|
-
|
|
|
-type State = AsyncComponent['state'] & {
|
|
|
- isDismissed: boolean;
|
|
|
- match: StacktraceLinkResult;
|
|
|
- promptLoaded: boolean;
|
|
|
-};
|
|
|
-
|
|
|
-class StacktraceLink extends AsyncComponent<Props, State> {
|
|
|
- get project() {
|
|
|
- // we can't use the withProject HoC on an the issue page
|
|
|
- // so we ge around that by using the withProjects HoC
|
|
|
- // and look up the project from the list
|
|
|
- const {projects, event} = this.props;
|
|
|
- return projects.find(project => project.id === event.projectID);
|
|
|
- }
|
|
|
-
|
|
|
- componentDidMount() {
|
|
|
- this.promptsCheck();
|
|
|
- }
|
|
|
-
|
|
|
- async promptsCheck() {
|
|
|
- const {organization} = this.props;
|
|
|
-
|
|
|
- const prompt = await promptsCheck(this.api, {
|
|
|
- organizationId: organization.id,
|
|
|
- projectId: this.project?.id,
|
|
|
- feature: 'stacktrace_link',
|
|
|
- });
|
|
|
+ project?: Project;
|
|
|
+}
|
|
|
|
|
|
- this.setState({
|
|
|
- isDismissed: promptIsDismissed(prompt),
|
|
|
- promptLoaded: true,
|
|
|
- });
|
|
|
- }
|
|
|
+function StacktraceLinkSetup({organization, project, event}: StacktraceLinkSetupProps) {
|
|
|
+ const api = useApi();
|
|
|
+ const queryClient = useQueryClient();
|
|
|
|
|
|
- dismissPrompt = () => {
|
|
|
- const {organization} = this.props;
|
|
|
- promptsUpdate(this.api, {
|
|
|
+ const dismissPrompt = () => {
|
|
|
+ promptsUpdate(api, {
|
|
|
organizationId: organization.id,
|
|
|
- projectId: this.project?.id,
|
|
|
+ projectId: project?.id,
|
|
|
feature: 'stacktrace_link',
|
|
|
status: 'dismissed',
|
|
|
});
|
|
|
|
|
|
+ // Update cached query data
|
|
|
+ // Will set prompt to dismissed
|
|
|
+ queryClient.setQueryData<PromptResponse>(
|
|
|
+ makePromptsCheckQueryKey({
|
|
|
+ feature: 'stacktrace_link',
|
|
|
+ organizationId: organization.id,
|
|
|
+ projectId: project?.id,
|
|
|
+ }),
|
|
|
+ () => {
|
|
|
+ const dimissedTs = new Date().getTime() / 1000;
|
|
|
+ return {
|
|
|
+ data: {dismissed_ts: dimissedTs},
|
|
|
+ features: {stacktrace_link: {dismissed_ts: dimissedTs}},
|
|
|
+ };
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
trackIntegrationAnalytics('integrations.stacktrace_link_cta_dismissed', {
|
|
|
view: 'stacktrace_issue_details',
|
|
|
organization,
|
|
|
- ...getAnalyicsDataForEvent(this.props.event),
|
|
|
+ ...getAnalyicsDataForEvent(event),
|
|
|
});
|
|
|
-
|
|
|
- this.setState({isDismissed: true});
|
|
|
};
|
|
|
|
|
|
- getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
|
|
|
- const {organization, frame, event} = this.props;
|
|
|
- const project = this.project;
|
|
|
- if (!project) {
|
|
|
- throw new Error('Unable to find project');
|
|
|
- }
|
|
|
+ return (
|
|
|
+ <CodeMappingButtonContainer columnQuantity={2}>
|
|
|
+ <StyledLink to={`/settings/${organization.slug}/integrations/`}>
|
|
|
+ <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
|
|
|
+ {t(
|
|
|
+ 'Add a GitHub, Bitbucket, or similar integration to make sh*t easier for your team'
|
|
|
+ )}
|
|
|
+ </StyledLink>
|
|
|
+ <CloseButton type="button" priority="link" onClick={dismissPrompt}>
|
|
|
+ <IconClose size="xs" aria-label={t('Close')} />
|
|
|
+ </CloseButton>
|
|
|
+ </CodeMappingButtonContainer>
|
|
|
+ );
|
|
|
+}
|
|
|
|
|
|
- const commitId = event.release?.lastCommit?.id;
|
|
|
- const platform = event.platform;
|
|
|
- const sdkName = event.sdk?.name;
|
|
|
- return [
|
|
|
- [
|
|
|
- 'match',
|
|
|
- `/projects/${organization.slug}/${project.slug}/stacktrace-link/`,
|
|
|
- {
|
|
|
- query: {
|
|
|
- file: frame.filename,
|
|
|
- platform,
|
|
|
- commitId,
|
|
|
- ...(sdkName && {sdkName}),
|
|
|
- ...(frame.absPath && {absPath: frame.absPath}),
|
|
|
- ...(frame.module && {module: frame.module}),
|
|
|
- ...(frame.package && {package: frame.package}),
|
|
|
- },
|
|
|
- },
|
|
|
- ],
|
|
|
- ];
|
|
|
- }
|
|
|
+interface StacktraceLinkProps {
|
|
|
+ event: Event;
|
|
|
+ frame: Frame;
|
|
|
+ /**
|
|
|
+ * The line of code being linked
|
|
|
+ */
|
|
|
+ line: string;
|
|
|
+}
|
|
|
+
|
|
|
+export function StacktraceLink({frame, event, line}: StacktraceLinkProps) {
|
|
|
+ const organization = useOrganization();
|
|
|
+ const {projects} = useProjects();
|
|
|
+ const project = useMemo(
|
|
|
+ () => projects.find(p => p.id === event.projectID),
|
|
|
+ [projects, event]
|
|
|
+ );
|
|
|
+ const prompt = usePromptsCheck({
|
|
|
+ feature: 'stacktrace_link',
|
|
|
+ organizationId: organization.id,
|
|
|
+ projectId: project?.id,
|
|
|
+ });
|
|
|
+ const isPromptDismissed =
|
|
|
+ prompt.isSuccess && prompt.data.data
|
|
|
+ ? promptIsDismissed({
|
|
|
+ dismissedTime: prompt.data.data.dismissed_ts,
|
|
|
+ snoozedTime: prompt.data.data.snoozed_ts,
|
|
|
+ })
|
|
|
+ : false;
|
|
|
+
|
|
|
+ const query = {
|
|
|
+ file: frame.filename,
|
|
|
+ platform: event.platform,
|
|
|
+ commitId: event.release?.lastCommit?.id,
|
|
|
+ ...(event.sdk?.name && {sdkName: event.sdk.name}),
|
|
|
+ ...(frame.absPath && {absPath: frame.absPath}),
|
|
|
+ ...(frame.module && {module: frame.module}),
|
|
|
+ ...(frame.package && {package: frame.package}),
|
|
|
+ };
|
|
|
+ const {
|
|
|
+ data: match,
|
|
|
+ isLoading,
|
|
|
+ refetch,
|
|
|
+ } = useQuery<StacktraceLinkResult>([
|
|
|
+ `/projects/${organization.slug}/${project?.slug}/stacktrace-link/`,
|
|
|
+ {query},
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // Track stacktrace analytics after loaded
|
|
|
+ useEffect(() => {
|
|
|
+ if (isLoading || prompt.isLoading || !match) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
- onRequestSuccess(resp: {data: StacktraceLinkResult; stateKey: 'match'}) {
|
|
|
- const {error, integrations, sourceUrl} = resp.data;
|
|
|
trackIntegrationAnalytics('integrations.stacktrace_link_viewed', {
|
|
|
view: 'stacktrace_issue_details',
|
|
|
- organization: this.props.organization,
|
|
|
- platform: this.project?.platform,
|
|
|
- project_id: this.project?.id,
|
|
|
+ organization,
|
|
|
+ platform: project?.platform,
|
|
|
+ project_id: project?.id,
|
|
|
state:
|
|
|
// Should follow the same logic in render
|
|
|
- sourceUrl
|
|
|
+ match.sourceUrl
|
|
|
? 'match'
|
|
|
- : error || integrations.length > 0
|
|
|
+ : match.error || match.integrations.length > 0
|
|
|
? 'no_match'
|
|
|
- : !this.state.isDismissed
|
|
|
+ : !isPromptDismissed
|
|
|
? 'prompt'
|
|
|
: 'empty',
|
|
|
- ...getAnalyicsDataForEvent(this.props.event),
|
|
|
+ ...getAnalyicsDataForEvent(event),
|
|
|
});
|
|
|
- }
|
|
|
+ // excluding isPromptDismissed because we want this only to record once
|
|
|
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
+ }, [isLoading, prompt.isLoading, match, organization, project, event]);
|
|
|
|
|
|
- onRequestError(resp: ResponseMeta) {
|
|
|
- handleXhrErrorResponse('Unable to fetch stack trace link')(resp);
|
|
|
- }
|
|
|
-
|
|
|
- getDefaultState(): State {
|
|
|
- return {
|
|
|
- ...super.getDefaultState(),
|
|
|
- showModal: false,
|
|
|
- sourceCodeInput: '',
|
|
|
- match: {integrations: []},
|
|
|
- isDismissed: false,
|
|
|
- promptLoaded: false,
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- onOpenLink = () => {
|
|
|
- const provider = this.state.match.config?.provider;
|
|
|
+ const onOpenLink = () => {
|
|
|
+ const provider = match!.config?.provider;
|
|
|
if (provider) {
|
|
|
trackIntegrationAnalytics(
|
|
|
StacktraceLinkEvents.OPEN_LINK,
|
|
|
{
|
|
|
view: 'stacktrace_issue_details',
|
|
|
provider: provider.key,
|
|
|
- organization: this.props.organization,
|
|
|
- ...getAnalyicsDataForEvent(this.props.event),
|
|
|
+ organization,
|
|
|
+ ...getAnalyicsDataForEvent(event),
|
|
|
},
|
|
|
{startSession: true}
|
|
|
);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- handleSubmit = () => {
|
|
|
- this.reloadData();
|
|
|
+ const handleSubmit = () => {
|
|
|
+ refetch();
|
|
|
};
|
|
|
|
|
|
- // don't show the error boundary if the component fails.
|
|
|
- // capture the endpoint error on onRequestError
|
|
|
- renderError(): React.ReactNode {
|
|
|
- return null;
|
|
|
+ if (isLoading || !match) {
|
|
|
+ return (
|
|
|
+ <CodeMappingButtonContainer columnQuantity={2}>
|
|
|
+ <Placeholder height="24px" width="60px" />
|
|
|
+ </CodeMappingButtonContainer>
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
- renderLoading() {
|
|
|
+ // Match found - display link to source
|
|
|
+ if (match.config && match.sourceUrl) {
|
|
|
return (
|
|
|
<CodeMappingButtonContainer columnQuantity={2}>
|
|
|
- <Placeholder height="24px" width="60px" />
|
|
|
+ <OpenInLink
|
|
|
+ onClick={onOpenLink}
|
|
|
+ href={`${match!.sourceUrl}#L${frame.lineNo}`}
|
|
|
+ openInNewTab
|
|
|
+ >
|
|
|
+ <StyledIconWrapper>
|
|
|
+ {getIntegrationIcon(match.config.provider.key, 'sm')}
|
|
|
+ </StyledIconWrapper>
|
|
|
+ {t('Open this line in %s', match.config.provider.name)}
|
|
|
+ </OpenInLink>
|
|
|
</CodeMappingButtonContainer>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- renderNoMatch() {
|
|
|
- const filename = this.props.frame.filename;
|
|
|
- const {integrations} = this.state.match;
|
|
|
- if (!this.project || !integrations.length || !filename) {
|
|
|
+ // Hide stacktrace link errors if the stacktrace might be minified javascript
|
|
|
+ // Check if the line starts and ends with {snip}
|
|
|
+ const hideErrors = event.platform === 'javascript' && /(\{snip\}).*\1/.test(line);
|
|
|
+
|
|
|
+ // No match found - Has integration but no code mappings
|
|
|
+ if (!hideErrors && (match.error || match.integrations.length > 0)) {
|
|
|
+ const filename = frame.filename;
|
|
|
+ if (!project || !match.integrations.length || !filename) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- const {organization} = this.props;
|
|
|
- const platform = this.props.event.platform;
|
|
|
- const sourceCodeProviders = integrations.filter(integration =>
|
|
|
+ const sourceCodeProviders = match.integrations.filter(integration =>
|
|
|
['github', 'gitlab'].includes(integration.provider?.key)
|
|
|
);
|
|
|
return (
|
|
@@ -219,25 +243,22 @@ class StacktraceLink extends AsyncComponent<Props, State> {
|
|
|
'integrations.stacktrace_start_setup',
|
|
|
{
|
|
|
view: 'stacktrace_issue_details',
|
|
|
- platform,
|
|
|
+ platform: event.platform,
|
|
|
organization,
|
|
|
- ...getAnalyicsDataForEvent(this.props.event),
|
|
|
+ ...getAnalyicsDataForEvent(event),
|
|
|
},
|
|
|
{startSession: true}
|
|
|
);
|
|
|
- openModal(
|
|
|
- deps =>
|
|
|
- this.project && (
|
|
|
- <StacktraceLinkModal
|
|
|
- onSubmit={this.handleSubmit}
|
|
|
- filename={filename}
|
|
|
- project={this.project}
|
|
|
- organization={organization}
|
|
|
- integrations={integrations}
|
|
|
- {...deps}
|
|
|
- />
|
|
|
- )
|
|
|
- );
|
|
|
+ openModal(deps => (
|
|
|
+ <StacktraceLinkModal
|
|
|
+ onSubmit={handleSubmit}
|
|
|
+ filename={filename}
|
|
|
+ project={project}
|
|
|
+ organization={organization}
|
|
|
+ integrations={match.integrations}
|
|
|
+ {...deps}
|
|
|
+ />
|
|
|
+ ));
|
|
|
}}
|
|
|
>
|
|
|
{t('Tell us where your source code is')}
|
|
@@ -246,69 +267,17 @@ class StacktraceLink extends AsyncComponent<Props, State> {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- renderNoIntegrations() {
|
|
|
- const {organization} = this.props;
|
|
|
- return (
|
|
|
- <CodeMappingButtonContainer columnQuantity={2}>
|
|
|
- <StyledLink to={`/settings/${organization.slug}/integrations/`}>
|
|
|
- <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
|
|
|
- {t(
|
|
|
- 'Add a GitHub, Bitbucket, or similar integration to make sh*t easier for your team'
|
|
|
- )}
|
|
|
- </StyledLink>
|
|
|
- <CloseButton type="button" priority="link" onClick={this.dismissPrompt}>
|
|
|
- <IconClose size="xs" aria-label={t('Close')} />
|
|
|
- </CloseButton>
|
|
|
- </CodeMappingButtonContainer>
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- renderLink() {
|
|
|
- const {config, sourceUrl} = this.state.match;
|
|
|
- const url = `${sourceUrl}#L${this.props.frame.lineNo}`;
|
|
|
- return (
|
|
|
- <CodeMappingButtonContainer columnQuantity={2}>
|
|
|
- <OpenInLink onClick={this.onOpenLink} href={url} openInNewTab>
|
|
|
- <StyledIconWrapper>
|
|
|
- {getIntegrationIcon(config!.provider.key, 'sm')}
|
|
|
- </StyledIconWrapper>
|
|
|
- {t('Open this line in %s', config!.provider.name)}
|
|
|
- </OpenInLink>
|
|
|
- </CodeMappingButtonContainer>
|
|
|
- );
|
|
|
+ // No integrations, but prompt is dismissed or hidden
|
|
|
+ if (hideErrors || isPromptDismissed) {
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
- renderBody() {
|
|
|
- const {config, error, sourceUrl, integrations} = this.state.match || {};
|
|
|
- const {isDismissed, promptLoaded} = this.state;
|
|
|
- const {event, line} = this.props;
|
|
|
-
|
|
|
- // Success state
|
|
|
- if (config && sourceUrl) {
|
|
|
- return this.renderLink();
|
|
|
- }
|
|
|
-
|
|
|
- // Hide stacktrace link errors if the stacktrace might be minified javascript
|
|
|
- // Check if the line starts and ends with {snip}
|
|
|
- const hideErrors = event.platform === 'javascript' && /(\{snip\}).*\1/.test(line);
|
|
|
-
|
|
|
- // Code mapping does not match
|
|
|
- // Has integration but no code mappings
|
|
|
- if (!hideErrors && (error || integrations.length > 0)) {
|
|
|
- return this.renderNoMatch();
|
|
|
- }
|
|
|
-
|
|
|
- if (hideErrors || !promptLoaded || (promptLoaded && isDismissed)) {
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- return this.renderNoIntegrations();
|
|
|
- }
|
|
|
+ // No integrations
|
|
|
+ return (
|
|
|
+ <StacktraceLinkSetup event={event} project={project} organization={organization} />
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
-export default withProjects(withOrganization(StacktraceLink));
|
|
|
-export {StacktraceLink};
|
|
|
-
|
|
|
export const CodeMappingButtonContainer = styled(OpenInContainer)`
|
|
|
justify-content: space-between;
|
|
|
min-height: 28px;
|