123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662 |
- import {Component, Fragment} from 'react';
- // eslint-disable-next-line no-restricted-imports
- import {withRouter, WithRouterProps} from 'react-router';
- import styled from '@emotion/styled';
- import map from 'lodash/map';
- import omit from 'lodash/omit';
- import {Client} from 'sentry/api';
- import Feature from 'sentry/components/acl/feature';
- import Alert from 'sentry/components/alert';
- import Button from 'sentry/components/button';
- import Clipboard from 'sentry/components/clipboard';
- import DateTime from 'sentry/components/dateTime';
- import DiscoverButton from 'sentry/components/discoverButton';
- import FileSize from 'sentry/components/fileSize';
- import ExternalLink from 'sentry/components/links/externalLink';
- import Link from 'sentry/components/links/link';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import {
- ErrorDot,
- ErrorLevel,
- ErrorMessageContent,
- ErrorMessageTitle,
- ErrorTitle,
- } from 'sentry/components/performance/waterfall/rowDetails';
- import Pill from 'sentry/components/pill';
- import Pills from 'sentry/components/pills';
- import {
- generateIssueEventTarget,
- generateTraceTarget,
- } from 'sentry/components/quickTrace/utils';
- import {ALL_ACCESS_PROJECTS, PAGE_URL_PARAM} from 'sentry/constants/pageFilters';
- import {IconLink} from 'sentry/icons';
- import {t, tn} from 'sentry/locale';
- import space from 'sentry/styles/space';
- import {Organization} from 'sentry/types';
- import {EventTransaction} from 'sentry/types/event';
- import {assert} from 'sentry/types/utils';
- import {defined} from 'sentry/utils';
- import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
- import EventView from 'sentry/utils/discover/eventView';
- import {generateEventSlug} from 'sentry/utils/discover/urls';
- import getDynamicText from 'sentry/utils/getDynamicText';
- import {QuickTraceEvent, TraceError} from 'sentry/utils/performance/quickTrace/types';
- import withApi from 'sentry/utils/withApi';
- import {spanDetailsRouteWithQuery} from 'sentry/views/performance/transactionSummary/transactionSpans/spanDetails/utils';
- import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
- import * as SpanEntryContext from './context';
- import InlineDocs from './inlineDocs';
- import {ParsedTraceType, ProcessedSpanType, rawSpanKeys, RawSpanType} from './types';
- import {
- getCumulativeAlertLevelFromErrors,
- getTraceDateTimeRange,
- isGapSpan,
- isOrphanSpan,
- scrollToSpan,
- } from './utils';
- const DEFAULT_ERRORS_VISIBLE = 5;
- const SIZE_DATA_KEYS = ['Encoded Body Size', 'Decoded Body Size', 'Transfer Size'];
- type TransactionResult = {
- id: string;
- 'project.name': string;
- 'trace.span': string;
- transaction: string;
- };
- type Props = WithRouterProps & {
- api: Client;
- childTransactions: QuickTraceEvent[] | null;
- event: Readonly<EventTransaction>;
- isRoot: boolean;
- organization: Organization;
- relatedErrors: TraceError[] | null;
- scrollToHash: (hash: string) => void;
- span: Readonly<ProcessedSpanType>;
- trace: Readonly<ParsedTraceType>;
- };
- type State = {
- errorsOpened: boolean;
- };
- class SpanDetail extends Component<Props, State> {
- state: State = {
- errorsOpened: false,
- };
- componentDidMount() {
- const {span, organization} = this.props;
- if ('type' in span) {
- return;
- }
- trackAdvancedAnalyticsEvent('performance_views.event_details.open_span_details', {
- organization,
- operation: span.op ?? 'undefined',
- });
- }
- renderTraversalButton(): React.ReactNode {
- if (!this.props.childTransactions) {
- // TODO: Amend size to use theme when we eventually refactor LoadingIndicator
- // 12px is consistent with theme.iconSizes['xs'] but theme returns a string.
- return (
- <StyledDiscoverButton size="xs" disabled>
- <StyledLoadingIndicator size={12} />
- </StyledDiscoverButton>
- );
- }
- if (this.props.childTransactions.length <= 0) {
- return null;
- }
- const {span, trace, event, organization} = this.props;
- assert(!isGapSpan(span));
- if (this.props.childTransactions.length === 1) {
- // Note: This is rendered by this.renderSpanChild() as a dedicated row
- return null;
- }
- const orgFeatures = new Set(organization.features);
- const {start, end} = getTraceDateTimeRange({
- start: trace.traceStartTimestamp,
- end: trace.traceEndTimestamp,
- });
- const childrenEventView = EventView.fromSavedQuery({
- id: undefined,
- name: `Children from Span ID ${span.span_id}`,
- fields: [
- 'transaction',
- 'project',
- 'trace.span',
- 'transaction.duration',
- 'timestamp',
- ],
- orderby: '-timestamp',
- query: `event.type:transaction trace:${span.trace_id} trace.parent_span:${span.span_id}`,
- projects: orgFeatures.has('global-views')
- ? [ALL_ACCESS_PROJECTS]
- : [Number(event.projectID)],
- version: 2,
- start,
- end,
- });
- return (
- <StyledDiscoverButton
- data-test-id="view-child-transactions"
- size="xs"
- to={childrenEventView.getResultsViewUrlTarget(organization.slug)}
- >
- {t('View Children')}
- </StyledDiscoverButton>
- );
- }
- renderSpanChild(): React.ReactNode {
- const {childTransactions, organization, location} = this.props;
- if (!childTransactions || childTransactions.length !== 1) {
- return null;
- }
- const childTransaction = childTransactions[0];
- const transactionResult: TransactionResult = {
- 'project.name': childTransaction.project_slug,
- transaction: childTransaction.transaction,
- 'trace.span': childTransaction.span_id,
- id: childTransaction.event_id,
- };
- const eventSlug = generateSlug(transactionResult);
- const viewChildButton = (
- <SpanEntryContext.Consumer>
- {({getViewChildTransactionTarget}) => {
- const to = getViewChildTransactionTarget({
- ...transactionResult,
- eventSlug,
- });
- if (!to) {
- return null;
- }
- const target = transactionSummaryRouteWithQuery({
- orgSlug: organization.slug,
- transaction: transactionResult.transaction,
- query: omit(location.query, Object.values(PAGE_URL_PARAM)),
- projectID: String(childTransaction.project_id),
- });
- return (
- <ButtonGroup>
- <StyledButton data-test-id="view-child-transaction" size="xs" to={to}>
- {t('View Transaction')}
- </StyledButton>
- <StyledButton size="xs" to={target}>
- {t('View Summary')}
- </StyledButton>
- </ButtonGroup>
- );
- }}
- </SpanEntryContext.Consumer>
- );
- return (
- <Row title="Child Transaction" extra={viewChildButton}>
- {`${transactionResult.transaction} (${transactionResult['project.name']})`}
- </Row>
- );
- }
- renderTraceButton() {
- const {span, organization, event} = this.props;
- if (isGapSpan(span)) {
- return null;
- }
- return (
- <StyledButton size="xs" to={generateTraceTarget(event, organization)}>
- {t('View Trace')}
- </StyledButton>
- );
- }
- renderViewSimilarSpansButton() {
- const {span, organization, location, event} = this.props;
- if (isGapSpan(span) || !span.op || !span.hash) {
- return null;
- }
- const transactionName = event.title;
- const target = spanDetailsRouteWithQuery({
- orgSlug: organization.slug,
- transaction: transactionName,
- query: location.query,
- spanSlug: {op: span.op, group: span.hash},
- projectID: event.projectID,
- });
- return (
- <StyledButton size="xs" to={target}>
- {t('View Similar Spans')}
- </StyledButton>
- );
- }
- renderOrphanSpanMessage() {
- const {span} = this.props;
- if (!isOrphanSpan(span)) {
- return null;
- }
- return (
- <Alert type="info" showIcon system>
- {t(
- 'This is a span that has no parent span within this transaction. It has been attached to the transaction root span by default.'
- )}
- </Alert>
- );
- }
- toggleErrors = () => {
- this.setState(({errorsOpened}) => ({errorsOpened: !errorsOpened}));
- };
- renderSpanErrorMessage() {
- const {span, organization, relatedErrors} = this.props;
- const {errorsOpened} = this.state;
- if (!relatedErrors || relatedErrors.length <= 0 || isGapSpan(span)) {
- return null;
- }
- const visibleErrors = errorsOpened
- ? relatedErrors
- : relatedErrors.slice(0, DEFAULT_ERRORS_VISIBLE);
- return (
- <Alert type={getCumulativeAlertLevelFromErrors(relatedErrors)} system>
- <ErrorMessageTitle>
- {tn(
- 'An error event occurred in this span.',
- '%s error events occurred in this span.',
- relatedErrors.length
- )}
- </ErrorMessageTitle>
- <ErrorMessageContent>
- {visibleErrors.map(error => (
- <Fragment key={error.event_id}>
- <ErrorDot level={error.level} />
- <ErrorLevel>{error.level}</ErrorLevel>
- <ErrorTitle>
- <Link to={generateIssueEventTarget(error, organization)}>
- {error.title}
- </Link>
- </ErrorTitle>
- </Fragment>
- ))}
- </ErrorMessageContent>
- {relatedErrors.length > DEFAULT_ERRORS_VISIBLE && (
- <ErrorToggle size="xs" onClick={this.toggleErrors}>
- {errorsOpened ? t('Show less') : t('Show more')}
- </ErrorToggle>
- )}
- </Alert>
- );
- }
- partitionSizes(data) {
- const sizeKeys = SIZE_DATA_KEYS.reduce((keys, key) => {
- if (data.hasOwnProperty(key)) {
- keys[key] = data[key];
- }
- return keys;
- }, {});
- const nonSizeKeys = {...data};
- SIZE_DATA_KEYS.forEach(key => delete nonSizeKeys[key]);
- return {
- sizeKeys,
- nonSizeKeys,
- };
- }
- renderSpanDetails() {
- const {span, event, location, organization, scrollToHash} = this.props;
- if (isGapSpan(span)) {
- return (
- <SpanDetails>
- <InlineDocs
- platform={event.sdk?.name || ''}
- orgSlug={organization.slug}
- projectSlug={event?.projectSlug ?? ''}
- />
- </SpanDetails>
- );
- }
- const startTimestamp: number = span.start_timestamp;
- const endTimestamp: number = span.timestamp;
- const duration = (endTimestamp - startTimestamp) * 1000;
- const durationString = `${Number(duration.toFixed(3)).toLocaleString()}ms`;
- const unknownKeys = Object.keys(span).filter(key => {
- return !rawSpanKeys.has(key as any);
- });
- const {sizeKeys, nonSizeKeys} = this.partitionSizes(span?.data ?? {});
- const allZeroSizes = SIZE_DATA_KEYS.map(key => sizeKeys[key]).every(
- value => value === 0
- );
- return (
- <Fragment>
- {this.renderOrphanSpanMessage()}
- {this.renderSpanErrorMessage()}
- <SpanDetails>
- <table className="table key-value">
- <tbody>
- <Row
- title={
- isGapSpan(span) ? (
- <SpanIdTitle>Span ID</SpanIdTitle>
- ) : (
- <SpanIdTitle
- onClick={scrollToSpan(
- span.span_id,
- scrollToHash,
- location,
- organization
- )}
- >
- Span ID
- <Clipboard
- value={`${window.location.href.replace(
- window.location.hash,
- ''
- )}#span-${span.span_id}`}
- >
- <StyledIconLink />
- </Clipboard>
- </SpanIdTitle>
- )
- }
- extra={this.renderTraversalButton()}
- >
- {span.span_id}
- </Row>
- <Row title="Parent Span ID">{span.parent_span_id || ''}</Row>
- {this.renderSpanChild()}
- <Row title="Trace ID" extra={this.renderTraceButton()}>
- {span.trace_id}
- </Row>
- <Row title="Description" extra={this.renderViewSimilarSpansButton()}>
- {span?.description ?? ''}
- </Row>
- <Row title="Status">{span.status || ''}</Row>
- <Row title="Start Date">
- {getDynamicText({
- fixed: 'Mar 16, 2020 9:10:12 AM UTC',
- value: (
- <Fragment>
- <DateTime date={startTimestamp * 1000} year seconds timeZone />
- {` (${startTimestamp})`}
- </Fragment>
- ),
- })}
- </Row>
- <Row title="End Date">
- {getDynamicText({
- fixed: 'Mar 16, 2020 9:10:13 AM UTC',
- value: (
- <Fragment>
- <DateTime date={endTimestamp * 1000} year seconds timeZone />
- {` (${endTimestamp})`}
- </Fragment>
- ),
- })}
- </Row>
- <Row title="Duration">{durationString}</Row>
- <Row title="Operation">{span.op || ''}</Row>
- <Row title="Same Process as Parent">
- {span.same_process_as_parent !== undefined
- ? String(span.same_process_as_parent)
- : null}
- </Row>
- <Feature
- organization={organization}
- features={['organizations:performance-suspect-spans-view']}
- >
- <Row title="Span Group">
- {defined(span.hash) ? String(span.hash) : null}
- </Row>
- <Row title="Span Self Time">
- {defined(span.exclusive_time)
- ? `${Number(span.exclusive_time.toFixed(3)).toLocaleString()}ms`
- : null}
- </Row>
- </Feature>
- <Tags span={span} />
- {allZeroSizes && (
- <TextTr>
- The following sizes were not collected for security reasons. Check if
- the host serves the appropriate
- <ExternalLink href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Timing-Allow-Origin">
- <span className="val-string">Timing-Allow-Origin</span>
- </ExternalLink>
- header. You may have to enable this collection manually.
- </TextTr>
- )}
- {map(sizeKeys, (value, key) => (
- <Row title={key} key={key}>
- <Fragment>
- <FileSize bytes={value} />
- {value >= 1024 && (
- <span>{` (${JSON.stringify(value, null, 4) || ''} B)`}</span>
- )}
- </Fragment>
- </Row>
- ))}
- {map(nonSizeKeys, (value, key) => (
- <Row title={key} key={key}>
- {JSON.stringify(value, null, 4) || ''}
- </Row>
- ))}
- {unknownKeys.map(key => (
- <Row title={key} key={key}>
- {JSON.stringify(span[key], null, 4) || ''}
- </Row>
- ))}
- </tbody>
- </table>
- </SpanDetails>
- </Fragment>
- );
- }
- render() {
- return (
- <SpanDetailContainer
- data-component="span-detail"
- onClick={event => {
- // prevent toggling the span detail
- event.stopPropagation();
- }}
- >
- {this.renderSpanDetails()}
- </SpanDetailContainer>
- );
- }
- }
- const StyledDiscoverButton = styled(DiscoverButton)`
- position: absolute;
- top: ${space(0.75)};
- right: ${space(0.5)};
- `;
- const StyledButton = styled(Button)``;
- export const SpanDetailContainer = styled('div')`
- border-bottom: 1px solid ${p => p.theme.border};
- cursor: auto;
- `;
- export const SpanDetails = styled('div')`
- padding: ${space(2)};
- `;
- const ValueTd = styled('td')`
- position: relative;
- `;
- const StyledLoadingIndicator = styled(LoadingIndicator)`
- display: flex;
- align-items: center;
- height: ${space(2)};
- margin: 0;
- `;
- const StyledText = styled('p')`
- font-size: ${p => p.theme.fontSizeMedium};
- margin: ${space(2)} ${space(0)};
- `;
- const TextTr = ({children}) => (
- <tr>
- <td className="key" />
- <ValueTd className="value">
- <StyledText>{children}</StyledText>
- </ValueTd>
- </tr>
- );
- const ErrorToggle = styled(Button)`
- margin-top: ${space(0.75)};
- `;
- const SpanIdTitle = styled('a')`
- display: flex;
- color: ${p => p.theme.textColor};
- :hover {
- color: ${p => p.theme.textColor};
- }
- `;
- const StyledIconLink = styled(IconLink)`
- display: block;
- color: ${p => p.theme.gray300};
- margin-left: ${space(1)};
- `;
- export const Row = ({
- title,
- keep,
- children,
- extra = null,
- }: {
- children: JSX.Element | string | null;
- title: JSX.Element | string | null;
- extra?: React.ReactNode;
- keep?: boolean;
- }) => {
- if (!keep && !children) {
- return null;
- }
- return (
- <tr>
- <td className="key">{title}</td>
- <ValueTd className="value">
- <ValueRow>
- <StyledPre>
- <span className="val-string">{children}</span>
- </StyledPre>
- <ButtonContainer>{extra}</ButtonContainer>
- </ValueRow>
- </ValueTd>
- </tr>
- );
- };
- export const Tags = ({span}: {span: RawSpanType}) => {
- const tags: {[tag_name: string]: string} | undefined = span?.tags;
- if (!tags) {
- return null;
- }
- const keys = Object.keys(tags);
- if (keys.length <= 0) {
- return null;
- }
- return (
- <tr>
- <td className="key">Tags</td>
- <td className="value">
- <Pills style={{padding: '8px'}}>
- {keys.map((key, index) => (
- <Pill key={index} name={key} value={String(tags[key]) || ''} />
- ))}
- </Pills>
- </td>
- </tr>
- );
- };
- function generateSlug(result: TransactionResult): string {
- return generateEventSlug({
- id: result.id,
- 'project.name': result['project.name'],
- });
- }
- const ButtonGroup = styled('div')`
- display: flex;
- flex-direction: column;
- gap: ${space(0.5)};
- `;
- const ValueRow = styled('div')`
- display: grid;
- grid-template-columns: auto min-content;
- gap: ${space(1)};
- border-radius: 4px;
- background-color: ${p => p.theme.surface100};
- margin: 2px;
- `;
- const StyledPre = styled('pre')`
- margin: 0 !important;
- background-color: transparent !important;
- `;
- const ButtonContainer = styled('div')`
- padding: 8px 10px;
- `;
- export default withApi(withRouter(SpanDetail));
|