diff --git a/static/app/views/explore/contexts/logs/logsPageParams.tsx b/static/app/views/explore/contexts/logs/logsPageParams.tsx index 126e7a36f86b1e..0c030dee221605 100644 --- a/static/app/views/explore/contexts/logs/logsPageParams.tsx +++ b/static/app/views/explore/contexts/logs/logsPageParams.tsx @@ -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] + ); +} diff --git a/static/app/views/explore/logs/constants.tsx b/static/app/views/explore/logs/constants.tsx index ad83f1e1fea5b7..bf9a24b1ab62f9 100644 --- a/static/app/views/explore/logs/constants.tsx +++ b/static/app/views/explore/logs/constants.tsx @@ -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]; diff --git a/static/app/views/explore/logs/styles.tsx b/static/app/views/explore/logs/styles.tsx index 791f334c457970..3bb5c0162e16d6 100644 --- a/static/app/views/explore/logs/styles.tsx +++ b/static/app/views/explore/logs/styles.tsx @@ -46,7 +46,7 @@ export const LogTableRow = styled(TableRow)` 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)` @@ -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; diff --git a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx index b48dc5c6cb6df4..7120e074351ffb 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx @@ -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( + + + } + canDeferRenderElements={false} + /> + , + {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'); + }); }); diff --git a/static/app/views/explore/logs/tables/logsTableRow.tsx b/static/app/views/explore/logs/tables/logsTableRow.tsx index dceb17f2930cd2..4779f9d46fc5f7 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.tsx @@ -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'; @@ -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, @@ -50,6 +52,8 @@ import { DetailsWrapper, getLogColors, LogAttributeTreeWrapper, + LogDetailTableActionsButtonBar, + LogDetailTableActionsCell, LogDetailTableBodyCell, LogFirstCellContent, LogsTableBodyFirstCell, @@ -70,6 +74,7 @@ import { adjustAliases, getLogRowItem, getLogSeverityLevel, + ourlogToJson, } from 'sentry/views/explore/logs/utils'; type LogsRowProps = { @@ -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(null); @@ -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]; @@ -380,6 +368,7 @@ function LogRowDetails({ }) { const location = useLocation(); const organization = useOrganization(); + const addSearchFilter = useLogsAddSearchFilter(); const project = useProjectFromId({ project_id: '' + dataRow[OurLogKnownFieldKey.PROJECT_ID], }); @@ -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 = @@ -470,6 +472,62 @@ function LogRowDetails({ )} + {!isPending && data && ( + + + + + + + + + + + )} ); } diff --git a/static/app/views/explore/logs/types.tsx b/static/app/views/explore/logs/types.tsx index 9611e054259848..321b1a0b012e91 100644 --- a/static/app/views/explore/logs/types.tsx +++ b/static/app/views/explore/logs/types.tsx @@ -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; diff --git a/static/app/views/explore/logs/utils.tsx b/static/app/views/explore/logs/utils.tsx index 0f4f73b7a262f7..bf30c057461bce 100644 --- a/static/app/views/explore/logs/utils.tsx +++ b/static/app/views/explore/logs/utils.tsx @@ -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'; @@ -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 = { + ...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; + } + } + } + return JSON.stringify(copy, null, 2); +}