Skip to content

Commit 593f2d6

Browse files
Andrew McKnightAndrew McKnight
authored andcommitted
Merge remote-tracking branch 'origin/master' into andrewmcknight/reveng-221-admin-show-of-invoices-that-are-1-difference
# Conflicts: # static/gsAdmin/views/invoiceComparison.tsx
2 parents dddffee + 080f677 commit 593f2d6

148 files changed

Lines changed: 4244 additions & 2778 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

migrations_lockfile.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ will then be regenerated, and you should be able to merge without conflicts.
77

88
discover: 0003_discover_json_field
99

10-
explore: 0008_add_trace_item_attribute_context
10+
explore: 0009_add_trace_item_attribute_value_context
1111

1212
feedback: 0007_cleanup_failed_safe_deletes
1313

src/sentry/api/endpoints/organization_events_validate.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,30 @@ class Validation:
3535

3636

3737
@dataclass(kw_only=True)
38-
class AttributeValidation(Validation):
38+
class NamedValidation(Validation):
3939
name: str
40+
41+
42+
@dataclass(kw_only=True)
43+
class AttributeValidation(NamedValidation):
4044
# None when its an error
4145
attrType: str | None
4246

4347

48+
@dataclass(kw_only=True)
49+
class QueryValidation(Validation):
50+
fields: list[AttributeValidation] = dataclass_field(default_factory=list)
51+
52+
4453
@dataclass(kw_only=True)
4554
class ValidationResponse:
4655
valid: bool
47-
dataset: list[Validation] = dataclass_field(default_factory=list)
56+
dataset: list[NamedValidation] = dataclass_field(default_factory=list)
4857
environment: list[Validation] = dataclass_field(default_factory=list)
4958
field: list[AttributeValidation] = dataclass_field(default_factory=list)
5059
orderby: list[AttributeValidation] = dataclass_field(default_factory=list)
5160
projects: list[Validation] = dataclass_field(default_factory=list)
52-
query: list[Validation | AttributeValidation] = dataclass_field(default_factory=list)
61+
query: QueryValidation
5362

5463

5564
def serialize_type(search_type: constants.SearchType) -> str:
@@ -183,7 +192,7 @@ def get(self, request: Request, organization: Organization) -> Response:
183192
if not self.has_feature(organization, request):
184193
return Response(status=400)
185194

186-
response = ValidationResponse(valid=True)
195+
response = ValidationResponse(valid=True, query=QueryValidation(valid=True, error=None))
187196

188197
try:
189198
snuba_params = self.get_snuba_params(
@@ -205,12 +214,17 @@ def get(self, request: Request, organization: Organization) -> Response:
205214
dataset = self.get_dataset(request, organization)
206215
except ParseError as error:
207216
response.valid = False
208-
response.dataset.append(Validation(valid=False, error=str(error)))
217+
response.dataset.append(
218+
NamedValidation(
219+
name=request.GET.get("dataset", "discover"), valid=False, error=str(error)
220+
)
221+
)
209222
return self.serialize_response(response)
210223

211224
if dataset not in RPC_DATASETS:
212225
response.dataset.append(
213-
Validation(
226+
NamedValidation(
227+
name=request.GET.get("dataset", "discover"),
214228
valid=True,
215229
error="This dataset is not compatible with the validate endpoint, your request may still be valid",
216230
)
@@ -230,23 +244,36 @@ def get(self, request: Request, organization: Organization) -> Response:
230244

231245
# Validate query
232246
query_string = request.GET.get("query", "")
233-
query_validity: list[AttributeValidation] = []
234247
query_attributes_to_lookup: dict[AttributeKey.Type.ValueType, list[ResolvedAttribute]] = {}
235248
query_columns = []
236249
try:
237-
# While resolve_query also runs parse_search_query, we don't need the resolved_query just want to dry-run it
238-
# to get any errors
239-
resolver.resolve_query(query_string)
240-
parsed_terms = resolver.parse_search_query(query_string)
250+
try:
251+
parsed_terms = resolver.parse_search_query(query_string)
252+
except InvalidSearchQuery as err:
253+
# If we fail to parse, try again but truncate the query to hopefully get some terms
254+
if err.extra is not None:
255+
try:
256+
parsed_terms = resolver.parse_search_query(
257+
query_string[: err.extra.get("idx", 0) - 1]
258+
)
259+
except InvalidSearchQuery:
260+
# If we fail again don't bubble the error up
261+
parsed_terms = []
262+
else:
263+
parsed_terms = []
241264
query_columns = resolver.collect_terms(parsed_terms)
242-
query_validity, query_attributes_to_lookup, valid = self.validate_columns(
265+
response.query.fields, query_attributes_to_lookup, valid = self.validate_columns(
243266
query_columns, resolver
244267
)
245268
if not valid:
246269
response.valid = valid
270+
# While resolve_query also runs parse_search_query, we don't need the resolved_query just want to dry-run it
271+
# to get any errors
272+
resolver.resolve_query(query_string)
247273
except InvalidSearchQuery as error:
248274
response.valid = False
249-
response.query.append(Validation(error=str(error), valid=False))
275+
response.query.error = str(error)
276+
response.query.valid = False
250277

251278
# Lookup unknown fields and add to validities
252279
# Combine the lookup dictionaries
@@ -288,12 +315,18 @@ def get(self, request: Request, organization: Organization) -> Response:
288315
column_validity.append(validity)
289316
if (
290317
resolved.public_alias in query_columns
291-
and validity not in query_validity
318+
and validity not in response.query.fields
292319
):
293-
query_validity.append(validity)
320+
response.query.fields.append(validity)
294321

295322
response.field.extend(column_validity)
296-
response.query.extend(query_validity)
323+
# If the response is still valid check if there's a field validity we wanna use
324+
if response.query.valid:
325+
for field in response.query.fields:
326+
if not field.valid:
327+
response.query.valid = False
328+
response.query.error = field.error
329+
break
297330

298331
# Validate orderby
299332
orderby_validity = []

src/sentry/api/endpoints/organization_trace_item_attributes.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from rest_framework import serializers
1010
from rest_framework.request import Request
1111
from rest_framework.response import Response
12+
from sentry_conventions.attributes import ATTRIBUTE_METADATA
1213
from sentry_protos.snuba.v1.endpoint_trace_item_attributes_pb2 import (
1314
TraceItemAttributeNamesRequest,
1415
TraceItemAttributeNamesResponse,
@@ -34,6 +35,7 @@
3435
from sentry.api.base import cell_silo_endpoint
3536
from sentry.api.bases import NoProjects, OrganizationEventsEndpointBase
3637
from sentry.api.endpoints.organization_trace_item_attributes_types import (
38+
TraceItemAttributeContext,
3739
TraceItemAttributeKey,
3840
TraceItemAttributeSource,
3941
)
@@ -185,6 +187,23 @@ def get_result(self, limit, cursor=None):
185187
description="Sentry [search syntax](https://docs.sentry.io/concepts/search/) to filter trace items before computing attributes.",
186188
)
187189

190+
EXPAND_QUERY_PARAM = OpenApiParameter(
191+
name="expand",
192+
location="query",
193+
required=False,
194+
many=True,
195+
type=str,
196+
enum=["context"],
197+
# Internal-only for now: gated behind the data-browsing-attribute-context
198+
# feature, so exclude it from the public OpenAPI spec.
199+
exclude=True,
200+
description=(
201+
"Optional fields to expand. Pass `context` to include the sentry "
202+
"conventions metadata (brief, examples, deprecation, etc.) for "
203+
"attributes that map to a known convention."
204+
),
205+
)
206+
188207

189208
class OrganizationTraceItemAttributesEndpointBase(OrganizationEventsEndpointBase):
190209
publish_status = {
@@ -221,6 +240,7 @@ class OrganizationTraceItemAttributesEndpointSerializer(serializers.Serializer):
221240
)
222241
substringMatch = serializers.CharField(required=False, source="substring_match")
223242
query = serializers.CharField(required=False)
243+
expand = serializers.MultipleChoiceField(choices=["context"], required=False)
224244

225245
def validate(self, attrs: Any) -> Any:
226246
if attrs.get("item_type") is None and attrs.get("dataset") is None:
@@ -277,11 +297,60 @@ def resolve_attribute_values_referrer(item_type: str) -> Referrer:
277297
raise ValueError(f"Invalid item type: {item_type}")
278298

279299

300+
def build_sentry_convention_context(
301+
public_name: str, internal_name: str
302+
) -> TraceItemAttributeContext | None:
303+
"""
304+
Build the sentry conventions context for an attribute, if it maps to a known
305+
convention. Only fields actually present in the conventions metadata are
306+
included.
307+
308+
A convention may be keyed in ``ATTRIBUTE_METADATA`` by either the public
309+
alias or the internal name (see
310+
``_update_attribute_definitions_with_deprecations`` in
311+
``search/eap/spans/attributes.py``), so we try the public name first and
312+
fall back to the internal name.
313+
"""
314+
metadata = ATTRIBUTE_METADATA.get(public_name) or ATTRIBUTE_METADATA.get(internal_name)
315+
if metadata is None:
316+
return None
317+
318+
deprecation = metadata.deprecation
319+
320+
# brief and isDeprecated are always present for a known convention.
321+
context: TraceItemAttributeContext = {
322+
"brief": metadata.brief,
323+
"isDeprecated": bool(
324+
deprecation is not None and (deprecation.status is not None or deprecation.replacement)
325+
),
326+
}
327+
328+
if metadata.additional_context:
329+
context["details"] = (
330+
list(metadata.additional_context)
331+
if isinstance(metadata.additional_context, (list, tuple))
332+
else [metadata.additional_context]
333+
)
334+
335+
if metadata.example is not None:
336+
context["examples"] = (
337+
list(metadata.example)
338+
if isinstance(metadata.example, (list, tuple))
339+
else [metadata.example]
340+
)
341+
342+
if deprecation is not None and deprecation.replacement:
343+
context["replacementAttribute"] = deprecation.replacement
344+
345+
return context
346+
347+
280348
def as_attribute_key(
281349
name: str,
282350
attr_type: Literal["string", "number", "boolean"],
283351
item_type: SupportedTraceItemType,
284352
is_proxy: bool = False,
353+
include_context: bool = False,
285354
) -> TraceItemAttributeKey:
286355
public_key, public_name, attribute_source = translate_internal_to_public_alias(
287356
name, attr_type, item_type
@@ -326,6 +395,11 @@ def as_attribute_key(
326395
if secondary_aliases:
327396
attribute_key["secondaryAliases"] = sorted(secondary_aliases)
328397

398+
if include_context and serialized_source["source_type"] == AttributeSourceType.SENTRY.value:
399+
context = build_sentry_convention_context(public_name, name)
400+
if context is not None:
401+
attribute_key["context"] = context
402+
329403
return attribute_key
330404

331405

@@ -365,6 +439,7 @@ class OrganizationTraceItemAttributesEndpoint(OrganizationTraceItemAttributesEnd
365439
ATTRIBUTE_TYPE_QUERY_PARAM,
366440
SUBSTRING_MATCH_QUERY_PARAM,
367441
SEARCH_QUERY_PARAM,
442+
EXPAND_QUERY_PARAM,
368443
CursorQueryParam,
369444
],
370445
responses={
@@ -438,6 +513,15 @@ def get(
438513
debug = request.user.is_superuser and request.GET.get("debug", False)
439514
debug_infos: list[dict] = []
440515

516+
# Only expand the sentry conventions context when explicitly requested
517+
# via `expand=context` and the feature is enabled for the org. When the
518+
# feature is disabled this is a no-op even if `expand=context` is passed.
519+
include_context = "context" in serialized.get("expand", set()) and features.has(
520+
"organizations:data-browsing-attribute-context",
521+
organization,
522+
actor=request.user,
523+
)
524+
441525
def data_fn(offset: int, limit: int) -> list[TraceItemAttributeKey]:
442526
futures = []
443527
with ContextPropagatingThreadPoolExecutor(
@@ -457,6 +541,7 @@ def data_fn(offset: int, limit: int) -> list[TraceItemAttributeKey]:
457541
column_definitions,
458542
trace_item_type,
459543
include_internal,
544+
include_context,
460545
debug=debug,
461546
)
462547
)
@@ -490,6 +575,7 @@ def query_trace_attributes(
490575
column_definitions: ColumnDefinitions,
491576
trace_item_type: SupportedTraceItemType,
492577
include_internal: bool,
578+
include_context: bool = False,
493579
debug: str | bool = False,
494580
) -> tuple[list[TraceItemAttributeKey], dict | None]:
495581
debug_info: dict | None = None
@@ -580,6 +666,7 @@ def query_trace_attributes(
580666
substring_match,
581667
aliased_attributes,
582668
all_aliased_attributes,
669+
include_context,
583670
)
584671

585672
sentry_sdk.set_context("api_response", {"attributes": attributes})
@@ -597,6 +684,7 @@ def serialize_trace_attributes(
597684
substring_match: str,
598685
aliased_attributes: list[ResolvedAttribute | ProxyResolvedAttribute],
599686
exclude_attributes: list[ResolvedAttribute | ProxyResolvedAttribute],
687+
include_context: bool = False,
600688
) -> list[TraceItemAttributeKey]:
601689
attribute_keys = {}
602690
for attribute in rpc_response.attributes:
@@ -609,6 +697,7 @@ def serialize_trace_attributes(
609697
attribute.name,
610698
attribute_type,
611699
trace_item_type,
700+
include_context=include_context,
612701
)
613702
if (
614703
not is_sentry_convention_replacement_attribute(
@@ -645,6 +734,7 @@ def serialize_trace_attributes(
645734
attribute_type,
646735
trace_item_type,
647736
is_proxy=isinstance(aliased_attr, ProxyResolvedAttribute),
737+
include_context=include_context,
648738
)
649739
if can_expose_attribute_to_api(
650740
aliased_attr.internal_name,
Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,45 @@
1-
from typing import Literal, NotRequired, TypedDict
1+
from typing import Any, Literal, NotRequired, TypedDict
22

33

44
class TraceItemAttributeSource(TypedDict):
55
source_type: Literal["sentry", "user"]
66
is_transformed_alias: NotRequired[bool]
77

88

9+
class TraceItemAttributeContext(TypedDict):
10+
"""
11+
Additional, mostly-static metadata about an attribute sourced from the
12+
sentry conventions (``sentry_conventions.attributes.ATTRIBUTE_METADATA``).
13+
14+
Only attributes that map to a known sentry convention have context, and
15+
within the context only the fields actually present in the conventions
16+
metadata are included. This is only attached when the caller passes
17+
``expand=context`` and the ``data-browsing-attribute-context`` feature is
18+
enabled.
19+
"""
20+
21+
# A short, human-readable description of the attribute. Always present for a
22+
# known convention.
23+
brief: str
24+
# Whether the convention has been deprecated. Always present for a known
25+
# convention.
26+
isDeprecated: bool
27+
# Longer-form notes that add nuance beyond the brief (e.g. caveats,
28+
# double-counting warnings). Sourced from the convention's
29+
# ``additional_context``.
30+
details: NotRequired[list[str]]
31+
# Example value(s) for the attribute, normalized to a list.
32+
examples: NotRequired[list[Any]]
33+
# The attribute that replaces this one, when deprecated.
34+
replacementAttribute: NotRequired[str]
35+
36+
937
class TraceItemAttributeKey(TypedDict):
1038
key: str
1139
name: str
1240
secondaryAliases: NotRequired[list[str]]
1341
attributeSource: TraceItemAttributeSource
1442
attributeType: Literal["string", "number", "boolean"]
43+
# Sentry conventions metadata, only present when requested via
44+
# ``expand=context`` (and gated behind the feature flag).
45+
context: NotRequired[TraceItemAttributeContext]

src/sentry/api/event_search.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2018,12 +2018,14 @@ def parse_search_query(
20182018
idx = e.column()
20192019
prefix = query[max(0, idx - 5) : idx]
20202020
suffix = query[idx : (idx + 5)]
2021-
raise InvalidSearchQuery(
2021+
err = InvalidSearchQuery(
20222022
"{} {}".format(
20232023
f"Parse error at '{prefix}{suffix}' (column {e.column():d}).",
20242024
"This is commonly caused by unmatched parentheses. Enclose any text in double quotes.",
2025-
)
2025+
),
20262026
)
2027+
err.extra = {"idx": idx}
2028+
raise err
20272029

20282030
return SearchVisitor(
20292031
config,

0 commit comments

Comments
 (0)