Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions static/app/components/events/ourlogs/ourlogsDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import {useRef} from 'react';
import {useMemo, useRef} from 'react';
import styled from '@emotion/styled';
import moment from 'moment-timezone';

import {Flex} from '@sentry/scraps/layout';

import {ProjectAvatar} from 'sentry/components/core/avatar/projectAvatar';
import {LinkButton} from 'sentry/components/core/button/linkButton';
import {
CrumbContainer,
EventDrawerBody,
Expand All @@ -17,18 +21,23 @@ import {space} from 'sentry/styles/space';
import type {Event} from 'sentry/types/event';
import type {Group} from 'sentry/types/group';
import type {Project} from 'sentry/types/project';
import {getUtcDateString} from 'sentry/utils/dates';
import {getShortEventId} from 'sentry/utils/events';
import useOrganization from 'sentry/utils/useOrganization';
import {
TraceItemSearchQueryBuilder,
useSearchQueryBuilderProps,
} from 'sentry/views/explore/components/traceItemSearchQueryBuilder';
import {useTraceItemAttributes} from 'sentry/views/explore/contexts/traceItemAttributeContext';
import {LogsInfiniteTable} from 'sentry/views/explore/logs/tables/logsInfiniteTable';
import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types';
import {getLogsUrl} from 'sentry/views/explore/logs/utils';
import {
useQueryParamsSearch,
useSetQueryParamsQuery,
} from 'sentry/views/explore/queryParams/context';
import {TraceItemDataset} from 'sentry/views/explore/types';
import {getEventEnvironment} from 'sentry/views/issueDetails/utils';

interface LogIssueDrawerProps {
event: Event;
Expand All @@ -45,6 +54,7 @@ export function OurlogsDrawer({
group,
embeddedOptions,
}: LogIssueDrawerProps) {
const organization = useOrganization();
const setLogsQuery = useSetQueryParamsQuery();
const logsSearch = useQueryParamsSearch();

Expand All @@ -68,6 +78,45 @@ export function OurlogsDrawer({
);
const containerRef = useRef<HTMLDivElement>(null);

const additionalData = useMemo(
() => ({
event,
}),
[event]
);

const exploreUrl = useMemo(() => {
const traceId = event.contexts.trace?.trace_id;
if (!traceId) {
return null;
}

const eventTimestamp = event.dateCreated || event.dateReceived;
if (!eventTimestamp) {
return null;
}

const eventMoment = moment(eventTimestamp);
const start = getUtcDateString(eventMoment.clone().subtract(1, 'day'));
const end = getUtcDateString(eventMoment.clone().add(1, 'day'));
const environment = getEventEnvironment(event);
Comment on lines +99 to +102
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this create an end timestamp in the future? Does the time selector handle that gracefully?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does create an end?


return getLogsUrl({
organization,
selection: {
projects: [parseInt(project.id, 10)],
environments: environment ? [environment] : [],
datetime: {
start,
end,
period: null,
utc: null,
},
},
query: `${OurLogKnownFieldKey.TRACE_ID}:${traceId}`,
});
}, [event, organization, project.id]);

return (
<SearchQueryBuilderProvider {...searchQueryBuilderProps}>
<EventDrawerContainer>
Expand All @@ -88,14 +137,24 @@ export function OurlogsDrawer({
/>
</EventDrawerHeader>
<EventNavigator>
<TraceItemSearchQueryBuilder {...tracesItemSearchQueryBuilderProps} />
<Flex align="center" gap="sm">
<Flex flex="1">
<TraceItemSearchQueryBuilder {...tracesItemSearchQueryBuilderProps} />
</Flex>
{exploreUrl && (
<LinkButton size="sm" href={exploreUrl} target="_blank">
{t('Open in explore')}
</LinkButton>
)}
</Flex>
</EventNavigator>
<EventDrawerBody ref={containerRef}>
<LogsTableContainer>
<LogsInfiniteTable
embedded
scrollContainer={containerRef}
embeddedOptions={embeddedOptions}
additionalData={additionalData}
/>
</LogsTableContainer>
</EventDrawerBody>
Expand Down
51 changes: 51 additions & 0 deletions static/app/components/events/ourlogs/ourlogsSection.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jest.mock('@tanstack/react-virtual', () => {
{key: '3', index: 2, start: 100, end: 150, lane: 0},
]),
getTotalSize: jest.fn().mockReturnValue(150),
scrollToIndex: jest.fn(),
options: {
scrollMargin: 0,
},
Expand All @@ -42,6 +43,7 @@ jest.mock('@tanstack/react-virtual', () => {
{key: '3', index: 2, start: 100, end: 150, lane: 0},
]),
getTotalSize: jest.fn().mockReturnValue(150),
scrollToIndex: jest.fn(),
options: {
scrollMargin: 0,
},
Expand Down Expand Up @@ -155,6 +157,19 @@ describe('OurlogsSection', () => {
},
});

MockApiClient.addMockResponse({
url: `/projects/${organization.slug}/${project.slug}/trace-items/${logId}/`,
method: 'GET',
body: {
itemId: logId,
timestamp: '2025-04-03T15:50:10+00:00',
attributes: [
{name: 'severity', type: 'str', value: 'error'},
{name: 'special_field', type: 'str', value: 'special value'},
],
},
});

MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/trace-items/attributes/`,
method: 'GET',
Expand Down Expand Up @@ -233,4 +248,40 @@ describe('OurlogsSection', () => {
expect(within(aside).getByTestId('tree-key-severity')).toBeInTheDocument();
expect(within(aside).getByTestId('tree-key-severity')).toHaveTextContent('severity');
});

it('renders Open in explore button with correct URL when trace_id exists', async () => {
render(<OurlogsSection event={event} project={project} group={group} />, {
organization: OrganizationFixture({
features: ['ourlogs-enabled', 'visibility-explore-view'],
}),
initialRouterConfig: {
location: {
pathname: `/organizations/${organization.slug}/issues/${group.id}/`,
query: {
project: project.id,
},
},
},
});

await waitFor(() => {
expect(screen.getByText(/i am a log/)).toBeInTheDocument();
});

await userEvent.click(screen.getByText(/i am a log/));

const aside = screen.getByRole('complementary', {name: 'logs drawer'});
expect(aside).toBeInTheDocument();

const openInExploreButton = within(aside).getByRole('button', {
name: 'Open in explore',
});
expect(openInExploreButton).toBeInTheDocument();
expect(openInExploreButton).toHaveAttribute('target', '_blank');

const href = openInExploreButton.getAttribute('href');
expect(href).toBe(
'/organizations/org-slug/explore/logs/?end=2019-03-21T00%3A00%3A00&environment=dev&logsQuery=trace%3A00000000000000000000000000000000&project=2&start=2019-03-19T00%3A00%3A00'
);
});
});
43 changes: 42 additions & 1 deletion static/app/views/explore/logs/styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {Theme} from '@emotion/react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';

import {Flex} from '@sentry/scraps/layout';

import {Button} from 'sentry/components/core/button';
import {HighlightComponent} from 'sentry/components/highlight';
import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
Expand Down Expand Up @@ -43,6 +45,20 @@ export const LogTableRow = styled(TableRow)<LogTableRowProps>`
}
}

.log-table-row-pseudo-row-chevron-replacement {
width: 23px;
height: 24px;
}

&[data-row-highlighted='true']:not(thead > &) {
background-color: ${p => p.theme.yellow100};
color: ${p => p.theme.red300};

&:hover {
background-color: ${p => p.theme.yellow200};
}
}

&.beforeHoverTime + &.afterHoverTime:before {
border-top: 1px solid ${p => p.theme.purple200};
content: '';
Expand Down Expand Up @@ -156,7 +172,7 @@ export const DetailsWrapper = styled('tr')`
display: grid;
border-top: 1px solid ${p => p.theme.border};
border-bottom: 1px solid ${p => p.theme.border};
z-index: ${2 /* place above the grid resizing lines */};
z-index: ${1 /* place above the grid resizing lines */};
`;

export const DetailsContent = styled(StyledPanel)`
Expand Down Expand Up @@ -480,3 +496,28 @@ export const LogsFilterSection = styled('div')`
grid-template-columns: minmax(300px, auto) 1fr min-content;
}
`;

export const TraceIconStyleWrapper = styled(Flex)`
width: 18px;
height: 18px;

.TraceIcon {
background-color: ${p => p.theme.red300};
position: absolute;
transform: translate(-50%, -50%) scaleX(var(--inverse-span-scale)) translateZ(0);
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
margin-right: -2px;
}

.TraceIcon svg {
width: 12px;
height: 12px;
fill: #ffffff;
}
`;
71 changes: 66 additions & 5 deletions static/app/views/explore/logs/tables/logsInfiniteTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {GridResizer} from 'sentry/components/tables/gridEditable/styles';
import {IconArrow, IconWarning} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Event} from 'sentry/types/event';
import type {TagCollection} from 'sentry/types/group';
import {defined} from 'sentry/utils';
import {
Expand Down Expand Up @@ -51,12 +52,15 @@ import {
type OurLogsResponseItem,
} from 'sentry/views/explore/logs/types';
import {
createPseudoLogResponseItem,
getDynamicLogsNextFetchThreshold,
getLogBodySearchTerms,
getLogRowTimestampMillis,
getTableHeaderLabel,
isRegularLogResponseItem,
logsFieldAlignment,
quantizeTimestampToMinutes,
type LogTableRowItem,
} from 'sentry/views/explore/logs/utils';
import type {ReplayEmbeddedTableOptions} from 'sentry/views/explore/logs/utils/logsReplayUtils';
import {
Expand All @@ -68,6 +72,9 @@ import {
import {EmptyStateText} from 'sentry/views/explore/tables/tracesTable/styles';

type LogsTableProps = {
additionalData?: {
event?: Event;
};
allowPagination?: boolean;
embedded?: boolean;
embeddedOptions?: {
Expand Down Expand Up @@ -100,6 +107,7 @@ export function LogsInfiniteTable({
scrollContainer,
embeddedStyling,
embeddedOptions,
additionalData,
}: LogsTableProps) {
const theme = useTheme();
const fields = useQueryParamsFields();
Expand All @@ -123,16 +131,58 @@ export function LogsInfiniteTable({
resumeAutoFetch,
} = useLogsPageDataQueryResult();

// Use filtered items if provided, otherwise use original data
const data = localOnlyItemFilters?.filteredItems ?? originalData;
const baseData = localOnlyItemFilters?.filteredItems ?? originalData;

const pseudoRowIndex = useMemo(() => {
if (
!additionalData?.event ||
!baseData ||
baseData.length === 0 ||
isPending ||
isError
) {
return -1;
}
const event = additionalData.event;
const eventTimestamp = new Date(event.dateCreated || new Date()).getTime();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Pseudo row positioned incorrectly if event lacks dateCreated

When calculating where to insert the pseudo row, the code falls back to the current time (new Date()) if event.dateCreated is missing. This means events without a dateCreated will always appear at or near the end of the log list, regardless of their actual timestamp. The code should use event.dateReceived as a fallback like the drawer does, or handle missing timestamps more gracefully.

Fix in Cursor Fix in Web

const index = baseData.findIndex(
row =>
isRegularLogResponseItem(row) && getLogRowTimestampMillis(row) < eventTimestamp
);
return index === -1 ? baseData.length : index; // If the event is older than all the data, add it to the end.
}, [additionalData, baseData, isPending, isError]);

const data: LogTableRowItem[] = useMemo(() => {
if (
!additionalData?.event ||
!baseData ||
baseData.length === 0 ||
isPending ||
isError ||
pseudoRowIndex === -1
) {
return baseData || [];
}

const newData: LogTableRowItem[] = [...baseData];
newData.splice(
pseudoRowIndex,
0,
createPseudoLogResponseItem(
additionalData.event,
additionalData.event.projectID || ''
)
);
return newData;
}, [baseData, additionalData, isPending, isError, pseudoRowIndex]);

// Calculate quantized start and end times for replay links
const {logStart, logEnd} = useMemo(() => {
if (!data || data.length === 0) {
if (!baseData || baseData.length === 0) {
return {logStart: undefined, logEnd: undefined};
}

const timestamps = data.map(row => getLogRowTimestampMillis(row)).filter(Boolean);
const timestamps = baseData.map(row => getLogRowTimestampMillis(row)).filter(Boolean);
if (timestamps.length === 0) {
return {logStart: undefined, logEnd: undefined};
}
Expand All @@ -150,7 +200,7 @@ export function LogsInfiniteTable({
logStart: new Date(quantizedStart).toISOString(),
logEnd: new Date(quantizedEnd).toISOString(),
};
}, [data]);
}, [baseData]);

const tableRef = useRef<HTMLTableElement>(null);
const tableBodyRef = useRef<HTMLTableSectionElement>(null);
Expand Down Expand Up @@ -229,6 +279,17 @@ export function LogsInfiniteTable({
[virtualizer]
);

useEffect(() => {
if (pseudoRowIndex !== -1 && scrollContainer?.current) {
setTimeout(() => {
containerVirtualizer.scrollToIndex(pseudoRowIndex, {
behavior: 'smooth',
align: 'center',
});
}, 100);
}
}, [pseudoRowIndex, containerVirtualizer, scrollContainer]);

const hasReplay = !!embeddedOptions?.replay;

const replayJumpButtons = useJumpButtons({
Expand Down
Loading
Loading