Browse Source

feat(metrics layer): Create entry point for new metrics layer (#56492)

This PR is responsible for creating the base for the new metrics layer.
A new directory called `sentry/snuba/metrics_layer/` was created house
the code of the new metrics layer. As a first step, the `run_query()`
function was added which does some basic tasks like create tenant_ids,
process intervals, and resolve the metrics query. This function will
eventually replace `datasource.py::get_series()`. Currently, this
function is not complete and doesn't return any results.

A new test was created to validate resolving the metric query's MRI
utility function.

Depends on: 
* https://github.com/getsentry/snuba-sdk/pull/120
* https://github.com/getsentry/snuba-sdk/pull/121
Enoch Tang 1 year ago
parent
commit
3489d7c017

+ 1 - 0
.github/CODEOWNERS

@@ -26,6 +26,7 @@
 /tests/sentry/sentry_metrics/                            @getsentry/owners-snuba
 /src/sentry/snuba/metrics/                               @getsentry/owners-snuba @getsentry/telemetry-experience
 /src/sentry/snuba/metrics/query.py                       @getsentry/owners-snuba @getsentry/telemetry-experience
+/src/sentry/snuba/metrics_layer/                         @getsentry/owners-snuba
 /src/sentry/search/events/datasets/metrics_layer.py      @getsentry/owners-snuba
 
 ## Event Ingestion

+ 1 - 1
requirements-base.txt

@@ -63,7 +63,7 @@ sentry-kafka-schemas>=0.1.26
 sentry-redis-tools>=0.1.7
 sentry-relay>=0.8.30
 sentry-sdk>=1.31.0
-snuba-sdk>=2.0.1
+snuba-sdk>=2.0.2
 simplejson>=3.17.6
 sqlparse>=0.4.4
 statsd>=3.3

+ 1 - 1
requirements-dev-frozen.txt

@@ -176,7 +176,7 @@ sentry-sdk==1.31.0
 simplejson==3.17.6
 six==1.16.0
 sniffio==1.2.0
-snuba-sdk==2.0.1
+snuba-sdk==2.0.2
 sortedcontainers==2.4.0
 soupsieve==2.3.2.post1
 sqlparse==0.4.4

+ 1 - 1
requirements-frozen.txt

@@ -117,7 +117,7 @@ sentry-relay==0.8.30
 sentry-sdk==1.31.0
 simplejson==3.17.6
 six==1.16.0
-snuba-sdk==2.0.1
+snuba-sdk==2.0.2
 soupsieve==2.3.2.post1
 sqlparse==0.4.4
 statsd==3.3

+ 0 - 0
src/sentry/snuba/metrics_layer/__init__.py


+ 90 - 0
src/sentry/snuba/metrics_layer/query.py

@@ -0,0 +1,90 @@
+from typing import Sequence
+
+from snuba_sdk import Request
+from snuba_sdk.metrics_query import MetricsQuery
+
+from sentry.models import Project
+from sentry.sentry_metrics.utils import resolve_weak, string_to_use_case_id
+from sentry.snuba.metrics.fields.base import _get_entity_of_metric_mri, org_id_from_projects
+from sentry.snuba.metrics.naming_layer.mapping import get_mri, get_public_name_from_mri
+from sentry.snuba.metrics.utils import to_intervals
+
+
+def run_query(request: Request) -> None:
+    """
+    Entrypoint for executing a metrics query in Snuba.
+
+    First iteration:
+    The purpose of this function is to eventually replace datasource.py::get_series().
+    As a first iteration, this function will only support single timeseries metric queries.
+    This means that for now, other queries such as total, formula, or meta queries
+    will not be supported. Additionally, the first iteration will only support
+    querying raw metrics (no derived). This means that each call to this function will only
+    resolve into a single request (and single entity) to the Snuba API.
+    """
+    metrics_query = request.query
+    assert isinstance(metrics_query, MetricsQuery)
+
+    assert len(metrics_query.scope.org_ids) == 1  # Initially only allow 1 org id
+    organization_id = metrics_query.scope.org_ids[0]
+    tenant_ids = request.tenant_ids or {"organization_id": organization_id}
+    if "use_case_id" not in tenant_ids and metrics_query.scope.use_case_id is not None:
+        tenant_ids["use_case_id"] = metrics_query.scope.use_case_id
+    request.tenant_ids = tenant_ids
+
+    # Process intervals
+    assert metrics_query.rollup is not None
+    if metrics_query.rollup.interval:
+        start, end, _num_intervals = to_intervals(
+            metrics_query.start, metrics_query.end, metrics_query.rollup.interval
+        )
+        metrics_query = metrics_query.set_start(start).set_end(end)
+
+    # Resolves MRI or public name in metrics_query
+    resolved_metrics_query = resolve_metrics_query(metrics_query)
+    request.query = resolved_metrics_query
+
+    # TODO: executing MetricQuery validation and serialization, result formatting, etc.
+
+
+def resolve_metrics_query(metrics_query: MetricsQuery) -> MetricsQuery:
+    assert metrics_query.query is not None
+    metric = metrics_query.query.metric
+    scope = metrics_query.scope
+
+    if not metric.public_name and metric.mri:
+        public_name = get_public_name_from_mri(metric.mri)
+        metrics_query = metrics_query.set_query(
+            metrics_query.query.set_metric(metrics_query.query.metric.set_public_name(public_name))
+        )
+    elif not metric.mri and metric.public_name:
+        mri = get_mri(metric.public_name)
+        metrics_query = metrics_query.set_query(
+            metrics_query.query.set_metric(metrics_query.query.metric.set_mri(mri))
+        )
+
+    projects = get_projects(scope.project_ids)
+    use_case_id = string_to_use_case_id(scope.use_case_id)
+    metric_id = resolve_weak(
+        use_case_id, org_id_from_projects(projects), metrics_query.query.metric.mri
+    )  # only support raw metrics for now
+    metrics_query = metrics_query.set_query(
+        metrics_query.query.set_metric(metrics_query.query.metric.set_id(metric_id))
+    )
+
+    if not metrics_query.query.metric.entity:
+        entity = _get_entity_of_metric_mri(
+            projects, metrics_query.query.metric.mri, use_case_id
+        )  # TODO: will need reimplement this as this runs old metrics query
+        metrics_query = metrics_query.set_query(
+            metrics_query.query.set_metric(metrics_query.query.metric.set_entity(entity.value))
+        )
+    return metrics_query
+
+
+def get_projects(project_ids: Sequence[int]) -> Sequence[Project]:
+    try:
+        projects = list(Project.objects.filter(id__in=project_ids))
+        return projects
+    except Project.DoesNotExist:
+        raise Exception("Requested project does not exist")

+ 0 - 0
tests/sentry/snuba/metrics/test_metrics_query_layer/__init__.py


+ 47 - 0
tests/sentry/snuba/metrics/test_metrics_query_layer/test_metrics_query_layer.py

@@ -0,0 +1,47 @@
+"""
+Metrics Service Layer Tests for Performance
+"""
+
+import pytest
+from snuba_sdk.metrics_query import MetricsQuery
+from snuba_sdk.timeseries import Metric, MetricsScope, Timeseries
+
+from sentry.sentry_metrics import indexer
+from sentry.sentry_metrics.use_case_id_registry import UseCaseID
+from sentry.snuba.metrics.naming_layer import TransactionMRI
+from sentry.snuba.metrics_layer.query import resolve_metrics_query
+from sentry.testutils.cases import BaseMetricsLayerTestCase, TestCase
+from sentry.testutils.helpers.datetime import freeze_time
+
+pytestmark = pytest.mark.sentry_metrics
+
+
+@freeze_time(BaseMetricsLayerTestCase.MOCK_DATETIME)
+class MetricsQueryLayerTest(BaseMetricsLayerTestCase, TestCase):
+    @property
+    def now(self):
+        return BaseMetricsLayerTestCase.MOCK_DATETIME
+
+    def test_resolve_metrics_query(self):
+        self.store_performance_metric(
+            name=TransactionMRI.DURATION.value,
+            project_id=self.project.id,
+            tags={},
+            value=1,
+        )
+        metrics_query = MetricsQuery(
+            query=Timeseries(Metric(mri=TransactionMRI.DURATION.value), aggregate="count"),
+            scope=MetricsScope(
+                org_ids=[self.project.organization_id],
+                project_ids=[self.project.id],
+                use_case_id=UseCaseID.TRANSACTIONS.value,
+            ),
+        )
+
+        resolved_metrics_query = resolve_metrics_query(metrics_query)
+        assert resolved_metrics_query.query.metric.public_name == "transaction.duration"
+        assert resolved_metrics_query.query.metric.id == indexer.resolve(
+            UseCaseID.TRANSACTIONS,
+            self.project.organization_id,
+            TransactionMRI.DURATION.value,
+        )