diff --git a/src/sentry/interfaces/user.py b/src/sentry/interfaces/user.py index 0923ddcc6ae1b..814daac8d598b 100644 --- a/src/sentry/interfaces/user.py +++ b/src/sentry/interfaces/user.py @@ -15,6 +15,7 @@ class EventUserApiContext(TypedDict, total=False): username: str | None ip_address: str | None name: str | None + geo: dict[str, str] | None data: dict[str, Any] | None @@ -69,6 +70,7 @@ def get_api_context(self, is_public=False, platform=None) -> EventUserApiContext "username": self.username, "ip_address": self.ip_address, "name": self.name, + "geo": self.geo.to_json() if self.geo is not None else None, "data": self.data, } @@ -80,6 +82,7 @@ def get_api_meta(self, meta, is_public=False, platform=None): "username": meta.get("username"), "ip_address": meta.get("ip_address"), "name": meta.get("name"), + "geo": meta.get("geo"), "data": meta.get("data"), } diff --git a/static/app/components/events/contexts/default.tsx b/static/app/components/events/contexts/default.tsx deleted file mode 100644 index 52b93510f2725..0000000000000 --- a/static/app/components/events/contexts/default.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import ContextBlock from 'sentry/components/events/contexts/contextBlock'; -import type {Event} from 'sentry/types/event'; - -type Props = { - alias: string; - data: Record; - event: Event; -}; - -export function getDefaultContextData(data: Props['data']) { - return Object.entries(data) - .filter(([k]) => k !== 'type' && k !== 'title') - .map(([key, value]) => ({ - key, - subject: key, - value, - })); -} - -export function DefaultContext({data}: Props) { - return ; -} diff --git a/static/app/components/events/contexts/knownContext/user.spec.tsx b/static/app/components/events/contexts/knownContext/user.spec.tsx new file mode 100644 index 0000000000000..8ed1b952ffeb2 --- /dev/null +++ b/static/app/components/events/contexts/knownContext/user.spec.tsx @@ -0,0 +1,93 @@ +import {EventFixture} from 'sentry-fixture/event'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import ContextCard from 'sentry/components/events/contexts/contextCard'; +import { + getUserContextData, + type UserContext, +} from 'sentry/components/events/contexts/knownContext/user'; + +const MOCK_USER_CONTEXT: UserContext = { + email: 'leander.rodrigues@sentry.io', + ip_address: '127.0.0.1', + id: '808', + name: 'Leander', + username: 'leeandher', + geo: { + country_code: 'US', + city: 'San Francisco', + subdivision: 'California', + region: 'United States', + }, + // Extra data is still valid and preserved + extra_data: 'something', + unknown_key: 123, +}; + +const MOCK_REDACTION = { + name: { + '': { + rem: [['organization:0', 's', 0, 0]], + len: 5, + }, + }, +}; + +describe('UserContext', function () { + it('returns values and according to the parameters', function () { + expect(getUserContextData({data: MOCK_USER_CONTEXT})).toEqual([ + { + key: 'email', + subject: 'Email', + value: 'leander.rodrigues@sentry.io', + action: {link: 'mailto:leander.rodrigues@sentry.io'}, + }, + {key: 'ip_address', subject: 'IP Address', value: '127.0.0.1'}, + {key: 'id', subject: 'ID', value: '808'}, + {key: 'name', subject: 'Name', value: 'Leander'}, + {key: 'username', subject: 'Username', value: 'leeandher'}, + { + key: 'geo', + subject: 'Geography', + value: 'San Francisco, California, United States (US)', + }, + { + key: 'extra_data', + subject: 'extra_data', + value: 'something', + meta: undefined, + }, + { + key: 'unknown_key', + subject: 'unknown_key', + value: 123, + meta: undefined, + }, + ]); + }); + + it('renders with meta annotations correctly', function () { + const event = EventFixture({ + _meta: {contexts: {user: MOCK_REDACTION}}, + }); + + render( + + ); + + expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText('leander.rodrigues@sentry.io')).toBeInTheDocument(); + expect( + screen.getByRole('link', {name: 'leander.rodrigues@sentry.io'}) + ).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText(/redacted/)).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/events/contexts/knownContext/user.tsx b/static/app/components/events/contexts/knownContext/user.tsx new file mode 100644 index 0000000000000..e2e9a176b751d --- /dev/null +++ b/static/app/components/events/contexts/knownContext/user.tsx @@ -0,0 +1,121 @@ +import {getContextKeys} from 'sentry/components/events/contexts/utils'; +import {t} from 'sentry/locale'; +import type {KeyValueListData} from 'sentry/types/group'; +import {defined} from 'sentry/utils'; + +enum UserContextKeys { + ID = 'id', + EMAIL = 'email', + USERNAME = 'username', + IP_ADDRESS = 'ip_address', + NAME = 'name', + GEO = 'geo', +} + +export interface UserContext { + // Any custom keys users may set + [key: string]: any; + [UserContextKeys.ID]?: string; + [UserContextKeys.EMAIL]?: string; + [UserContextKeys.USERNAME]?: string; + [UserContextKeys.IP_ADDRESS]?: string; + [UserContextKeys.NAME]?: string; + [UserContextKeys.GEO]?: Partial>; +} + +enum UserContextGeoKeys { + CITY = 'city', + COUNTRY_CODE = 'country_code', + SUBDIVISION = 'subdivision', + REGION = 'region', +} + +const EMAIL_REGEX = /[^@]+@[^\.]+\..+/; + +function formatGeo(geoData: UserContext['geo'] = {}): string | undefined { + if (!geoData) { + return undefined; + } + + const geoStringArray: string[] = []; + + if (geoData.city) { + geoStringArray.push(geoData.city); + } + + if (geoData.subdivision) { + geoStringArray.push(geoData.subdivision); + } + + if (geoData.region) { + geoStringArray.push( + geoData.country_code + ? `${geoData.region} (${geoData.country_code})` + : geoData.region + ); + } + + return geoStringArray.join(', '); +} + +export function getUserContextData({ + data, + meta, +}: { + data: UserContext; + meta?: Record; +}): KeyValueListData { + return getContextKeys({data}).map(ctxKey => { + switch (ctxKey) { + case UserContextKeys.NAME: + return { + key: ctxKey, + subject: t('Name'), + value: data.name, + }; + case UserContextKeys.USERNAME: + return { + key: ctxKey, + subject: t('Username'), + value: data.username, + }; + case UserContextKeys.ID: + return { + key: ctxKey, + subject: t('ID'), + value: data.id, + }; + case UserContextKeys.IP_ADDRESS: + return { + key: ctxKey, + subject: t('IP Address'), + value: data.ip_address, + }; + case UserContextKeys.EMAIL: + return { + key: ctxKey, + subject: t('Email'), + value: data.email, + action: { + link: + defined(data.email) && EMAIL_REGEX.test(data.email) + ? `mailto:${data.email}` + : undefined, + }, + }; + case UserContextKeys.GEO: + return { + key: ctxKey, + subject: t('Geography'), + value: formatGeo(data.geo), + }; + default: + return { + key: ctxKey, + subject: ctxKey, + value: data[ctxKey], + meta: meta?.[ctxKey]?.[''], + }; + } + }); +} diff --git a/static/app/components/events/contexts/user/getUserKnownDataDetails.spec.tsx b/static/app/components/events/contexts/user/getUserKnownDataDetails.spec.tsx deleted file mode 100644 index 21de3b75fd488..0000000000000 --- a/static/app/components/events/contexts/user/getUserKnownDataDetails.spec.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import {userKnownDataValues} from 'sentry/components/events/contexts/user'; -import {getUserKnownDataDetails} from 'sentry/components/events/contexts/user/getUserKnownDataDetails'; - -import {userMockData} from './index.spec'; - -describe('getUserKnownDataDetails', function () { - it('returns values and according to the parameters', function () { - const allKnownData: ReturnType[] = []; - - for (const type of Object.keys(userKnownDataValues)) { - const userKnownData = getUserKnownDataDetails({ - type: userKnownDataValues[type], - data: userMockData, - }); - - if (!userKnownData) { - continue; - } - - allKnownData.push(userKnownData); - } - - expect(allKnownData).toEqual([ - { - subject: 'ID', - value: '', - }, - { - subject: 'Email', - subjectIcon: false, - value: null, - }, - { - subject: 'Username', - value: null, - }, - { - subject: 'IP Address', - value: null, - }, - { - subject: 'Name', - value: null, - }, - ]); - }); -}); diff --git a/static/app/components/events/contexts/user/getUserKnownDataDetails.tsx b/static/app/components/events/contexts/user/getUserKnownDataDetails.tsx deleted file mode 100644 index c9fd144f5b4db..0000000000000 --- a/static/app/components/events/contexts/user/getUserKnownDataDetails.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import styled from '@emotion/styled'; - -import type {KnownDataDetails} from 'sentry/components/events/contexts/utils'; -import ExternalLink from 'sentry/components/links/externalLink'; -import {IconMail} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {defined} from 'sentry/utils'; - -import type {UserEventContextData} from '.'; -import {UserKnownDataType} from '.'; - -const EMAIL_REGEX = /[^@]+@[^\.]+\..+/; - -type Props = { - data: UserEventContextData; - type: UserKnownDataType; -}; - -export function getUserKnownDataDetails({data, type}: Props): KnownDataDetails { - switch (type) { - case UserKnownDataType.NAME: - return { - subject: t('Name'), - value: data.name, - }; - case UserKnownDataType.USERNAME: - return { - subject: t('Username'), - value: data.username, - }; - case UserKnownDataType.ID: - return { - subject: t('ID'), - value: data.id, - }; - case UserKnownDataType.IP_ADDRESS: - return { - subject: t('IP Address'), - value: data.ip_address, - }; - case UserKnownDataType.EMAIL: - return { - subject: t('Email'), - value: data.email, - subjectIcon: defined(data.email) && EMAIL_REGEX.test(data.email) && ( - - - - ), - }; - default: - return undefined; - } -} - -const StyledIconMail = styled(IconMail)` - vertical-align: middle; -`; diff --git a/static/app/components/events/contexts/user/index.spec.tsx b/static/app/components/events/contexts/user/index.spec.tsx deleted file mode 100644 index 9b81a3aa0c87d..0000000000000 --- a/static/app/components/events/contexts/user/index.spec.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import {EventFixture} from 'sentry-fixture/event'; - -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; -import {textWithMarkupMatcher} from 'sentry-test/utils'; - -import type {UserEventContextData} from 'sentry/components/events/contexts/user'; -import {UserEventContext} from 'sentry/components/events/contexts/user'; - -// the values of this mock are correct and the types need to be updated -export const userMockData = { - data: null, - email: null, - id: '', - ip_address: null, - name: null, - username: null, -} as unknown as UserEventContextData; - -export const userMetaMockData = { - id: { - '': { - chunks: [ - { - remark: 'x', - rule_id: 'project:0', - text: '', - type: 'redaction', - }, - ], - len: 9, - rem: [['project:0', 'x', 0, 0]], - }, - }, - ip_address: { - '': { - err: [ - [ - 'invalid_data', - { - reason: 'expected an ip address', - }, - ], - ], - len: 14, - rem: [['project:0', 'x', 0, 0]], - val: '', - }, - }, -}; - -const event = { - ...EventFixture(), - _meta: { - user: userMetaMockData, - }, -}; - -describe('user event context', function () { - it('display redacted data', async function () { - render(); - - expect(screen.getByText('ID')).toBeInTheDocument(); // subject - expect(screen.getByText(/redacted/)).toBeInTheDocument(); // value - await userEvent.hover(screen.getByText(/redacted/)); - expect( - await screen.findByText( - textWithMarkupMatcher( - "Removed because of a data scrubbing rule in your project's settings" - ) // Fall back case - ) - ).toBeInTheDocument(); // tooltip description - - expect(screen.getByText('IP Address')).toBeInTheDocument(); // subject - await userEvent.hover(document.body); - expect(screen.getByText('null')).toBeInTheDocument(); // value - await userEvent.hover(screen.getByText('null')); - - // The content of the first tooltip is not removed from the DOM when it is hidden - // therefore we explicitly need to wait for both tooltips to be visible - // Fixes race condition that causes flakiness https://sentry.sentry.io/issues/3974475742/?project=4857230 - await waitFor(() => { - const tooltips = screen.getAllByText( - textWithMarkupMatcher( - "Removed because of a data scrubbing rule in your project's settings" - ) - ); - - expect(tooltips).toHaveLength(2); - expect(tooltips[1]).toBeInTheDocument(); - expect(tooltips[1]).toBeInTheDocument(); - }); - }); -}); diff --git a/static/app/components/events/contexts/user/index.tsx b/static/app/components/events/contexts/user/index.tsx deleted file mode 100644 index 64a8668bfd543..0000000000000 --- a/static/app/components/events/contexts/user/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import UserAvatar from 'sentry/components/avatar/userAvatar'; -import ErrorBoundary from 'sentry/components/errorBoundary'; -import ContextBlock from 'sentry/components/events/contexts/contextBlock'; -import KeyValueList from 'sentry/components/events/interfaces/keyValueList'; -import {userContextToActor} from 'sentry/components/events/interfaces/utils'; -import type {Event} from 'sentry/types/event'; -import type {AvatarUser} from 'sentry/types/user'; -import {defined} from 'sentry/utils'; - -import { - getContextMeta, - getKnownData, - getKnownStructuredData, - getUnknownData, -} from '../utils'; - -import {getUserKnownDataDetails} from './getUserKnownDataDetails'; - -export type UserEventContextData = { - data: Record; -} & AvatarUser; - -type Props = { - data: UserEventContextData; - event: Event; - meta?: Record; -}; - -export enum UserKnownDataType { - ID = 'id', - EMAIL = 'email', - USERNAME = 'username', - IP_ADDRESS = 'ip_address', - NAME = 'name', -} - -export enum UserIgnoredDataType { - DATA = 'data', -} - -export const userKnownDataValues = [ - UserKnownDataType.ID, - UserKnownDataType.EMAIL, - UserKnownDataType.USERNAME, - UserKnownDataType.IP_ADDRESS, - UserKnownDataType.NAME, -]; - -const userIgnoredDataValues = [UserIgnoredDataType.DATA]; - -export function getKnownUserContextData({data, meta}: Pick) { - return getKnownData({ - data, - meta, - knownDataTypes: userKnownDataValues, - onGetKnownDataDetails: v => getUserKnownDataDetails(v), - }).map(v => ({ - ...v, - subjectDataTestId: `user-context-${v.key.toLowerCase()}-value`, - })); -} - -export function getUnknownUserContextData({data, meta}: Pick) { - return getUnknownData({ - allData: data, - knownKeys: [...userKnownDataValues, ...userIgnoredDataValues], - meta, - }); -} -export function UserEventContext({data, event, meta: propsMeta}: Props) { - const meta = propsMeta ?? getContextMeta(event, 'user'); - const knownData = getKnownUserContextData({data, meta}); - const knownStructuredData = getKnownStructuredData(knownData, meta); - const unknownData = getUnknownUserContextData({data, meta}); - - return ( -
-
- -
- - - {defined(data?.data) && ( - - ({ - key, - value, - subject: key, - meta: meta[key]?.[''], - }))} - isContextData - /> - - )} -
- ); -} diff --git a/static/app/components/events/contexts/utils.tsx b/static/app/components/events/contexts/utils.tsx index 79e773fca652f..69473f3bd040a 100644 --- a/static/app/components/events/contexts/utils.tsx +++ b/static/app/components/events/contexts/utils.tsx @@ -25,6 +25,7 @@ import {getRuntimeContextData} from 'sentry/components/events/contexts/knownCont import {getStateContextData} from 'sentry/components/events/contexts/knownContext/state'; import {getThreadPoolInfoContext} from 'sentry/components/events/contexts/knownContext/threadPoolInfo'; import {getTraceContextData} from 'sentry/components/events/contexts/knownContext/trace'; +import {getUserContextData} from 'sentry/components/events/contexts/knownContext/user'; import {userContextToActor} from 'sentry/components/events/interfaces/utils'; import StructuredEventData from 'sentry/components/structuredEventData'; import {t} from 'sentry/locale'; @@ -37,7 +38,6 @@ import type {AvatarUser} from 'sentry/types/user'; import {defined} from 'sentry/utils'; import commonTheme from 'sentry/utils/theme'; -import {getDefaultContextData} from './default'; import {getKnownDeviceContextData, getUnknownDeviceContextData} from './device'; import { getKnownPlatformContextData, @@ -46,7 +46,6 @@ import { KNOWN_PLATFORM_CONTEXTS, } from './platform'; import {getKnownUnityContextData, getUnknownUnityContextData} from './unity'; -import {getKnownUserContextData, getUnknownUserContextData} from './user'; /** * Generates the class name used for contexts @@ -420,10 +419,7 @@ export function getFormattedContextData({ case 'runtime': return getRuntimeContextData({data: contextValue, meta}); case 'user': - return [ - ...getKnownUserContextData({data: contextValue, meta}), - ...getUnknownUserContextData({data: contextValue, meta}), - ]; + return getUserContextData({data: contextValue, meta}); case 'gpu': return getGPUContextData({data: contextValue, meta}); case 'trace': @@ -451,7 +447,12 @@ export function getFormattedContextData({ case 'missing_instrumentation': return getMissingInstrumentationContextData({data: contextValue, meta}); default: - return getDefaultContextData(contextValue); + return getContextKeys({data: contextValue}).map(ctxKey => ({ + key: ctxKey, + subject: ctxKey, + value: contextValue[ctxKey], + meta: meta?.[ctxKey]?.[''], + })); } } /** diff --git a/static/app/components/events/interfaces/frame/contexts.spec.tsx b/static/app/components/events/interfaces/frame/contexts.spec.tsx index 2856687f03329..bd78f89022cc9 100644 --- a/static/app/components/events/interfaces/frame/contexts.spec.tsx +++ b/static/app/components/events/interfaces/frame/contexts.spec.tsx @@ -4,67 +4,8 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {DeviceEventContext} from 'sentry/components/events/contexts/device'; import {commonDisplayResolutions} from 'sentry/components/events/contexts/device/utils'; -import {UserEventContext} from 'sentry/components/events/contexts/user'; -import {FILTER_MASK} from 'sentry/constants'; import type {DeviceContext} from 'sentry/types/event'; -describe('User', function () { - it("displays filtered values but doesn't use them for avatar", function () { - const {rerender} = render( - - ); - - expect(screen.getByTestId('user-context-name-value')).toHaveTextContent(FILTER_MASK); - expect(screen.getByText('?')).toBeInTheDocument(); - - rerender( - - ); - - expect(screen.getByTestId('user-context-email-value')).toHaveTextContent(FILTER_MASK); - expect(screen.getByText('?')).toBeInTheDocument(); - - rerender( - - ); - - expect(screen.getByTestId('user-context-username-value')).toHaveTextContent( - FILTER_MASK - ); - expect(screen.getByText('?')).toBeInTheDocument(); - }); -}); - describe('Device', function () { const device: DeviceContext = { type: 'device',