Skip to content

Add logs table row buttons (filters, copy as json etc.) #97545

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
22 changes: 22 additions & 0 deletions static/app/views/explore/contexts/logs/logsPageParams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -552,3 +552,25 @@ function getLogsParamsStorageKey(version: number) {
function getPastLogsParamsStorageKey(version: number) {
return `logs-params-v${version - 1}`;
}

export function useLogsAddSearchFilter() {
const setLogsSearch = useSetLogsSearch();
const search = useLogsSearch();

return useCallback(
({
key,
value,
negated,
}: {
key: string;
value: string | number | boolean;
negated?: boolean;
}) => {
const newSearch = search.copy();
newSearch.addFilterValue(`${negated ? '!' : ''}${key}`, String(value));
setLogsSearch(newSearch);
},
[setLogsSearch, search]
);
}
26 changes: 25 additions & 1 deletion static/app/views/explore/logs/styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const LogTableRow = styled(TableRow)<LogTableRowProps>`

export const LogAttributeTreeWrapper = styled('div')`
padding: ${space(1)} ${space(1)};
border-bottom: 1px solid ${p => p.theme.innerBorder};
border-bottom: 0px;
`;

export const LogTableBodyCell = styled(TableBodyCell)`
Expand Down Expand Up @@ -90,6 +90,30 @@ export const LogDetailTableBodyCell = styled(TableBodyCell)`
padding: 0;
}
`;
export const LogDetailTableActionsCell = styled(TableBodyCell)`
padding-left: ${space(2)};
padding-right: ${space(2)};
padding-top: ${space(0.5)};
padding-bottom: 0;
min-height: 0px;

${LogTableRow} & {
padding-left: ${space(2)};
padding-right: ${space(2)};
padding-top: ${space(0.5)};
padding-bottom: 0;
}
&:last-child {
padding-left: ${space(2)};
padding-right: ${space(2)};
padding-top: ${space(0.5)};
padding-bottom: 0;
}
`;
export const LogDetailTableActionsButtonBar = styled('div')`
display: flex;
gap: ${space(1)};
`;

export const DetailsWrapper = styled('tr')`
align-items: center;
Expand Down
70 changes: 70 additions & 0 deletions static/app/views/explore/logs/tables/logsTableRow.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,4 +380,74 @@ describe('logsTableRow', () => {
'https://github.com/example/repo/blob/main/file.py'
);
});

it('copies log as JSON when Copy as JSON button is clicked', async () => {
const mockWriteText = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(window.navigator, 'clipboard', {
value: {
writeText: mockWriteText,
},
writable: true,
});

render(
<ProviderWrapper>
<LogRowContent
dataRow={rowData}
highlightTerms={[]}
meta={LogFixtureMeta(rowData)}
sharedHoverTimeoutRef={
{
current: null,
} as React.MutableRefObject<NodeJS.Timeout | null>
}
canDeferRenderElements={false}
/>
</ProviderWrapper>,
{organization, initialRouterConfig}
);

// Expand the row to show the action buttons
const logTableRow = await screen.findByTestId('log-table-row');
await userEvent.click(logTableRow);

await waitFor(() => {
expect(rowDetailsMock).toHaveBeenCalledTimes(1);
});

// Find and click the Copy as JSON button
const copyButton = await screen.findByRole('button', {name: 'Copy as JSON'});
expect(copyButton).toBeInTheDocument();

await userEvent.click(copyButton);

// Verify clipboard was called with JSON representation of the log
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledTimes(1);
});

const callArgs = mockWriteText.mock.calls[0];
expect(callArgs).toBeDefined();
expect(callArgs).toHaveLength(1);

const copiedText = callArgs![0];
expect(typeof copiedText).toBe('string');

// Verify it's valid JSON
expect(() => JSON.parse(copiedText)).not.toThrow();

// Verify it contains expected log data
const parsedData = JSON.parse(copiedText);
expect(parsedData).toMatchObject({
message: 'test log body',
trace: '7b91699f',
severity: 'error',
item_id: '1',
});

// Verify the JSON structure matches what ourlogToJson produces
expect(parsedData).toHaveProperty('item_id', '1');
expect(parsedData['tags[timestamp_precise,number]']).toBeDefined();
expect(parsedData).not.toHaveProperty('sentry.item_id');
});
});
124 changes: 103 additions & 21 deletions static/app/views/explore/logs/tables/logsTableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@ import type {ComponentProps, SyntheticEvent} from 'react';
import {Fragment, memo, useCallback, useLayoutEffect, useRef, useState} from 'react';
import {useTheme} from '@emotion/react';

import {Button} from 'sentry/components/core/button';
import {EmptyStreamWrapper} from 'sentry/components/emptyStateWarning';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import {IconWarning} from 'sentry/icons';
import {IconAdd, IconJson, IconSpan, IconSubtract, IconWarning} from 'sentry/icons';
import {IconChevron} from 'sentry/icons/iconChevron';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {defined} from 'sentry/utils';
import {trackAnalytics} from 'sentry/utils/analytics';
import type {TableDataRow} from 'sentry/utils/discover/discoverQuery';
import type {EventsMetaType} from 'sentry/utils/discover/eventView';
import {FieldValueType} from 'sentry/utils/fields';
import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
import useOrganization from 'sentry/utils/useOrganization';
import useProjectFromId from 'sentry/utils/useProjectFromId';
import CellAction, {
Expand All @@ -28,10 +32,10 @@ import {
useSetLogsAutoRefresh,
} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext';
import {
stripLogParamsFromLocation,
useLogsAddSearchFilter,
useLogsAnalyticsPageSource,
useLogsFields,
useLogsSearch,
useSetLogsSearch,
} from 'sentry/views/explore/contexts/logs/logsPageParams';
import {
DEFAULT_TRACE_ITEM_HOVER_TIMEOUT,
Expand All @@ -51,6 +55,8 @@ import {
DetailsWrapper,
getLogColors,
LogAttributeTreeWrapper,
LogDetailTableActionsButtonBar,
LogDetailTableActionsCell,
LogDetailTableBodyCell,
LogFirstCellContent,
LogsTableBodyFirstCell,
Expand All @@ -69,9 +75,13 @@ import {
} from 'sentry/views/explore/logs/useLogsQuery';
import {
adjustAliases,
adjustLogTraceID,
getLogRowItem,
getLogSeverityLevel,
ourlogToJson,
} from 'sentry/views/explore/logs/utils';
import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceHeader/breadcrumbs';
import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils';

type LogsRowProps = {
dataRow: OurLogsResponseItem;
Expand Down Expand Up @@ -124,8 +134,6 @@ export const LogRowContent = memo(function LogRowContent({
const location = useLocation();
const organization = useOrganization();
const fields = useLogsFields();
const search = useLogsSearch();
const setLogsSearch = useSetLogsSearch();
const autorefreshEnabled = useLogsAutoRefreshEnabled();
const setAutorefresh = useSetLogsAutoRefresh();
const measureRef = useRef<HTMLTableRowElement>(null);
Expand Down Expand Up @@ -186,22 +194,7 @@ export const LogRowContent = memo(function LogRowContent({
}
}, [isExpanded, onExpandHeight, dataRow]);

const addSearchFilter = useCallback(
({
key,
value,
negated,
}: {
key: string;
value: string | number | boolean;
negated?: boolean;
}) => {
const newSearch = search.copy();
newSearch.addFilterValue(`${negated ? '!' : ''}${key}`, String(value));
setLogsSearch(newSearch);
},
[setLogsSearch, search]
);
const addSearchFilter = useLogsAddSearchFilter();
const theme = useTheme();

const severityNumber = dataRow[OurLogKnownFieldKey.SEVERITY_NUMBER];
Expand Down Expand Up @@ -383,6 +376,13 @@ function LogRowDetails({
}) {
const location = useLocation();
const organization = useOrganization();
const navigate = useNavigate();
const {onClick: betterCopyToClipboard} = useCopyToClipboard({
text: ourlogToJson(dataRow),
successMessage: t('Copied!'),
errorMessage: t('Failed to copy'),
});
const addSearchFilter = useLogsAddSearchFilter();
const project = useProjectFromId({
project_id: '' + dataRow[OurLogKnownFieldKey.PROJECT_ID],
});
Expand Down Expand Up @@ -469,6 +469,88 @@ function LogRowDetails({
</Fragment>
)}
</LogDetailTableBodyCell>
{!isPending && data && (
<LogDetailTableActionsCell
colSpan={colSpan}
style={{
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: 'row',
}}
>
<LogDetailTableActionsButtonBar>
<Button
priority="link"
size="sm"
borderless
onClick={() => {
addSearchFilter({
key: OurLogKnownFieldKey.MESSAGE,
value: dataRow[OurLogKnownFieldKey.MESSAGE],
});
}}
>
<IconAdd size="md" style={{paddingRight: space(0.5)}} />
{t('Add to filter')}
</Button>
<Button
priority="link"
size="sm"
borderless
onClick={() => {
addSearchFilter({
key: OurLogKnownFieldKey.MESSAGE,
value: dataRow[OurLogKnownFieldKey.MESSAGE],
negated: true,
});
}}
>
<IconSubtract size="md" style={{paddingRight: space(0.5)}} />
{t('Exclude from filter')}
</Button>
</LogDetailTableActionsButtonBar>

<LogDetailTableActionsButtonBar>
<Button
priority="link"
size="sm"
borderless
onClick={() => {
betterCopyToClipboard();
}}
>
<IconJson size="md" style={{paddingRight: space(0.5)}} />
{t('Copy as JSON')}
</Button>
<Button
priority="link"
size="sm"
borderless
onClick={() => {
const traceId = adjustLogTraceID(dataRow[OurLogKnownFieldKey.TRACE_ID]);
const locationStripped = stripLogParamsFromLocation(location);
const timestamp = dataRow[OurLogKnownFieldKey.TIMESTAMP];
const target = getTraceDetailsUrl({
traceSlug: traceId,
spanId: dataRow[OurLogKnownFieldKey.SPAN_ID] as string | undefined,
timestamp:
typeof timestamp === 'string' || typeof timestamp === 'number'
? timestamp
: undefined,
organization,
dateSelection: locationStripped,
location: locationStripped,
source: TraceViewSources.LOGS,
});
navigate(target);
}}
>
<IconSpan size="md" style={{paddingRight: space(0.5)}} />
{t('View Trace')}
</Button>
</LogDetailTableActionsButtonBar>
</LogDetailTableActionsCell>
)}
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Table Rendering Issues and Button Crashes

The expanded log row (DetailsWrapper, a <tr>) incorrectly renders two <td> cells (LogDetailTableBodyCell and LogDetailTableActionsCell), each set to span all columns. This invalid table structure can cause unpredictable rendering and layout issues.

Additionally, clicking the "View Trace" button crashes for logs without a trace_id because adjustLogTraceID is called with an undefined value, leading to a TypeError when .replace() is invoked. The button should be hidden/disabled or the call guarded.

Finally, the LogDetailTableActionsCell is missing display: "flex", preventing its alignItems, justifyContent, and flexDirection styles from applying, which can lead to incorrect button layout.

Fix in Cursor Fix in Web

</DetailsWrapper>
);
}
33 changes: 33 additions & 0 deletions static/app/views/explore/logs/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -445,3 +445,36 @@ export function getLogsUrlFromSavedQueryUrl({
},
});
}

export function ourlogToJson(ourlog: OurLogsResponseItem): string {
const copy = {...ourlog};
let warned = false;
const warnOnce = (key: string) => {
if (!warned) {
warned = true;
warn(
fmt`Found sentry. prefix in ${key} while copying [project_id: ${ourlog.project_id}, user_email: ${ourlog.user_email}]`
);
}
};
// Trimming any sentry. prefixes
for (const key in copy) {
if (key.startsWith('sentry.')) {
const value = copy[key];
if (value !== undefined) {
warnOnce(key);
delete copy[key];
copy[key.replace('sentry.', '')] = value;
}
}
if (key.startsWith('tags[sentry.')) {
const value = copy[key];
if (value !== undefined) {
warnOnce(key);
delete copy[key];
copy[key.replace('tags[sentry.', 'tags[')] = value;
}
}
}
return JSON.stringify(copy, null, 2);
}
Loading