Browse Source

feat(profiling): add Dataset and QueryBuilder for profile functions metrics (#71342)

Add Dataset and QueryBuilder utilities for profile functions metrics.

This will allow us to query the metrics data that we have started to
ingest into the generic metrics platform.
Francesco Vigliaturo 9 months ago
parent
commit
7c650aff10

+ 8 - 0
src/sentry/search/events/builder/__init__.py

@@ -21,6 +21,11 @@ from .profile_functions import (  # NOQA
     ProfileFunctionsTimeseriesQueryBuilder,
     ProfileTopFunctionsTimeseriesQueryBuilder,
 )
+from .profile_functions_metrics import (  # NOQA
+    ProfileFunctionsMetricsQueryBuilder,
+    TimeseriesProfileFunctionsMetricsQueryBuilder,
+    TopProfileFunctionsMetricsQueryBuilder,
+)
 from .profiles import ProfilesQueryBuilder, ProfilesTimeseriesQueryBuilder  # NOQA
 from .sessions import SessionsV2QueryBuilder, TimeseriesSessionsV2QueryBuilder  # NOQA
 from .spans_indexed import (  # NOQA
@@ -54,6 +59,9 @@ __all__ = [
     "ProfileFunctionsQueryBuilder",
     "ProfileFunctionsTimeseriesQueryBuilder",
     "ProfileTopFunctionsTimeseriesQueryBuilder",
+    "ProfileFunctionsMetricsQueryBuilder",
+    "TimeseriesProfileFunctionsMetricsQueryBuilder",
+    "TopProfileFunctionsMetricsQueryBuilder",
     "SessionsV2QueryBuilder",
     "SpansIndexedQueryBuilder",
     "TimeseriesSpanIndexedQueryBuilder",

+ 6 - 0
src/sentry/search/events/builder/discover.py

@@ -54,6 +54,9 @@ from sentry.search.events.datasets.metrics import MetricsDatasetConfig
 from sentry.search.events.datasets.metrics_layer import MetricsLayerDatasetConfig
 from sentry.search.events.datasets.metrics_summaries import MetricsSummariesDatasetConfig
 from sentry.search.events.datasets.profile_functions import ProfileFunctionsDatasetConfig
+from sentry.search.events.datasets.profile_functions_metrics import (
+    ProfileFunctionsMetricsDatasetConfig,
+)
 from sentry.search.events.datasets.profiles import ProfilesDatasetConfig
 from sentry.search.events.datasets.sessions import SessionsDatasetConfig
 from sentry.search.events.datasets.spans_indexed import SpansIndexedDatasetConfig
@@ -93,6 +96,7 @@ class BaseQueryBuilder:
     uuid_fields = {"id", "trace", "profile.id", "replay.id"}
     function_alias_prefix: str | None = None
     spans_metrics_builder = False
+    profile_functions_metrics_builder = False
     entity: Entity | None = None
 
     def get_middle(self):
@@ -364,6 +368,8 @@ class BaseQueryBuilder:
                 # if self.builder_config.use_metrics_layer:
                 #     self.config = SpansMetricsLayerDatasetConfig(self)
                 self.config = SpansMetricsDatasetConfig(self)
+            elif self.profile_functions_metrics_builder:
+                self.config = ProfileFunctionsMetricsDatasetConfig(self)
             elif self.builder_config.use_metrics_layer:
                 self.config = MetricsLayerDatasetConfig(self)
             else:

+ 7 - 4
src/sentry/search/events/builder/metrics.py

@@ -82,6 +82,7 @@ class MetricsQueryBuilder(QueryBuilder):
     organization_column: str = "organization_id"
 
     column_remapping = {}
+    default_metric_tags = constants.DEFAULT_METRIC_TAGS
 
     def __init__(
         self,
@@ -362,6 +363,8 @@ class MetricsQueryBuilder(QueryBuilder):
             return UseCaseID.SPANS
         elif self.is_performance:
             return UseCaseID.TRANSACTIONS
+        elif self.profile_functions_metrics_builder:
+            return UseCaseID.PROFILES
         else:
             return UseCaseID.SESSIONS
 
@@ -659,9 +662,9 @@ class MetricsQueryBuilder(QueryBuilder):
 
     def resolve_tag_value(self, value: str) -> int | str | None:
         # We only use the indexer for alerts queries
-        if self.is_alerts_query and not self.use_metrics_layer:
-            return self.resolve_metric_index(value)
-        return value
+        if self.is_performance or self.use_metrics_layer or self.profile_functions_metrics_builder:
+            return value
+        return self.resolve_metric_index(value)
 
     def resolve_tag_key(self, value: str) -> int | str | None:
         # some tag keys needs to be remapped to a different column name
@@ -669,7 +672,7 @@ class MetricsQueryBuilder(QueryBuilder):
         value = self.column_remapping.get(value, value)
 
         if self.use_default_tags:
-            if value in constants.DEFAULT_METRIC_TAGS:
+            if value in self.default_metric_tags:
                 return self.resolve_metric_index(value)
             else:
                 raise IncompatibleMetricsQuery(f"{value} is not a tag in the metrics dataset")

+ 59 - 0
src/sentry/search/events/builder/profile_functions_metrics.py

@@ -0,0 +1,59 @@
+from sentry.search.events.builder import (
+    MetricsQueryBuilder,
+    TimeseriesMetricQueryBuilder,
+    TopMetricsQueryBuilder,
+)
+from sentry.search.events.types import SelectType
+
+
+class ProfileFunctionsMetricsQueryBuilder(MetricsQueryBuilder):
+    requires_organization_condition = True
+    profile_functions_metrics_builder = True
+
+    column_remapping = {
+        # We want to remap `message` to `name` for the free
+        # text search use case so that it searches the `name`
+        # (function name) when the user performs a free text search
+        "message": "name",
+    }
+    default_metric_tags = {
+        "project_id",
+        "transaction",
+        "fingerprint",
+        "name",
+        "package",
+        "is_application",
+        "platform",
+        "environment",
+        "release",
+        "os_name",
+        "os_version",
+    }
+
+    @property
+    def use_default_tags(self) -> bool:
+        return True
+
+    def get_field_type(self, field: str) -> str | None:
+        if field in self.meta_resolver_map:
+            return self.meta_resolver_map[field]
+        return None
+
+    def resolve_select(
+        self, selected_columns: list[str] | None, equations: list[str] | None
+    ) -> list[SelectType]:
+        if selected_columns and "transaction" in selected_columns:
+            self.has_transaction = True  # if always true can we skip this?
+        return super().resolve_select(selected_columns, equations)
+
+
+class TimeseriesProfileFunctionsMetricsQueryBuilder(
+    ProfileFunctionsMetricsQueryBuilder, TimeseriesMetricQueryBuilder
+):
+    pass
+
+
+class TopProfileFunctionsMetricsQueryBuilder(
+    TimeseriesProfileFunctionsMetricsQueryBuilder, TopMetricsQueryBuilder
+):
+    pass

+ 3 - 0
src/sentry/search/events/constants.py

@@ -342,6 +342,9 @@ SPAN_METRICS_MAP = {
     "mobile.frames_delay": "g:spans/mobile.frames_delay@second",
     "messaging.message.receive.latency": SPAN_MESSAGING_LATENCY,
 }
+PROFILE_METRICS_MAP = {
+    "function.duration": "d:profiles/function.duration@millisecond",
+}
 # 50 to match the size of tables in the UI + 1 for pagination reasons
 METRICS_MAX_LIMIT = 101
 

+ 2 - 2
src/sentry/search/events/datasets/base.py

@@ -1,6 +1,6 @@
 import abc
 from collections.abc import Callable, Mapping
-from typing import Any
+from typing import Any, ClassVar
 
 from snuba_sdk import OrderBy
 
@@ -13,7 +13,7 @@ from sentry.search.events.types import SelectType, WhereType
 class DatasetConfig(abc.ABC):
     custom_threshold_columns: set[str] = set()
     non_nullable_keys: set[str] = set()
-    missing_function_error = InvalidSearchQuery
+    missing_function_error: ClassVar[type[Exception]] = InvalidSearchQuery
 
     @property
     @abc.abstractmethod

+ 509 - 0
src/sentry/search/events/datasets/profile_functions_metrics.py

@@ -0,0 +1,509 @@
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping
+from datetime import datetime
+
+from snuba_sdk import Column, Function, OrderBy
+
+from sentry.api.event_search import SearchFilter
+from sentry.exceptions import IncompatibleMetricsQuery, InvalidSearchQuery
+from sentry.search.events import builder, constants, fields
+from sentry.search.events.constants import PROJECT_ALIAS, PROJECT_NAME_ALIAS
+from sentry.search.events.datasets import field_aliases, filter_aliases, function_aliases
+from sentry.search.events.datasets.base import DatasetConfig
+from sentry.search.events.types import SelectType, WhereType
+
+
+class ProfileFunctionsMetricsDatasetConfig(DatasetConfig):
+    missing_function_error = IncompatibleMetricsQuery
+
+    def __init__(self, builder: builder.ProfileFunctionsMetricsQueryBuilder):
+        self.builder = builder
+
+    def resolve_mri(self, value: str) -> Column:
+        return Column(constants.PROFILE_METRICS_MAP[value])
+
+    @property
+    def search_filter_converter(
+        self,
+    ) -> Mapping[str, Callable[[SearchFilter], WhereType | None]]:
+        return {
+            PROJECT_ALIAS: self._project_slug_filter_converter,
+            PROJECT_NAME_ALIAS: self._project_slug_filter_converter,
+        }
+
+    @property
+    def orderby_converter(self) -> Mapping[str, OrderBy]:
+        return {}
+
+    @property
+    def field_alias_converter(self) -> Mapping[str, Callable[[str], SelectType]]:
+        return {
+            PROJECT_ALIAS: self._resolve_project_slug_alias,
+            PROJECT_NAME_ALIAS: self._resolve_project_slug_alias,
+        }
+
+    def _project_slug_filter_converter(self, search_filter: SearchFilter) -> WhereType | None:
+        return filter_aliases.project_slug_converter(self.builder, search_filter)
+
+    def _resolve_project_slug_alias(self, alias: str) -> SelectType:
+        return field_aliases.resolve_project_slug_alias(self.builder, alias)
+
+    def resolve_metric(self, value: str) -> int:
+        # "function.duration" --> "d:profiles/function.duration@millisecond"
+        metric_id = self.builder.resolve_metric_index(
+            constants.PROFILE_METRICS_MAP.get(value, value)
+        )
+        # If it's still None its not a custom measurement
+        if metric_id is None:
+            raise IncompatibleMetricsQuery(f"Metric: {value} could not be resolved")
+        self.builder.metric_ids.add(metric_id)
+        return metric_id
+
+    def _resolve_cpm(
+        self,
+        args: Mapping[str, str | Column | SelectType | int | float],
+        alias: str | None,
+        extra_condition: Function | None = None,
+    ) -> SelectType:
+        assert (
+            self.builder.params.end is not None and self.builder.params.start is not None
+        ), f"params.end: {self.builder.params.end} - params.start: {self.builder.params.start}"
+        interval = (self.builder.params.end - self.builder.params.start).total_seconds()
+
+        base_condition = Function(
+            "equals",
+            [
+                Column("metric_id"),
+                self.resolve_metric("function.duration"),
+            ],
+        )
+        if extra_condition:
+            condition = Function("and", [base_condition, extra_condition])
+        else:
+            condition = base_condition
+
+        return Function(
+            "divide",
+            [
+                Function(
+                    "countIf",
+                    [
+                        Column("value"),
+                        condition,
+                    ],
+                ),
+                Function("divide", [interval, 60]),
+            ],
+            alias,
+        )
+
+    def _resolve_cpm_cond(
+        self,
+        args: Mapping[str, str | Column | SelectType | int | float | datetime],
+        alias: str | None,
+        cond: str,
+    ) -> SelectType:
+        timestmp = args["timestamp"]
+        if cond == "greater":
+            assert isinstance(self.builder.params.end, datetime) and isinstance(
+                timestmp, datetime
+            ), f"params.end: {self.builder.params.end} - timestmp: {timestmp}"
+            interval = (self.builder.params.end - timestmp).total_seconds()
+            # interval = interval
+        elif cond == "less":
+            assert isinstance(self.builder.params.start, datetime) and isinstance(
+                timestmp, datetime
+            ), f"params.start: {self.builder.params.start} - timestmp: {timestmp}"
+            interval = (timestmp - self.builder.params.start).total_seconds()
+        else:
+            raise InvalidSearchQuery(f"Unsupported condition for cpm: {cond}")
+
+        metric_id_condition = Function(
+            "equals", [Column("metric_id"), self.resolve_metric("function.duration")]
+        )
+
+        return Function(
+            "divide",
+            [
+                Function(
+                    "countIf",
+                    [
+                        Column("value"),
+                        Function(
+                            "and",
+                            [
+                                metric_id_condition,
+                                Function(
+                                    cond,
+                                    [
+                                        Column("timestamp"),
+                                        args["timestamp"],
+                                    ],
+                                ),
+                            ],
+                        ),  # close and condition
+                    ],
+                ),
+                Function("divide", [interval, 60]),
+            ],
+            alias,
+        )
+
+    def _resolve_cpm_delta(
+        self,
+        args: Mapping[str, str | Column | SelectType | int | float],
+        alias: str,
+    ) -> SelectType:
+        return Function(
+            "minus",
+            [
+                self._resolve_cpm_cond(args, None, "greater"),
+                self._resolve_cpm_cond(args, None, "less"),
+            ],
+            alias,
+        )
+
+    def _resolve_regression_score(
+        self,
+        args: Mapping[str, str | Column | SelectType | int | float | datetime],
+        alias: str | None = None,
+    ) -> SelectType:
+        return Function(
+            "minus",
+            [
+                Function(
+                    "multiply",
+                    [
+                        self._resolve_cpm_cond(args, alias, "greater"),
+                        function_aliases.resolve_metrics_percentile(
+                            args=args,
+                            alias=alias,
+                            extra_conditions=[
+                                Function("greater", [Column("timestamp"), args["timestamp"]])
+                            ],
+                        ),
+                    ],
+                ),
+                Function(
+                    "multiply",
+                    [
+                        self._resolve_cpm_cond(args, alias, "less"),
+                        function_aliases.resolve_metrics_percentile(
+                            args=args,
+                            alias=alias,
+                            extra_conditions=[
+                                Function("less", [Column("timestamp"), args["timestamp"]])
+                            ],
+                        ),
+                    ],
+                ),
+            ],
+            alias,
+        )
+
+    @property
+    def function_converter(self) -> Mapping[str, fields.MetricsFunction]:
+        """While the final functions in clickhouse must have their -Merge combinators in order to function, we don't
+        need to add them here since snuba has a FunctionMapper that will add it for us. Basically it turns expressions
+        like quantiles(0.9)(value) into quantilesMerge(0.9)(percentiles)
+        Make sure to update METRIC_FUNCTION_LIST_BY_TYPE when adding functions here, can't be a dynamic list since the
+        Metric Layer will actually handle which dataset each function goes to
+        """
+        resolve_metric_id = {
+            "name": "metric_id",
+            "fn": lambda args: self.resolve_metric(args["column"]),
+        }
+        function_converter = {
+            function.name: function
+            for function in [
+                fields.MetricsFunction(
+                    "count",
+                    snql_distribution=lambda args, alias: Function(
+                        "countIf",
+                        [
+                            Column("value"),
+                            Function(
+                                "equals",
+                                [
+                                    Column("metric_id"),
+                                    self.resolve_metric("function.duration"),
+                                ],
+                            ),
+                        ],
+                        alias,
+                    ),
+                    default_result_type="integer",
+                ),
+                fields.MetricsFunction(
+                    "cpm",  # calls per minute
+                    snql_distribution=lambda args, alias: self._resolve_cpm(args, alias),
+                    default_result_type="number",
+                ),
+                fields.MetricsFunction(
+                    "cpm_before",
+                    required_args=[fields.TimestampArg("timestamp")],
+                    snql_distribution=lambda args, alias: self._resolve_cpm_cond(
+                        args, alias, "less"
+                    ),
+                    default_result_type="number",
+                ),
+                fields.MetricsFunction(
+                    "cpm_after",
+                    required_args=[fields.TimestampArg("timestamp")],
+                    snql_distribution=lambda args, alias: self._resolve_cpm_cond(
+                        args, alias, "greater"
+                    ),
+                    default_result_type="number",
+                ),
+                fields.MetricsFunction(
+                    "cpm_delta",
+                    required_args=[fields.TimestampArg("timestamp")],
+                    snql_distribution=self._resolve_cpm_delta,
+                    default_result_type="number",
+                ),
+                fields.MetricsFunction(
+                    "percentile",
+                    required_args=[
+                        fields.with_default(
+                            "function.duration",
+                            fields.MetricArg("column", allowed_columns=["function.duration"]),
+                        ),
+                        fields.NumberRange("percentile", 0, 1),
+                    ],
+                    calculated_args=[resolve_metric_id],
+                    snql_distribution=function_aliases.resolve_metrics_percentile,
+                    is_percentile=True,
+                    result_type_fn=self.reflective_result_type(),
+                    default_result_type="duration",
+                ),
+                fields.MetricsFunction(
+                    "p50",
+                    optional_args=[
+                        fields.with_default(
+                            "function.duration",
+                            fields.MetricArg(
+                                "column",
+                                allowed_columns=["function.duration"],
+                                allow_custom_measurements=False,
+                            ),
+                        ),
+                    ],
+                    calculated_args=[resolve_metric_id],
+                    snql_distribution=lambda args, alias: function_aliases.resolve_metrics_percentile(
+                        args=args, alias=alias, fixed_percentile=0.50
+                    ),
+                    is_percentile=True,
+                    default_result_type="duration",
+                ),
+                fields.MetricsFunction(
+                    "p75",
+                    optional_args=[
+                        fields.with_default(
+                            "function.duration",
+                            fields.MetricArg(
+                                "column",
+                                allowed_columns=["function.duration"],
+                                allow_custom_measurements=False,
+                            ),
+                        ),
+                    ],
+                    calculated_args=[resolve_metric_id],
+                    snql_distribution=lambda args, alias: function_aliases.resolve_metrics_percentile(
+                        args=args, alias=alias, fixed_percentile=0.75
+                    ),
+                    is_percentile=True,
+                    default_result_type="duration",
+                ),
+                fields.MetricsFunction(
+                    "p95",
+                    optional_args=[
+                        fields.with_default(
+                            "function.duration",
+                            fields.MetricArg(
+                                "column",
+                                allowed_columns=["function.duration"],
+                                allow_custom_measurements=False,
+                            ),
+                        ),
+                    ],
+                    calculated_args=[resolve_metric_id],
+                    snql_distribution=lambda args, alias: function_aliases.resolve_metrics_percentile(
+                        args=args, alias=alias, fixed_percentile=0.95
+                    ),
+                    is_percentile=True,
+                    default_result_type="duration",
+                ),
+                fields.MetricsFunction(
+                    "p99",
+                    optional_args=[
+                        fields.with_default(
+                            "function.duration",
+                            fields.MetricArg(
+                                "column",
+                                allowed_columns=["function.duration"],
+                                allow_custom_measurements=False,
+                            ),
+                        ),
+                    ],
+                    calculated_args=[resolve_metric_id],
+                    snql_distribution=lambda args, alias: function_aliases.resolve_metrics_percentile(
+                        args=args, alias=alias, fixed_percentile=0.99
+                    ),
+                    is_percentile=True,
+                    default_result_type="duration",
+                ),
+                fields.MetricsFunction(
+                    "avg",
+                    optional_args=[
+                        fields.with_default(
+                            "function.duration",
+                            fields.MetricArg(
+                                "column",
+                                allowed_columns=["function.duration"],
+                            ),
+                        ),
+                    ],
+                    snql_metric_layer=lambda args, alias: Function(
+                        "avg",
+                        [self.resolve_mri(args["column"])],
+                        alias,
+                    ),
+                    result_type_fn=self.reflective_result_type(),
+                    default_result_type="duration",
+                ),
+                fields.MetricsFunction(
+                    "sum",
+                    optional_args=[
+                        fields.with_default(
+                            "function.duration",
+                            fields.MetricArg(
+                                "column",
+                                allowed_columns=["function.duration"],
+                                allow_custom_measurements=False,
+                            ),
+                        ),
+                    ],
+                    calculated_args=[resolve_metric_id],
+                    snql_distribution=lambda args, alias: Function(
+                        "sumIf",
+                        [
+                            Column("value"),
+                            Function("equals", [Column("metric_id"), args["metric_id"]]),
+                        ],
+                        alias,
+                    ),
+                    default_result_type="duration",
+                ),
+                fields.MetricsFunction(
+                    "percentile_before",
+                    required_args=[
+                        fields.TimestampArg("timestamp"),
+                        fields.NumberRange("percentile", 0, 1),
+                    ],
+                    optional_args=[
+                        fields.with_default(
+                            "function.duration",
+                            fields.MetricArg(
+                                "column",
+                                allowed_columns=["function.duration"],
+                                allow_custom_measurements=False,
+                            ),
+                        ),
+                    ],
+                    calculated_args=[resolve_metric_id],
+                    snql_distribution=lambda args, alias: function_aliases.resolve_metrics_percentile(
+                        args=args,
+                        alias=alias,
+                        extra_conditions=[
+                            Function("less", [Column("timestamp"), args["timestamp"]])
+                        ],
+                    ),
+                    is_percentile=True,
+                    default_result_type="duration",
+                ),
+                fields.MetricsFunction(
+                    "percentile_after",
+                    required_args=[
+                        fields.TimestampArg("timestamp"),
+                        fields.NumberRange("percentile", 0, 1),
+                    ],
+                    optional_args=[
+                        fields.with_default(
+                            "function.duration",
+                            fields.MetricArg(
+                                "column",
+                                allowed_columns=["function.duration"],
+                                allow_custom_measurements=False,
+                            ),
+                        ),
+                    ],
+                    calculated_args=[resolve_metric_id],
+                    snql_distribution=lambda args, alias: function_aliases.resolve_metrics_percentile(
+                        args=args,
+                        alias=alias,
+                        extra_conditions=[
+                            Function("greater", [Column("timestamp"), args["timestamp"]])
+                        ],
+                    ),
+                    is_percentile=True,
+                    default_result_type="duration",
+                ),
+                fields.MetricsFunction(
+                    "percentile_delta",
+                    required_args=[
+                        fields.TimestampArg("timestamp"),
+                        fields.NumberRange("percentile", 0, 1),
+                    ],
+                    optional_args=[
+                        fields.with_default(
+                            "function.duration",
+                            fields.MetricArg(
+                                "column",
+                                allowed_columns=["function.duration"],
+                                allow_custom_measurements=False,
+                            ),
+                        ),
+                    ],
+                    calculated_args=[resolve_metric_id],
+                    snql_distribution=lambda args, alias: Function(
+                        "minus",
+                        [
+                            function_aliases.resolve_metrics_percentile(
+                                args=args,
+                                alias=alias,
+                                extra_conditions=[
+                                    Function("greater", [Column("timestamp"), args["timestamp"]])
+                                ],
+                            ),
+                            function_aliases.resolve_metrics_percentile(
+                                args=args,
+                                alias=alias,
+                                extra_conditions=[
+                                    Function("less", [Column("timestamp"), args["timestamp"]])
+                                ],
+                            ),
+                        ],
+                        alias,
+                    ),
+                    is_percentile=True,
+                    default_result_type="duration",
+                ),
+                fields.MetricsFunction(
+                    "regression_score",
+                    required_args=[
+                        fields.MetricArg(
+                            "column",
+                            allowed_columns=["function.duration"],
+                            allow_custom_measurements=False,
+                        ),
+                        fields.TimestampArg("timestamp"),
+                        fields.NumberRange("percentile", 0, 1),
+                    ],
+                    calculated_args=[resolve_metric_id],
+                    snql_distribution=self._resolve_regression_score,
+                    default_result_type="number",
+                ),
+            ]
+        }
+        return function_converter

+ 98 - 0
tests/sentry/search/events/builder/test_profile_functions_metrics.py

@@ -0,0 +1,98 @@
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from snuba_sdk.column import Column
+from snuba_sdk.conditions import Condition, Op
+from snuba_sdk.function import Function
+
+from sentry.search.events.builder.profile_functions_metrics import (
+    ProfileFunctionsMetricsQueryBuilder,
+)
+from sentry.testutils.factories import Factories
+from sentry.testutils.pytest.fixtures import django_db_all
+
+pytestmark = pytest.mark.sentry_metrics
+
+
+@pytest.fixture
+def now():
+    return datetime(2022, 10, 31, 0, 0, tzinfo=timezone.utc)
+
+
+@pytest.fixture
+def today(now):
+    return now.replace(hour=0, minute=0, second=0, microsecond=0)
+
+
+@pytest.fixture
+def params(now, today):
+    organization = Factories.create_organization()
+    team = Factories.create_team(organization=organization)
+    project1 = Factories.create_project(organization=organization, teams=[team])
+    project2 = Factories.create_project(organization=organization, teams=[team])
+
+    user = Factories.create_user()
+    Factories.create_team_membership(team=team, user=user)
+
+    return {
+        "start": now - timedelta(days=7),
+        "end": now - timedelta(seconds=1),
+        "project_id": [project1.id, project2.id],
+        "project_objects": [project1, project2],
+        "organization_id": organization.id,
+        "user_id": user.id,
+        "team_id": [team.id],
+    }
+
+
+@pytest.mark.parametrize(
+    "search,condition",
+    [
+        pytest.param(
+            'package:""',
+            Condition(
+                Function("has", parameters=[Column("tags.key"), 9223372036854776075]), Op("!="), 1
+            ),
+            id="empty package",
+        ),
+        pytest.param(
+            '!package:""',
+            Condition(
+                Function("has", parameters=[Column("tags.key"), 9223372036854776075]), Op("="), 1
+            ),
+            id="not empty package",
+        ),
+        pytest.param(
+            'function:""',
+            Condition(
+                Function("has", parameters=[Column("tags.key"), 9223372036854776074]), Op("!="), 1
+            ),
+            id="empty function",
+        ),
+        pytest.param(
+            '!function:""',
+            Condition(
+                Function("has", parameters=[Column("tags.key"), 9223372036854776074]), Op("="), 1
+            ),
+            id="not empty function",
+        ),
+        pytest.param(
+            "fingerprint:123",
+            Condition(Column("tags[9223372036854776076]"), Op("="), "123"),
+            id="fingerprint",
+        ),
+        pytest.param(
+            "!fingerprint:123",
+            Condition(Column("tags[9223372036854776076]"), Op("!="), "123"),
+            id="not fingerprint",
+        ),
+    ],
+)
+@django_db_all
+def test_where(params, search, condition):
+    builder = ProfileFunctionsMetricsQueryBuilder(
+        params,
+        query=search,
+        selected_columns=["count()"],
+    )
+    assert condition in builder.where