diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eaf9805cacf..60c66f90d14 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,7 +37,7 @@ repos: name: validate-configs-syntax entry: env SNUBA_SETTINGS=test python3 -m snuba.validate_configs language: python - additional_dependencies: [ 'fastjsonschema', 'pyyaml', 'sentry_sdk' ] + additional_dependencies: [ 'fastjsonschema', 'pyyaml', 'sentry_sdk>=2.66.1' ] pass_filenames: false files: 'snuba/datasets/configuration/.*' - id: cargo-fmt diff --git a/docs/source/profiler.rst b/docs/source/profiler.rst index a4a4c25a6d6..97053b46d09 100644 --- a/docs/source/profiler.rst +++ b/docs/source/profiler.rst @@ -1,41 +1,10 @@ Profiling ========= -Snuba has two ways of using `Sentry's own profiling -`_: Profiles captured as part of -regular transactions like API calls, and "on-demand" profiles. - -Regular transaction profiling -============================= +Snuba uses `Sentry's own profiling +`_ to capture profiles as part of +regular tracing. Only enabled for a few deployments selectively via environment variables. Snuba admin is sampled at 100%, and some low-scale consumers also have a sample rate -set. - -On-demand profiling -=================== - -For consumers who have ``SNUBA_PROFILES_SAMPLE_RATE`` set to a nonzero value, -it is possible to capture a profile of the main thread for a fixed duration of -time via runtime config. - -For example, if the deployment ``snuba-querylog`` misbehaves, you can pick a -specific pod by ``pod_name`` like -``snuba-querylog-consumer-production-6d95f9c8d9-4cqht`` and enable profiling -just for that one pod. - -Since profiling has unknown amount of overhead, pick only a few pods at a time -to minimize the risk and size of a backlog. - -Once you have that ``pod_name``, set the runtime config -``ondemand_profiler_hostnames`` to that value, without quotes. Separate -multiple values by comma. - -To stop profiling and send the profile to Sentry, unset the value. - -You should see a warning message in Sentry saying "Starting ondemand profile -for ", and once you unset runtime config, a new profile with a -transaction name of "ondemand profile: " - -Profiles are capped to 30 second runtime, so if the runtime config is set -forever, there will be profiles sent to Sentry every 30 seconds. +set via ``SNUBA_PROFILES_SAMPLE_RATE``. diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 0d451107264..7ea5769854a 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -571,11 +571,6 @@ "default": false, "description": "When true, the ClickHouse read path uses the clickhouse-connect (HTTP) driver instead of the native protocol. Lets the driver migration roll out and roll back without a deploy. Migrated from the runtime config of the same name." }, - "ondemand_profiler_hostnames": { - "type": "string", - "default": "", - "description": "Comma-separated list of hostnames for which the on-demand profiler should capture a profile." - }, "max_spans_per_transaction": { "type": "integer", "default": 2000, diff --git a/snuba/cli/__init__.py b/snuba/cli/__init__.py index c7214c0d5e8..66097652643 100644 --- a/snuba/cli/__init__.py +++ b/snuba/cli/__init__.py @@ -6,13 +6,14 @@ from typing import Any import click -import sentry_sdk import structlog +from sentry_sdk import traces from snuba.core import initialize from snuba.environment import metrics as environment_metrics from snuba.environment import setup_logging, setup_sentry from snuba.utils.metrics.wrapper import MetricsWrapper +from snuba.utils.sentry import SENTRY_OP setup_sentry() @@ -46,7 +47,13 @@ def get_command(self, ctx: Any, name: str) -> click.Command: # by default. To mimic this behavior we have to do that here # since all of our infrastructure depends on this being the # case - with sentry_sdk.start_transaction(op="snuba_init", name=f"[cli init] {name}", sampled=True): + # parent_span=None => service span. No stream-mode sampled=True; obeys + # SENTRY_TRACE_SAMPLE_RATE. snuba_init_time metric below is unaffected. + with traces.start_span( + name=f"[cli init] {name}", + attributes={SENTRY_OP: "snuba_init"}, + parent_span=None, + ): actual_command_name = name.replace("-", "_") ns: dict[str, click.Command] = {} # NOTE: WE initialize snuba before we compile the command code diff --git a/snuba/cli/consumer.py b/snuba/cli/consumer.py index 66f95851d7a..f961175a07f 100644 --- a/snuba/cli/consumer.py +++ b/snuba/cli/consumer.py @@ -204,6 +204,7 @@ def consumer( logger.info("Consumer Starting") storage_key = StorageKey(storage_name) + # Tag (not attribute): consumers have no active span; tags reach errors. sentry_sdk.set_tag("storage", storage_name) logger.info("Checking Clickhouse connections...") diff --git a/snuba/clickhouse/connect.py b/snuba/clickhouse/connect.py index 9f1e1422438..0b1a959a578 100644 --- a/snuba/clickhouse/connect.py +++ b/snuba/clickhouse/connect.py @@ -11,9 +11,11 @@ import clickhouse_connect import sentry_sdk from clickhouse_connect import common as clickhouse_connect_common +from clickhouse_connect.driver.binding import quote_identifier from clickhouse_connect.driver.client import Client from clickhouse_connect.driver.exceptions import ClickHouseError, OperationalError from clickhouse_connect.driver.httputil import get_pool_manager +from sentry_sdk import traces from snuba import environment, settings, state from snuba.clickhouse.errors import ClickhouseError @@ -25,11 +27,37 @@ ) from snuba.reader import unwrap_nullable_type from snuba.utils.metrics.wrapper import MetricsWrapper +from snuba.utils.sentry import SENTRY_OP logger = logging.getLogger("snuba.clickhouse.connect") metrics = MetricsWrapper(environment.metrics, "clickhouse.connect") + +def _driver_params(params: Params) -> Sequence[Any] | dict[str, Any] | None: + """Narrow ``Params`` to clickhouse-connect's accepted forms. + + Falsy => None. Mappings are copied to a plain ``dict`` (driver wants an + invariant dict); sequences pass through as positional binds. + """ + if not params: + return None + if isinstance(params, Mapping): + return dict(params) + return params + + +def _insert_statement(table: str, column_names: Sequence[str]) -> str: + """Rebuild the INSERT statement clickhouse-connect sends on the wire. + + ``client.insert`` takes a row matrix, not SQL, but still emits + ``INSERT INTO () FORMAT Native``. Row data is excluded + (unbounded, may hold PII). + """ + columns = ", ".join(quote_identifier(name) for name in column_names) + return f"INSERT INTO {table} ({columns}) FORMAT Native" + + # Stand-in for "no read timeout" on the HTTP path. The native driver maps a # profile with no timeout (``None``) to an unbounded socket, but clickhouse-connect # cannot safely take ``None`` (its progress-interval computation does arithmetic @@ -178,8 +206,8 @@ def _build_query_settings( settings: Mapping[str, Any] | None, query_id: str | None, capture_trace: bool, - ) -> Mapping[str, Any] | None: - query_settings = dict(settings) if settings else {} + ) -> dict[str, Any] | None: + query_settings: dict[str, Any] = dict(settings) if settings else {} if query_id is not None: query_settings["query_id"] = query_id if capture_trace: @@ -207,13 +235,20 @@ def _execute_once( client = self._get_client() query_settings = self._build_query_settings(settings, query_id, capture_trace) - with sentry_sdk.start_span(description=query, op="db.clickhouse") as span: - span.set_data(sentry_sdk.consts.SPANDATA.DB_SYSTEM, "clickhouse") - span.set_data("query_id", query_id) - span.set_data("settings", query_settings) + with traces.start_span( + name="clickhouse query", + attributes={ + SENTRY_OP: "db.clickhouse", + sentry_sdk.consts.SPANDATA.DB_SYSTEM: "clickhouse", + sentry_sdk.consts.SPANDATA.DB_QUERY_TEXT: query, + }, + ) as span: + if query_id is not None: + span.set_attribute("query_id", query_id) + span.set_attribute("settings", json.dumps(query_settings, default=repr)) query_result = client.query( query, - parameters=params if params else None, + parameters=_driver_params(params), settings=query_settings, column_oriented=columnar, ) @@ -292,12 +327,19 @@ def execute_with_totals( if query_id is not None: json_settings["query_id"] = query_id - with sentry_sdk.start_span(description=query, op="db.clickhouse") as span: - span.set_data(sentry_sdk.consts.SPANDATA.DB_SYSTEM, "clickhouse") - span.set_data("query_id", query_id) + with traces.start_span( + name="clickhouse query", + attributes={ + SENTRY_OP: "db.clickhouse", + sentry_sdk.consts.SPANDATA.DB_SYSTEM: "clickhouse", + sentry_sdk.consts.SPANDATA.DB_QUERY_TEXT: query, + }, + ) as span: + if query_id is not None: + span.set_attribute("query_id", query_id) raw = client.raw_query( query, - parameters=params if params else None, + parameters=_driver_params(params), settings=json_settings, fmt="JSONCompact", ) @@ -445,11 +487,20 @@ def insert( with self._translate_clickhouse_errors(): client = self._get_client() - with sentry_sdk.start_span( - description=f"INSERT INTO {table}", op="db.clickhouse" + with traces.start_span( + name=f"INSERT INTO {table}", + attributes={ + SENTRY_OP: "db.clickhouse", + sentry_sdk.consts.SPANDATA.DB_SYSTEM: "clickhouse", + sentry_sdk.consts.SPANDATA.DB_QUERY_TEXT: _insert_statement( + table, column_names + ), + }, ) as span: - span.set_data(sentry_sdk.consts.SPANDATA.DB_SYSTEM, "clickhouse") - span.set_data("query_id", query_id) + span.set_attribute( + "query_id", + query_id if query_id is not None else "unknown-query-id", + ) client.insert( table, matrix, @@ -506,8 +557,14 @@ def execute_explain(self, query: str) -> ClickhouseResult: """ with self._translate_clickhouse_errors(): client = self._get_client() - with sentry_sdk.start_span(description=query, op="db.clickhouse") as span: - span.set_data(sentry_sdk.consts.SPANDATA.DB_SYSTEM, "clickhouse") + with traces.start_span( + name="clickhouse query", + attributes={ + SENTRY_OP: "db.clickhouse", + sentry_sdk.consts.SPANDATA.DB_SYSTEM: "clickhouse", + sentry_sdk.consts.SPANDATA.DB_QUERY_TEXT: query, + }, + ): output = client.command(query) return self._explain_result(output) diff --git a/snuba/clickhouse/native.py b/snuba/clickhouse/native.py index 9e53fbd7e2f..d1ec3bf034b 100644 --- a/snuba/clickhouse/native.py +++ b/snuba/clickhouse/native.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import queue import re @@ -20,6 +21,7 @@ import sentry_sdk from clickhouse_driver import Client, errors from dateutil.tz import tz +from sentry_sdk import traces from sentry_sdk.integrations.logging import ignore_logger from snuba import environment, settings @@ -29,6 +31,7 @@ from snuba.state.sentry_options import get_option from snuba.utils.metrics.gauge import ThreadSafeGauge from snuba.utils.metrics.wrapper import MetricsWrapper +from snuba.utils.sentry import SENTRY_OP ignore_logger("clickhouse_driver.connection") @@ -279,10 +282,17 @@ def execute( ) def query_execute(conn: Client = conn, settings: Any = settings) -> Any: - with sentry_sdk.start_span(description=query, op="db.clickhouse") as span: - span.set_data(sentry_sdk.consts.SPANDATA.DB_SYSTEM, "clickhouse") - span.set_data("query_id", query_id) - span.set_data("settings", settings) + with traces.start_span( + name="clickhouse query", + attributes={ + SENTRY_OP: "db.clickhouse", + sentry_sdk.consts.SPANDATA.DB_SYSTEM: "clickhouse", + sentry_sdk.consts.SPANDATA.DB_QUERY_TEXT: query, + }, + ) as span: + if query_id is not None: + span.set_attribute("query_id", query_id) + span.set_attribute("settings", json.dumps(settings, default=repr)) return conn.execute( query, params=params, diff --git a/snuba/datasets/configuration/json_schema.py b/snuba/datasets/configuration/json_schema.py index 949c343d12c..d305f68c93c 100644 --- a/snuba/datasets/configuration/json_schema.py +++ b/snuba/datasets/configuration/json_schema.py @@ -4,9 +4,10 @@ from typing import Any import fastjsonschema -import sentry_sdk +from sentry_sdk import traces from snuba import settings +from snuba.utils.sentry import SENTRY_OP # Snubadocs are automatically generated from this file. When adding new schemas or individual keys, # please ensure you add a description key in the same level and succinctly describe the property. @@ -864,17 +865,17 @@ def registered_class_array_schema( } if settings.VALIDATE_DATASET_YAMLS_ON_STARTUP: - with sentry_sdk.start_span(op="compile", description="Storage Validators"): + with traces.start_span(name="Storage Validators", attributes={SENTRY_OP: "compile"}): STORAGE_VALIDATORS = { "readable_storage": fastjsonschema.compile(V1_READABLE_STORAGE_SCHEMA), "writable_storage": fastjsonschema.compile(V1_WRITABLE_STORAGE_SCHEMA), "cdc_storage": fastjsonschema.compile(V1_CDC_STORAGE_SCHEMA), } - with sentry_sdk.start_span(op="compile", description="Entity Validators"): + with traces.start_span(name="Entity Validators", attributes={SENTRY_OP: "compile"}): ENTITY_VALIDATORS = {"entity": fastjsonschema.compile(V1_ENTITY_SCHEMA)} - with sentry_sdk.start_span(op="compile", description="Dataset Validators"): + with traces.start_span(name="Dataset Validators", attributes={SENTRY_OP: "compile"}): DATASET_VALIDATORS = {"dataset": fastjsonschema.compile(V1_DATASET_SCHEMA)} else: STORAGE_VALIDATORS = {} diff --git a/snuba/datasets/configuration/loader.py b/snuba/datasets/configuration/loader.py index b55c479db58..94cf7955abd 100644 --- a/snuba/datasets/configuration/loader.py +++ b/snuba/datasets/configuration/loader.py @@ -2,10 +2,11 @@ from typing import Any -import sentry_sdk +from sentry_sdk import traces from yaml import safe_load from snuba import settings +from snuba.utils.sentry import SENTRY_OP def load_configuration_data(path: str, validators: dict[str, Any]) -> dict[str, Any]: @@ -13,12 +14,14 @@ def load_configuration_data(path: str, validators: dict[str, Any]) -> dict[str, Loads a configuration file from the given path Returns an untyped dict of dicts """ - with sentry_sdk.start_span(op="load_and_validate") as span: - span.set_tag("file", path) + with traces.start_span( + name="load_and_validate", + attributes={SENTRY_OP: "load_and_validate", "file": path}, + ) as span: with open(path) as file: config = safe_load(file) assert isinstance(config, dict) if settings.VALIDATE_DATASET_YAMLS_ON_STARTUP: validators[config["kind"]](config) - span.description = config["name"] + span.name = config["name"] return config diff --git a/snuba/datasets/entities/factory.py b/snuba/datasets/entities/factory.py index 17dd457a62a..c2519f1c0ca 100644 --- a/snuba/datasets/entities/factory.py +++ b/snuba/datasets/entities/factory.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from glob import glob -import sentry_sdk +from sentry_sdk import traces from snuba import settings from snuba.datasets.configuration.entity_builder import build_entity_from_config @@ -13,12 +13,13 @@ from snuba.datasets.storages.factory import initialize_storage_factory from snuba.datasets.table_storage import TableWriter from snuba.utils.config_component_factory import ConfigComponentFactory +from snuba.utils.sentry import SENTRY_OP from snuba.utils.serializable_exception import SerializableException class _EntityFactory(ConfigComponentFactory[Entity, EntityKey]): def __init__(self) -> None: - with sentry_sdk.start_span(op="initialize", description="Entity Factory"): + with traces.start_span(name="Entity Factory", attributes={SENTRY_OP: "initialize"}): initialize_storage_factory() self._entity_map: dict[EntityKey, PluggableEntity] = {} self._name_map: dict[type[Entity], EntityKey] = {} diff --git a/snuba/datasets/factory.py b/snuba/datasets/factory.py index 2a9dfe4305d..2f5244e1d34 100644 --- a/snuba/datasets/factory.py +++ b/snuba/datasets/factory.py @@ -2,7 +2,7 @@ from glob import glob -import sentry_sdk +from sentry_sdk import traces from snuba import settings from snuba.datasets.configuration.dataset_builder import build_dataset_from_config @@ -11,12 +11,13 @@ from snuba.datasets.pluggable_dataset import PluggableDataset from snuba.utils.config_component_factory import ConfigComponentFactory from snuba.utils.metrics.util import with_span +from snuba.utils.sentry import SENTRY_OP from snuba.utils.serializable_exception import SerializableException class _DatasetFactory(ConfigComponentFactory[Dataset, str]): def __init__(self) -> None: - with sentry_sdk.start_span(op="initialize", description="Dataset Factory"): + with traces.start_span(name="Dataset Factory", attributes={SENTRY_OP: "initialize"}): initialize_entity_factory() self._dataset_map: dict[str, Dataset] = {} self._name_map: dict[type[Dataset], str] = {} diff --git a/snuba/datasets/plans/entity_processing.py b/snuba/datasets/plans/entity_processing.py index 3d3066f107e..3692718b363 100644 --- a/snuba/datasets/plans/entity_processing.py +++ b/snuba/datasets/plans/entity_processing.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from typing import cast -import sentry_sdk +from sentry_sdk import traces from snuba.clickhouse.query import Query from snuba.clusters.cluster import ClickhouseCluster @@ -26,6 +26,7 @@ from snuba.query.processors.physical import ClickhouseQueryProcessor from snuba.query.query_settings import QuerySettings from snuba.state import explain_meta +from snuba.utils.sentry import SENTRY_OP class EntityProcessingExecutor: @@ -57,8 +58,8 @@ def __init__( self.__partition_key_column_name = partition_key_column_name def get_storage(self, query: LogicalQuery, settings: QuerySettings) -> EntityStorageConnection: - with sentry_sdk.start_span( - op="build_plan.storage_query_plan_builder", description="select_storage" + with traces.start_span( + name="select_storage", attributes={SENTRY_OP: "build_plan.storage_query_plan_builder"} ): return self.__selector.select_storage(query, settings, self.__storages) @@ -66,8 +67,8 @@ def get_cluster( self, storage: ReadableStorage, query: LogicalQuery, settings: QuerySettings ) -> ClickhouseCluster: if is_storage_set_sliced(storage.get_storage_set_key()): - with sentry_sdk.start_span( - op="build_plan.sliced_storage", description="select_storage" + with traces.start_span( + name="select_storage", attributes={SENTRY_OP: "build_plan.sliced_storage"} ): assert self.__partition_key_column_name is not None, ( "partition key column name must be defined for a sliced storage" @@ -91,8 +92,8 @@ def translate_query_and_apply_mappers( check_storage_readiness(storage) - with sentry_sdk.start_span( - op="build_plan.storage_query_plan_builder", description="translate" + with traces.start_span( + name="translate", attributes={SENTRY_OP: "build_plan.storage_query_plan_builder"} ): # The QueryTranslator class should be instantiated once for each call to # translate_query_and_apply_mappers to avoid cache conflicts. diff --git a/snuba/datasets/plans/entity_validation.py b/snuba/datasets/plans/entity_validation.py index bfb6ab64443..7ae9aa1877f 100644 --- a/snuba/datasets/plans/entity_validation.py +++ b/snuba/datasets/plans/entity_validation.py @@ -1,6 +1,6 @@ from __future__ import annotations -import sentry_sdk +from sentry_sdk import traces from snuba.datasets.entities.factory import get_entity from snuba.query import Query @@ -12,6 +12,7 @@ from snuba.query.parser.validation.functions import FunctionCallsValidator from snuba.query.query_settings import QuerySettings from snuba.state import explain_meta +from snuba.utils.sentry import SENTRY_OP EXPRESSION_VALIDATORS = [FunctionCallsValidator()] @@ -69,7 +70,7 @@ def run_entity_validators( """ for validator_func in VALIDATORS: description = getattr(validator_func, "__name__", "custom") - with sentry_sdk.start_span(op="validator", description=description): + with traces.start_span(name=description, attributes={SENTRY_OP: "validator"}): if settings and settings.get_dry_run(): with explain_meta.with_query_differ("entity_validator", description, query): validator_func(query) diff --git a/snuba/datasets/plans/storage_processing.py b/snuba/datasets/plans/storage_processing.py index adc971af2bb..3684c4b131f 100644 --- a/snuba/datasets/plans/storage_processing.py +++ b/snuba/datasets/plans/storage_processing.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from typing import TypeVar -import sentry_sdk +from sentry_sdk import traces from snuba import settings as snuba_settings from snuba.clickhouse.query import Query @@ -39,6 +39,7 @@ from snuba.query.query_settings import QuerySettings from snuba.state import explain_meta from snuba.utils.metrics.util import with_span +from snuba.utils.sentry import SENTRY_OP TQuery = TypeVar("TQuery", bound=AbstractQuery) @@ -143,7 +144,7 @@ def apply_storage_processors( ) assert isinstance(query_plan.query, Query) for processor in query_plan.db_query_processors: - with sentry_sdk.start_span(description=type(processor).__name__, op="processor"): + with traces.start_span(name=type(processor).__name__, attributes={SENTRY_OP: "processor"}): if settings.get_dry_run(): with explain_meta.with_query_differ( "storage_processor", type(processor).__name__, query_plan.query diff --git a/snuba/datasets/storages/factory.py b/snuba/datasets/storages/factory.py index 0c111acc568..0d316592402 100644 --- a/snuba/datasets/storages/factory.py +++ b/snuba/datasets/storages/factory.py @@ -4,7 +4,7 @@ from collections.abc import MutableSequence, Sequence from glob import glob -import sentry_sdk +from sentry_sdk import traces from snuba import settings from snuba.datasets.cdc.cdcstorage import CdcStorage @@ -14,11 +14,12 @@ from snuba.datasets.storages.storage_key import StorageKey from snuba.datasets.storages.validator import StorageValidator from snuba.utils.config_component_factory import ConfigComponentFactory +from snuba.utils.sentry import SENTRY_OP class _StorageFactory(ConfigComponentFactory[Storage, StorageKey]): def __init__(self) -> None: - with sentry_sdk.start_span(op="initialize", description="Storage Factory"): + with traces.start_span(name="Storage Factory", attributes={SENTRY_OP: "initialize"}): self._config_built_storages: dict[StorageKey, Storage] = {} self._all_storages: dict[StorageKey, Storage] = {} self.__initialize() diff --git a/snuba/environment.py b/snuba/environment.py index 08c7cdad584..f689c3dcc51 100644 --- a/snuba/environment.py +++ b/snuba/environment.py @@ -181,25 +181,15 @@ def setup_sentry() -> None: release=os.getenv("SNUBA_RELEASE"), traces_sample_rate=settings.SENTRY_TRACE_SAMPLE_RATE, profiles_sample_rate=settings.SNUBA_PROFILES_SAMPLE_RATE, - _experiments={ - # Turns on the metrics module - "enable_metrics": True, - # Enables sending of code locations for metrics - "metric_code_locations": True, - }, + # Stream spans as they finish. Disables the legacy tracing API + # (start_span/start_transaction/update_current_span/scope.span). + trace_lifecycle="stream", ) from snuba.state.sentry_options import init_options init_options() - from snuba.utils.profiler import run_ondemand_profiler - - if settings.SENTRY_DSN is not None: - # Do not run ondemand profiler in tests, it interferes with mocked - # `time.sleep()` and assertions on that mock. - run_ondemand_profiler() - metrics = create_metrics( "snuba", diff --git a/snuba/pipeline/composite_storage_processing.py b/snuba/pipeline/composite_storage_processing.py index f11f1989d73..7851b9c30fb 100644 --- a/snuba/pipeline/composite_storage_processing.py +++ b/snuba/pipeline/composite_storage_processing.py @@ -3,7 +3,7 @@ from collections.abc import Mapping, Sequence from typing import NamedTuple -import sentry_sdk +from sentry_sdk import traces from snuba.clickhouse.query import Query as ClickhouseQuery from snuba.clusters.storage_sets import StorageSetKey, is_valid_storage_set_combination @@ -22,6 +22,7 @@ from snuba.query.processors.physical import ClickhouseQueryProcessor from snuba.query.query_settings import QuerySettings from snuba.state import explain_meta +from snuba.utils.sentry import SENTRY_OP def apply_composite_storage_processors( @@ -269,8 +270,9 @@ def __process_simple_query( processors: Sequence[ClickhouseQueryProcessor], ) -> None: for clickhouse_processor in processors: - with sentry_sdk.start_span( - description=type(clickhouse_processor).__name__, op="processor" + with traces.start_span( + name=type(clickhouse_processor).__name__, + attributes={SENTRY_OP: "processor"}, ): clickhouse_processor.process_query(clickhouse_query, self.__settings) diff --git a/snuba/pipeline/processors.py b/snuba/pipeline/processors.py index c78b977f3fb..5dca4664695 100644 --- a/snuba/pipeline/processors.py +++ b/snuba/pipeline/processors.py @@ -1,9 +1,10 @@ -import sentry_sdk +from sentry_sdk import traces from snuba.datasets.entities.factory import get_entity from snuba.query.logical import EntityQuery from snuba.query.query_settings import QuerySettings from snuba.state import explain_meta +from snuba.utils.sentry import SENTRY_OP def execute_entity_processors(query: EntityQuery, settings: QuerySettings) -> None: @@ -14,7 +15,7 @@ def execute_entity_processors(query: EntityQuery, settings: QuerySettings) -> No entity = get_entity(query.get_from_clause().key) for processor in entity.get_query_processors(): - with sentry_sdk.start_span(description=type(processor).__name__, op="processor"): + with traces.start_span(name=type(processor).__name__, attributes={SENTRY_OP: "processor"}): if settings.get_dry_run(): with explain_meta.with_query_differ( "entity_processor", type(processor).__name__, query diff --git a/snuba/pipeline/stages/query_execution.py b/snuba/pipeline/stages/query_execution.py index aa0acbc1dc2..b7f07556ce2 100644 --- a/snuba/pipeline/stages/query_execution.py +++ b/snuba/pipeline/stages/query_execution.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import textwrap from collections import defaultdict @@ -9,6 +10,7 @@ from typing import Any import sentry_sdk +from sentry_sdk import traces from snuba import environment from snuba import settings as snuba_settings @@ -35,6 +37,7 @@ from snuba.utils.metrics.gauge import Gauge from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.wrapper import MetricsWrapper +from snuba.utils.sentry import SENTRY_OP, set_tag_and_attribute from snuba.web import ( QueryException, QueryExtraData, @@ -105,9 +108,9 @@ def _dry_run_query_runner( clickhouse_query: ClickhouseQuery | CompositeQuery[Table], cluster_name: str, ) -> QueryResult: - with sentry_sdk.start_span(description="dryrun_create_query", op="function") as span: + with traces.start_span(name="dryrun_create_query", attributes={SENTRY_OP: "function"}) as span: formatted_query = format_query(clickhouse_query) - span.set_data("query", formatted_query.structured()) + span.set_attribute("query", json.dumps(formatted_query.structured(), default=repr)) return QueryResult( {"data": [], "meta": []}, @@ -189,19 +192,19 @@ def _format_storage_query_and_run( visitor = TablesCollector() visitor.visit(from_clause) table_names = ",".join(sorted(visitor.get_tables())) - with sentry_sdk.start_span(description="create_query", op="function") as span: + with traces.start_span(name="create_query", attributes={SENTRY_OP: "function"}) as span: _apply_turbo_sampling_if_needed(clickhouse_query, query_settings) formatted_query = format_query(clickhouse_query) formatted_sql = formatted_query.get_sql() query_size_bytes = len(formatted_sql.encode("utf-8")) - span.set_data( + span.set_attribute( "query", textwrap.wrap(formatted_sql, 100, break_long_words=False) ) # To avoid the query being truncated - span.set_data("table", table_names) - span.set_data("query_size_bytes", query_size_bytes) - sentry_sdk.set_tag("query_size_group", get_query_size_group(query_size_bytes)) + span.set_attribute("table", table_names) + span.set_attribute("query_size_bytes", query_size_bytes) + set_tag_and_attribute("query_size_group", get_query_size_group(query_size_bytes)) metrics.increment( "execute", tags={ @@ -251,8 +254,14 @@ def _format_storage_query_and_run( experiments=clickhouse_query.get_experiments(), ), ) from cause - with sentry_sdk.start_span(description=formatted_sql, op="function") as span: - span.set_tag("table", table_names) + with traces.start_span( + name="execute_query", + attributes={ + SENTRY_OP: "function", + sentry_sdk.consts.SPANDATA.DB_QUERY_TEXT: formatted_sql, + "table": table_names, + }, + ) as span: def execute() -> QueryResult: try: diff --git a/snuba/query/allocation_policies/__init__.py b/snuba/query/allocation_policies/__init__.py index 725b2fc6c5e..95cccd8a9c7 100644 --- a/snuba/query/allocation_policies/__init__.py +++ b/snuba/query/allocation_policies/__init__.py @@ -1,13 +1,14 @@ from __future__ import annotations +import json import os from abc import ABC, abstractmethod from dataclasses import asdict, dataclass, field from enum import Enum from typing import Any, cast -import sentry_sdk from redis.exceptions import TimeoutError as RedisTimeoutError +from sentry_sdk import traces from snuba import environment, settings from snuba.configs.configuration import ( @@ -20,6 +21,7 @@ from snuba.datasets.storages.storage_key import StorageKey from snuba.utils.metrics.wrapper import MetricsWrapper from snuba.utils.registered_class import import_submodules_in_directory +from snuba.utils.sentry import SENTRY_OP from snuba.utils.serializable_exception import JsonSerializable, SerializableException from snuba.web import QueryResult @@ -451,11 +453,12 @@ def _get_default_config_definitions(self) -> list[Configuration]: def get_quota_allowance( self, tenant_ids: dict[str, str | int], query_id: str ) -> QuotaAllowance: - with sentry_sdk.start_span( - op="allocation_policy.get_quota_allowance", name=self.__class__.__name__ + with traces.start_span( + name=self.__class__.__name__, + attributes={SENTRY_OP: "allocation_policy.get_quota_allowance"}, ) as span: for t, tid in tenant_ids.items(): - span.set_data(f"tenant_ids.{t}", str(tid)) + span.set_attribute(f"tenant_ids.{t}", str(tid)) try: if not self.is_active: allowance = QuotaAllowance( @@ -515,7 +518,7 @@ def get_quota_allowance( "max_threads": str(allowance.max_threads), }, ) - span.set_data("db_request_throttled", True) + span.set_attribute("db_request_throttled", True) if not self.is_enforced: allowance = QuotaAllowance( can_run=True, @@ -531,7 +534,11 @@ def get_quota_allowance( # make sure we always know which storage key we rejected a query from allowance.explanation["storage_key"] = self._resource_identifier.value for k, v in allowance.to_dict().items(): - span.set_data(f"quota_allowance.{k}", v) + # Attributes only accept scalars; stringify nested values. + span.set_attribute( + f"quota_allowance.{k}", + v if isinstance(v, (str, int, float, bool)) else json.dumps(v, default=repr), + ) return allowance @abstractmethod diff --git a/snuba/query/mql/parser.py b/snuba/query/mql/parser.py index a1dc2ed3802..944a912be7a 100644 --- a/snuba/query/mql/parser.py +++ b/snuba/query/mql/parser.py @@ -5,9 +5,9 @@ from dataclasses import dataclass, replace from typing import Any -import sentry_sdk from parsimonious.exceptions import IncompleteParseError from parsimonious.nodes import Node, NodeVisitor +from sentry_sdk import traces from snuba_sdk import BooleanCondition, Condition from snuba_sdk.metrics_visitors import AGGREGATE_ALIAS from snuba_sdk.mql.mql import MQL_GRAMMAR @@ -72,6 +72,7 @@ ) from snuba.state import explain_meta from snuba.utils.metrics.timer import Timer +from snuba.utils.sentry import SENTRY_OP # The parser returns a bunch of different types, so create a single aggregate type to # capture everything. @@ -1265,7 +1266,7 @@ def parse_mql_query( # NOTE (volo): The anonymizer that runs after this function call chokes on # OR and AND clauses with multiple parameters so we have to treeify them # before we run the anonymizer and the rest of the post processors - with sentry_sdk.start_span(op="processor", description="treeify_conditions"): + with traces.start_span(name="treeify_conditions", attributes={SENTRY_OP: "processor"}): _post_process(query, [_treeify_or_and_conditions], settings) res = PostProcessAndValidateMQLQuery().execute( @@ -1295,13 +1296,17 @@ def _process_data( ) -> LogicalQuery: mql_str, dataset, mql_context_dict, settings = pipe_input.data - with sentry_sdk.start_span(op="parser", description="parse_mql_query_initial"): + with traces.start_span(name="parse_mql_query_initial", attributes={SENTRY_OP: "parser"}): query = parse_mql_query_body(mql_str, dataset) - with sentry_sdk.start_span(op="parser", description="populate_query_from_mql_context"): + with traces.start_span( + name="populate_query_from_mql_context", attributes={SENTRY_OP: "parser"} + ): query, mql_context = populate_query_from_mql_context(query, mql_context_dict) - with sentry_sdk.start_span(op="processor", description="resolve_indexer_mappings"): + with traces.start_span( + name="resolve_indexer_mappings", attributes={SENTRY_OP: "processor"} + ): resolve_mappings(query, mql_context.indexer_mappings, dataset) if settings and settings.get_dry_run(): @@ -1331,7 +1336,7 @@ def _process_data( ], ) -> LogicalQuery: query, settings, custom_processing = pipe_input.data - with sentry_sdk.start_span(op="processor", description="post_processors"): + with traces.start_span(name="post_processors", attributes={SENTRY_OP: "processor"}): _post_process( query, MQL_POST_PROCESSORS, @@ -1339,23 +1344,25 @@ def _process_data( ) # Filter in select optimizer - with sentry_sdk.start_span(op="processor", description="filter_in_select_optimize"): + with traces.start_span( + name="filter_in_select_optimize", attributes={SENTRY_OP: "processor"} + ): if settings is None: FilterInSelectOptimizer().process_query(query, HTTPQuerySettings()) else: FilterInSelectOptimizer().process_query(query, settings) # Custom processing to tweak the AST before validation - with sentry_sdk.start_span(op="processor", description="custom_processing"): + with traces.start_span(name="custom_processing", attributes={SENTRY_OP: "processor"}): if custom_processing is not None: _post_process(query, custom_processing, settings) # Time based processing - with sentry_sdk.start_span(op="processor", description="time_based_processing"): + with traces.start_span(name="time_based_processing", attributes={SENTRY_OP: "processor"}): _post_process(query, [_replace_time_condition], settings) # Validating - with sentry_sdk.start_span(op="validate", description="expression_validators"): + with traces.start_span(name="expression_validators", attributes={SENTRY_OP: "validate"}): _post_process(query, VALIDATORS) return query diff --git a/snuba/query/snql/parser.py b/snuba/query/snql/parser.py index e5134645bd5..d4fbceb8ceb 100644 --- a/snuba/query/snql/parser.py +++ b/snuba/query/snql/parser.py @@ -11,10 +11,10 @@ cast, ) -import sentry_sdk from parsimonious.exceptions import IncompleteParseError from parsimonious.grammar import Grammar from parsimonious.nodes import Node, NodeVisitor +from sentry_sdk import traces from snuba.clickhouse.columns import Array, ColumnSet from snuba.clickhouse.query_dsl.accessors import get_time_range_expressions @@ -103,6 +103,7 @@ from snuba.state.sentry_options import get_option from snuba.util import parse_datetime from snuba.utils.metrics.timer import Timer +from snuba.utils.sentry import SENTRY_OP MAX_LIMIT = 10000 @@ -1400,7 +1401,7 @@ def _post_process( # custom processors can be partials instead of functions but partials don't # have the __name__ attribute set automatically (and we don't set it manually) description = getattr(func, "__name__", "custom") - with sentry_sdk.start_span(op="processor", description=description): + with traces.start_span(name=description, attributes={SENTRY_OP: "processor"}): if settings and settings.get_dry_run(): with explain_meta.with_query_differ("snql_parsing", description, query): func(query) @@ -1440,7 +1441,7 @@ def parse_snql_query( custom_processing: CustomProcessors | None = None, settings: QuerySettings | None = None, ) -> CompositeQuery[LogicalDataSource] | LogicalQuery: - with sentry_sdk.start_span(op="parser", description="parse_snql_query_initial"): + with traces.start_span(name="parse_snql_query_initial", attributes={SENTRY_OP: "parser"}): query = parse_snql_query_initial(body) if settings and settings.get_dry_run(): @@ -1450,7 +1451,7 @@ def parse_snql_query( # NOTE (volo): The anonymizer that runs after this function call chokes on # OR and AND clauses with multiple parameters so we have to treeify them # before we run the anonymizer and the rest of the post processors - with sentry_sdk.start_span(op="processor", description="treeify_conditions"): + with traces.start_span(name="treeify_conditions", attributes={SENTRY_OP: "processor"}): _post_process(query, [_treeify_or_and_conditions], settings) timer = Timer("snql_pipeline") @@ -1495,22 +1496,22 @@ def _process_data( query, dataset, custom_processing = pipe_input.data settings = pipe_input.query_settings - with sentry_sdk.start_span(op="processor", description="post_processors"): + with traces.start_span(name="post_processors", attributes={SENTRY_OP: "processor"}): _post_process( query, POST_PROCESSORS, settings, ) # Custom processing to tweak the AST before validation - with sentry_sdk.start_span(op="processor", description="custom_processing"): + with traces.start_span(name="custom_processing", attributes={SENTRY_OP: "processor"}): if custom_processing is not None: _post_process(query, custom_processing, settings) # Time based processing - with sentry_sdk.start_span(op="processor", description="time_based_processing"): + with traces.start_span(name="time_based_processing", attributes={SENTRY_OP: "processor"}): _post_process(query, [_replace_time_condition], settings) _post_process(query, [_select_entity_for_dataset(dataset)], settings) # Validating - with sentry_sdk.start_span(op="validate", description="expression_validators"): + with traces.start_span(name="expression_validators", attributes={SENTRY_OP: "validate"}): _post_process(query, VALIDATORS) return query diff --git a/snuba/querylog/__init__.py b/snuba/querylog/__init__.py index 295a2a3e81c..661017e6410 100644 --- a/snuba/querylog/__init__.py +++ b/snuba/querylog/__init__.py @@ -6,8 +6,8 @@ from typing import Any from uuid import UUID -import sentry_sdk from sentry_kafka_schemas.schema_types import snuba_queries_v1 +from sentry_sdk import traces from usageaccountant import UsageUnit from snuba import environment, settings, state @@ -24,6 +24,7 @@ from snuba.state.sentry_options import get_option from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.wrapper import MetricsWrapper +from snuba.utils.sentry import set_tag_and_attribute from snuba.web import QueryException, QueryResult metrics = MetricsWrapper(environment.metrics, "api") @@ -222,19 +223,19 @@ def _add_tags( experiments: Mapping[str, Any] | None = None, metadata: SnubaQueryMetadata | None = None, ) -> None: - if sentry_sdk.get_current_span(): + if traces.get_current_span() is not None: duration_group = timer.get_duration_group() - sentry_sdk.set_tag("duration_group", duration_group) + set_tag_and_attribute("duration_group", duration_group) if duration_group == ">30s": - sentry_sdk.set_tag("timeout", "too_long") + set_tag_and_attribute("timeout", "too_long") if experiments is not None: for name, value in experiments.items(): - sentry_sdk.set_tag(name, str(value)) + set_tag_and_attribute(name, str(value)) if metadata is not None: for query_data in metadata.query_list: max_threads = query_data.stats.get("max_threads") if max_threads is not None: - sentry_sdk.set_tag("max_threads", max_threads) + set_tag_and_attribute("max_threads", max_threads) break diff --git a/snuba/replacers/projects_query_flags.py b/snuba/replacers/projects_query_flags.py index a4f1060bc6a..c8019ffb193 100644 --- a/snuba/replacers/projects_query_flags.py +++ b/snuba/replacers/projects_query_flags.py @@ -9,12 +9,14 @@ import sentry_sdk from redis.cluster import ClusterPipeline as StrictClusterPipeline +from sentry_sdk import traces from snuba import settings from snuba.processor import ReplacementType from snuba.redis import RedisClientKey, get_redis_client from snuba.replacers.replacer_processor import ReplacerState from snuba.state.sentry_options import get_option +from snuba.utils.sentry import SENTRY_OP redis_client = get_redis_client(RedisClientKey.REPLACEMENTS_STORE) @@ -127,23 +129,35 @@ def load_from_redis( try: with redis_client.pipeline() as p: - with sentry_sdk.start_span(op="function", description="build_redis_pipeline"): + with traces.start_span( + name="build_redis_pipeline", attributes={SENTRY_OP: "function"} + ): cls._query_redis(s_project_ids, state_name, p) - with sentry_sdk.start_span( - op="function", description="execute_redis_pipeline" + with traces.start_span( + name="execute_redis_pipeline", attributes={SENTRY_OP: "function"} ) as span: results = p.execute() # getting size of str(results) since sys.getsizeof() doesn't count recursively - span.set_tag("results_size", sys.getsizeof(str(results))) + span.set_attribute("results_size", sys.getsizeof(str(results))) - with sentry_sdk.start_span(op="function", description="process_redis_results") as span: + with traces.start_span( + name="process_redis_results", attributes={SENTRY_OP: "function"} + ) as span: flags = cls._process_redis_results(results, len(s_project_ids)) - span.set_tag("projects", s_project_ids) - span.set_tag("exclude_groups", flags.group_ids_to_exclude) - span.set_tag("len(exclude_groups)", len(flags.group_ids_to_exclude)) - span.set_tag("latest_replacement_time", flags.latest_replacement_time) - span.set_tag("replacement_types", flags.replacement_types) + # Attributes need scalars/homogeneous lists; convert explicitly. + span.set_attribute("projects", sorted(s_project_ids)) + span.set_attribute("exclude_groups", sorted(flags.group_ids_to_exclude)) + span.set_attribute("len(exclude_groups)", len(flags.group_ids_to_exclude)) + span.set_attribute( + "latest_replacement_time", + ( + flags.latest_replacement_time.isoformat() + if flags.latest_replacement_time is not None + else "" + ), + ) + span.set_attribute("replacement_types", sorted(flags.replacement_types)) return flags except Exception as e: diff --git a/snuba/request/validation.py b/snuba/request/validation.py index 75c89e1412d..79fb3696db4 100644 --- a/snuba/request/validation.py +++ b/snuba/request/validation.py @@ -7,6 +7,7 @@ from typing import Any, Protocol import sentry_sdk +from sentry_sdk import traces from snuba import environment, settings from snuba.attribution import get_app_id @@ -34,6 +35,7 @@ from snuba.state.sentry_options import get_mapped_option, get_option from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.wrapper import MetricsWrapper +from snuba.utils.sentry import SENTRY_OP, set_tag_and_attribute metrics = MetricsWrapper(environment.metrics, "snuba.validation") @@ -109,7 +111,7 @@ def build_request( referrer: str, custom_processing: CustomProcessors | None = None, ) -> Request: - with sentry_sdk.start_span(description="build_request", op="validate") as span: + with traces.start_span(name="build_request", attributes={SENTRY_OP: "validate"}) as span: try: dataset_name = get_dataset_name(dataset) if get_mapped_option( @@ -151,11 +153,11 @@ def build_request( ) raise exception - span.set_data( + span.set_attribute( "snuba_query_parsed", repr(query).split("\n"), ) - span.set_data( + span.set_attribute( "snuba_query_raw", textwrap.wrap(repr(request.original_body), 100, break_long_words=False), ) @@ -235,11 +237,11 @@ def _build_request( ) -> Request: org_ids = get_object_ids_in_query_ast(query, "org_id") if org_ids is not None and len(org_ids) == 1: - sentry_sdk.set_tag("snuba_org_id", org_ids.pop()) + set_tag_and_attribute("snuba_org_id", org_ids.pop()) query_project_id = _get_project_id(query) if query_project_id: - sentry_sdk.set_tag("snuba_project_id", query_project_id) + set_tag_and_attribute("snuba_project_id", query_project_id) attribution_info = _get_attribution_info(request_parts, referrer, query_project_id) diff --git a/snuba/utils/metrics/backends/sentry.py b/snuba/utils/metrics/backends/sentry.py index dc144e1401b..2ae10e46773 100644 --- a/snuba/utils/metrics/backends/sentry.py +++ b/snuba/utils/metrics/backends/sentry.py @@ -1,11 +1,22 @@ from __future__ import annotations +from typing import Any, cast + from sentry_sdk import metrics from snuba.utils.metrics.backends.abstract import MetricsBackend from snuba.utils.metrics.types import Tags +def _attributes(tags: Tags | None) -> dict[str, Any] | None: + """Cast ``Tags`` for the SDK's invariant ``dict`` annotation. + + The SDK only iterates ``.items()`` into a fresh dict, so a ``Mapping`` is + fine at runtime and copying here would be pure waste. + """ + return cast("dict[str, Any] | None", tags) + + class SentryMetricsBackend(MetricsBackend): """ A metrics backend that records metrics to Sentry. @@ -21,7 +32,7 @@ def increment( tags: Tags | None = None, unit: str | None = None, ) -> None: - metrics.incr(name, value, unit or "none", tags) + metrics.count(name, value, unit or "none", _attributes(tags)) def gauge( self, @@ -30,7 +41,7 @@ def gauge( tags: Tags | None = None, unit: str | None = None, ) -> None: - metrics.gauge(name, value, unit or "none", tags) + metrics.gauge(name, value, unit or "none", _attributes(tags)) def timing( self, @@ -39,8 +50,8 @@ def timing( tags: Tags | None = None, unit: str | None = None, ) -> None: - # The Sentry SDK has strict typing on the unit, so it doesn't allow passing arbitrary units - metrics.timing(name, value, unit or "millisecond", tags) # type: ignore[arg-type] + # SDK dropped timing; emit as a millisecond distribution. + metrics.distribution(name, value, unit or "millisecond", _attributes(tags)) def distribution( self, @@ -49,7 +60,7 @@ def distribution( tags: Tags | None = None, unit: str | None = None, ) -> None: - metrics.distribution(name, value, unit or "none", tags) + metrics.distribution(name, value, unit or "none", _attributes(tags)) def events( self, diff --git a/snuba/utils/metrics/util.py b/snuba/utils/metrics/util.py index 06a4eae1345..8697b7c23ea 100644 --- a/snuba/utils/metrics/util.py +++ b/snuba/utils/metrics/util.py @@ -1,14 +1,17 @@ +from __future__ import annotations + import _strptime # NOQA fixes _strptime deferred import issue import inspect +from collections.abc import Callable, Mapping from functools import wraps from typing import Any, TypeVar, cast -from collections.abc import Callable, Mapping -import sentry_sdk +from sentry_sdk import traces from snuba import settings from snuba.utils.metrics import MetricsBackend from snuba.utils.metrics.types import Tags +from snuba.utils.sentry import SENTRY_OP def create_metrics( @@ -71,14 +74,24 @@ def with_span(op: str = "function") -> Callable[[F], F]: def decorator(func: F) -> F: frame_info = inspect.stack()[1] - filename = frame_info.filename + attributes: dict[str, Any] = {SENTRY_OP: op, "filename": frame_info.filename} @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: - with sentry_sdk.start_span(description=func.__name__, op=op) as span: - span.set_data("filename", filename) + with traces.start_span(name=func.__name__, attributes=attributes): return func(*args, **kwargs) return cast(F, wrapper) return decorator + + +def set_current_span_attributes(attributes: Mapping[str, Any]) -> None: + """Set attributes on the active stream-mode span, if any. + + Replaces ``sentry_sdk.update_current_span()``, a no-op under stream mode. + """ + span = traces.get_current_span() + if span is None: + return + span.set_attributes(dict(attributes)) diff --git a/snuba/utils/profiler.py b/snuba/utils/profiler.py deleted file mode 100644 index 7e856dd9623..00000000000 --- a/snuba/utils/profiler.py +++ /dev/null @@ -1,65 +0,0 @@ -import logging -import socket -import threading -import time - -import sentry_sdk -from sentry_sdk.tracing import NoOpSpan, Transaction - -from snuba.state.sentry_options import get_option - -logger = logging.getLogger(__name__) - - -def run_ondemand_profiler() -> None: - thread = threading.Thread(target=_profiler_main, name="snuba ondemand profiler", daemon=True) - thread.start() - - -def _profiler_main() -> None: - current_transaction = None - own_hostname = socket.gethostname() - - while True: - queried_hostnames = (get_option("ondemand_profiler_hostnames", "")).split(",") - - if own_hostname in queried_hostnames and current_transaction is None: - # Log an error to Sentry on purpose, if the pod slows down it - # should be obvious why. - logger.warning("starting ondemand profile for %s", own_hostname) - - with sentry_sdk.Hub.main: - open_transaction: Transaction | NoOpSpan | None = sentry_sdk.start_transaction( - name=f"ondemand profile: {own_hostname}", sampled=True - ) - assert isinstance(open_transaction, Transaction) - assert open_transaction._profile is not None - open_transaction._profile.sampled = True - if open_transaction._profile.scheduler is None: - logger.warning( - "unable to start ondemand profile, need to turn on profiling globally" - ) - return - - # Set main thread as active thread -- this current thread is - # fairly uninteresting to look at. - # The profile contains all threads anyway. - open_transaction._profile.active_thread_id = threading.main_thread().ident - - open_transaction.__enter__() - transaction_start = time.time() - current_transaction = open_transaction, transaction_start - - continue - - if current_transaction is not None: - open_transaction, transaction_start = current_transaction - if own_hostname not in queried_hostnames or time.time() - transaction_start >= 30: - logger.warning("stopping ondemand profile for %s", own_hostname) - with sentry_sdk.Hub.main: - open_transaction.__exit__(None, None, None) - current_transaction = None - - continue - - time.sleep(5) diff --git a/snuba/utils/sentry.py b/snuba/utils/sentry.py new file mode 100644 index 00000000000..527c969c982 --- /dev/null +++ b/snuba/utils/sentry.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any + +import sentry_sdk + +# Stream-mode span op is an attribute, not a dedicated field. +SENTRY_OP = "sentry.op" + + +def set_tag_and_attribute(key: str, value: Any) -> None: + """Dual-write a scope tag (errors) and attribute (spans) during the transition.""" + sentry_sdk.set_tag(key, value) + sentry_sdk.set_attribute(key, value) diff --git a/snuba/web/db_query.py b/snuba/web/db_query.py index a8281cfe857..a945f697f8e 100644 --- a/snuba/web/db_query.py +++ b/snuba/web/db_query.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import random import uuid @@ -11,10 +12,10 @@ from typing import Any, cast import rapidjson -import sentry_sdk from clickhouse_driver.errors import ErrorCodes from sentry_kafka_schemas.schema_types import snuba_queries_v1 from sentry_options import OptionValue +from sentry_sdk import traces from sentry_sdk.api import configure_scope from snuba import environment, settings @@ -64,8 +65,9 @@ from snuba.util import force_bytes from snuba.utils.codecs import ExceptionAwareCodec from snuba.utils.metrics.timer import Timer -from snuba.utils.metrics.util import with_span +from snuba.utils.metrics.util import set_current_span_attributes, with_span from snuba.utils.metrics.wrapper import MetricsWrapper +from snuba.utils.sentry import SENTRY_OP, set_tag_and_attribute from snuba.utils.serializable_exception import ( SerializableException, SerializableExceptionDict, @@ -323,7 +325,7 @@ def execute_query_with_readthrough_caching( ): query_id = f"randomized-{uuid.uuid4().hex}" clickhouse_query_settings["query_id"] = query_id - sentry_sdk.update_current_span(attributes={"query_id": query_id}) + set_current_span_attributes({"query_id": query_id}) return execute_query( clickhouse_query, query_settings, @@ -337,7 +339,7 @@ def execute_query_with_readthrough_caching( clickhouse_query_settings["query_id"] = f"randomized-{uuid.uuid4().hex}" - sentry_sdk.update_current_span(attributes={"query_id": query_id}) + set_current_span_attributes({"query_id": query_id}) def record_cache_hit_type(hit_type: int) -> None: span_tag = "cache_miss" @@ -349,8 +351,7 @@ def record_cache_hit_type(hit_type: int) -> None: span_tag = "cache_wait" elif hit_type == SIMPLE_READTHROUGH: stats["cache_hit_simple"] = 1 - sentry_sdk.set_tag("cache_status", span_tag) - sentry_sdk.update_current_span(attributes={"cache_status": span_tag}) + set_tag_and_attribute("cache_status", span_tag) cache_partition = _get_cache_partition(reader) metrics.increment( @@ -537,9 +538,8 @@ def _raw_query( elif isinstance(cause, (TimeoutError, ExecutionTimeoutError)): status = QueryStatus.TIMEOUT - with configure_scope() as scope: - if scope.span: - sentry_sdk.set_tag("slo_status", request_status.status.value) + # No `if scope.span:` guard: scope.span is always None in stream mode. + set_tag_and_attribute("slo_status", request_status.status.value) stats = update_with_status( status=status or QueryStatus.ERROR, @@ -867,16 +867,20 @@ def _apply_allocation_policies_quota( rejection_quota_and_policy = None throttle_quota_and_policy = None min_threads_across_policies = MAX_THRESHOLD - with sentry_sdk.start_span( - op="allocation_policy", description="_apply_allocation_policies_quota" + with traces.start_span( + name="_apply_allocation_policies_quota", + attributes={SENTRY_OP: "allocation_policy"}, ) as span: for allocation_policy in allocation_policies: allowance = allocation_policy.get_quota_allowance(attribution_info.tenant_ids, query_id) can_run &= allowance.can_run quota_allowances[allocation_policy.class_name()] = allowance - span.set_data( + # QuotaAllowance isn't a valid attribute value; serialize it. + span.set_attribute( "quota_allowance", - quota_allowances[allocation_policy.class_name()], + json.dumps( + quota_allowances[allocation_policy.class_name()].to_dict(), default=repr + ), ) if allowance.is_throttled and allowance.max_threads < min_threads_across_policies: throttle_quota_and_policy = _QuotaAndPolicy( @@ -919,8 +923,8 @@ def _apply_allocation_policies_quota( if rejection_quota_and_policy is not None else "unknown" ) - span.set_data("policy", rejecting_policy) - span.set_data("action", "rejected") + span.set_attribute("policy", rejecting_policy) + span.set_attribute("action", "rejected") metrics.increment( "rejected_query", tags={ @@ -932,8 +936,8 @@ def _apply_allocation_policies_quota( if throttle_quota_and_policy is not None: throttling_policy = throttle_quota_and_policy.policy.class_name() - span.set_data("policy", throttling_policy) - span.set_data("action", "throttled") + span.set_attribute("policy", throttling_policy) + span.set_attribute("action", "throttled") metrics.increment( "throttled_query", tags={ @@ -947,5 +951,5 @@ def _apply_allocation_policies_quota( tags={"storage_key": allocation_policies[0].resource_identifier.value}, ) max_threads = min_threads_across_policies - span.set_data("max_threads", max_threads) + span.set_attribute("max_threads", max_threads) query_settings.set_resource_quota(ResourceQuota(max_threads=max_threads)) diff --git a/snuba/web/query.py b/snuba/web/query.py index eb1426eeb99..9de4b8a8374 100644 --- a/snuba/web/query.py +++ b/snuba/web/query.py @@ -3,7 +3,7 @@ import logging from typing import Any -import sentry_sdk +from sentry_sdk import traces from snuba import environment, settings from snuba.datasets.dataset import Dataset @@ -26,6 +26,7 @@ from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.util import with_span from snuba.utils.metrics.wrapper import MetricsWrapper +from snuba.utils.sentry import SENTRY_OP from snuba.web import QueryException, QueryExtraData, QueryResult logger = logging.getLogger("snuba.query") @@ -140,7 +141,7 @@ def parse_and_run_query( referrer (str): legacy param, you probably don't need to provide this. It should be in the tenant_ids of the body """ - with sentry_sdk.start_span(description="build_schema", op="validate"): + with traces.start_span(name="build_schema", attributes={SENTRY_OP: "validate"}): schema = RequestSchema.build(HTTPQuerySettings, is_mql) # NOTE(Volo): dataset is not necessary for queries because storages can be queried directly diff --git a/snuba/web/rpc/__init__.py b/snuba/web/rpc/__init__.py index a08168ff442..6d04682bd75 100644 --- a/snuba/web/rpc/__init__.py +++ b/snuba/web/rpc/__init__.py @@ -12,6 +12,7 @@ from sentry_protos.snuba.v1.downsampled_storage_pb2 import DownsampledStorageConfig from sentry_protos.snuba.v1.error_pb2 import Error as ErrorProto from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta, TraceItemType +from sentry_sdk import traces from snuba import environment from snuba.query.allocation_policies import AllocationPolicyViolations @@ -24,6 +25,7 @@ RegisteredClass, import_submodules_in_directory, ) +from snuba.utils.sentry import SENTRY_OP, set_tag_and_attribute from snuba.web import QueryException from snuba.web.rpc.common.common import Tin, Tout from snuba.web.rpc.common.exceptions import ( @@ -84,19 +86,18 @@ def _flush_logs() -> None: def _set_rpc_error_tags(in_msg: ProtobufMessage) -> None: - sentry_sdk.set_tag("source", "rpc_api") + set_tag_and_attribute("source", "rpc_api") - # Extract and tag fields from meta if available if hasattr(in_msg, "meta"): meta = in_msg.meta if hasattr(meta, "referrer") and meta.referrer: - sentry_sdk.set_tag("referrer", meta.referrer) + set_tag_and_attribute("referrer", meta.referrer) if hasattr(meta, "organization_id") and meta.organization_id: - sentry_sdk.set_tag("organization_id", str(meta.organization_id)) + set_tag_and_attribute("organization_id", str(meta.organization_id)) if hasattr(meta, "request_id") and meta.request_id: - sentry_sdk.set_tag("request_id", str(meta.request_id)) + set_tag_and_attribute("request_id", str(meta.request_id)) class TraceItemDataResolver(Generic[Tin, Tout], metaclass=RegisteredClass): @@ -202,10 +203,8 @@ def _uses_storage_routing(self, in_msg: Tin) -> bool: @final def execute(self, in_msg: Tin) -> Tout: scope = sentry_sdk.get_current_scope() + # Renames the service span (segment) directly in stream mode. scope.set_transaction_name(self.config_key()) - span = scope.span - if span is not None: - span.description = self.config_key() self.routing_context = RoutingContext( timer=self._timer, in_msg=in_msg, @@ -218,8 +217,13 @@ def execute(self, in_msg: Tin) -> Tout: meta = getattr(in_msg, "meta", RequestMeta()) try: if self.routing_decision.can_run: - with sentry_sdk.start_span(op="execute") as span: - span.set_data("selected_tier", self.routing_decision.tier) + with traces.start_span( + name="execute", + attributes={ + SENTRY_OP: "execute", + "selected_tier": self.routing_decision.tier.name, + }, + ): out = self._execute(in_msg) else: raise RPCAllocationPolicyException( diff --git a/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py b/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py index e292037b358..f7a91a059f4 100644 --- a/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py +++ b/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py @@ -2,7 +2,6 @@ from datetime import UTC, datetime, timedelta from typing import cast -import sentry_sdk from google.protobuf.json_format import MessageToDict from sentry_protos.snuba.v1.endpoint_get_traces_pb2 import GetTracesRequest from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeriesRequest @@ -25,6 +24,7 @@ from snuba.query.query_settings import OutcomesQuerySettings from snuba.request import Request as SnubaRequest from snuba.state.sentry_options import get_mapped_option, get_option +from snuba.utils.metrics.util import set_current_span_attributes from snuba.web.query import run_query from snuba.web.rpc.common.common import ( timestamp_in_range_condition, @@ -259,8 +259,8 @@ def _update_routing_decision( ): routing_decision.tier = Tier.TIER_8 - sentry_sdk.update_current_span( - attributes={ + set_current_span_attributes( + { "downsampling_mode": ( "highest_accuracy" if self._is_highest_accuracy_mode(in_msg_meta) else "normal" ), @@ -310,8 +310,8 @@ def _update_routing_decision( elif ingested_items > max_items_before_downsampling * 100: routing_decision.tier = Tier.TIER_512 - sentry_sdk.update_current_span( - attributes={ + set_current_span_attributes( + { "ingested_items": ingested_items, "max_items_before_downsampling": max_items_before_downsampling, "tier": routing_decision.tier.name, diff --git a/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py b/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py index ba5fc237f49..f6f9daced73 100644 --- a/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py +++ b/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os from abc import ABC from collections.abc import Callable @@ -22,6 +23,7 @@ from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeriesRequest from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import TraceItemTableRequest from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta +from sentry_sdk import traces from snuba import environment, settings from snuba.configs.configuration import ( @@ -50,8 +52,10 @@ from snuba.state import record_query from snuba.state.sentry_options import get_mapped_option, get_option from snuba.utils.metrics.timer import Timer +from snuba.utils.metrics.util import set_current_span_attributes from snuba.utils.metrics.wrapper import MetricsWrapper from snuba.utils.registered_class import import_submodules_in_directory +from snuba.utils.sentry import SENTRY_OP from snuba.web import QueryException, QueryResult from snuba.web.rpc.common.exceptions import RPCAllocationPolicyException from snuba.web.rpc.storage_routing.common import extract_message_meta @@ -210,7 +214,9 @@ def get_stats_dict( def _construct_hacky_querylog_payload( strategy: BaseRoutingStrategy, routing_decision: RoutingDecision ) -> snuba_queries_v1.Querylog: - cur_span = sentry_sdk.get_current_span() + # Propagation context has a trace id even with no active/sampled span. + propagation_context = sentry_sdk.get_current_scope().get_active_propagation_context() + trace_id = propagation_context.trace_id if propagation_context is not None else "" assert routing_decision.routing_context is not None query_result = routing_decision.routing_context.query_result or QueryResult( {}, {"stats": {}, "sql": "", "experiments": {}} @@ -247,7 +253,7 @@ def _construct_hacky_querylog_payload( "end_timestamp": in_message_meta.end_timestamp.seconds, "stats": get_stats_dict(routing_decision), "status": "0", - "trace_id": cur_span.trace_id if cur_span else "", + "trace_id": trace_id or "", "profile": { "time_range": None, "table": "eap_items", @@ -408,7 +414,7 @@ def _record_value_in_span_and_DD( "value": value, "tags": tags, } - sentry_sdk.update_current_span(attributes={name: value}) + set_current_span_attributes({name: value}) def _update_routing_decision( self, @@ -459,17 +465,18 @@ def _get_recommendations_from_allocation_policies( recommendations: dict[str, QuotaAllowance] = {} for allocation_policy in self.get_allocation_policies(): allocation_policy_name = allocation_policy.class_name() - with sentry_sdk.start_span( - op="allocation_policy.get_quota_allowance", - description=allocation_policy_name, + with traces.start_span( + name=allocation_policy_name, + attributes={SENTRY_OP: "allocation_policy.get_quota_allowance"}, ) as span: recommendations[allocation_policy_name] = allocation_policy.get_quota_allowance( routing_context.tenant_ids, routing_context.query_id, ) - span.set_data( + # QuotaAllowance isn't a valid attribute value; serialize it. + span.set_attribute( f"{allocation_policy_name}_quota_allowance", - recommendations[allocation_policy_name], + json.dumps(recommendations[allocation_policy_name].to_dict(), default=repr), ) return recommendations @@ -481,7 +488,7 @@ def get_routing_decision(self, routing_context: RoutingContext) -> RoutingDecisi default_tier = self._get_default_routing_decision_tier() - with sentry_sdk.start_span(op="decide_tier") as span: + with traces.start_span(name="decide_tier", attributes={SENTRY_OP: "decide_tier"}) as span: try: routing_context.timer.mark(_START_ESTIMATION_MARK) @@ -541,7 +548,7 @@ def get_routing_decision(self, routing_context: RoutingContext) -> RoutingDecisi if settings.RAISE_ON_ROUTING_STRATEGY_FAILURES: raise e - span.set_data("decided_tier", routing_decision.tier) + span.set_attribute("decided_tier", routing_decision.tier.name) return routing_decision @final diff --git a/snuba/web/rpc/v1/endpoint_get_trace.py b/snuba/web/rpc/v1/endpoint_get_trace.py index 58c504707cd..ace4b97a819 100644 --- a/snuba/web/rpc/v1/endpoint_get_trace.py +++ b/snuba/web/rpc/v1/endpoint_get_trace.py @@ -23,6 +23,7 @@ ComparisonFilter, TraceItemFilter, ) +from sentry_sdk import traces from snuba.attribution.appid import AppID from snuba.attribution.attribution_info import AttributionInfo @@ -47,6 +48,7 @@ ) from snuba.state.sentry_options import get_option from snuba.utils.metrics.util import with_span +from snuba.utils.sentry import SENTRY_OP from snuba.web.query import run_query from snuba.web.rpc import RPCEndpoint from snuba.web.rpc.common.common import ( @@ -347,9 +349,9 @@ def _build_query( if random.random() < _get_apply_final_rollout_percentage(): query.set_final(True) - span = sentry_sdk.get_current_span() - if span: - span.set_data("is_final", query.get_final()) + span = traces.get_current_span() + if span is not None: + span.set_attribute("is_final", query.get_final()) treeify_or_and_conditions(query) @@ -494,7 +496,7 @@ def _process_results( # First pass: parse rows and build attribute dicts parsed_rows: list[tuple[str, Timestamp, dict[str, GetTraceResponse.Item.Attribute]]] = [] - with sentry_sdk.start_span(op="function", description="add_attributes"): + with traces.start_span(name="add_attributes", attributes={SENTRY_OP: "function"}): for row in data: id = row.pop("id") ts = row.pop("timestamp") @@ -564,7 +566,7 @@ def add_attribute( # Second pass: sort attributes and assemble items items: list[GetTraceResponse.Item] = [] - with sentry_sdk.start_span(op="function", description="sort_attributes"): + with traces.start_span(name="sort_attributes", attributes={SENTRY_OP: "function"}): for id, timestamp, attributes in parsed_rows: item = GetTraceResponse.Item( id=id, @@ -576,9 +578,9 @@ def add_attribute( ) items.append(item) - current_span = sentry_sdk.get_current_span() + current_span = traces.get_current_span() if current_span is not None: - current_span.set_data("rows_processed", len(parsed_rows)) + current_span.set_attribute("rows_processed", len(parsed_rows)) return ProcessedResults( items=items, @@ -663,7 +665,7 @@ def _execute(self, in_msg: GetTraceRequest) -> GetTraceResponse: page_token = EndpointGetTracePageToken(i, last_seen_timestamp_precise, last_seen_id) break - with sentry_sdk.start_span(op="function", description="assemble_response"): + with traces.start_span(name="assemble_response", attributes={SENTRY_OP: "function"}): response_meta = extract_response_meta( in_msg.meta.request_id, in_msg.meta.debug, diff --git a/snuba/web/views.py b/snuba/web/views.py index 5598dd4d256..aceb3da41ba 100644 --- a/snuba/web/views.py +++ b/snuba/web/views.py @@ -28,6 +28,7 @@ from flask import request as http_request from flask_compress import Compress from sentry_protos.snuba.v1.error_pb2 import Error as ErrorProto +from sentry_sdk import traces from werkzeug import Response as WerkzeugResponse from werkzeug.exceptions import InternalServerError @@ -60,6 +61,7 @@ ) from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.util import with_span +from snuba.utils.sentry import SENTRY_OP, set_tag_and_attribute from snuba.web import QueryException, QueryTooLongException from snuba.web.bulk_delete_query import delete_from_storage as bulk_delete_from_storage from snuba.web.constants import get_http_status_for_clickhouse_error @@ -96,14 +98,14 @@ def truncate_dataset(dataset: Dataset) -> None: def _add_compression_attrs(response: Response) -> Response: - # TODO: update to attributes once we update the sdk - # leaving these as tags so I can aggregate by them in the meantime - sentry_sdk.set_tag("snuba.req_accept_encoding", http_request.headers.get("Accept-Encoding")) - sentry_sdk.set_tag("snuba.resp_encoding", response.headers.get("Content-Encoding")) - sentry_sdk.set_tag("snuba.resp_mime_type", response.mimetype) - span = sentry_sdk.get_current_span() - if span is not None: - span.set_data("snuba.resp_content_length", response.content_length) + set_tag_and_attribute( + "snuba.req_accept_encoding", http_request.headers.get("Accept-Encoding") or "" + ) + set_tag_and_attribute("snuba.resp_encoding", response.headers.get("Content-Encoding") or "") + set_tag_and_attribute("snuba.resp_mime_type", response.mimetype or "") + span = traces.get_current_span() + if span is not None and response.content_length is not None: + span.set_attribute("snuba.resp_content_length", response.content_length) return response @@ -232,7 +234,7 @@ def health() -> Response: def parse_request_body(http_request: Request) -> dict[str, Any]: - with sentry_sdk.start_span(description="parse_request_body", op="parse"): + with traces.start_span(name="parse_request_body", attributes={SENTRY_OP: "parse"}): metrics.timing("http_request_body_length", len(http_request.data)) try: body = json.loads(http_request.data) @@ -243,49 +245,37 @@ def parse_request_body(http_request: Request) -> dict[str, Any]: def _trace_transaction(dataset_name: str) -> None: - span = sentry_sdk.get_current_span() - if span: - span.set_tag("dataset", dataset_name) - span.set_tag("referrer", http_request.referrer) - - scope = sentry_sdk.get_current_scope() - if scope.transaction: - scope.set_transaction_name( - f"{scope.transaction.name}__{dataset_name}__{http_request.referrer}" - ) - scope.transaction.set_tag("dataset", dataset_name) - scope.transaction.set_tag("referrer", http_request.referrer) + # Scope-level so every span in the request inherits them. Normalize missing + # Referer once so the transaction name and referrer tag/attribute agree. + referrer = http_request.referrer or "" + set_tag_and_attribute("dataset", dataset_name) + set_tag_and_attribute("referrer", referrer) + # scope.transaction is None in stream mode; rebuild the name from the + # endpoint (Flask's default transaction_style) and rename via the scope. + endpoint = http_request.url_rule.endpoint if http_request.url_rule else "unknown" + sentry_sdk.get_current_scope().set_transaction_name(f"{endpoint}__{dataset_name}__{referrer}") -def _set_snql_api_error_tags(body: dict[str, Any], http_referrer: str | None) -> None: - """Set Sentry tags for SnQL API error tracking. - Tags all errors in the SnQL API with: - - source: snql_api - - referrer: from HTTP header or request body - - tenant_ids: as context and individual tags +def _set_snql_api_error_tags(body: dict[str, Any], http_referrer: str | None) -> None: + """Annotate SnQL API errors with source, referrer, and tenant_ids. - This function is wrapped in a try-except to ensure that any failure - in setting tags does not crash the API request. + Failures here must not crash the request. """ try: - sentry_sdk.set_tag("source", "snql_api") + set_tag_and_attribute("source", "snql_api") - # Extract and tag referrer referrer = http_referrer or body.get("tenant_ids", {}).get("referrer", "") - sentry_sdk.set_tag("referrer", referrer) + set_tag_and_attribute("referrer", referrer) - # Extract and set tenant_ids as context for better error tracking tenant_ids = body.get("tenant_ids", {}) if tenant_ids: sentry_sdk.set_context("tenant_ids", tenant_ids) - # Also set individual tenant_id tags for easier filtering for key, value in tenant_ids.items(): - if key != "referrer": # Skip referrer as it's already a tag - sentry_sdk.set_tag(f"tenant_id.{key}", str(value)) + if key != "referrer": + set_tag_and_attribute(f"tenant_id.{key}", str(value)) except Exception as e: - # Log the error but don't let it crash the API request - logger.warning("Failed to set Sentry tags for SnQL API", exc_info=e) + logger.warning("Failed to set Sentry tags/attributes for SnQL API", exc_info=e) @application.route("/query", methods=["GET", "POST"]) diff --git a/tests/clickhouse/test_connect.py b/tests/clickhouse/test_connect.py index 1d32c57bcd9..c583a15d8df 100644 --- a/tests/clickhouse/test_connect.py +++ b/tests/clickhouse/test_connect.py @@ -6,7 +6,7 @@ import pytest -from snuba.clickhouse.connect import ClickhouseConnectPool +from snuba.clickhouse.connect import ClickhouseConnectPool, _insert_statement from snuba.clickhouse.errors import ClickhouseError from snuba.clickhouse.formatter.nodes import FormattedQuery from snuba.clusters.cluster import ClickhouseClientSettings @@ -132,6 +132,13 @@ def test_insert_multiple_rows_build_a_matrix() -> None: ] +def test_insert_statement_mirrors_what_the_driver_sends() -> None: + assert ( + _insert_statement("migrations_local", ["group", "migration_id"]) + == "INSERT INTO migrations_local (`group`, `migration_id`) FORMAT Native" + ) + + def test_insert_empty_rows_short_circuits() -> None: client = mock.Mock()