Skip to content

Commit 7780004

Browse files
authored
Enable queries using project slug as filter and groupby in Metrics API (#69111)
1 parent 5f79cb2 commit 7780004

File tree

12 files changed

+666
-12
lines changed

12 files changed

+666
-12
lines changed

src/sentry/sentry_metrics/querying/data/api.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
from collections.abc import Sequence
22
from datetime import datetime
3-
from typing import cast
43

54
from snuba_sdk import MetricsQuery, MetricsScope, Rollup
65

76
from sentry import features
87
from sentry.models.environment import Environment
98
from sentry.models.organization import Organization
109
from sentry.models.project import Project
11-
from sentry.sentry_metrics.querying.data.execution import QueryExecutor, QueryResult
10+
from sentry.sentry_metrics.querying.data.execution import QueryExecutor
11+
from sentry.sentry_metrics.querying.data.mapping.mapper import MapperConfig, Project2ProjectIDMapper
1212
from sentry.sentry_metrics.querying.data.parsing import QueryParser
13+
from sentry.sentry_metrics.querying.data.postprocessing.base import run_post_processing_steps
14+
from sentry.sentry_metrics.querying.data.postprocessing.remapping import QueryRemappingStep
1315
from sentry.sentry_metrics.querying.data.preparation.base import (
1416
IntermediateQuery,
17+
PreparationStep,
1518
run_preparation_steps,
1619
)
20+
from sentry.sentry_metrics.querying.data.preparation.mapping import QueryMappingStep
1721
from sentry.sentry_metrics.querying.data.preparation.units_normalization import (
1822
UnitsNormalizationStep,
1923
)
2024
from sentry.sentry_metrics.querying.data.query import MQLQueriesResult, MQLQuery
2125
from sentry.sentry_metrics.querying.types import QueryType
2226

27+
DEFAULT_MAPPINGS: MapperConfig = MapperConfig().add(Project2ProjectIDMapper)
28+
2329

2430
def run_queries(
2531
mql_queries: Sequence[MQLQuery],
@@ -62,12 +68,15 @@ def run_queries(
6268
)
6369
)
6470

65-
preparation_steps = []
71+
preparation_steps: list[PreparationStep] = []
72+
6673
if features.has(
6774
"organizations:ddm-metrics-api-unit-normalization", organization=organization, actor=None
6875
):
6976
preparation_steps.append(UnitsNormalizationStep())
7077

78+
preparation_steps.append(QueryMappingStep(projects, DEFAULT_MAPPINGS))
79+
7180
# We run a series of preparation steps which operate on the entire list of queries.
7281
intermediate_queries = run_preparation_steps(intermediate_queries, *preparation_steps)
7382

@@ -77,6 +86,7 @@ def run_queries(
7786
executor.schedule(intermediate_query=intermediate_query, query_type=query_type)
7887

7988
results = executor.execute()
89+
results = run_post_processing_steps(results, QueryRemappingStep(projects))
8090

8191
# We wrap the result in a class that exposes some utils methods to operate on results.
82-
return MQLQueriesResult(cast(list[QueryResult], results))
92+
return MQLQueriesResult(results)

src/sentry/sentry_metrics/querying/data/execution.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Mapping, Sequence
2-
from dataclasses import dataclass, replace
2+
from dataclasses import dataclass, field, replace
33
from datetime import datetime
44
from enum import Enum
55
from typing import Any, Union, cast
@@ -11,6 +11,7 @@
1111
from sentry.models.organization import Organization
1212
from sentry.models.project import Project
1313
from sentry.sentry_metrics.querying.constants import SNUBA_QUERY_LIMIT
14+
from sentry.sentry_metrics.querying.data.mapping.mapper import Mapper
1415
from sentry.sentry_metrics.querying.data.preparation.base import IntermediateQuery
1516
from sentry.sentry_metrics.querying.data.utils import adjust_time_bounds_with_interval
1617
from sentry.sentry_metrics.querying.errors import (
@@ -145,6 +146,7 @@ class ScheduledQuery:
145146
unit_family: UnitFamily | None = None
146147
unit: MeasurementUnit | None = None
147148
scaling_factor: float | None = None
149+
mappers: list[Mapper] = field(default_factory=list)
148150

149151
def initialize(
150152
self,
@@ -318,7 +320,7 @@ def _align_date_range(cls, metrics_query: MetricsQuery) -> tuple[MetricsQuery, i
318320
return metrics_query, None
319321

320322

321-
@dataclass(frozen=True)
323+
@dataclass
322324
class QueryResult:
323325
"""
324326
Represents the result of a ScheduledQuery containing its associated series and totals results.
@@ -445,12 +447,24 @@ def modified_end(self) -> datetime:
445447

446448
@property
447449
def series(self) -> Sequence[Mapping[str, Any]]:
450+
if "series" not in self.result:
451+
return []
448452
return self.result["series"]["data"]
449453

454+
@series.setter
455+
def series(self, value: Sequence[Mapping[str, Any]]) -> None:
456+
self.result["series"]["data"] = value
457+
450458
@property
451459
def totals(self) -> Sequence[Mapping[str, Any]]:
460+
if "totals" not in self.result:
461+
return []
452462
return self.result["totals"]["data"]
453463

464+
@totals.setter
465+
def totals(self, value: Sequence[Mapping[str, Any]]) -> None:
466+
self.result["totals"]["data"] = value
467+
454468
@property
455469
def meta(self) -> Sequence[Mapping[str, str]]:
456470
# By default, we extract the metadata from the totals query, if that is not there we extract from the series
@@ -464,7 +478,11 @@ def group_bys(self) -> list[str]:
464478
# that we can correctly render groups in case they are not returned from the db because of missing data.
465479
#
466480
# Sorting of the groups is done to maintain consistency across function calls.
467-
return sorted(UsedGroupBysVisitor().visit(self._any_query().metrics_query.query))
481+
scheduled_query = self._any_query()
482+
mappers = [mapper for mapper in scheduled_query.mappers if mapper.applied_on_groupby]
483+
return sorted(
484+
UsedGroupBysVisitor(mappers=mappers).visit(scheduled_query.metrics_query.query)
485+
)
468486

469487
@property
470488
def interval(self) -> int | None:
@@ -774,7 +792,7 @@ def _execution_loop(self):
774792
while continue_execution:
775793
continue_execution = self._bulk_execute()
776794

777-
def execute(self) -> Sequence[QueryResult]:
795+
def execute(self) -> list[QueryResult]:
778796
"""
779797
Executes the scheduled queries in the execution loop.
780798
@@ -798,7 +816,7 @@ def execute(self) -> Sequence[QueryResult]:
798816
"Not all queries were executed in the execution loop"
799817
)
800818

801-
return cast(Sequence[QueryResult], self._query_results)
819+
return cast(list[QueryResult], self._query_results)
802820

803821
def schedule(self, intermediate_query: IntermediateQuery, query_type: QueryType):
804822
"""
@@ -813,6 +831,7 @@ def schedule(self, intermediate_query: IntermediateQuery, query_type: QueryType)
813831
unit_family=intermediate_query.unit_family,
814832
unit=intermediate_query.unit,
815833
scaling_factor=intermediate_query.scaling_factor,
834+
mappers=intermediate_query.mappers,
816835
)
817836

818837
# In case the user chooses to run also a series query, we will duplicate the query and chain it after totals.

src/sentry/sentry_metrics/querying/data/mapping/__init__.py

Whitespace-only changes.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import abc
2+
from collections.abc import Sequence
3+
from typing import Any, TypeVar
4+
5+
from sentry.models.project import Project
6+
7+
8+
class Mapper(abc.ABC):
9+
from_key: str = ""
10+
to_key: str = ""
11+
applied_on_groupby: bool = False
12+
13+
def __init__(self):
14+
# This exists to satisfy mypy, which complains otherwise
15+
self.map: dict[Any, Any] = {}
16+
17+
def __hash__(self):
18+
return hash((self.from_key, self.to_key))
19+
20+
@abc.abstractmethod
21+
def forward(self, projects: Sequence[Project], value: Any) -> Any:
22+
return value
23+
24+
@abc.abstractmethod
25+
def backward(self, projects: Sequence[Project], value: Any) -> Any:
26+
return value
27+
28+
29+
TMapper = TypeVar("TMapper", bound=Mapper)
30+
31+
32+
class MapperConfig:
33+
def __init__(self):
34+
self.mappers: set[type[Mapper]] = set()
35+
36+
def add(self, mapper: type[Mapper]) -> "MapperConfig":
37+
self.mappers.add(mapper)
38+
return self
39+
40+
def get(self, from_key: str | None = None, to_key: str | None = None) -> type[Mapper] | None:
41+
for mapper in self.mappers:
42+
if mapper.from_key == from_key:
43+
return mapper
44+
if mapper.to_key == to_key:
45+
return mapper
46+
return None
47+
48+
49+
def get_or_create_mapper(
50+
mapper_config: MapperConfig,
51+
mappers: list[Mapper],
52+
from_key: str | None = None,
53+
to_key: str | None = None,
54+
) -> Mapper | None:
55+
# retrieve the mapper type that is applicable for the given key
56+
mapper_class = mapper_config.get(from_key=from_key, to_key=to_key)
57+
# check if a mapper of the type already exists
58+
if mapper_class:
59+
for mapper in mappers:
60+
if mapper_class == type(mapper):
61+
# if a mapper already exists, return the existing mapper
62+
return mapper
63+
else:
64+
# if no mapper exists yet, instantiate the object and append it to the mappers list
65+
mapper_instance = mapper_class()
66+
mappers.append(mapper_instance)
67+
return mapper_instance
68+
else:
69+
# if no mapper is configured for the key, return None
70+
return None
71+
72+
73+
class Project2ProjectIDMapper(Mapper):
74+
from_key: str = "project"
75+
to_key: str = "project_id"
76+
77+
def __init__(self):
78+
super().__init__()
79+
80+
def forward(self, projects: Sequence[Project], value: str) -> int:
81+
if value not in self.map:
82+
self.map[value] = None
83+
for project in projects:
84+
if project.slug == value:
85+
self.map[value] = project.id
86+
return self.map[value]
87+
88+
def backward(self, projects: Sequence[Project], value: int) -> str:
89+
if value not in self.map:
90+
for project in projects:
91+
if project.id == value:
92+
self.map[value] = project.slug
93+
94+
return self.map[value]

src/sentry/sentry_metrics/querying/data/postprocessing/__init__.py

Whitespace-only changes.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from abc import ABC, abstractmethod
2+
3+
from sentry.sentry_metrics.querying.data.execution import QueryResult
4+
5+
6+
class PostProcessingStep(ABC):
7+
"""
8+
Represents an abstract step that post-processes a collection of QueryResult objects.
9+
10+
The post-processing of these objects might include transforming them or just obtaining some intermediate data that
11+
is useful to compute other things before returning the results.
12+
"""
13+
14+
@abstractmethod
15+
def run(self, query_results: list[QueryResult]) -> list[QueryResult]:
16+
"""
17+
Runs the post-processing steps on a list of query results.
18+
19+
Returns:
20+
A list of post-processed query results.
21+
"""
22+
raise NotImplementedError
23+
24+
25+
def run_post_processing_steps(query_results: list[QueryResult], *steps) -> list[QueryResult]:
26+
"""
27+
Takes a series of query results and steps and runs the post-processing steps one after each other in order they are
28+
supplied in.
29+
30+
Returns:
31+
A list of query results after running the post-processing steps.
32+
"""
33+
for step in steps:
34+
if isinstance(step, PostProcessingStep):
35+
query_results = step.run(query_results=query_results)
36+
37+
return query_results
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from collections.abc import Mapping, Sequence
2+
from copy import deepcopy
3+
from typing import Any, cast
4+
5+
from sentry.models.project import Project
6+
from sentry.sentry_metrics.querying.data.execution import QueryResult
7+
from sentry.sentry_metrics.querying.data.mapping.mapper import Mapper
8+
from sentry.sentry_metrics.querying.data.postprocessing.base import PostProcessingStep
9+
10+
11+
class QueryRemappingStep(PostProcessingStep):
12+
def __init__(self, projects: Sequence[Project]):
13+
self.projects = projects
14+
15+
def run(self, query_results: list[QueryResult]) -> list[QueryResult]:
16+
for query_result in query_results:
17+
if (
18+
query_result.totals is not None
19+
and query_result.totals_query is not None
20+
and len(query_result.totals) > 0
21+
):
22+
query_result.totals = self._unmap_data(
23+
query_result.totals, query_result.totals_query.mappers
24+
)
25+
if (
26+
query_result.series is not None
27+
and query_result.series_query is not None
28+
and len(query_result.series) > 0
29+
):
30+
query_result.series = self._unmap_data(
31+
query_result.series, query_result.series_query.mappers
32+
)
33+
34+
return query_results
35+
36+
def _unmap_data(
37+
self, data: Sequence[Mapping[str, Any]], mappers: list[Mapper]
38+
) -> Sequence[Mapping[str, Any]]:
39+
unmapped_data: list[dict[str, Any]] = cast(list[dict[str, Any]], deepcopy(data))
40+
for element in unmapped_data:
41+
updated_element = dict()
42+
keys_to_delete = []
43+
for result_key in element.keys():
44+
for mapper in mappers:
45+
if mapper.to_key == result_key and mapper.applied_on_groupby:
46+
original_value = mapper.backward(self.projects, element[result_key])
47+
updated_element[mapper.from_key] = original_value
48+
keys_to_delete.append(result_key)
49+
50+
for key in keys_to_delete:
51+
del element[key]
52+
element.update(updated_element)
53+
54+
return cast(Sequence[Mapping[str, Any]], unmapped_data)

src/sentry/sentry_metrics/querying/data/preparation/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from abc import ABC, abstractmethod
2-
from dataclasses import dataclass
2+
from dataclasses import dataclass, field
33

44
from snuba_sdk import MetricsQuery
55

6+
from sentry.sentry_metrics.querying.data.mapping.mapper import Mapper
67
from sentry.sentry_metrics.querying.types import QueryOrder
78
from sentry.sentry_metrics.querying.units import MeasurementUnit, UnitFamily
89

@@ -27,6 +28,7 @@ class IntermediateQuery:
2728
unit_family: UnitFamily | None = None
2829
unit: MeasurementUnit | None = None
2930
scaling_factor: float | None = None
31+
mappers: list[Mapper] = field(default_factory=list)
3032

3133

3234
class PreparationStep(ABC):
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from collections.abc import Sequence
2+
from dataclasses import replace
3+
4+
from sentry.models.project import Project
5+
from sentry.sentry_metrics.querying.data.mapping.mapper import MapperConfig
6+
from sentry.sentry_metrics.querying.data.preparation.base import IntermediateQuery, PreparationStep
7+
from sentry.sentry_metrics.querying.visitors.query_expression import MapperVisitor
8+
9+
10+
class QueryMappingStep(PreparationStep):
11+
def __init__(self, projects: Sequence[Project], mapper_config: MapperConfig):
12+
self.projects = projects
13+
self.mapper_config = mapper_config
14+
15+
def _get_mapped_intermediate_query(
16+
self, intermediate_query: IntermediateQuery
17+
) -> IntermediateQuery:
18+
visitor = MapperVisitor(self.projects, self.mapper_config)
19+
mapped_query = visitor.visit(intermediate_query.metrics_query.query)
20+
21+
return replace(
22+
intermediate_query,
23+
metrics_query=intermediate_query.metrics_query.set_query(mapped_query),
24+
mappers=visitor.mappers,
25+
)
26+
27+
def run(self, intermediate_queries: list[IntermediateQuery]) -> list[IntermediateQuery]:
28+
mapped_intermediate_queries = []
29+
30+
for intermediate_query in intermediate_queries:
31+
mapped_intermediate_queries.append(
32+
self._get_mapped_intermediate_query(intermediate_query)
33+
)
34+
35+
return mapped_intermediate_queries

0 commit comments

Comments
 (0)