Skip to content

Commit 57328fe

Browse files
committed
Add logs table row buttons (filters, copy as json etc.)
This adds some actions below the details when opening a log in the logs table.
1 parent 037b029 commit 57328fe

File tree

5 files changed

+253
-22
lines changed

5 files changed

+253
-22
lines changed

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,25 @@ function getLogsParamsStorageKey(version: number) {
552552
function getPastLogsParamsStorageKey(version: number) {
553553
return `logs-params-v${version - 1}`;
554554
}
555+
556+
export function useLogsAddSearchFilter() {
557+
const setLogsSearch = useSetLogsSearch();
558+
const search = useLogsSearch();
559+
560+
return useCallback(
561+
({
562+
key,
563+
value,
564+
negated,
565+
}: {
566+
key: string;
567+
value: string | number | boolean;
568+
negated?: boolean;
569+
}) => {
570+
const newSearch = search.copy();
571+
newSearch.addFilterValue(`${negated ? '!' : ''}${key}`, String(value));
572+
setLogsSearch(newSearch);
573+
},
574+
[setLogsSearch, search]
575+
);
576+
}

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

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

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

5151
export const LogTableBodyCell = styled(TableBodyCell)`
@@ -90,6 +90,30 @@ export const LogDetailTableBodyCell = styled(TableBodyCell)`
9090
padding: 0;
9191
}
9292
`;
93+
export const LogDetailTableActionsCell = styled(TableBodyCell)`
94+
padding-left: ${space(2)};
95+
padding-right: ${space(2)};
96+
padding-top: ${space(0.5)};
97+
padding-bottom: 0;
98+
min-height: 0px;
99+
100+
${LogTableRow} & {
101+
padding-left: ${space(2)};
102+
padding-right: ${space(2)};
103+
padding-top: ${space(0.5)};
104+
padding-bottom: 0;
105+
}
106+
&:last-child {
107+
padding-left: ${space(2)};
108+
padding-right: ${space(2)};
109+
padding-top: ${space(0.5)};
110+
padding-bottom: 0;
111+
}
112+
`;
113+
export const LogDetailTableActionsButtonBar = styled('div')`
114+
display: flex;
115+
gap: ${space(1)};
116+
`;
93117

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

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

Lines changed: 103 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@ 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, IconSpan, 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';
19+
import {useNavigate} from 'sentry/utils/useNavigate';
1620
import useOrganization from 'sentry/utils/useOrganization';
1721
import useProjectFromId from 'sentry/utils/useProjectFromId';
1822
import CellAction, {
@@ -28,10 +32,10 @@ import {
2832
useSetLogsAutoRefresh,
2933
} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext';
3034
import {
35+
stripLogParamsFromLocation,
36+
useLogsAddSearchFilter,
3137
useLogsAnalyticsPageSource,
3238
useLogsFields,
33-
useLogsSearch,
34-
useSetLogsSearch,
3539
} from 'sentry/views/explore/contexts/logs/logsPageParams';
3640
import {
3741
DEFAULT_TRACE_ITEM_HOVER_TIMEOUT,
@@ -51,6 +55,8 @@ import {
5155
DetailsWrapper,
5256
getLogColors,
5357
LogAttributeTreeWrapper,
58+
LogDetailTableActionsButtonBar,
59+
LogDetailTableActionsCell,
5460
LogDetailTableBodyCell,
5561
LogFirstCellContent,
5662
LogsTableBodyFirstCell,
@@ -69,9 +75,13 @@ import {
6975
} from 'sentry/views/explore/logs/useLogsQuery';
7076
import {
7177
adjustAliases,
78+
adjustLogTraceID,
7279
getLogRowItem,
7380
getLogSeverityLevel,
81+
ourlogToJson,
7482
} from 'sentry/views/explore/logs/utils';
83+
import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceHeader/breadcrumbs';
84+
import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils';
7585

7686
type LogsRowProps = {
7787
dataRow: OurLogsResponseItem;
@@ -124,8 +134,6 @@ export const LogRowContent = memo(function LogRowContent({
124134
const location = useLocation();
125135
const organization = useOrganization();
126136
const fields = useLogsFields();
127-
const search = useLogsSearch();
128-
const setLogsSearch = useSetLogsSearch();
129137
const autorefreshEnabled = useLogsAutoRefreshEnabled();
130138
const setAutorefresh = useSetLogsAutoRefresh();
131139
const measureRef = useRef<HTMLTableRowElement>(null);
@@ -186,22 +194,7 @@ export const LogRowContent = memo(function LogRowContent({
186194
}
187195
}, [isExpanded, onExpandHeight, dataRow]);
188196

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

207200
const severityNumber = dataRow[OurLogKnownFieldKey.SEVERITY_NUMBER];
@@ -383,6 +376,13 @@ function LogRowDetails({
383376
}) {
384377
const location = useLocation();
385378
const organization = useOrganization();
379+
const navigate = useNavigate();
380+
const {onClick: betterCopyToClipboard} = useCopyToClipboard({
381+
text: ourlogToJson(dataRow),
382+
successMessage: t('Copied!'),
383+
errorMessage: t('Failed to copy'),
384+
});
385+
const addSearchFilter = useLogsAddSearchFilter();
386386
const project = useProjectFromId({
387387
project_id: '' + dataRow[OurLogKnownFieldKey.PROJECT_ID],
388388
});
@@ -469,6 +469,88 @@ function LogRowDetails({
469469
</Fragment>
470470
)}
471471
</LogDetailTableBodyCell>
472+
{!isPending && data && (
473+
<LogDetailTableActionsCell
474+
colSpan={colSpan}
475+
style={{
476+
alignItems: 'center',
477+
justifyContent: 'space-between',
478+
flexDirection: 'row',
479+
}}
480+
>
481+
<LogDetailTableActionsButtonBar>
482+
<Button
483+
priority="link"
484+
size="sm"
485+
borderless
486+
onClick={() => {
487+
addSearchFilter({
488+
key: OurLogKnownFieldKey.MESSAGE,
489+
value: dataRow[OurLogKnownFieldKey.MESSAGE],
490+
});
491+
}}
492+
>
493+
<IconAdd size="md" style={{paddingRight: space(0.5)}} />
494+
{t('Add to filter')}
495+
</Button>
496+
<Button
497+
priority="link"
498+
size="sm"
499+
borderless
500+
onClick={() => {
501+
addSearchFilter({
502+
key: OurLogKnownFieldKey.MESSAGE,
503+
value: dataRow[OurLogKnownFieldKey.MESSAGE],
504+
negated: true,
505+
});
506+
}}
507+
>
508+
<IconSubtract size="md" style={{paddingRight: space(0.5)}} />
509+
{t('Exclude from filter')}
510+
</Button>
511+
</LogDetailTableActionsButtonBar>
512+
513+
<LogDetailTableActionsButtonBar>
514+
<Button
515+
priority="link"
516+
size="sm"
517+
borderless
518+
onClick={() => {
519+
betterCopyToClipboard();
520+
}}
521+
>
522+
<IconJson size="md" style={{paddingRight: space(0.5)}} />
523+
{t('Copy as JSON')}
524+
</Button>
525+
<Button
526+
priority="link"
527+
size="sm"
528+
borderless
529+
onClick={() => {
530+
const traceId = adjustLogTraceID(dataRow[OurLogKnownFieldKey.TRACE_ID]);
531+
const locationStripped = stripLogParamsFromLocation(location);
532+
const timestamp = dataRow[OurLogKnownFieldKey.TIMESTAMP];
533+
const target = getTraceDetailsUrl({
534+
traceSlug: traceId,
535+
spanId: dataRow[OurLogKnownFieldKey.SPAN_ID] as string | undefined,
536+
timestamp:
537+
typeof timestamp === 'string' || typeof timestamp === 'number'
538+
? timestamp
539+
: undefined,
540+
organization,
541+
dateSelection: locationStripped,
542+
location: locationStripped,
543+
source: TraceViewSources.LOGS,
544+
});
545+
navigate(target);
546+
}}
547+
>
548+
<IconSpan size="md" style={{paddingRight: space(0.5)}} />
549+
{t('View Trace')}
550+
</Button>
551+
</LogDetailTableActionsButtonBar>
552+
</LogDetailTableActionsCell>
553+
)}
472554
</DetailsWrapper>
473555
);
474556
}

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,3 +445,36 @@ export function getLogsUrlFromSavedQueryUrl({
445445
},
446446
});
447447
}
448+
449+
export function ourlogToJson(ourlog: OurLogsResponseItem): string {
450+
const copy = {...ourlog};
451+
let warned = false;
452+
const warnOnce = (key: string) => {
453+
if (!warned) {
454+
warned = true;
455+
warn(
456+
fmt`Found sentry. prefix in ${key} while copying [project_id: ${ourlog.project_id}, user_email: ${ourlog.user_email}]`
457+
);
458+
}
459+
};
460+
// Trimming any sentry. prefixes
461+
for (const key in copy) {
462+
if (key.startsWith('sentry.')) {
463+
const value = copy[key];
464+
if (value !== undefined) {
465+
warnOnce(key);
466+
delete copy[key];
467+
copy[key.replace('sentry.', '')] = value;
468+
}
469+
}
470+
if (key.startsWith('tags[sentry.')) {
471+
const value = copy[key];
472+
if (value !== undefined) {
473+
warnOnce(key);
474+
delete copy[key];
475+
copy[key.replace('tags[sentry.', 'tags[')] = value;
476+
}
477+
}
478+
}
479+
return JSON.stringify(copy, null, 2);
480+
}

0 commit comments

Comments
 (0)