import {Fragment} from 'react'; import type {RouteComponentProps} from 'react-router'; import {browserHistory} from 'react-router'; import styled from '@emotion/styled'; import type {Location, LocationDescriptor, Query} from 'history'; import moment from 'moment'; import {restoreRelease} from 'sentry/actionCreators/release'; import {Client} from 'sentry/api'; import Feature from 'sentry/components/acl/feature'; import SessionsRequest from 'sentry/components/charts/sessionsRequest'; import type {DateTimeObject} from 'sentry/components/charts/utils'; import DateTime from 'sentry/components/dateTime'; import PerformanceCardTable from 'sentry/components/discover/performanceCardTable'; import type {DropdownOption} from 'sentry/components/discover/transactionsList'; import TransactionsList from 'sentry/components/discover/transactionsList'; import * as Layout from 'sentry/components/layouts/thirds'; import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import type {ChangeData} from 'sentry/components/timeRangeSelector'; import {TimeRangeSelector} from 'sentry/components/timeRangeSelector'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {NewQuery, Organization, PageFilters, ReleaseProject} from 'sentry/types'; import {SessionFieldWithOperation} from 'sentry/types'; import {getUtcDateString} from 'sentry/utils/dates'; import type {TableDataRow} from 'sentry/utils/discover/discoverQuery'; import EventView from 'sentry/utils/discover/eventView'; import {MobileVital, SpanOpBreakdown, WebVital} from 'sentry/utils/fields'; import {formatVersion} from 'sentry/utils/formatters'; import {decodeScalar} from 'sentry/utils/queryString'; import routeTitleGen from 'sentry/utils/routeTitle'; import withApi from 'sentry/utils/withApi'; import withOrganization from 'sentry/utils/withOrganization'; import withPageFilters from 'sentry/utils/withPageFilters'; import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView'; import { DisplayModes, transactionSummaryRouteWithQuery, } from 'sentry/views/performance/transactionSummary/utils'; import type {TrendView} from 'sentry/views/performance/trends/types'; import {TrendChangeType} from 'sentry/views/performance/trends/types'; import { platformToPerformanceType, ProjectPerformanceType, } from 'sentry/views/performance/utils'; import type {ReleaseBounds} from '../../utils'; import {getReleaseParams, isReleaseArchived, searchReleaseVersion} from '../../utils'; import {ReleaseContext} from '..'; import CommitAuthorBreakdown from './sidebar/commitAuthorBreakdown'; import Deploys from './sidebar/deploys'; import OtherProjects from './sidebar/otherProjects'; import ProjectReleaseDetails from './sidebar/projectReleaseDetails'; import ReleaseAdoption from './sidebar/releaseAdoption'; import ReleaseStats from './sidebar/releaseStats'; import ThresholdStatuses from './sidebar/thresholdStatuses'; import TotalCrashFreeUsers from './sidebar/totalCrashFreeUsers'; import ReleaseArchivedNotice from './releaseArchivedNotice'; import ReleaseComparisonChart from './releaseComparisonChart'; import ReleaseIssues from './releaseIssues'; const RELEASE_PERIOD_KEY = 'release'; export enum TransactionsListOption { FAILURE_COUNT = 'failure_count', TPM = 'tpm', SLOW = 'slow', SLOW_LCP = 'slow_lcp', REGRESSION = 'regression', IMPROVEMENT = 'improved', } type RouteParams = { orgId: string; release: string; }; type Props = RouteComponentProps & { api: Client; organization: Organization; selection: PageFilters; }; class ReleaseOverview extends DeprecatedAsyncView { getTitle() { const {params, organization} = this.props; return routeTitleGen( t('Release %s', formatVersion(params.release)), organization.slug, false ); } handleRestore = async (project: ReleaseProject, successCallback: () => void) => { const {params, organization} = this.props; try { await restoreRelease(new Client(), { orgSlug: organization.slug, projectSlug: project.slug, releaseVersion: params.release, }); successCallback(); } catch { // do nothing, action creator is already displaying error message } }; getReleaseEventView( version: string, projectId: number, selectedSort: DropdownOption, releaseBounds: ReleaseBounds ): EventView { const {selection, location} = this.props; const {environments} = selection; const {start, end, statsPeriod} = getReleaseParams({ location, releaseBounds, }); const baseQuery: NewQuery = { id: undefined, version: 2, name: `Release ${formatVersion(version)}`, query: `event.type:transaction ${searchReleaseVersion(version)}`, fields: ['transaction', 'failure_count()', 'epm()', 'p50()'], orderby: '-failure_count', range: statsPeriod || undefined, environment: environments, projects: [projectId], start: start ? getUtcDateString(start) : undefined, end: end ? getUtcDateString(end) : undefined, }; switch (selectedSort.value) { case TransactionsListOption.SLOW_LCP: return EventView.fromSavedQuery({ ...baseQuery, query: `event.type:transaction release:${version} epm():>0.01 has:measurements.lcp`, fields: ['transaction', 'failure_count()', 'epm()', 'p75(measurements.lcp)'], orderby: 'p75_measurements_lcp', }); case TransactionsListOption.SLOW: return EventView.fromSavedQuery({ ...baseQuery, query: `event.type:transaction release:${version} epm():>0.01`, }); case TransactionsListOption.FAILURE_COUNT: return EventView.fromSavedQuery({ ...baseQuery, query: `event.type:transaction release:${version} failure_count():>0`, }); default: return EventView.fromSavedQuery(baseQuery); } } getReleaseTrendView( version: string, projectId: number, versionDate: string, releaseBounds: ReleaseBounds ): EventView { const {selection, location} = this.props; const {environments} = selection; const {start, end, statsPeriod} = getReleaseParams({ location, releaseBounds, }); const trendView = EventView.fromSavedQuery({ id: undefined, version: 2, name: `Release ${formatVersion(version)}`, fields: ['transaction'], query: 'tpm():>0.01 trend_percentage():>0%', range: statsPeriod || undefined, environment: environments, projects: [projectId], start: start ? getUtcDateString(start) : undefined, end: end ? getUtcDateString(end) : undefined, }) as TrendView; trendView.middle = versionDate; return trendView; } getReleasePerformanceEventView( performanceType: string, baseQuery: NewQuery ): EventView { const eventView = performanceType === ProjectPerformanceType.FRONTEND ? (EventView.fromSavedQuery({ ...baseQuery, fields: [ ...baseQuery.fields, `p75(${WebVital.FCP})`, `p75(${WebVital.FID})`, `p75(${WebVital.LCP})`, `p75(${WebVital.CLS})`, `p75(${SpanOpBreakdown.SPANS_HTTP})`, `p75(${SpanOpBreakdown.SPANS_BROWSER})`, `p75(${SpanOpBreakdown.SPANS_RESOURCE})`, ], }) as EventView) : performanceType === ProjectPerformanceType.BACKEND ? (EventView.fromSavedQuery({ ...baseQuery, fields: [ ...baseQuery.fields, 'apdex()', 'p75(spans.http)', 'p75(spans.db)', ], }) as EventView) : performanceType === ProjectPerformanceType.MOBILE ? (EventView.fromSavedQuery({ ...baseQuery, fields: [ ...baseQuery.fields, `p75(${MobileVital.APP_START_COLD})`, `p75(${MobileVital.APP_START_WARM})`, `p75(${MobileVital.FRAMES_SLOW})`, `p75(${MobileVital.FRAMES_FROZEN})`, ], }) as EventView) : (EventView.fromSavedQuery({ ...baseQuery, }) as EventView); return eventView; } getAllReleasesPerformanceView( projectId: number, performanceType: string, releaseBounds: ReleaseBounds ) { const {selection, location} = this.props; const {environments} = selection; const {start, end, statsPeriod} = getReleaseParams({ location, releaseBounds, }); const baseQuery: NewQuery = { id: undefined, version: 2, name: 'All Releases', query: 'event.type:transaction', fields: ['user_misery()'], range: statsPeriod || undefined, environment: environments, projects: [projectId], start: start ? getUtcDateString(start) : undefined, end: end ? getUtcDateString(end) : undefined, }; return this.getReleasePerformanceEventView(performanceType, baseQuery); } getReleasePerformanceView( version: string, projectId: number, performanceType: string, releaseBounds: ReleaseBounds ) { const {selection, location} = this.props; const {environments} = selection; const {start, end, statsPeriod} = getReleaseParams({ location, releaseBounds, }); const baseQuery: NewQuery = { id: undefined, version: 2, name: `Release:${version}`, query: `event.type:transaction release:${version}`, fields: ['user_misery()'], range: statsPeriod || undefined, environment: environments, projects: [projectId], start: start ? getUtcDateString(start) : undefined, end: end ? getUtcDateString(end) : undefined, }; return this.getReleasePerformanceEventView(performanceType, baseQuery); } get pageDateTime(): DateTimeObject { const query = this.props.location.query; const {start, end, statsPeriod} = normalizeDateTimeParams(query, { allowEmptyPeriod: true, allowAbsoluteDatetime: true, allowAbsolutePageDatetime: true, }); if (statsPeriod) { return {period: statsPeriod}; } if (start && end) { return { start: moment.utc(start).format(), end: moment.utc(end).format(), }; } return {}; } handleTransactionsListSortChange = (value: string) => { const {location} = this.props; const target = { pathname: location.pathname, query: {...location.query, showTransactions: value, transactionCursor: undefined}, }; browserHistory.push(target); }; handleDateChange = (datetime: ChangeData) => { const {router, location} = this.props; const {start, end, relative, utc} = datetime; if (start && end) { const parser = utc ? moment.utc : moment; router.push({ ...location, query: { ...location.query, pageStatsPeriod: undefined, pageStart: parser(start).format(), pageEnd: parser(end).format(), pageUtc: utc ?? undefined, }, }); return; } router.push({ ...location, query: { ...location.query, pageStatsPeriod: relative === RELEASE_PERIOD_KEY ? undefined : relative, pageStart: undefined, pageEnd: undefined, pageUtc: undefined, }, }); }; render() { const {organization, selection, location, api} = this.props; const {start, end, period, utc} = this.pageDateTime; const hasV2ReleaseUIEnabled = organization.features.includes('releases-v2-internal') || organization.features.includes('releases-v2') || organization.features.includes('releases-v2-st'); return ( {({ release, project, deploys, releaseMeta, refetchData, hasHealthData, releaseBounds, }) => { const {commitCount, version} = release; const hasDiscover = organization.features.includes('discover-basic'); const hasPerformance = organization.features.includes('performance-view'); const hasReleaseComparisonPerformance = organization.features.includes( 'release-comparison-performance' ); const {environments} = selection; const performanceType = platformToPerformanceType([project], [project.id]); const {selectedSort, sortOptions} = getTransactionsListSort(location); const releaseEventView = this.getReleaseEventView( version, project.id, selectedSort, releaseBounds ); const titles = selectedSort.value !== TransactionsListOption.SLOW_LCP ? [t('transaction'), t('failure_count()'), t('tpm()'), t('p50()')] : [t('transaction'), t('failure_count()'), t('tpm()'), t('p75(lcp)')]; const releaseTrendView = this.getReleaseTrendView( version, project.id, releaseMeta.released, releaseBounds ); const allReleasesPerformanceView = this.getAllReleasesPerformanceView( project.id, performanceType, releaseBounds ); const releasePerformanceView = this.getReleasePerformanceView( version, project.id, performanceType, releaseBounds ); const generateLink = { transaction: generateTransactionLink( version, project.id, selection, location.query.showTransactions ), }; const sessionsRequestProps: Omit = { api, organization, field: [ SessionFieldWithOperation.USERS, SessionFieldWithOperation.SESSIONS, SessionFieldWithOperation.DURATION, ], groupBy: ['session.status'], ...getReleaseParams({location, releaseBounds}), shouldFilterSessionsInTimeWindow: true, }; const defaultDateTimeSelected = !period && !start && !end; const releaseBoundsLabel = releaseBounds.type === 'clamped' ? t('Clamped Release Period') : t('Entire Release Period'); return ( {({ loading: allReleasesLoading, reloading: allReleasesReloading, errored: allReleasesErrored, response: allReleases, }) => ( {({ loading: thisReleaseLoading, reloading: thisReleaseReloading, errored: thisReleaseErrored, response: thisRelease, }) => { const loading = allReleasesLoading || thisReleaseLoading; const reloading = allReleasesReloading || thisReleaseReloading; const errored = allReleasesErrored || thisReleaseErrored; return ( {isReleaseArchived(release) && ( this.handleRestore(project, refetchData)} /> )} releaseBounds.type !== 'ancient' ? { [RELEASE_PERIOD_KEY]: ( {releaseBoundsLabel}
), ...defaultOptions, ...arbitraryOptions, } : {...defaultOptions, ...arbitraryOptions} } defaultPeriod={ releaseBounds.type !== 'ancient' ? RELEASE_PERIOD_KEY : '90d' } defaultAbsolute={{ start: moment(releaseBounds.releaseStart) .subtract(1, 'hour') .toDate(), end: releaseBounds.releaseEnd ? moment(releaseBounds.releaseEnd) .add(1, 'hour') .toDate() : undefined, }} />
{(hasDiscover || hasPerformance || hasHealthData) && ( )} {hasReleaseComparisonPerformance ? ( ) : ( )}
{hasV2ReleaseUIEnabled && ( )} {hasHealthData && ( )} {commitCount > 0 && ( )} {releaseMeta.projects.length > 1 && ( p.slug !== project.slug )} location={location} version={version} organization={organization} /> )} {hasHealthData && ( )} {deploys.length > 0 && ( )}
); }}
)}
); }}
); } } function generateTransactionLink( version: string, projectId: number, selection: PageFilters, value: string ) { return ( organization: Organization, tableRow: TableDataRow, _query: Query ): LocationDescriptor => { const {transaction} = tableRow; const trendTransaction = ['regression', 'improved'].includes(value); const {environments, datetime} = selection; const {start, end, period} = datetime; return transactionSummaryRouteWithQuery({ orgSlug: organization.slug, transaction: transaction! as string, query: { query: trendTransaction ? '' : `release:${version}`, environment: environments, start: start ? getUtcDateString(start) : undefined, end: end ? getUtcDateString(end) : undefined, statsPeriod: period, }, projectID: projectId.toString(), display: trendTransaction ? DisplayModes.TREND : DisplayModes.DURATION, }); }; } function getDropdownOptions(): DropdownOption[] { return [ { sort: {kind: 'desc', field: 'failure_count'}, value: TransactionsListOption.FAILURE_COUNT, label: t('Failing Transactions'), }, { sort: {kind: 'desc', field: 'epm'}, value: TransactionsListOption.TPM, label: t('Frequent Transactions'), }, { sort: {kind: 'desc', field: 'p50'}, value: TransactionsListOption.SLOW, label: t('Slow Transactions'), }, { sort: {kind: 'desc', field: 'p75_measurements_lcp'}, value: TransactionsListOption.SLOW_LCP, label: t('Slow LCP'), }, { sort: {kind: 'desc', field: 'trend_percentage()'}, query: [['confidence()', '>6']], trendType: TrendChangeType.REGRESSION, value: TransactionsListOption.REGRESSION, label: t('Trending Regressions'), }, { sort: {kind: 'asc', field: 'trend_percentage()'}, query: [['confidence()', '>6']], trendType: TrendChangeType.IMPROVED, value: TransactionsListOption.IMPROVEMENT, label: t('Trending Improvements'), }, ]; } function getTransactionsListSort(location: Location): { selectedSort: DropdownOption; sortOptions: DropdownOption[]; } { const sortOptions = getDropdownOptions(); const urlParam = decodeScalar( location.query.showTransactions, TransactionsListOption.FAILURE_COUNT ); const selectedSort = sortOptions.find(opt => opt.value === urlParam) || sortOptions[0]; return {selectedSort, sortOptions}; } const ReleaseDetailsPageFilters = styled('div')` display: grid; grid-template-columns: minmax(0, max-content) 1fr; gap: ${space(2)}; margin-bottom: ${space(2)}; @media (max-width: ${p => p.theme.breakpoints.small}) { grid-template-columns: auto; } `; const ReleaseBoundsDescription = styled('span')<{primary: boolean}>` font-size: ${p => p.theme.fontSizeSmall}; color: ${p => (p.primary ? p.theme.activeText : p.theme.subText)}; `; ReleaseOverview.contextType = ReleaseContext; export default withApi(withPageFilters(withOrganization(ReleaseOverview)));