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

Merged
merged 7 commits into from
Aug 13, 2025
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
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 @@ -526,3 +526,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]
);
}
4 changes: 4 additions & 0 deletions static/app/views/explore/logs/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export const HiddenLogDetailFields: OurLogFieldKey[] = [
'span_id',
];

export const DeprecatedLogDetailFields: OurLogFieldKey[] = [
OurLogKnownFieldKey.TIMESTAMP_NANOS,
];

export const HiddenColumnEditorLogFields: OurLogFieldKey[] = [...AlwaysHiddenLogFields];

export const HiddenLogSearchFields: string[] = [...AlwaysHiddenLogFields];
Expand Down
20 changes: 19 additions & 1 deletion static/app/views/explore/logs/styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,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 @@ -91,6 +91,24 @@ export const LogDetailTableBodyCell = styled(TableBodyCell)`
padding: 0;
}
`;
export const LogDetailTableActionsCell = styled(TableBodyCell)`
padding: ${space(0.5)} ${space(2)};
min-height: 0px;

${LogTableRow} & {
padding: ${space(0.5)} ${space(2)};
}
&:last-child {
padding: ${space(0.5)} ${space(2)};
}
`;
export const LogDetailTableActionsButtonBar = styled('div')`
display: flex;
gap: ${space(1)};
& button {
font-weight: ${p => p.theme.fontWeight.normal};
}
`;

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 @@ -383,4 +383,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[OurLogKnownFieldKey.TIMESTAMP_PRECISE]).toBeDefined();
expect(parsedData).not.toHaveProperty('sentry.item_id');
});
});
100 changes: 79 additions & 21 deletions static/app/views/explore/logs/tables/logsTableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ 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, 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 useOrganization from 'sentry/utils/useOrganization';
import useProjectFromId from 'sentry/utils/useProjectFromId';
Expand All @@ -27,10 +30,9 @@ import {
useSetLogsAutoRefresh,
} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext';
import {
useLogsAddSearchFilter,
useLogsAnalyticsPageSource,
useLogsFields,
useLogsSearch,
useSetLogsSearch,
} from 'sentry/views/explore/contexts/logs/logsPageParams';
import {
DEFAULT_TRACE_ITEM_HOVER_TIMEOUT,
Expand All @@ -50,6 +52,8 @@ import {
DetailsWrapper,
getLogColors,
LogAttributeTreeWrapper,
LogDetailTableActionsButtonBar,
LogDetailTableActionsCell,
LogDetailTableBodyCell,
LogFirstCellContent,
LogsTableBodyFirstCell,
Expand All @@ -70,6 +74,7 @@ import {
adjustAliases,
getLogRowItem,
getLogSeverityLevel,
ourlogToJson,
} from 'sentry/views/explore/logs/utils';

type LogsRowProps = {
Expand Down Expand Up @@ -123,8 +128,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 @@ -185,22 +188,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 @@ -380,6 +368,7 @@ function LogRowDetails({
}) {
const location = useLocation();
const organization = useOrganization();
const addSearchFilter = useLogsAddSearchFilter();
const project = useProjectFromId({
project_id: '' + dataRow[OurLogKnownFieldKey.PROJECT_ID],
});
Expand All @@ -401,6 +390,19 @@ function LogRowDetails({
enabled: !missingLogId,
});

const {onClick: betterCopyToClipboard} = useCopyToClipboard({
text: isPending || isError ? '' : ourlogToJson(data),
onCopy: () => {
trackAnalytics('logs.table.row_copied_as_json', {
log_id: String(dataRow[OurLogKnownFieldKey.ID]),
organization,
});
},

successMessage: t('Copied!'),
errorMessage: t('Failed to copy'),
});

const theme = useTheme();
const logColors = getLogColors(level, theme);
const attributes =
Expand Down Expand Up @@ -470,6 +472,62 @@ 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>
</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>
);
}
3 changes: 3 additions & 0 deletions static/app/views/explore/logs/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ export enum OurLogKnownFieldKey {

// From the EAP dataset directly not using a column alias, should be hidden.
ITEM_TYPE = 'sentry.item_type',

// Deprecated fields
TIMESTAMP_NANOS = 'sentry.timestamp_nanos',
}

export type OurLogFieldKey = OurLogCustomFieldKey | OurLogKnownFieldKey;
Expand Down
51 changes: 50 additions & 1 deletion static/app/views/explore/logs/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,12 @@ import {
import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys';
import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode';
import {SavedQuery} from 'sentry/views/explore/hooks/useGetSavedQueries';
import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails';
import type {
TraceItemDetailsResponse,
TraceItemResponseAttribute,
} from 'sentry/views/explore/hooks/useTraceItemDetails';
import {
DeprecatedLogDetailFields,
LogAttributesHumanLabel,
LOGS_GRID_SCROLL_MIN_ITEM_THRESHOLD,
} from 'sentry/views/explore/logs/constants';
Expand Down Expand Up @@ -445,3 +449,48 @@ export function getLogsUrlFromSavedQueryUrl({
},
});
}

export function ourlogToJson(ourlog: TraceItemDetailsResponse | undefined): string {
if (!ourlog) {
warn(fmt`cannot copy undefined ourlog`);
return '';
}

const copy: Record<string, string | number | boolean> = {
...ourlog.attributes.reduce((it, {name, value}) => ({...it, [name]: value}), {}),
id: ourlog.itemId,
};
let warned = false;
const warnAttributeOnce = (key: string) => {
if (!warned) {
warned = true;
warn(
fmt`Found sentry. prefix in ${key} while copying [project_id: ${copy.project_id ?? 'unknown'}, user_email: ${copy['user.email'] ?? 'unknown'}]`
);
}
};

// Trimming any sentry. prefixes
for (const key in copy) {
if (DeprecatedLogDetailFields.includes(key)) {
continue;
}
if (key.startsWith('sentry.')) {
const value = copy[key];
if (value !== undefined) {
warnAttributeOnce(key);
delete copy[key];
copy[key.replace('sentry.', '')] = value;
}
}
if (key.startsWith('tags[sentry.')) {
const value = copy[key];
if (value !== undefined) {
warnAttributeOnce(key);
delete copy[key];
copy[key.replace('tags[sentry.', 'tags[')] = value;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Log Attribute Cleanup and PII Logging Issue

The ourlogToJson function incorrectly handles log attributes when copying as JSON. It fails to remove deprecated fields (listed in DeprecatedLogDetailFields), leaving them in the output with their original sentry. prefixes, which contradicts the intended cleanup. Additionally, it logs PII (user_email, project_id) via Sentry.logger.warn whenever a sentry.-prefixed key is processed, leading to unnecessary exfiltration of sensitive customer data to product telemetry.

Fix in Cursor Fix in Web

}
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Data Overwrite and Iteration Issues

The ourlogToJson function contains multiple bugs:

  • Silent Data Loss: When both a sentry. or tags[sentry. prefixed key (e.g., sentry.project_id) and its non-prefixed equivalent (e.g., project_id) exist, the non-prefixed key's original value is silently overwritten by the prefixed key's value.
  • Unpredictable Iteration: The function modifies the object (copy) during for...in iteration, which can lead to unpredictable behavior such as skipped keys or incorrect processing order.
  • Ineffective Warning: The warning message logs undefined for project_id and user_email because it accesses them via dot notation (ourlog.project_id) instead of the expected OurLogKnownFieldKey constants.
Fix in Cursor Fix in Web

return JSON.stringify(copy, null, 2);
Copy link
Member

Choose a reason for hiding this comment

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

would user expect we return formatted json here? I'd just return JSON.stringify(copy).

Also this can throw, but I guess we can just rely on the error sentry captures to report back.

Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: PII Leakage and Data Overwrite in JSON Conversion

The ourlogToJson function introduces two bugs: it logs a warning containing project_id and user_email when encountering sentry.-prefixed keys, leading to PII leakage in client logs. Additionally, when renaming sentry.-prefixed keys (e.g., sentry.foo to foo), it overwrites existing unprefixed keys with the same name, resulting in data loss and incorrect JSON output.

Fix in Cursor Fix in Web

}
Loading