Skip to content

feat(event-tags): Add fields in project settings to set highlights #69058

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

Closed
wants to merge 20 commits into from
Closed
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
12 changes: 12 additions & 0 deletions src/sentry/api/endpoints/project_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from sentry.grouping.enhancer.exceptions import InvalidEnhancerConfig
from sentry.grouping.fingerprinting import FingerprintingRules, InvalidFingerprintingConfig
from sentry.ingest.inbound_filters import FilterTypes
from sentry.issues.highlights import HighlightContextField
from sentry.lang.native.sources import (
InvalidSourcesError,
parse_backfill_sources,
Expand Down Expand Up @@ -120,6 +121,8 @@ class ProjectMemberSerializer(serializers.Serializer):
"performanceIssueCreationRate",
"performanceIssueCreationThroughPlatform",
"performanceIssueSendToPlatform",
"highlightContext",
"highlightTags",
]
)
class ProjectAdminSerializer(ProjectMemberSerializer):
Expand Down Expand Up @@ -179,6 +182,8 @@ class ProjectAdminSerializer(ProjectMemberSerializer):
dataScrubberDefaults = serializers.BooleanField(required=False)
sensitiveFields = ListField(child=serializers.CharField(), required=False)
safeFields = ListField(child=serializers.CharField(), required=False)
highlightContext = HighlightContextField(required=False)
highlightTags = ListField(child=serializers.CharField(), required=False)
storeCrashReports = serializers.IntegerField(
min_value=-1, max_value=STORE_CRASH_REPORTS_MAX, required=False, allow_null=True
)
Expand Down Expand Up @@ -639,6 +644,13 @@ def put(self, request: Request, project) -> Response:
if result.get("safeFields") is not None:
if project.update_option("sentry:safe_fields", result["safeFields"]):
changed_proj_settings["sentry:safe_fields"] = result["safeFields"]
if features.has("organizations:event-tags-tree-ui", project.organization):
if result.get("highlightContext") is not None:
if project.update_option("sentry:highlight_context", result["highlightContext"]):
changed_proj_settings["sentry:highlight_context"] = result["highlightContext"]
if result.get("highlightTags") is not None:
if project.update_option("sentry:highlight_tags", result["highlightTags"]):
changed_proj_settings["sentry:highlight_tags"] = result["highlightTags"]
if result.get("storeCrashReports") is not None:
if project.get_option("sentry:store_crash_reports") != result["storeCrashReports"]:
changed_proj_settings["sentry:store_crash_reports"] = result["storeCrashReports"]
Expand Down
10 changes: 10 additions & 0 deletions src/sentry/api/serializers/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from sentry.eventstore.models import DEFAULT_SUBJECT_TEMPLATE
from sentry.features.base import ProjectFeature
from sentry.ingest.inbound_filters import FilterTypes
from sentry.issues.highlights import get_highlight_preset_for_project
from sentry.lang.native.sources import parse_sources, redact_source_secrets
from sentry.lang.native.utils import convert_crashreport_count
from sentry.models.environment import EnvironmentProject
Expand Down Expand Up @@ -906,6 +907,7 @@ def get_attrs(
"org": orgs[str(item.organization_id)],
"options": options_by_project[item.id],
"processing_issues": processing_issues_by_project.get(item.id, 0),
"highlight_preset": get_highlight_preset_for_project(item),
}
)
return attrs
Expand Down Expand Up @@ -945,6 +947,14 @@ def serialize(
"verifySSL": bool(attrs["options"].get("sentry:verify_ssl", False)),
"scrubIPAddresses": bool(attrs["options"].get("sentry:scrub_ip_address", False)),
"scrapeJavaScript": bool(attrs["options"].get("sentry:scrape_javascript", True)),
"highlightTags": attrs["options"].get(
"sentry:highlight_tags",
attrs["highlight_preset"].get("tags", []),
),
"highlightContext": attrs["options"].get(
"sentry:highlight_context",
attrs["highlight_preset"].get("context", {}),
),
"groupingConfig": self.get_value_with_default(attrs, "sentry:grouping_config"),
"groupingEnhancements": self.get_value_with_default(
attrs, "sentry:grouping_enhancements"
Expand Down
72 changes: 72 additions & 0 deletions src/sentry/issues/highlights.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from collections.abc import Mapping
from typing import TypedDict

from jsonschema import Draft7Validator
from jsonschema.exceptions import ValidationError as SchemaValidationError
from rest_framework import serializers
from rest_framework.serializers import ValidationError

from sentry.models.project import Project
from sentry.utils.json import JSONData
from sentry.utils.platform_categories import BACKEND, FRONTEND, MOBILE

HIGHLIGHT_CONTEXT_SCHEMA: JSONData = {
"type": "object",
"patternProperties": {"^.*$": {"type": "array", "items": {"type": "string"}}},
"additionalProperties": False,
}


class HighlightContextField(serializers.Field):
def to_internal_value(self, data):
if data is None:
return

if data == "" or data == {} or data == []:
return {}

v = Draft7Validator(HIGHLIGHT_CONTEXT_SCHEMA)
try:
v.validate(data)
except SchemaValidationError as e:
raise ValidationError(e.message)

return data


class HighlightPreset(TypedDict):
tags: list[str]
context: Mapping[str, list[str]]


SENTRY_TAGS = ["handled", "level", "release", "environment"]

BACKEND_HIGHLIGHTS: HighlightPreset = {
"tags": SENTRY_TAGS + ["url", "transaction", "status_code"],
"context": {"trace": ["trace_id"], "runtime": ["name", "version"]},
}
FRONTEND_HIGHLIGHTS: HighlightPreset = {
"tags": SENTRY_TAGS + ["url", "transaction", "browser", "replayId", "user"],
"context": {"browser": ["name"], "user": ["email"]},
}
MOBILE_HIGHLIGHTS: HighlightPreset = {
"tags": SENTRY_TAGS + ["mobile", "main_thread"],
"context": {"profile": ["profile_id"], "app": ["name"], "device": ["family"]},
}

FALLBACK_HIGLIGHTS: HighlightPreset = {
"tags": SENTRY_TAGS,
"context": {"user": ["email"], "trace": ["trace_id"]},
}


def get_highlight_preset_for_project(project: Project) -> HighlightPreset:
if not project.platform or project.platform == "other":
return FALLBACK_HIGLIGHTS
elif project.platform in FRONTEND:
return FRONTEND_HIGHLIGHTS
elif project.platform in BACKEND:
return BACKEND_HIGHLIGHTS
elif project.platform in MOBILE:
return MOBILE_HIGHLIGHTS
return FALLBACK_HIGLIGHTS
2 changes: 2 additions & 0 deletions src/sentry/models/options/project_option.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"sentry:builtin_symbol_sources",
"sentry:symbol_sources",
"sentry:sensitive_fields",
"sentry:highlight_tags",
"sentry:highlight_context",
"sentry:csp_ignored_sources_defaults",
"sentry:csp_ignored_sources",
"sentry:default_environment",
Expand Down
16 changes: 0 additions & 16 deletions static/app/components/events/contextSummary/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import styled from '@emotion/styled';

import {EventDataSection} from 'sentry/components/events/eventDataSection';
import {useHasNewTagsUI} from 'sentry/components/events/eventTags/util';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Event} from 'sentry/types/event';
Expand Down Expand Up @@ -53,7 +51,6 @@ type Props = {
};

function ContextSummary({event}: Props) {
const hasNewTagsUI = useHasNewTagsUI();
if (objectIsEmpty(event.contexts)) {
return null;
}
Expand Down Expand Up @@ -117,19 +114,6 @@ function ContextSummary({event}: Props) {
return <Component key={key} {...props} />;
});

if (hasNewTagsUI) {
// TODO(Leander): When a design is confirmed, move this to HighlightsDataSection
return (
<EventDataSection
title={t('Highlighted Event Data')}
data-test-id="highlighted-event-data"
type="highlighted-event-data"
>
<Wrapper>{contexts}</Wrapper>
</EventDataSection>
);
}

return <Wrapper>{contexts}</Wrapper>;
}

Expand Down
113 changes: 70 additions & 43 deletions static/app/components/events/contexts/contextCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {Link} from 'react-router';
import styled from '@emotion/styled';
import startCase from 'lodash/startCase';

import {
getContextMeta,
Expand All @@ -10,7 +11,7 @@ import {AnnotatedTextErrors} from 'sentry/components/events/meta/annotatedText/a
import Panel from 'sentry/components/panels/panel';
import {StructuredData} from 'sentry/components/structuredEventData';
import {space} from 'sentry/styles/space';
import type {Group, Project} from 'sentry/types';
import type {Group, KeyValueListDataItem, Project} from 'sentry/types';
import type {Event} from 'sentry/types/event';
import {defined, objectIsEmpty} from 'sentry/utils';
import useOrganization from 'sentry/utils/useOrganization';
Expand All @@ -24,7 +25,71 @@ interface ContextCardProps {
value?: Record<string, any>;
}

function ContextCard({alias, event, type, project, value = {}}: ContextCardProps) {
interface ContextCardContentConfig {
disableErrors?: boolean;
includeAliasInSubject?: boolean;
}

export interface ContextCardContentProps {
item: KeyValueListDataItem;
meta: Record<string, any>;
alias?: string;
config?: ContextCardContentConfig;
}

export function ContextCardContent({
item,
alias,
meta,
config,
...props
}: ContextCardContentProps) {
const {key: contextKey, subject, value: contextValue, action = {}} = item;
if (contextKey === 'type') {
return null;
}
const contextMeta = meta?.[contextKey];
const contextErrors = contextMeta?.['']?.err ?? [];
const hasErrors = contextErrors.length > 0 && !config?.disableErrors;

const dataComponent = (
<StructuredData
value={contextValue}
depth={0}
maxDefaultDepth={0}
meta={contextMeta}
withAnnotatedText
withOnlyFormattedText
/>
);

const contextSubject =
config?.includeAliasInSubject && alias ? `${startCase(alias)}: ${subject}` : subject;

return (
<ContextContent hasErrors={hasErrors} {...props}>
<ContextSubject>{contextSubject}</ContextSubject>
<ContextValue hasErrors={hasErrors} className="ctx-row-value">
{defined(action?.link) ? (
<Link to={action.link}>{dataComponent}</Link>
) : (
dataComponent
)}
</ContextValue>
<ContextErrors>
<AnnotatedTextErrors errors={contextErrors} />
</ContextErrors>
</ContextContent>
);
}

export default function ContextCard({
alias,
event,
type,
project,
value = {},
}: ContextCardProps) {
const organization = useOrganization();
if (objectIsEmpty(value)) {
return null;
Expand All @@ -39,43 +104,9 @@ function ContextCard({alias, event, type, project, value = {}}: ContextCardProps
project,
});

const content = contextItems.map(
({key, subject, value: contextValue, action = {}}, i) => {
if (key === 'type') {
return null;
}
const contextMeta = meta?.[key];
const contextErrors = contextMeta?.['']?.err ?? [];
const hasErrors = contextErrors.length > 0;

const dataComponent = (
<StructuredData
value={contextValue}
depth={0}
maxDefaultDepth={0}
meta={contextMeta}
withAnnotatedText
withOnlyFormattedText
/>
);

return (
<ContextContent key={i} hasErrors={hasErrors}>
<ContextSubject>{subject}</ContextSubject>
<ContextValue hasErrors={hasErrors}>
{defined(action?.link) ? (
<Link to={action.link}>{dataComponent}</Link>
) : (
dataComponent
)}
</ContextValue>
<ContextErrors>
<AnnotatedTextErrors errors={contextErrors} />
</ContextErrors>
</ContextContent>
);
}
);
const content = contextItems.map((item, i) => (
<ContextCardContent key={`context-card-${i}`} meta={meta} item={item} />
));

return (
<Card>
Expand Down Expand Up @@ -127,10 +158,6 @@ const ContextSubject = styled('div')`
const ContextValue = styled(ContextSubject)<{hasErrors: boolean}>`
color: ${p => (p.hasErrors ? 'inherit' : p.theme.textColor)};
grid-column: span ${p => (p.hasErrors ? 1 : 2)};
/* justify-content: space-between;
display: inline-flex; */
`;

const ContextErrors = styled('div')``;

export default ContextCard;
22 changes: 20 additions & 2 deletions static/app/components/events/contexts/contextDataSection.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
import {useRef} from 'react';
import styled from '@emotion/styled';

import {getOrderedContextItems} from 'sentry/components/events/contexts';
import ContextCard from 'sentry/components/events/contexts/contextCard';
import {CONTEXT_DOCS_LINK} from 'sentry/components/events/contextSummary/utils';
import {EventDataSection} from 'sentry/components/events/eventDataSection';
import {useIssueDetailsColumnCount} from 'sentry/components/events/eventTags/util';
import ExternalLink from 'sentry/components/links/externalLink';
import {t, tct} from 'sentry/locale';
import type {Event, Group, Project} from 'sentry/types';

interface ContextDataSectionProps {
cards: React.ReactNode[];
event: Event;
group?: Group;
project?: Project;
}

function ContextDataSection({cards}: ContextDataSectionProps) {
function ContextDataSection({event, group, project}: ContextDataSectionProps) {
const containerRef = useRef<HTMLDivElement>(null);
const columnCount = useIssueDetailsColumnCount(containerRef);
const columns: React.ReactNode[] = [];

const cards = getOrderedContextItems(event).map(([alias, contextValue]) => (
<ContextCard
key={alias}
type={contextValue.type}
alias={alias}
value={contextValue}
event={event}
group={group}
project={project}
/>
));

const columnSize = Math.ceil(cards.length / columnCount);
for (let i = 0; i < cards.length; i += columnSize) {
columns.push(<CardColumn key={i}>{cards.slice(i, i + columnSize)}</CardColumn>);
Expand Down
Loading
Loading