Skip to content

Commit 994a5b1

Browse files
authored
Add logs table row buttons (filters, copy as json etc.) (#97545)
### Summary This adds some actions below the details when opening a log in the logs table.
1 parent 3b43533 commit 994a5b1

File tree

7 files changed

+247
-23
lines changed

7 files changed

+247
-23
lines changed

static/app/views/explore/contexts/logs/logsPageParams.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,3 +526,25 @@ function getLogsParamsStorageKey(version: number) {
526526
function getPastLogsParamsStorageKey(version: number) {
527527
return `logs-params-v${version - 1}`;
528528
}
529+
530+
export function useLogsAddSearchFilter() {
531+
const setLogsSearch = useSetLogsSearch();
532+
const search = useLogsSearch();
533+
534+
return useCallback(
535+
({
536+
key,
537+
value,
538+
negated,
539+
}: {
540+
key: string;
541+
value: string | number | boolean;
542+
negated?: boolean;
543+
}) => {
544+
const newSearch = search.copy();
545+
newSearch.addFilterValue(`${negated ? '!' : ''}${key}`, String(value));
546+
setLogsSearch(newSearch);
547+
},
548+
[setLogsSearch, search]
549+
);
550+
}

static/app/views/explore/logs/constants.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export const HiddenLogDetailFields: OurLogFieldKey[] = [
5959
'span_id',
6060
];
6161

62+
export const DeprecatedLogDetailFields: OurLogFieldKey[] = [
63+
OurLogKnownFieldKey.TIMESTAMP_NANOS,
64+
];
65+
6266
export const HiddenColumnEditorLogFields: OurLogFieldKey[] = [...AlwaysHiddenLogFields];
6367

6468
export const HiddenLogSearchFields: string[] = [...AlwaysHiddenLogFields];

static/app/views/explore/logs/styles.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const LogTableRow = styled(TableRow)<LogTableRowProps>`
4646

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

5252
export const LogTableBodyCell = styled(TableBodyCell)`
@@ -91,6 +91,24 @@ export const LogDetailTableBodyCell = styled(TableBodyCell)`
9191
padding: 0;
9292
}
9393
`;
94+
export const LogDetailTableActionsCell = styled(TableBodyCell)`
95+
padding: ${space(0.5)} ${space(2)};
96+
min-height: 0px;
97+
98+
${LogTableRow} & {
99+
padding: ${space(0.5)} ${space(2)};
100+
}
101+
&:last-child {
102+
padding: ${space(0.5)} ${space(2)};
103+
}
104+
`;
105+
export const LogDetailTableActionsButtonBar = styled('div')`
106+
display: flex;
107+
gap: ${space(1)};
108+
& button {
109+
font-weight: ${p => p.theme.fontWeight.normal};
110+
}
111+
`;
94112

95113
export const DetailsWrapper = styled('tr')`
96114
align-items: center;

static/app/views/explore/logs/tables/logsTableRow.spec.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,4 +383,74 @@ describe('logsTableRow', () => {
383383
'https://github.com/example/repo/blob/main/file.py'
384384
);
385385
});
386+
387+
it('copies log as JSON when Copy as JSON button is clicked', async () => {
388+
const mockWriteText = jest.fn().mockResolvedValue(undefined);
389+
Object.defineProperty(window.navigator, 'clipboard', {
390+
value: {
391+
writeText: mockWriteText,
392+
},
393+
writable: true,
394+
});
395+
396+
render(
397+
<ProviderWrapper>
398+
<LogRowContent
399+
dataRow={rowData}
400+
highlightTerms={[]}
401+
meta={LogFixtureMeta(rowData)}
402+
sharedHoverTimeoutRef={
403+
{
404+
current: null,
405+
} as React.MutableRefObject<NodeJS.Timeout | null>
406+
}
407+
canDeferRenderElements={false}
408+
/>
409+
</ProviderWrapper>,
410+
{organization, initialRouterConfig}
411+
);
412+
413+
// Expand the row to show the action buttons
414+
const logTableRow = await screen.findByTestId('log-table-row');
415+
await userEvent.click(logTableRow);
416+
417+
await waitFor(() => {
418+
expect(rowDetailsMock).toHaveBeenCalledTimes(1);
419+
});
420+
421+
// Find and click the Copy as JSON button
422+
const copyButton = await screen.findByRole('button', {name: 'Copy as JSON'});
423+
expect(copyButton).toBeInTheDocument();
424+
425+
await userEvent.click(copyButton);
426+
427+
// Verify clipboard was called with JSON representation of the log
428+
await waitFor(() => {
429+
expect(mockWriteText).toHaveBeenCalledTimes(1);
430+
});
431+
432+
const callArgs = mockWriteText.mock.calls[0];
433+
expect(callArgs).toBeDefined();
434+
expect(callArgs).toHaveLength(1);
435+
436+
const copiedText = callArgs![0];
437+
expect(typeof copiedText).toBe('string');
438+
439+
// Verify it's valid JSON
440+
expect(() => JSON.parse(copiedText)).not.toThrow();
441+
442+
// Verify it contains expected log data
443+
const parsedData = JSON.parse(copiedText);
444+
expect(parsedData).toMatchObject({
445+
message: 'test log body',
446+
trace: '7b91699f',
447+
severity: 'error',
448+
item_id: '1',
449+
});
450+
451+
// Verify the JSON structure matches what ourlogToJson produces
452+
expect(parsedData).toHaveProperty('item_id', '1');
453+
expect(parsedData[OurLogKnownFieldKey.TIMESTAMP_PRECISE]).toBeDefined();
454+
expect(parsedData).not.toHaveProperty('sentry.item_id');
455+
});
386456
});

static/app/views/explore/logs/tables/logsTableRow.tsx

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ import type {ComponentProps, SyntheticEvent} from 'react';
22
import {Fragment, memo, useCallback, useLayoutEffect, useRef, useState} from 'react';
33
import {useTheme} from '@emotion/react';
44

5+
import {Button} from 'sentry/components/core/button';
56
import {EmptyStreamWrapper} from 'sentry/components/emptyStateWarning';
67
import LoadingIndicator from 'sentry/components/loadingIndicator';
7-
import {IconWarning} from 'sentry/icons';
8+
import {IconAdd, IconJson, IconSubtract, IconWarning} from 'sentry/icons';
89
import {IconChevron} from 'sentry/icons/iconChevron';
910
import {t} from 'sentry/locale';
11+
import {space} from 'sentry/styles/space';
1012
import {defined} from 'sentry/utils';
1113
import {trackAnalytics} from 'sentry/utils/analytics';
1214
import type {TableDataRow} from 'sentry/utils/discover/discoverQuery';
1315
import type {EventsMetaType} from 'sentry/utils/discover/eventView';
1416
import {FieldValueType} from 'sentry/utils/fields';
17+
import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
1518
import {useLocation} from 'sentry/utils/useLocation';
1619
import useOrganization from 'sentry/utils/useOrganization';
1720
import useProjectFromId from 'sentry/utils/useProjectFromId';
@@ -27,10 +30,9 @@ import {
2730
useSetLogsAutoRefresh,
2831
} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext';
2932
import {
33+
useLogsAddSearchFilter,
3034
useLogsAnalyticsPageSource,
3135
useLogsFields,
32-
useLogsSearch,
33-
useSetLogsSearch,
3436
} from 'sentry/views/explore/contexts/logs/logsPageParams';
3537
import {
3638
DEFAULT_TRACE_ITEM_HOVER_TIMEOUT,
@@ -50,6 +52,8 @@ import {
5052
DetailsWrapper,
5153
getLogColors,
5254
LogAttributeTreeWrapper,
55+
LogDetailTableActionsButtonBar,
56+
LogDetailTableActionsCell,
5357
LogDetailTableBodyCell,
5458
LogFirstCellContent,
5559
LogsTableBodyFirstCell,
@@ -70,6 +74,7 @@ import {
7074
adjustAliases,
7175
getLogRowItem,
7276
getLogSeverityLevel,
77+
ourlogToJson,
7378
} from 'sentry/views/explore/logs/utils';
7479

7580
type LogsRowProps = {
@@ -123,8 +128,6 @@ export const LogRowContent = memo(function LogRowContent({
123128
const location = useLocation();
124129
const organization = useOrganization();
125130
const fields = useLogsFields();
126-
const search = useLogsSearch();
127-
const setLogsSearch = useSetLogsSearch();
128131
const autorefreshEnabled = useLogsAutoRefreshEnabled();
129132
const setAutorefresh = useSetLogsAutoRefresh();
130133
const measureRef = useRef<HTMLTableRowElement>(null);
@@ -185,22 +188,7 @@ export const LogRowContent = memo(function LogRowContent({
185188
}
186189
}, [isExpanded, onExpandHeight, dataRow]);
187190

188-
const addSearchFilter = useCallback(
189-
({
190-
key,
191-
value,
192-
negated,
193-
}: {
194-
key: string;
195-
value: string | number | boolean;
196-
negated?: boolean;
197-
}) => {
198-
const newSearch = search.copy();
199-
newSearch.addFilterValue(`${negated ? '!' : ''}${key}`, String(value));
200-
setLogsSearch(newSearch);
201-
},
202-
[setLogsSearch, search]
203-
);
191+
const addSearchFilter = useLogsAddSearchFilter();
204192
const theme = useTheme();
205193

206194
const severityNumber = dataRow[OurLogKnownFieldKey.SEVERITY_NUMBER];
@@ -380,6 +368,7 @@ function LogRowDetails({
380368
}) {
381369
const location = useLocation();
382370
const organization = useOrganization();
371+
const addSearchFilter = useLogsAddSearchFilter();
383372
const project = useProjectFromId({
384373
project_id: '' + dataRow[OurLogKnownFieldKey.PROJECT_ID],
385374
});
@@ -401,6 +390,19 @@ function LogRowDetails({
401390
enabled: !missingLogId,
402391
});
403392

393+
const {onClick: betterCopyToClipboard} = useCopyToClipboard({
394+
text: isPending || isError ? '' : ourlogToJson(data),
395+
onCopy: () => {
396+
trackAnalytics('logs.table.row_copied_as_json', {
397+
log_id: String(dataRow[OurLogKnownFieldKey.ID]),
398+
organization,
399+
});
400+
},
401+
402+
successMessage: t('Copied!'),
403+
errorMessage: t('Failed to copy'),
404+
});
405+
404406
const theme = useTheme();
405407
const logColors = getLogColors(level, theme);
406408
const attributes =
@@ -470,6 +472,62 @@ function LogRowDetails({
470472
</Fragment>
471473
)}
472474
</LogDetailTableBodyCell>
475+
{!isPending && data && (
476+
<LogDetailTableActionsCell
477+
colSpan={colSpan}
478+
style={{
479+
alignItems: 'center',
480+
justifyContent: 'space-between',
481+
flexDirection: 'row',
482+
}}
483+
>
484+
<LogDetailTableActionsButtonBar>
485+
<Button
486+
priority="link"
487+
size="sm"
488+
borderless
489+
onClick={() => {
490+
addSearchFilter({
491+
key: OurLogKnownFieldKey.MESSAGE,
492+
value: dataRow[OurLogKnownFieldKey.MESSAGE],
493+
});
494+
}}
495+
>
496+
<IconAdd size="md" style={{paddingRight: space(0.5)}} />
497+
{t('Add to filter')}
498+
</Button>
499+
<Button
500+
priority="link"
501+
size="sm"
502+
borderless
503+
onClick={() => {
504+
addSearchFilter({
505+
key: OurLogKnownFieldKey.MESSAGE,
506+
value: dataRow[OurLogKnownFieldKey.MESSAGE],
507+
negated: true,
508+
});
509+
}}
510+
>
511+
<IconSubtract size="md" style={{paddingRight: space(0.5)}} />
512+
{t('Exclude from filter')}
513+
</Button>
514+
</LogDetailTableActionsButtonBar>
515+
516+
<LogDetailTableActionsButtonBar>
517+
<Button
518+
priority="link"
519+
size="sm"
520+
borderless
521+
onClick={() => {
522+
betterCopyToClipboard();
523+
}}
524+
>
525+
<IconJson size="md" style={{paddingRight: space(0.5)}} />
526+
{t('Copy as JSON')}
527+
</Button>
528+
</LogDetailTableActionsButtonBar>
529+
</LogDetailTableActionsCell>
530+
)}
473531
</DetailsWrapper>
474532
);
475533
}

static/app/views/explore/logs/types.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ export enum OurLogKnownFieldKey {
5353

5454
// From the EAP dataset directly not using a column alias, should be hidden.
5555
ITEM_TYPE = 'sentry.item_type',
56+
57+
// Deprecated fields
58+
TIMESTAMP_NANOS = 'sentry.timestamp_nanos',
5659
}
5760

5861
export type OurLogFieldKey = OurLogCustomFieldKey | OurLogKnownFieldKey;

static/app/views/explore/logs/utils.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@ import {
3131
import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys';
3232
import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode';
3333
import {SavedQuery} from 'sentry/views/explore/hooks/useGetSavedQueries';
34-
import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails';
34+
import type {
35+
TraceItemDetailsResponse,
36+
TraceItemResponseAttribute,
37+
} from 'sentry/views/explore/hooks/useTraceItemDetails';
3538
import {
39+
DeprecatedLogDetailFields,
3640
LogAttributesHumanLabel,
3741
LOGS_GRID_SCROLL_MIN_ITEM_THRESHOLD,
3842
} from 'sentry/views/explore/logs/constants';
@@ -445,3 +449,48 @@ export function getLogsUrlFromSavedQueryUrl({
445449
},
446450
});
447451
}
452+
453+
export function ourlogToJson(ourlog: TraceItemDetailsResponse | undefined): string {
454+
if (!ourlog) {
455+
warn(fmt`cannot copy undefined ourlog`);
456+
return '';
457+
}
458+
459+
const copy: Record<string, string | number | boolean> = {
460+
...ourlog.attributes.reduce((it, {name, value}) => ({...it, [name]: value}), {}),
461+
id: ourlog.itemId,
462+
};
463+
let warned = false;
464+
const warnAttributeOnce = (key: string) => {
465+
if (!warned) {
466+
warned = true;
467+
warn(
468+
fmt`Found sentry. prefix in ${key} while copying [project_id: ${copy.project_id ?? 'unknown'}, user_email: ${copy['user.email'] ?? 'unknown'}]`
469+
);
470+
}
471+
};
472+
473+
// Trimming any sentry. prefixes
474+
for (const key in copy) {
475+
if (DeprecatedLogDetailFields.includes(key)) {
476+
continue;
477+
}
478+
if (key.startsWith('sentry.')) {
479+
const value = copy[key];
480+
if (value !== undefined) {
481+
warnAttributeOnce(key);
482+
delete copy[key];
483+
copy[key.replace('sentry.', '')] = value;
484+
}
485+
}
486+
if (key.startsWith('tags[sentry.')) {
487+
const value = copy[key];
488+
if (value !== undefined) {
489+
warnAttributeOnce(key);
490+
delete copy[key];
491+
copy[key.replace('tags[sentry.', 'tags[')] = value;
492+
}
493+
}
494+
}
495+
return JSON.stringify(copy, null, 2);
496+
}

0 commit comments

Comments
 (0)