Skip to content

Commit b4beb8e

Browse files
Zylphrexandrewshie-sentry
authored andcommitted
feat(trace-items): Autocomplete for semver attributes (#92515)
This adds autocompletion for attributes related to semver relases. Most of these directly search postgres instead of EAP.
1 parent 66c965c commit b4beb8e

File tree

2 files changed

+315
-6
lines changed

2 files changed

+315
-6
lines changed

src/sentry/api/endpoints/organization_trace_item_attributes.py

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Callable
12
from datetime import datetime, timedelta
23
from typing import Literal
34

@@ -25,6 +26,10 @@
2526
from sentry.api.serializers import serialize
2627
from sentry.api.utils import handle_query_errors
2728
from sentry.models.organization import Organization
29+
from sentry.models.release import Release
30+
from sentry.models.releaseenvironment import ReleaseEnvironment
31+
from sentry.models.releaseprojectenvironment import ReleaseStages
32+
from sentry.models.releases.release_project import ReleaseProject
2833
from sentry.search.eap import constants
2934
from sentry.search.eap.columns import ColumnDefinitions
3035
from sentry.search.eap.ourlogs.definitions import OURLOG_DEFINITIONS
@@ -37,6 +42,13 @@
3742
translate_internal_to_public_alias,
3843
translate_to_sentry_conventions,
3944
)
45+
from sentry.search.events.constants import (
46+
RELEASE_STAGE_ALIAS,
47+
SEMVER_ALIAS,
48+
SEMVER_BUILD_ALIAS,
49+
SEMVER_PACKAGE_ALIAS,
50+
)
51+
from sentry.search.events.filter import _flip_field_sort
4052
from sentry.search.events.types import SnubaParams
4153
from sentry.snuba.referrer import Referrer
4254
from sentry.tagstore.types import TagValue
@@ -346,6 +358,16 @@ def __init__(
346358
params=snuba_params, config=SearchResolverConfig(), definitions=definitions
347359
)
348360
self.search_type, self.attribute_key = self.resolve_attribute_key(key, snuba_params)
361+
self.autocomplete_function: dict[str, Callable[[], list[TagValue]]] = (
362+
{key: self.project_id_autocomplete_function for key in self.PROJECT_ID_KEYS}
363+
| {key: self.project_slug_autocomplete_function for key in self.PROJECT_SLUG_KEYS}
364+
| {
365+
RELEASE_STAGE_ALIAS: self.release_stage_autocomplete_function,
366+
SEMVER_ALIAS: self.semver_autocomplete_function,
367+
SEMVER_BUILD_ALIAS: self.semver_build_autocomplete_function,
368+
SEMVER_PACKAGE_ALIAS: self.semver_package_autocomplete_function,
369+
}
370+
)
349371

350372
def resolve_attribute_key(
351373
self, key: str, snuba_params: SnubaParams
@@ -354,11 +376,10 @@ def resolve_attribute_key(
354376
return resolved_attr.search_type, resolved_attr.proto_definition
355377

356378
def execute(self) -> list[TagValue]:
357-
if self.key in self.PROJECT_ID_KEYS:
358-
return self.project_id_autocomplete_function()
379+
func = self.autocomplete_function.get(self.key)
359380

360-
if self.key in self.PROJECT_SLUG_KEYS:
361-
return self.project_slug_autocomplete_function()
381+
if func is not None:
382+
return func()
362383

363384
if self.search_type == "boolean":
364385
return self.boolean_autocomplete_function()
@@ -368,6 +389,146 @@ def execute(self) -> list[TagValue]:
368389

369390
return []
370391

392+
def release_stage_autocomplete_function(self):
393+
return [
394+
TagValue(
395+
key=self.key,
396+
value=stage.value,
397+
times_seen=None,
398+
first_seen=None,
399+
last_seen=None,
400+
)
401+
for stage in ReleaseStages
402+
if not self.query or self.query in stage.value
403+
]
404+
405+
def semver_autocomplete_function(self):
406+
versions = Release.objects.filter(version__contains="@" + self.query)
407+
408+
project_ids = self.snuba_params.project_ids
409+
if project_ids:
410+
release_projects = ReleaseProject.objects.filter(project_id__in=project_ids)
411+
versions = versions.filter(id__in=release_projects.values_list("release_id", flat=True))
412+
413+
environment_ids = self.snuba_params.environment_ids
414+
if environment_ids:
415+
release_environments = ReleaseEnvironment.objects.filter(
416+
environment_id__in=environment_ids
417+
)
418+
versions = versions.filter(
419+
id__in=release_environments.values_list("release_id", flat=True)
420+
)
421+
422+
order_by = map(_flip_field_sort, Release.SEMVER_COLS + ["package"])
423+
versions = versions.filter_to_semver() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet
424+
versions = versions.annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet
425+
versions = versions.order_by(*order_by)
426+
427+
seen = set()
428+
formatted_versions = []
429+
# We want to format versions here in a way that makes sense for autocomplete. So we
430+
# - Only include package if we think the user entered a package
431+
# - Exclude build number, since it's not used as part of filtering
432+
# When we don't include package, this can result in duplicate version numbers, so we
433+
# also de-dupe here. This can result in less than 1000 versions returned, but we
434+
# typically use very few values so this works ok.
435+
for version in versions.values_list("version", flat=True)[:1000]:
436+
formatted_version = version.split("@", 1)[1]
437+
formatted_version = formatted_version.split("+", 1)[0]
438+
if formatted_version in seen:
439+
continue
440+
441+
seen.add(formatted_version)
442+
formatted_versions.append(
443+
TagValue(
444+
key=self.key,
445+
value=formatted_version,
446+
times_seen=None,
447+
first_seen=None,
448+
last_seen=None,
449+
)
450+
)
451+
452+
return formatted_versions
453+
454+
def semver_build_autocomplete_function(self):
455+
build = self.query if self.query else ""
456+
if not build.endswith("*"):
457+
build += "*"
458+
459+
organization_id = self.snuba_params.organization_id
460+
assert organization_id is not None
461+
462+
versions = Release.objects.filter_by_semver_build(
463+
organization_id,
464+
"exact",
465+
build,
466+
self.snuba_params.project_ids,
467+
)
468+
469+
environment_ids = self.snuba_params.environment_ids
470+
if environment_ids:
471+
release_environments = ReleaseEnvironment.objects.filter(
472+
environment_id__in=environment_ids
473+
)
474+
versions = versions.filter(
475+
id__in=release_environments.values_list("release_id", flat=True)
476+
)
477+
478+
builds = (
479+
versions.values_list("build_code", flat=True).distinct().order_by("build_code")[:1000]
480+
)
481+
482+
return [
483+
TagValue(
484+
key=self.key,
485+
value=build,
486+
times_seen=None,
487+
first_seen=None,
488+
last_seen=None,
489+
)
490+
for build in builds
491+
]
492+
493+
def semver_package_autocomplete_function(self):
494+
packages = (
495+
Release.objects.filter(
496+
organization_id=self.snuba_params.organization_id, package__startswith=self.query
497+
)
498+
.values_list("package")
499+
.distinct()
500+
)
501+
502+
versions = Release.objects.filter(
503+
organization_id=self.snuba_params.organization_id,
504+
package__in=packages,
505+
id__in=ReleaseProject.objects.filter(
506+
project_id__in=self.snuba_params.project_ids
507+
).values_list("release_id", flat=True),
508+
).annotate_prerelease_column() # type: ignore[attr-defined] # mypy doesn't know about ReleaseQuerySet
509+
510+
environment_ids = self.snuba_params.environment_ids
511+
if environment_ids:
512+
release_environments = ReleaseEnvironment.objects.filter(
513+
environment_id__in=environment_ids
514+
)
515+
versions = versions.filter(
516+
id__in=release_environments.values_list("release_id", flat=True)
517+
)
518+
519+
packages = versions.values_list("package", flat=True).distinct().order_by("package")[:1000]
520+
521+
return [
522+
TagValue(
523+
key=self.key,
524+
value=package,
525+
times_seen=None,
526+
first_seen=None,
527+
last_seen=None,
528+
)
529+
for package in packages
530+
]
531+
371532
def boolean_autocomplete_function(self) -> list[TagValue]:
372533
return [
373534
TagValue(

tests/snuba/api/endpoints/test_organization_trace_item_attributes.py

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77

88
from sentry.exceptions import InvalidSearchQuery
99
from sentry.search.eap.types import SupportedTraceItemType
10-
from sentry.testutils.cases import APITestCase, BaseSpansTestCase, OurLogTestCase, SnubaTestCase
10+
from sentry.testutils.cases import (
11+
APITestCase,
12+
BaseSpansTestCase,
13+
OurLogTestCase,
14+
SnubaTestCase,
15+
SpanTestCase,
16+
)
1117
from sentry.testutils.helpers import parse_link_header
1218
from sentry.testutils.helpers.datetime import before_now
1319
from sentry.testutils.helpers.options import override_options
@@ -523,7 +529,7 @@ def test_attribute_values(self):
523529

524530

525531
class OrganizationTraceItemAttributeValuesEndpointSpansTest(
526-
OrganizationTraceItemAttributeValuesEndpointBaseTest, BaseSpansTestCase
532+
OrganizationTraceItemAttributeValuesEndpointBaseTest, BaseSpansTestCase, SpanTestCase
527533
):
528534
feature_flags = {"organizations:visibility-explore-view": True}
529535
item_type = SupportedTraceItemType.SPANS
@@ -1271,3 +1277,145 @@ def test_pagination(self):
12711277
"lastSeen": mock.ANY,
12721278
},
12731279
]
1280+
1281+
def test_autocomplete_release_semver_attributes(self):
1282+
release_1 = self.create_release(version="[email protected]+121")
1283+
release_2 = self.create_release(version="[email protected]+122")
1284+
self.store_spans(
1285+
[
1286+
self.create_span(
1287+
{"sentry_tags": {"release": release_1.version}},
1288+
start_ts=before_now(days=0, minutes=10),
1289+
),
1290+
self.create_span(
1291+
{"sentry_tags": {"release": release_2.version}},
1292+
start_ts=before_now(days=0, minutes=10),
1293+
),
1294+
],
1295+
is_eap=True,
1296+
)
1297+
1298+
response = self.do_request(key="release")
1299+
assert response.status_code == 200
1300+
assert response.data == [
1301+
{
1302+
"count": mock.ANY,
1303+
"key": "release",
1304+
"value": release,
1305+
"name": release,
1306+
"firstSeen": mock.ANY,
1307+
"lastSeen": mock.ANY,
1308+
}
1309+
for release in ["[email protected]+121", "[email protected]+122"]
1310+
]
1311+
1312+
response = self.do_request(key="release", query={"substringMatch": "121"})
1313+
assert response.status_code == 200
1314+
assert response.data == [
1315+
{
1316+
"count": mock.ANY,
1317+
"key": "release",
1318+
"value": "[email protected]+121",
1319+
"name": "[email protected]+121",
1320+
"firstSeen": mock.ANY,
1321+
"lastSeen": mock.ANY,
1322+
}
1323+
]
1324+
1325+
response = self.do_request(key="release.stage")
1326+
assert response.status_code == 200
1327+
assert response.data == [
1328+
{
1329+
"count": mock.ANY,
1330+
"key": "release.stage",
1331+
"value": stage,
1332+
"name": stage,
1333+
"firstSeen": mock.ANY,
1334+
"lastSeen": mock.ANY,
1335+
}
1336+
for stage in ["adopted", "low_adoption", "replaced"]
1337+
]
1338+
1339+
response = self.do_request(key="release.stage", query={"substringMatch": "adopt"})
1340+
assert response.status_code == 200
1341+
assert response.data == [
1342+
{
1343+
"count": mock.ANY,
1344+
"key": "release.stage",
1345+
"value": stage,
1346+
"name": stage,
1347+
"firstSeen": mock.ANY,
1348+
"lastSeen": mock.ANY,
1349+
}
1350+
for stage in ["adopted", "low_adoption"]
1351+
]
1352+
1353+
response = self.do_request(key="release.version")
1354+
assert response.status_code == 200
1355+
assert response.data == [
1356+
{
1357+
"count": mock.ANY,
1358+
"key": "release.version",
1359+
"value": version,
1360+
"name": version,
1361+
"firstSeen": mock.ANY,
1362+
"lastSeen": mock.ANY,
1363+
}
1364+
for version in ["1.2.3", "2.2.4"]
1365+
]
1366+
1367+
response = self.do_request(key="release.version", query={"substringMatch": "2"})
1368+
assert response.status_code == 200
1369+
assert response.data == [
1370+
{
1371+
"count": mock.ANY,
1372+
"key": "release.version",
1373+
"value": version,
1374+
"name": version,
1375+
"firstSeen": mock.ANY,
1376+
"lastSeen": mock.ANY,
1377+
}
1378+
for version in ["2.2.4"]
1379+
]
1380+
1381+
response = self.do_request(key="release.package")
1382+
assert response.status_code == 200
1383+
assert response.data == [
1384+
{
1385+
"count": mock.ANY,
1386+
"key": "release.package",
1387+
"value": version,
1388+
"name": version,
1389+
"firstSeen": mock.ANY,
1390+
"lastSeen": mock.ANY,
1391+
}
1392+
for version in ["foo", "qux"]
1393+
]
1394+
1395+
response = self.do_request(key="release.package", query={"substringMatch": "q"})
1396+
assert response.status_code == 200
1397+
assert response.data == [
1398+
{
1399+
"count": mock.ANY,
1400+
"key": "release.package",
1401+
"value": version,
1402+
"name": version,
1403+
"firstSeen": mock.ANY,
1404+
"lastSeen": mock.ANY,
1405+
}
1406+
for version in ["qux"]
1407+
]
1408+
1409+
response = self.do_request(key="release.build")
1410+
assert response.status_code == 200
1411+
assert response.data == [
1412+
{
1413+
"count": mock.ANY,
1414+
"key": "release.build",
1415+
"value": version,
1416+
"name": version,
1417+
"firstSeen": mock.ANY,
1418+
"lastSeen": mock.ANY,
1419+
}
1420+
for version in ["121", "122"]
1421+
]

0 commit comments

Comments
 (0)