mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-25 09:30:31 +01:00
Compare commits
5 Commits
issue_5267
...
trace-deta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e84ccdf91 | ||
|
|
0a5f3c3b39 | ||
|
|
38d6f35e72 | ||
|
|
db1ed748a7 | ||
|
|
9c92335548 |
@@ -20,6 +20,7 @@ import {
|
||||
} from 'pages/TracesFunnels/FunnelContext';
|
||||
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import './AddSpanToFunnelModal.styles.scss';
|
||||
@@ -71,7 +72,9 @@ function FunnelDetailsView({
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span}
|
||||
// Dead V2 code (removed in the trace-v2 cleanup sweep); FunnelConfiguration
|
||||
// now takes SpanV3, so bridge the V2 Span here to keep the build green.
|
||||
span={span as unknown as SpanV3}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Redirect, useParams } from 'react-router-dom';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
// Legacy /trace-old/:id now redirects to the current /trace/:id view,
|
||||
// preserving the query string and hash.
|
||||
export default function TraceDetailOldRedirect(): JSX.Element {
|
||||
const { id } = useParams<TraceDetailV2URLProps>();
|
||||
const { id } = useParams<TraceDetailV3URLProps>();
|
||||
|
||||
return (
|
||||
<Redirect
|
||||
|
||||
90
frontend/src/pages/TraceDetailsV3/NoData/NoData.module.scss
Normal file
90
frontend/src/pages/TraceDetailsV3/NoData/NoData.module.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
.notFoundTrace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
width: 500px;
|
||||
gap: var(--spacing-12);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.notFoundImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.notFoundText1 {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.notFoundText2 {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.reasons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.reason {
|
||||
display: flex;
|
||||
padding: var(--spacing-6);
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-6);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
|
||||
.reasonImg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.reasonText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.noneOfAbove {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.noneText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.actionBtns {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 160px;
|
||||
}
|
||||
73
frontend/src/pages/TraceDetailsV3/NoData/NoData.tsx
Normal file
73
frontend/src/pages/TraceDetailsV3/NoData/NoData.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { LifeBuoy, RefreshCw } from '@signozhq/icons';
|
||||
|
||||
import broomUrl from '@/assets/Icons/broom.svg';
|
||||
import constructionUrl from '@/assets/Icons/construction.svg';
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
|
||||
function NoData(): JSX.Element {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
return (
|
||||
<div className={styles.notFoundTrace} data-testid="trace-no-data">
|
||||
<section className={styles.description}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.notFoundImg} />
|
||||
<Typography.Text className={styles.notFoundText1}>
|
||||
Uh-oh! We cannot show the selected trace.
|
||||
<span className={styles.notFoundText2}>
|
||||
This can happen in either of the two scenarios -
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.reasons}>
|
||||
<div className={styles.reason}>
|
||||
<img src={constructionUrl} alt="no-data" className={styles.reasonImg} />
|
||||
<Typography.Text className={styles.reasonText}>
|
||||
The trace data has not been rendered on your SigNoz server yet. You can
|
||||
wait for a bit and refresh this page if this is the case.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.reason}>
|
||||
<img src={broomUrl} alt="no-data" className={styles.reasonImg} />
|
||||
<Typography.Text className={styles.reasonText}>
|
||||
The trace has been deleted as the data has crossed it’s retention period.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.noneOfAbove}>
|
||||
<Typography.Text className={styles.noneText}>
|
||||
If you feel the issue is none of the above, please contact support.
|
||||
</Typography.Text>
|
||||
<div className={styles.actionBtns}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.actionBtn}
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={(): void => window.location.reload()}
|
||||
testId="trace-no-data-refresh-button"
|
||||
>
|
||||
Refresh this page
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.actionBtn}
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
onClick={(): void => handleContactSupport(isCloudUserVal)}
|
||||
testId="trace-no-data-contact-support-button"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -0,0 +1,145 @@
|
||||
.noEvents {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.eventsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.event {
|
||||
:global(.ant-collapse) {
|
||||
border: none;
|
||||
}
|
||||
:global(.ant-collapse-content) {
|
||||
border-top: none;
|
||||
}
|
||||
:global(.ant-collapse-item) {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
:global(.ant-collapse-content-box) {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
}
|
||||
:global(.ant-collapse-header) {
|
||||
display: flex;
|
||||
padding: var(--spacing-4) var(--spacing-3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
:global(.ant-collapse-expand-icon) {
|
||||
padding-inline-start: 0px;
|
||||
padding-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.collapseTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.diamond {
|
||||
fill: var(--accent-primary);
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.attributeContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.attributeKey {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.timestampContainer {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
|
||||
.attributeValue {
|
||||
display: flex;
|
||||
padding: 2px var(--spacing-4);
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.timestampText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
padding: 2px var(--spacing-4);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
|
||||
.attributeValue {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.fullView {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Collapse, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { Diamond } from '@signozhq/icons';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EventAttribute from './components/EventAttribute';
|
||||
import NoData from './NoData/NoData';
|
||||
|
||||
import styles from './Events.module.scss';
|
||||
|
||||
interface IEventsTableProps {
|
||||
span: SpanV3;
|
||||
startTime: number;
|
||||
isSearchVisible: boolean;
|
||||
}
|
||||
|
||||
function EventsTable(props: IEventsTableProps): JSX.Element {
|
||||
const { span, startTime, isSearchVisible } = props;
|
||||
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
|
||||
const [modalContent, setModalContent] = useState<{
|
||||
title: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
|
||||
const showAttributeModal = (title: string, content: string): void => {
|
||||
setModalContent({ title, content });
|
||||
};
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setModalContent(null);
|
||||
};
|
||||
|
||||
const events = span.events;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{events.length === 0 && (
|
||||
<div className={styles.noEvents}>
|
||||
<NoData name="events" />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.eventsContainer}>
|
||||
{isSearchVisible && events.length > 0 && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search for events..."
|
||||
value={fieldSearchInput}
|
||||
onChange={(e): void => setFieldSearchInput(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{events
|
||||
.filter((eve) =>
|
||||
eve.name?.toLowerCase().includes(fieldSearchInput.toLowerCase()),
|
||||
)
|
||||
.map((event) => (
|
||||
<div
|
||||
className={styles.event}
|
||||
key={`${event.name} ${JSON.stringify(event.attributeMap)}`}
|
||||
>
|
||||
<Collapse
|
||||
size="small"
|
||||
defaultActiveKey="1"
|
||||
expandIconPosition="right"
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div className={styles.collapseTitle}>
|
||||
<Diamond size={14} className={styles.diamond} />
|
||||
<Typography.Text>{event.name}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.attributeContainer} key="timeUnixNano">
|
||||
<Typography.Text className={styles.attributeKey}>
|
||||
Start Time
|
||||
</Typography.Text>
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
{getYAxisFormattedValue(
|
||||
`${(event.timeUnixNano || 0) / 1e6 - startTime}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.timestampText}>
|
||||
since trace start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.timestampContainer}>
|
||||
<Typography.Text className={styles.attributeValue}>
|
||||
{getYAxisFormattedValue(
|
||||
`${(event.timeUnixNano || 0) / 1e6 - span.timestamp}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.timestampText}>
|
||||
since span start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
{event.attributeMap &&
|
||||
Object.keys(event.attributeMap).map((attributeKey) => (
|
||||
<EventAttribute
|
||||
key={attributeKey}
|
||||
attributeKey={attributeKey}
|
||||
attributeValue={event.attributeMap[attributeKey]}
|
||||
onExpand={showAttributeModal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Modal
|
||||
title={modalContent?.title}
|
||||
open={!!modalContent}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width="80vw"
|
||||
centered
|
||||
>
|
||||
<pre className={styles.fullView}>{modalContent?.content}</pre>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventsTable;
|
||||
@@ -0,0 +1,20 @@
|
||||
.noData {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.noDataImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.noDataText {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './NoData.module.scss';
|
||||
|
||||
interface INoDataProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function NoData(props: INoDataProps): JSX.Element {
|
||||
const { name } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.noData}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.noDataImg} />
|
||||
<Typography.Text className={styles.noDataText}>
|
||||
No {name} found for selected span
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoData;
|
||||
@@ -0,0 +1,22 @@
|
||||
.popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
.preview {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: var(--spacing-4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Fullscreen } from '@signozhq/icons';
|
||||
|
||||
import styles from '../Events.module.scss';
|
||||
|
||||
import popoverStyles from './AttributeWithExpandablePopover.module.scss';
|
||||
|
||||
interface AttributeWithExpandablePopoverProps {
|
||||
attributeKey: string;
|
||||
attributeValue: string;
|
||||
onExpand: (title: string, content: string) => void;
|
||||
}
|
||||
|
||||
function AttributeWithExpandablePopover({
|
||||
attributeKey,
|
||||
attributeValue,
|
||||
onExpand,
|
||||
}: AttributeWithExpandablePopoverProps): JSX.Element {
|
||||
const popoverContent = (
|
||||
<div className={popoverStyles.popover}>
|
||||
<pre className={popoverStyles.preview}>{attributeValue}</pre>
|
||||
<Button
|
||||
onClick={(): void => onExpand(attributeKey, attributeValue)}
|
||||
size="sm"
|
||||
className={popoverStyles.expandButton}
|
||||
prefix={<Fullscreen size={14} />}
|
||||
>
|
||||
Expand
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.attributeContainer} key={attributeKey}>
|
||||
<Tooltip title={attributeKey}>
|
||||
<Typography.Text className={styles.attributeKey} truncate={1}>
|
||||
{attributeKey}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
<Popover content={popoverContent} trigger="hover" placement="topRight">
|
||||
<Typography.Text className={styles.attributeValue} truncate={1}>
|
||||
{attributeValue}
|
||||
</Typography.Text>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeWithExpandablePopover;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from '../Events.module.scss';
|
||||
|
||||
import AttributeWithExpandablePopover from './AttributeWithExpandablePopover';
|
||||
|
||||
const EXPANDABLE_ATTRIBUTE_KEYS = ['exception.stacktrace', 'exception.message'];
|
||||
const ATTRIBUTE_LENGTH_THRESHOLD = 100;
|
||||
|
||||
interface EventAttributeProps {
|
||||
attributeKey: string;
|
||||
attributeValue: string;
|
||||
onExpand: (title: string, content: string) => void;
|
||||
}
|
||||
|
||||
function EventAttribute({
|
||||
attributeKey,
|
||||
attributeValue,
|
||||
onExpand,
|
||||
}: EventAttributeProps): JSX.Element {
|
||||
const shouldExpand =
|
||||
EXPANDABLE_ATTRIBUTE_KEYS.includes(attributeKey) ||
|
||||
attributeValue.length > ATTRIBUTE_LENGTH_THRESHOLD;
|
||||
|
||||
if (shouldExpand) {
|
||||
return (
|
||||
<AttributeWithExpandablePopover
|
||||
attributeKey={attributeKey}
|
||||
attributeValue={attributeValue}
|
||||
onExpand={onExpand}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.attributeContainer} key={attributeKey}>
|
||||
<Tooltip title={attributeKey}>
|
||||
<Typography.Text className={styles.attributeKey} truncate={1}>
|
||||
{attributeKey}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<div className={styles.wrapper}>
|
||||
<Tooltip title={attributeValue}>
|
||||
<Typography.Text className={styles.attributeValue} truncate={1}>
|
||||
{attributeValue}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventAttribute;
|
||||
@@ -27,9 +27,6 @@ import {
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
@@ -68,6 +65,9 @@ import {
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
import Events from './Events/Events';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
|
||||
@@ -424,9 +424,8 @@ function SpanDetailsContent({
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
|
||||
<Events
|
||||
span={{ ...selectedSpan, event: selectedSpan.events } as any}
|
||||
span={selectedSpan}
|
||||
startTime={traceStartTime || 0}
|
||||
isSearchVisible
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
.spanLogs {
|
||||
margin-inline: var(--spacing-8);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spanLogsVirtuoso {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
|
||||
.spanLogsListContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.logsLoadingSkeleton {
|
||||
height: 100%;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top: none;
|
||||
color: var(--l2-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.spanLogsEmptyContent {
|
||||
height: 100%;
|
||||
border-top: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: var(--spacing-48);
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-6);
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.noDataImg {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.noDataText1 {
|
||||
color: var(--l2-foreground);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.noDataText2 {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.actionSection {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
OPERATORS,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import styles from './SpanLogs.module.scss';
|
||||
|
||||
interface SpanLogsProps {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
handleExplorerPageRedirect: () => void;
|
||||
emptyStateConfig?: EmptyLogsListConfig;
|
||||
}
|
||||
|
||||
function SpanLogs({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
handleExplorerPageRedirect,
|
||||
emptyStateConfig,
|
||||
}: SpanLogsProps): JSX.Element {
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
|
||||
// Create trace_id and span_id filters for logs explorer navigation
|
||||
const createLogsFilter = useCallback(
|
||||
(targetSpanId: string): TagFilter => {
|
||||
const traceIdKey: BaseAutocompleteData = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
};
|
||||
|
||||
const spanIdKey: BaseAutocompleteData = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'span_id',
|
||||
};
|
||||
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: targetSpanId,
|
||||
key: spanIdKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
},
|
||||
[traceId],
|
||||
);
|
||||
|
||||
// Navigate to logs explorer with trace_id and span_id filters
|
||||
const handleLogClick = useCallback(
|
||||
(log: ILog): void => {
|
||||
// Determine if this is a span log or context log
|
||||
const isSpanLog = isLogSpanRelated(log.id);
|
||||
|
||||
// Extract log's span_id (handles both spanID and span_id properties)
|
||||
const logSpanId = log.spanID || log.span_id || '';
|
||||
|
||||
// Use appropriate span ID: current span for span logs, individual log's span for context logs
|
||||
const targetSpanId = isSpanLog ? spanId : logSpanId;
|
||||
const filters = createLogsFilter(targetSpanId);
|
||||
|
||||
// Create base query
|
||||
const baseQuery = updateAllQueriesOperators(
|
||||
initialQueriesMap[DataSource.LOGS],
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
// Add appropriate filters to the query
|
||||
const updatedQuery = {
|
||||
...baseQuery,
|
||||
builder: {
|
||||
...baseQuery.builder,
|
||||
queryData: baseQuery.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
filters,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const queryParams = {
|
||||
[QueryParams.activeLogId]: `"${log.id}"`,
|
||||
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
|
||||
};
|
||||
|
||||
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
|
||||
|
||||
openInNewTab(url);
|
||||
},
|
||||
[
|
||||
isLogSpanRelated,
|
||||
createLogsFilter,
|
||||
spanId,
|
||||
updateAllQueriesOperators,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
);
|
||||
|
||||
// Footer rendering for pagination
|
||||
const hasReachedEndOfLogs = false;
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: number, logToRender: ILog): JSX.Element => {
|
||||
const getIsSpanRelated = (log: ILog, currentSpanId: string): boolean => {
|
||||
if (log.spanID) {
|
||||
return log.spanID === currentSpanId;
|
||||
}
|
||||
return log.span_id === currentSpanId;
|
||||
};
|
||||
|
||||
const isSpanRelated = getIsSpanRelated(logToRender, spanId);
|
||||
|
||||
return (
|
||||
<RawLogView
|
||||
key={logToRender.id}
|
||||
data={logToRender}
|
||||
linesPerRow={1}
|
||||
fontSize={FontSize.MEDIUM}
|
||||
onLogClick={handleLogClick}
|
||||
isHighlighted={isSpanRelated}
|
||||
helpTooltip={
|
||||
isSpanRelated ? 'This log belongs to the current span' : undefined
|
||||
}
|
||||
selectedFields={[
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
},
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'timestamp',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleLogClick, spanId],
|
||||
);
|
||||
|
||||
const renderFooter = useCallback((): JSX.Element | null => {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasReachedEndOfLogs) {
|
||||
return <div className={styles.logsLoadingSkeleton}> *** End *** </div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [isFetching, hasReachedEndOfLogs]);
|
||||
|
||||
const renderContent = useMemo(
|
||||
() => (
|
||||
<div className={styles.spanLogsListContainer}>
|
||||
<OverlayScrollbar isVirtuoso>
|
||||
<Virtuoso
|
||||
className={styles.spanLogsVirtuoso}
|
||||
key="span-logs-virtuoso"
|
||||
style={{ height: '100%' }}
|
||||
data={logs}
|
||||
totalCount={logs.length}
|
||||
itemContent={getItemContent}
|
||||
overscan={200}
|
||||
components={{
|
||||
Footer: renderFooter,
|
||||
}}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
),
|
||||
[logs, getItemContent, renderFooter],
|
||||
);
|
||||
|
||||
const renderNoLogsFound = (): JSX.Element => (
|
||||
<div className={styles.spanLogsEmptyContent}>
|
||||
<section className={styles.description}>
|
||||
<img src={noDataUrl} alt="no-data" className={styles.noDataImg} />
|
||||
<Typography.Text className={styles.noDataText1}>
|
||||
No logs found for selected span.
|
||||
<span className={styles.noDataText2}>
|
||||
View logs for the current trace.
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.actionSection}>
|
||||
<Button
|
||||
className={styles.actionBtn}
|
||||
variant="action"
|
||||
prefix={<Compass size={14} />}
|
||||
onClick={handleExplorerPageRedirect}
|
||||
size="md"
|
||||
>
|
||||
View Logs
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSpanLogsContent = (): JSX.Element | null => {
|
||||
if (isLoading || isFetching) {
|
||||
return <LogsLoading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <LogsError />;
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
if (emptyStateConfig) {
|
||||
return (
|
||||
<EmptyLogsSearch
|
||||
dataSource={DataSource.LOGS}
|
||||
panelType="LIST"
|
||||
customMessage={emptyStateConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return renderNoLogsFound();
|
||||
}
|
||||
|
||||
return renderContent;
|
||||
};
|
||||
|
||||
return <div className={styles.spanLogs}>{renderSpanLogsContent()}</div>;
|
||||
}
|
||||
SpanLogs.defaultProps = {
|
||||
emptyStateConfig: undefined,
|
||||
};
|
||||
|
||||
export default SpanLogs;
|
||||
@@ -0,0 +1,211 @@
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import SpanLogs from '../SpanLogs';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock window.open
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
|
||||
// Mock Virtuoso to avoid complex virtualization
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: jest.fn(({ data, itemContent }: any) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data?.map((item: any, index: number) => (
|
||||
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock RawLogView component
|
||||
jest.mock(
|
||||
'components/Logs/RawLogView',
|
||||
() =>
|
||||
function MockRawLogView({
|
||||
data,
|
||||
onLogClick,
|
||||
isHighlighted,
|
||||
helpTooltip,
|
||||
}: any): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`raw-log-${data.id}`}
|
||||
className={isHighlighted ? 'log-highlighted' : 'log-context'}
|
||||
title={helpTooltip}
|
||||
onClick={(e): void => onLogClick?.(data, e)}
|
||||
>
|
||||
<div>{data.body}</div>
|
||||
<div>{data.timestamp}</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
default: ({ children }: any): JSX.Element => (
|
||||
<div data-testid="overlay-scrollbar">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock LogsLoading component
|
||||
jest.mock('container/LogsLoading/LogsLoading', () => ({
|
||||
LogsLoading: function MockLogsLoading(): JSX.Element {
|
||||
return <div data-testid="logs-loading">Loading logs...</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock LogsError component
|
||||
jest.mock(
|
||||
'container/LogsError/LogsError',
|
||||
() =>
|
||||
function MockLogsError(): JSX.Element {
|
||||
return <div data-testid="logs-error">Error loading logs</div>;
|
||||
},
|
||||
);
|
||||
|
||||
// Don't mock EmptyLogsSearch - test the actual component behavior
|
||||
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
|
||||
const defaultProps = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
spanId: TEST_SPAN_ID,
|
||||
timeRange: {
|
||||
startTime: 1640995200000,
|
||||
endTime: 1640995260000,
|
||||
},
|
||||
logs: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLogSpanRelated: jest.fn().mockReturnValue(false),
|
||||
handleExplorerPageRedirect: jest.fn(),
|
||||
};
|
||||
|
||||
describe('SpanLogs', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should show simple empty state when emptyStateConfig is not provided', () => {
|
||||
render(<SpanLogs {...defaultProps} />);
|
||||
|
||||
// Should show simple empty state (no emptyStateConfig provided)
|
||||
expect(
|
||||
screen.getByText('No logs found for selected span.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('View logs for the current trace.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should NOT show enhanced empty state
|
||||
expect(screen.queryByTestId('empty-logs-search')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('documentation-links')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show enhanced empty state when entire trace has no logs', () => {
|
||||
render(
|
||||
<SpanLogs
|
||||
{...defaultProps}
|
||||
emptyStateConfig={getEmptyLogsListConfig(jest.fn())}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show enhanced empty state with custom message
|
||||
expect(screen.getByText('No logs found for this trace.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This could be because :')).toBeInTheDocument();
|
||||
|
||||
// Should show description list
|
||||
expect(
|
||||
screen.getByText('Logs are not linked to Traces.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Logs are not being sent to SigNoz.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('No logs are associated with this particular trace/span.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should show documentation links
|
||||
expect(screen.getByText('RESOURCES')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sending logs to SigNoz')).toBeInTheDocument();
|
||||
expect(screen.getByText('Correlate traces and logs')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show simple empty state
|
||||
expect(
|
||||
screen.queryByText('No logs found for selected span.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleExplorerPageRedirect when Log Explorer button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockHandleExplorerPageRedirect = jest.fn();
|
||||
|
||||
render(
|
||||
<SpanLogs
|
||||
{...defaultProps}
|
||||
handleExplorerPageRedirect={mockHandleExplorerPageRedirect}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logExplorerButton = screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
});
|
||||
await user.click(logExplorerButton);
|
||||
|
||||
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Creates a query payload for fetching logs related to a specific span
|
||||
* @param start - Start time in milliseconds
|
||||
* @param end - End time in milliseconds
|
||||
* @param filter - V5 filter expression for trace_id and span_id
|
||||
* @param order - Timestamp ordering ('desc' for newest first, 'asc' for oldest first)
|
||||
* @returns Query payload for logs API
|
||||
*/
|
||||
export const getSpanLogsQueryPayload = (
|
||||
start: number,
|
||||
end: number,
|
||||
filter: Filter,
|
||||
order: 'asc' | 'desc' = 'desc',
|
||||
): GetQueryResultsProps => ({
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
query: {
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter,
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order,
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
id: uuidv4(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates tag filters for querying logs by trace_id only (for context logs)
|
||||
* @param traceId - The trace identifier
|
||||
* @returns Tag filters for the query builder
|
||||
*/
|
||||
export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
|
||||
items: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
key: {
|
||||
id: uuidv4(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
},
|
||||
op: '=',
|
||||
value: traceId,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { getSpanLogsQueryPayload, getTraceOnlyFilters } from './constants';
|
||||
|
||||
interface UseSpanContextLogsProps {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isDrawerOpen?: boolean;
|
||||
}
|
||||
|
||||
interface UseSpanContextLogsReturn {
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
spanLogIds: Set<string>;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
hasTraceIdLogs: boolean;
|
||||
}
|
||||
|
||||
const traceIdKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
};
|
||||
/**
|
||||
* Creates v5 filter expression for querying logs by trace_id and span_id (for span logs)
|
||||
*/
|
||||
const createSpanLogsFilters = (traceId: string, spanId: string): Filter => {
|
||||
const spanIdKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'span_id',
|
||||
};
|
||||
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: spanId,
|
||||
key: spanIdKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
return convertFiltersToExpression(filters);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates v5 filter expression for querying context logs with id constraints
|
||||
*/
|
||||
const createContextFilters = (
|
||||
traceId: string,
|
||||
logId: string,
|
||||
operator: 'lt' | 'gt',
|
||||
): Filter => {
|
||||
const idKey = {
|
||||
id: uuid(),
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
key: 'id',
|
||||
};
|
||||
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS['=']),
|
||||
value: traceId,
|
||||
key: traceIdKey,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
op: getOperatorValue(operator === 'lt' ? OPERATORS['<'] : OPERATORS['>']),
|
||||
value: logId,
|
||||
key: idKey,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
return convertFiltersToExpression(filters);
|
||||
};
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
export const useSpanContextLogs = ({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
isDrawerOpen = true,
|
||||
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
|
||||
const [allLogs, setAllLogs] = useState<ILog[]>([]);
|
||||
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Phase 1: Fetch span-specific logs (trace_id + span_id)
|
||||
const spanFilter = useMemo(
|
||||
() => createSpanLogsFilters(traceId, spanId),
|
||||
[traceId, spanId],
|
||||
);
|
||||
const spanQueryPayload = useMemo(
|
||||
() =>
|
||||
getSpanLogsQueryPayload(timeRange.startTime, timeRange.endTime, spanFilter),
|
||||
[timeRange.startTime, timeRange.endTime, spanFilter],
|
||||
);
|
||||
|
||||
const {
|
||||
data: spanData,
|
||||
isLoading: isSpanLoading,
|
||||
isError: isSpanError,
|
||||
isFetching: isSpanFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_LOGS,
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(spanQueryPayload, ENTITY_VERSION_V5),
|
||||
enabled: !!traceId && !!spanId,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Extract span logs and track their IDs
|
||||
const spanLogs = useMemo(() => {
|
||||
if (!spanData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||
setSpanLogIds(new Set());
|
||||
return [];
|
||||
}
|
||||
|
||||
const logs = spanData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
// Track span log IDs
|
||||
const logIds = new Set(logs.map((log: ILog) => log.id));
|
||||
setSpanLogIds(logIds);
|
||||
|
||||
return logs;
|
||||
}, [spanData]);
|
||||
|
||||
// Get first and last span logs for context queries
|
||||
const { firstSpanLog, lastSpanLog } = useMemo(() => {
|
||||
if (spanLogs.length === 0) {
|
||||
return { firstSpanLog: null, lastSpanLog: null };
|
||||
}
|
||||
|
||||
const sortedLogs = [...spanLogs].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
firstSpanLog: sortedLogs[0],
|
||||
lastSpanLog: sortedLogs[sortedLogs.length - 1],
|
||||
};
|
||||
}, [spanLogs]);
|
||||
// Phase 2: Fetch context logs before first span log
|
||||
const beforeFilter = useMemo(() => {
|
||||
if (!firstSpanLog) {
|
||||
return null;
|
||||
}
|
||||
return createContextFilters(traceId, firstSpanLog.id, 'lt');
|
||||
}, [traceId, firstSpanLog]);
|
||||
|
||||
const beforeQueryPayload = useMemo(() => {
|
||||
if (!beforeFilter) {
|
||||
return null;
|
||||
}
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
beforeFilter,
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, beforeFilter]);
|
||||
|
||||
const { data: beforeData, isFetching: isBeforeFetching } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_BEFORE_LOGS,
|
||||
traceId,
|
||||
firstSpanLog?.id,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(beforeQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: !!beforeQueryPayload && !!firstSpanLog,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Phase 3: Fetch context logs after last span log
|
||||
const afterFilter = useMemo(() => {
|
||||
if (!lastSpanLog) {
|
||||
return null;
|
||||
}
|
||||
return createContextFilters(traceId, lastSpanLog.id, 'gt');
|
||||
}, [traceId, lastSpanLog]);
|
||||
|
||||
const afterQueryPayload = useMemo(() => {
|
||||
if (!afterFilter) {
|
||||
return null;
|
||||
}
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
afterFilter,
|
||||
'asc',
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, afterFilter]);
|
||||
|
||||
const { data: afterData, isFetching: isAfterFetching } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.SPAN_AFTER_LOGS,
|
||||
traceId,
|
||||
lastSpanLog?.id,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(afterQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: !!afterQueryPayload && !!lastSpanLog,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
// Extract context logs
|
||||
const beforeLogs = useMemo(() => {
|
||||
if (!beforeData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return beforeData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
}, [beforeData]);
|
||||
|
||||
const afterLogs = useMemo(() => {
|
||||
if (!afterData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return afterData.payload.data.newResult.data.result[0].list.map(
|
||||
(item: any) => ({
|
||||
...item.data,
|
||||
timestamp: item.timestamp,
|
||||
}),
|
||||
);
|
||||
}, [afterData]);
|
||||
|
||||
useEffect(() => {
|
||||
const combined = [...afterLogs.reverse(), ...spanLogs, ...beforeLogs];
|
||||
setAllLogs(combined);
|
||||
}, [beforeLogs, spanLogs, afterLogs]);
|
||||
|
||||
// Phase 4: Check for trace_id-only logs when span has no logs
|
||||
// This helps differentiate between "no logs for span" vs "no logs for trace"
|
||||
const traceOnlyFilter = useMemo(() => {
|
||||
if (spanLogs.length > 0) {
|
||||
return null;
|
||||
}
|
||||
const filters = getTraceOnlyFilters(traceId);
|
||||
return convertFiltersToExpression(filters);
|
||||
}, [traceId, spanLogs.length]);
|
||||
|
||||
const traceOnlyQueryPayload = useMemo(() => {
|
||||
if (!traceOnlyFilter) {
|
||||
return null;
|
||||
}
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
traceOnlyFilter,
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, traceOnlyFilter]);
|
||||
|
||||
const { data: traceOnlyData } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.TRACE_ONLY_LOGS,
|
||||
traceId,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
const hasTraceIdLogs = useMemo(() => {
|
||||
if (spanLogs.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return !!(
|
||||
traceOnlyData?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0
|
||||
);
|
||||
}, [spanLogs.length, traceOnlyData]);
|
||||
|
||||
// Helper function to check if a log belongs to the span
|
||||
const isLogSpanRelated = useCallback(
|
||||
(logId: string): boolean => spanLogIds.has(logId),
|
||||
[spanLogIds],
|
||||
);
|
||||
|
||||
return {
|
||||
logs: allLogs,
|
||||
isLoading: isSpanLoading && spanLogs.length === 0,
|
||||
isError: isSpanError,
|
||||
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
|
||||
spanLogIds,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
};
|
||||
};
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
@@ -81,7 +81,7 @@ function TraceDetailsHeader({
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
const { id: traceID } = useParams<TraceDetailV3URLProps>();
|
||||
const [showTraceDetails, setShowTraceDetails] = useState(true);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
|
||||
@@ -72,7 +72,7 @@ function FunnelDetailsView({
|
||||
<FunnelConfiguration
|
||||
funnel={funnel}
|
||||
isTraceDetailsPage
|
||||
span={span as any}
|
||||
span={span}
|
||||
triggerAutoSave={triggerAutoSave}
|
||||
showNotifications={showNotifications}
|
||||
/>
|
||||
|
||||
@@ -1,35 +1,45 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import SpanLineActionButtons from '../index';
|
||||
|
||||
// Mock the useCopySpanLink hook
|
||||
jest.mock('hooks/trace/useCopySpanLink');
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
name: 'test-span',
|
||||
serviceName: 'test-service',
|
||||
durationNano: 1000,
|
||||
const mockSpan: SpanV3 = {
|
||||
span_id: 'test-span-id',
|
||||
trace_id: 'test-trace-id',
|
||||
parent_span_id: 'test-parent-span-id',
|
||||
timestamp: 1234567890,
|
||||
rootSpanId: 'test-root-span-id',
|
||||
parentSpanId: 'test-parent-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
hasError: false,
|
||||
duration_nano: 1000,
|
||||
name: 'test-span',
|
||||
'service.name': 'test-service',
|
||||
has_error: false,
|
||||
status_message: 'test-status-message',
|
||||
status_code: 0,
|
||||
status_code_string: 'test-status-code-string',
|
||||
kind: 0,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [],
|
||||
rootName: 'test-root-name',
|
||||
statusMessage: 'test-status-message',
|
||||
statusCodeString: 'test-status-code-string',
|
||||
spanKind: 'test-span-kind',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 0,
|
||||
kind_string: 'test-span-kind',
|
||||
has_children: false,
|
||||
has_sibling: false,
|
||||
sub_tree_node_count: 0,
|
||||
level: 0,
|
||||
attributes: {},
|
||||
resource: {},
|
||||
events: [],
|
||||
http_method: '',
|
||||
http_url: '',
|
||||
http_host: '',
|
||||
db_name: '',
|
||||
db_operation: '',
|
||||
external_http_method: '',
|
||||
external_http_url: '',
|
||||
response_status_code: '',
|
||||
is_remote: '',
|
||||
flags: 0,
|
||||
trace_state: '',
|
||||
};
|
||||
|
||||
describe('SpanLineActionButtons', () => {
|
||||
@@ -94,7 +104,7 @@ describe('SpanLineActionButtons', () => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mockUrlQuery.delete('spanId');
|
||||
mockUrlQuery.set('spanId', mockSpan.spanId);
|
||||
mockUrlQuery.set('spanId', mockSpan.span_id);
|
||||
const link = `${
|
||||
window.location.origin
|
||||
}${mockPathname}?${mockUrlQuery.toString()}`;
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { Link } from '@signozhq/icons';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import styles from './SpanLineActionButtons.module.scss';
|
||||
|
||||
export interface SpanLineActionButtonsProps {
|
||||
span: Span;
|
||||
span: SpanV3;
|
||||
}
|
||||
export default function SpanLineActionButtons({
|
||||
span,
|
||||
|
||||
@@ -11,12 +11,12 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import NoData from 'pages/TraceDetailV2/NoData/NoData';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import NoData from './NoData/NoData';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
|
||||
@@ -9,7 +9,7 @@ import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/Funnel
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { FunnelData } from 'types/api/traceFunnels';
|
||||
|
||||
import AddFunnelDescriptionModal from './AddFunnelDescriptionModal';
|
||||
@@ -23,7 +23,7 @@ import './FunnelConfiguration.styles.scss';
|
||||
interface FunnelConfigurationProps {
|
||||
funnel: FunnelData;
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: Span;
|
||||
span?: SpanV3;
|
||||
triggerAutoSave?: boolean;
|
||||
showNotifications?: boolean;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import { Plus, Undo2 } from '@signozhq/icons';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import FunnelStep from './FunnelStep';
|
||||
import InterStepConfig from './InterStepConfig';
|
||||
@@ -18,7 +18,7 @@ function StepsContent({
|
||||
span,
|
||||
}: {
|
||||
isTraceDetailsPage?: boolean;
|
||||
span?: Span;
|
||||
span?: SpanV3;
|
||||
}): JSX.Element {
|
||||
const { steps, handleAddStep, handleReplaceStep } = useFunnelContext();
|
||||
const { hasEditPermission } = useAppContext();
|
||||
@@ -30,7 +30,7 @@ function StepsContent({
|
||||
|
||||
const stepWasAdded = handleAddStep();
|
||||
if (stepWasAdded) {
|
||||
handleReplaceStep(steps.length, span.serviceName, span.name);
|
||||
handleReplaceStep(steps.length, span['service.name'], span.name);
|
||||
}
|
||||
logEvent(
|
||||
'Trace Funnels: span added for a new step from trace details page',
|
||||
@@ -61,12 +61,12 @@ function StepsContent({
|
||||
className="funnel-step-wrapper__replace-button"
|
||||
icon={<Undo2 size={12} />}
|
||||
disabled={
|
||||
(step.service_name === span.serviceName &&
|
||||
(step.service_name === span['service.name'] &&
|
||||
step.span_name === span.name) ||
|
||||
!hasEditPermission
|
||||
}
|
||||
onClick={(): void =>
|
||||
handleReplaceStep(index, span.serviceName, span.name)
|
||||
handleReplaceStep(index, span['service.name'], span.name)
|
||||
}
|
||||
>
|
||||
Replace
|
||||
|
||||
Reference in New Issue
Block a user