diff --git a/backend/app/celery/tasks/job_execution.py b/backend/app/celery/tasks/job_execution.py index 1a84f9eb5..1a8892f5d 100644 --- a/backend/app/celery/tasks/job_execution.py +++ b/backend/app/celery/tasks/job_execution.py @@ -22,6 +22,7 @@ from app.celery.celery_app import celery_app from app.celery.utils import gevent_timeout from app.core.config import settings +from app.core.telemetry import suppress_db_instrumentation logger = logging.getLogger(__name__) @@ -82,16 +83,19 @@ def run_llm_job(self, project_id: int, job_id: str, trace_id: str, **kwargs): from app.services.llm.jobs import execute_job _set_trace(trace_id) - return _run_with_otel_parent( - self, - lambda: execute_job( - project_id=project_id, - job_id=job_id, - task_id=current_task.request.id, - task_instance=self, - **kwargs, - ), - ) + # DB spans suppressed job-wide so LLM waterfalls stay clean (drops these queries + # from the Sentry Queries page too — accepted trade-off). + with suppress_db_instrumentation(): + return _run_with_otel_parent( + self, + lambda: execute_job( + project_id=project_id, + job_id=job_id, + task_id=current_task.request.id, + task_instance=self, + **kwargs, + ), + ) @celery_app.task(bind=True, queue="default", priority=9) @@ -100,16 +104,17 @@ def run_llm_chain_job(self, project_id: int, job_id: str, trace_id: str, **kwarg from app.services.llm.jobs import execute_chain_job _set_trace(trace_id) - return _run_with_otel_parent( - self, - lambda: execute_chain_job( - project_id=project_id, - job_id=job_id, - task_id=current_task.request.id, - task_instance=self, - **kwargs, - ), - ) + with suppress_db_instrumentation(): + return _run_with_otel_parent( + self, + lambda: execute_chain_job( + project_id=project_id, + job_id=job_id, + task_id=current_task.request.id, + task_instance=self, + **kwargs, + ), + ) @celery_app.task(bind=True, queue="default", priority=9) @@ -118,16 +123,17 @@ def run_response_job(self, project_id: int, job_id: str, trace_id: str, **kwargs from app.services.response.jobs import execute_job _set_trace(trace_id) - return _run_with_otel_parent( - self, - lambda: execute_job( - project_id=project_id, - job_id=job_id, - task_id=current_task.request.id, - task_instance=self, - **kwargs, - ), - ) + with suppress_db_instrumentation(): + return _run_with_otel_parent( + self, + lambda: execute_job( + project_id=project_id, + job_id=job_id, + task_id=current_task.request.id, + task_instance=self, + **kwargs, + ), + ) @celery_app.task(bind=True, queue="default", priority=9) diff --git a/backend/app/core/telemetry.py b/backend/app/core/telemetry.py index 99d2fc959..2a1cd3afb 100644 --- a/backend/app/core/telemetry.py +++ b/backend/app/core/telemetry.py @@ -1,6 +1,5 @@ import json import logging -import time from collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar @@ -24,10 +23,37 @@ logger = logging.getLogger(__name__) +# Postgres SQLSTATE codes surfaced as named Sentry tags; others pass through as the raw code. +NOTABLE_SQLSTATES: dict[str, str] = { + "40P01": "deadlock_detected", + "57014": "query_canceled", # statement timeout + "40001": "serialization_failure", + "55P03": "lock_not_available", # lock timeout + "08006": "connection_failure", + "08003": "connection_does_not_exist", + "53300": "too_many_connections", +} + _log_context_var: ContextVar[dict[str, str] | None] = ContextVar( "kaapi_log_context", default=None ) +# OTel instrumentation scope emitted by SQLAlchemyInstrumentor; used to filter its spans. +_SQLALCHEMY_SCOPE = "opentelemetry.instrumentation.sqlalchemy" + +# When True in the current context, SQLAlchemy DB spans are dropped before reaching Sentry. +_suppress_db_spans_var: ContextVar[bool] = ContextVar( + "kaapi_suppress_db_spans", default=False +) + + +def _should_drop_db_span(otel_span: object) -> bool: + """True when DB-span suppression is active and `otel_span` is a SQLAlchemy span.""" + if not _suppress_db_spans_var.get(): + return False + scope = getattr(otel_span, "instrumentation_scope", None) + return scope is not None and getattr(scope, "name", None) == _SQLALCHEMY_SCOPE + def _emit_sentry_metric( metric_type: str, @@ -194,7 +220,20 @@ def setup_telemetry(service_name: str | None = None) -> None: if settings.SENTRY_DSN: from sentry_sdk.integrations.opentelemetry import SentrySpanProcessor - tracer_provider.add_span_processor(SentrySpanProcessor()) + class _DbSpanFilteringProcessor(SentrySpanProcessor): + """Drop SQLAlchemy DB spans from Sentry while suppress_db_instrumentation() is active. + + Filters by instrumentation scope so only DB spans are skipped — HTTP/Requests + spans keep flowing. Skipping on_start leaves the span unmapped, and the parent + on_end safely no-ops on unmapped spans. + """ + + def on_start(self, otel_span, parent_context=None): # type: ignore[override] + if _should_drop_db_span(otel_span): + return + super().on_start(otel_span, parent_context) + + tracer_provider.add_span_processor(_DbSpanFilteringProcessor()) trace.set_tracer_provider(tracer_provider) @@ -358,30 +397,56 @@ def suppress_http_instrumentation() -> Iterator[None]: otel_context.detach(token) -def record_db_query_finished( +@contextmanager +def suppress_db_instrumentation() -> Iterator[None]: + """Drop SQLAlchemy DB spans from the Sentry trace for the wrapped block. + + Wrap LLM job execution so its DB reads/writes do not clutter the LLM waterfall. + Only DB spans are filtered (by _DbSpanFilteringProcessor via instrumentation + scope) — HTTP/Requests instrumentation stays active. Trade-off: the wrapped + DB queries also drop from the Sentry Queries page. + """ + token = _suppress_db_spans_var.set(True) + try: + yield + finally: + _suppress_db_spans_var.reset(token) + + +def record_db_query_failed( *, - duration_ms: float, operation: str | None = None, - error: bool = False, + sqlstate: str | None = None, ) -> None: - """Emit DB query metrics to Sentry.""" + """Emit a DB query-failure counter to Sentry. Per-query duration/throughput come from spans.""" if not settings.OTEL_ENABLED: return - attrs: dict[str, str] = {} + attrs: dict[str, str | int | float] = {} if operation: attrs["db.operation"] = operation + if sqlstate: + attrs["db.sqlstate"] = sqlstate - _emit_sentry_metric("count", "db.query.total", 1, attributes=attrs) - _emit_sentry_metric( - "distribution", - "db.query.duration", - duration_ms, - unit="millisecond", - attributes=attrs, - ) - if error: - _emit_sentry_metric("count", "db.query.failed", 1, attributes=attrs) + _emit_sentry_metric("count", "db.query.failed", 1, attributes=attrs) + + +def _tag_db_error(sqlstate: str | None) -> None: + """Tag the active Sentry scope + span with a DB error's Postgres SQLSTATE.""" + if not sqlstate: + return + try: + span = trace.get_current_span() + if span.is_recording(): + span.set_attribute("db.sqlstate", sqlstate) + if sentry_sdk.get_client().is_active(): + sentry_sdk.set_tag("db.system", "postgresql") + sentry_sdk.set_tag("db.sqlstate", sqlstate) + name = NOTABLE_SQLSTATES.get(sqlstate) + if name: + sentry_sdk.set_tag("db.error.name", name) + except Exception: + logger.debug("[_tag_db_error] Failed to tag DB error | sqlstate: %s", sqlstate) def record_db_pool_stats( @@ -523,6 +588,17 @@ def instrument_db_engine(engine: object) -> None: if getattr(engine, "_kaapi_db_telemetry_instrumented", False): return + # DB query spans -> SentrySpanProcessor -> Sentry Insights/Queries. ProxyTracer defers + # to the real provider set later in setup_telemetry(), so load order here is safe. + try: + from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor + + SQLAlchemyInstrumentor().instrument(engine=engine) + except Exception: + logger.exception( + "[instrument_db_engine] Failed to load SQLAlchemy span instrumentation" + ) + try: from sqlalchemy import event except Exception: @@ -551,7 +627,7 @@ def _before_cursor_execute( conn, cursor, statement, parameters, context, executemany ) -> None: del cursor, parameters, executemany - context._kaapi_db_started_at = time.perf_counter() + # Operation kept for error attribution; per-query timing now lives on the span. context._kaapi_db_operation = ( str(statement).split(None, 1)[0].upper() if statement else "UNKNOWN" ) @@ -561,30 +637,20 @@ def _before_cursor_execute( def _after_cursor_execute( conn, cursor, statement, parameters, context, executemany ) -> None: - del cursor, statement, parameters, executemany - started_at = getattr(context, "_kaapi_db_started_at", None) - duration_ms = ( - (time.perf_counter() - started_at) * 1000 if started_at is not None else 0.0 - ) - operation = getattr(context, "_kaapi_db_operation", None) - record_db_query_finished( - duration_ms=duration_ms, operation=operation, error=False - ) + del cursor, statement, parameters, context, executemany _emit_pool_metrics(conn.engine.pool) @event.listens_for(engine, "handle_error") def _handle_error(exception_context) -> None: context = exception_context.execution_context - if context is None: - return - started_at = getattr(context, "_kaapi_db_started_at", None) - duration_ms = ( - (time.perf_counter() - started_at) * 1000 if started_at is not None else 0.0 - ) - operation = getattr(context, "_kaapi_db_operation", None) - record_db_query_finished( - duration_ms=duration_ms, operation=operation, error=True + operation = ( + getattr(context, "_kaapi_db_operation", None) + if context is not None + else None ) + sqlstate = getattr(exception_context.original_exception, "sqlstate", None) + _tag_db_error(sqlstate) + record_db_query_failed(operation=operation, sqlstate=sqlstate) @event.listens_for(engine.pool, "checkout") def _on_checkout(dbapi_connection, connection_record, connection_proxy) -> None: diff --git a/backend/app/tests/celery/test_job_execution.py b/backend/app/tests/celery/test_job_execution.py new file mode 100644 index 000000000..783e23222 --- /dev/null +++ b/backend/app/tests/celery/test_job_execution.py @@ -0,0 +1,69 @@ +"""Tests for the LLM/response Celery task wrappers in tasks/job_execution.py. + +The service entrypoints are mocked; no DB, OTel provider, or broker is used. +Celery tasks are callable and run synchronously when invoked directly (self is +bound), so we drive the real wrapper — including the gevent_timeout decorator +and suppress_db_instrumentation — end to end. +""" + +from unittest.mock import patch + +import pytest + +from app.celery.tasks import job_execution +from app.core import telemetry + +SENTINEL = object() + + +def _capture_suppression(captured: dict): + """execute_* stand-in that records whether DB spans are suppressed at call time.""" + + def _exec(**kwargs): + captured["suppressed"] = telemetry._suppress_db_spans_var.get() + return SENTINEL + + return _exec + + +@pytest.mark.parametrize( + ("task_name", "service_target"), + [ + ("run_llm_job", "app.services.llm.jobs.execute_job"), + ("run_llm_chain_job", "app.services.llm.jobs.execute_chain_job"), + ("run_response_job", "app.services.response.jobs.execute_job"), + ], +) +class TestJobWrappers: + def test_returns_service_result(self, task_name, service_target): + task = getattr(job_execution, task_name) + captured: dict = {} + with ( + patch.object(job_execution, "_set_trace", lambda trace_id: None), + patch(service_target, _capture_suppression(captured)), + ): + result = task(project_id=1, job_id="job-1", trace_id="trace-1") + + assert result is SENTINEL + + def test_db_spans_suppressed_during_execution(self, task_name, service_target): + task = getattr(job_execution, task_name) + captured: dict = {} + with ( + patch.object(job_execution, "_set_trace", lambda trace_id: None), + patch(service_target, _capture_suppression(captured)), + ): + task(project_id=1, job_id="job-1", trace_id="trace-1") + + assert captured["suppressed"] is True + + def test_suppression_resets_after_return(self, task_name, service_target): + task = getattr(job_execution, task_name) + captured: dict = {} + with ( + patch.object(job_execution, "_set_trace", lambda trace_id: None), + patch(service_target, _capture_suppression(captured)), + ): + task(project_id=1, job_id="job-1", trace_id="trace-1") + + assert telemetry._suppress_db_spans_var.get() is False diff --git a/backend/app/tests/core/test_telemetry.py b/backend/app/tests/core/test_telemetry.py new file mode 100644 index 000000000..00a6bf36c --- /dev/null +++ b/backend/app/tests/core/test_telemetry.py @@ -0,0 +1,271 @@ +"""Tests for the DB-observability helpers in telemetry.py. + +Sentry and OTel emission are mocked; no real Sentry connection or OTel provider +is used. The instrument_db_engine tests DO use a real in-memory SQLite engine to +drive the SQLAlchemy event hooks, but stub out the span instrumentor and the +Sentry-backed emit helpers. Most emitters gate on settings.OTEL_ENABLED, so the +enabled cases patch it True. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.pool import QueuePool + +from app.core import telemetry + + +def _active_sentry() -> MagicMock: + """A sentry_sdk stand-in whose client reports active.""" + fake = MagicMock() + fake.get_client.return_value.is_active.return_value = True + return fake + + +def _inactive_sentry() -> MagicMock: + fake = MagicMock() + fake.get_client.return_value.is_active.return_value = False + return fake + + +class TestNotableSqlstates: + def test_maps_known_postgres_codes(self): + assert telemetry.NOTABLE_SQLSTATES["40P01"] == "deadlock_detected" + assert telemetry.NOTABLE_SQLSTATES["57014"] == "query_canceled" + assert telemetry.NOTABLE_SQLSTATES["40001"] == "serialization_failure" + + def test_unknown_code_absent(self): + assert "99999" not in telemetry.NOTABLE_SQLSTATES + + +class TestRecordDbQueryFailed: + def test_emits_count_with_operation_and_sqlstate(self): + fake = _active_sentry() + with ( + patch.object(telemetry.settings, "OTEL_ENABLED", True), + patch.object(telemetry, "sentry_sdk", fake), + ): + telemetry.record_db_query_failed(operation="SELECT", sqlstate="40P01") + + fake.metrics.count.assert_called_once() + kwargs = fake.metrics.count.call_args.kwargs + assert kwargs["name"] == "db.query.failed" + assert kwargs["value"] == 1 + assert kwargs["attributes"]["db.operation"] == "SELECT" + assert kwargs["attributes"]["db.sqlstate"] == "40P01" + + def test_omits_missing_attributes(self): + fake = _active_sentry() + with ( + patch.object(telemetry.settings, "OTEL_ENABLED", True), + patch.object(telemetry, "sentry_sdk", fake), + ): + telemetry.record_db_query_failed() + + assert fake.metrics.count.call_args.kwargs["attributes"] == {} + + def test_noop_when_otel_disabled(self): + fake = _active_sentry() + with ( + patch.object(telemetry.settings, "OTEL_ENABLED", False), + patch.object(telemetry, "sentry_sdk", fake), + ): + telemetry.record_db_query_failed(operation="SELECT", sqlstate="40P01") + + fake.metrics.count.assert_not_called() + + def test_noop_when_sentry_inactive(self): + fake = _inactive_sentry() + with ( + patch.object(telemetry.settings, "OTEL_ENABLED", True), + patch.object(telemetry, "sentry_sdk", fake), + ): + telemetry.record_db_query_failed(operation="SELECT", sqlstate="40P01") + + fake.metrics.count.assert_not_called() + + +class TestTagDbError: + def _recording_span(self) -> MagicMock: + span = MagicMock() + span.is_recording.return_value = True + return span + + def test_known_code_sets_named_tag_and_span_attribute(self): + fake = _active_sentry() + span = self._recording_span() + with ( + patch.object(telemetry, "sentry_sdk", fake), + patch.object(telemetry.trace, "get_current_span", return_value=span), + ): + telemetry._tag_db_error("40P01") + + fake.set_tag.assert_any_call("db.system", "postgresql") + fake.set_tag.assert_any_call("db.sqlstate", "40P01") + fake.set_tag.assert_any_call("db.error.name", "deadlock_detected") + span.set_attribute.assert_called_once_with("db.sqlstate", "40P01") + + def test_unknown_code_skips_error_name_tag(self): + fake = _active_sentry() + span = self._recording_span() + with ( + patch.object(telemetry, "sentry_sdk", fake), + patch.object(telemetry.trace, "get_current_span", return_value=span), + ): + telemetry._tag_db_error("99999") + + fake.set_tag.assert_any_call("db.system", "postgresql") + fake.set_tag.assert_any_call("db.sqlstate", "99999") + tag_names = [c.args[0] for c in fake.set_tag.call_args_list] + assert "db.error.name" not in tag_names + + def test_none_sqlstate_is_noop(self): + fake = _active_sentry() + span = self._recording_span() + with ( + patch.object(telemetry, "sentry_sdk", fake), + patch.object(telemetry.trace, "get_current_span", return_value=span), + ): + telemetry._tag_db_error(None) + + fake.set_tag.assert_not_called() + span.set_attribute.assert_not_called() + + def test_swallows_exceptions(self): + fake = MagicMock() + fake.get_client.side_effect = RuntimeError("sentry exploded") + span = self._recording_span() + with ( + patch.object(telemetry, "sentry_sdk", fake), + patch.object(telemetry.trace, "get_current_span", return_value=span), + ): + # Must not raise. + telemetry._tag_db_error("40P01") + + +class TestSuppressDbInstrumentation: + def test_scope_constant(self): + assert telemetry._SQLALCHEMY_SCOPE == "opentelemetry.instrumentation.sqlalchemy" + + def _sqlalchemy_span(self) -> SimpleNamespace: + return SimpleNamespace( + instrumentation_scope=SimpleNamespace( + name="opentelemetry.instrumentation.sqlalchemy" + ) + ) + + def _httpx_span(self) -> SimpleNamespace: + return SimpleNamespace( + instrumentation_scope=SimpleNamespace( + name="opentelemetry.instrumentation.httpx" + ) + ) + + def test_outside_cm_never_drops(self): + assert telemetry._suppress_db_spans_var.get() is False + assert telemetry._should_drop_db_span(self._sqlalchemy_span()) is False + + def test_inside_cm_drops_only_sqlalchemy_spans(self): + with telemetry.suppress_db_instrumentation(): + assert telemetry._suppress_db_spans_var.get() is True + assert telemetry._should_drop_db_span(self._sqlalchemy_span()) is True + assert telemetry._should_drop_db_span(self._httpx_span()) is False + + def test_span_without_scope_not_dropped(self): + with telemetry.suppress_db_instrumentation(): + assert telemetry._should_drop_db_span(SimpleNamespace()) is False + + def test_contextvar_resets_after_exit(self): + with telemetry.suppress_db_instrumentation(): + assert telemetry._suppress_db_spans_var.get() is True + assert telemetry._suppress_db_spans_var.get() is False + + def test_contextvar_resets_even_when_body_raises(self): + try: + with telemetry.suppress_db_instrumentation(): + raise ValueError("boom") + except ValueError: + pass + assert telemetry._suppress_db_spans_var.get() is False + + +class TestInstrumentDbEngine: + """Drive the SQLAlchemy event hooks with a real in-memory SQLite engine. + + The span instrumentor is stubbed (no OTel provider needed) and the + Sentry-backed emit helpers are patched so we assert on the hooks alone. + """ + + def _engine(self): + return create_engine("sqlite://", poolclass=QueuePool) + + def test_successful_query_emits_pool_stats(self): + engine = self._engine() + pool_stats = MagicMock() + with ( + patch.object(telemetry.settings, "OTEL_ENABLED", True), + patch("opentelemetry.instrumentation.sqlalchemy.SQLAlchemyInstrumentor"), + patch.object(telemetry, "record_db_pool_stats", pool_stats), + ): + telemetry.instrument_db_engine(engine) + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + + pool_stats.assert_called() + kwargs = pool_stats.call_args.kwargs + assert set(kwargs) == {"active", "idle", "total", "overflow"} + + def test_failing_query_fires_error_hook(self): + engine = self._engine() + query_failed = MagicMock() + tag_error = MagicMock() + with ( + patch.object(telemetry.settings, "OTEL_ENABLED", True), + patch("opentelemetry.instrumentation.sqlalchemy.SQLAlchemyInstrumentor"), + patch.object(telemetry, "record_db_pool_stats", MagicMock()), + patch.object(telemetry, "record_db_query_failed", query_failed), + patch.object(telemetry, "_tag_db_error", tag_error), + ): + telemetry.instrument_db_engine(engine) + with engine.connect() as conn: + with pytest.raises(Exception): + conn.execute(text("SELECT * FROM does_not_exist")) + + query_failed.assert_called_once() + # SQLite driver errors carry no sqlstate, so the operation is SELECT and + # sqlstate is None — the hook still runs end to end. + assert query_failed.call_args.kwargs["operation"] == "SELECT" + assert query_failed.call_args.kwargs["sqlstate"] is None + tag_error.assert_called_once_with(None) + + def test_second_call_is_idempotent(self): + engine = self._engine() + with ( + patch.object(telemetry.settings, "OTEL_ENABLED", True), + patch( + "opentelemetry.instrumentation.sqlalchemy.SQLAlchemyInstrumentor" + ) as instrumentor, + patch.object(telemetry, "record_db_pool_stats", MagicMock()), + ): + telemetry.instrument_db_engine(engine) + assert engine._kaapi_db_telemetry_instrumented is True + instrumentor.return_value.instrument.assert_called_once() + + telemetry.instrument_db_engine(engine) + # Guard short-circuits: the instrumentor is not invoked a second time. + instrumentor.return_value.instrument.assert_called_once() + + def test_noop_when_otel_disabled(self): + engine = self._engine() + with ( + patch.object(telemetry.settings, "OTEL_ENABLED", False), + patch( + "opentelemetry.instrumentation.sqlalchemy.SQLAlchemyInstrumentor" + ) as instrumentor, + ): + telemetry.instrument_db_engine(engine) + + instrumentor.assert_not_called() + assert not getattr(engine, "_kaapi_db_telemetry_instrumented", False) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8d6219f76..0f0d94572 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "opentelemetry-instrumentation-httpx>=0.51b0", "opentelemetry-instrumentation-requests>=0.51b0", "opentelemetry-instrumentation-logging>=0.51b0", + "opentelemetry-instrumentation-sqlalchemy>=0.51b0", "pyjwt>=2.13.0,<3.0.0", "boto3>=1.37.20", "moto[s3]>=5.1.1", diff --git a/backend/uv.lock b/backend/uv.lock index 2ab854e81..e6d41531f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12, <4.0" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -285,6 +285,7 @@ dependencies = [ { name = "opentelemetry-instrumentation-httpx" }, { name = "opentelemetry-instrumentation-logging" }, { name = "opentelemetry-instrumentation-requests" }, + { name = "opentelemetry-instrumentation-sqlalchemy" }, { name = "opentelemetry-sdk" }, { name = "pandas" }, { name = "passlib", extra = ["bcrypt"] }, @@ -352,6 +353,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.51b0" }, { name = "opentelemetry-instrumentation-logging", specifier = ">=0.51b0" }, { name = "opentelemetry-instrumentation-requests", specifier = ">=0.51b0" }, + { name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.51b0" }, { name = "opentelemetry-sdk", specifier = ">=1.30.0" }, { name = "pandas", specifier = ">=2.3.2" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4,<2.0.0" }, @@ -2590,6 +2592,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/54/a73d688d969283f344de96c43366912addb94bdbef413bcebac22979545e/opentelemetry_instrumentation_requests-0.62b0-py3-none-any.whl", hash = "sha256:edf61785ecb3ec6923e33c24074c82067f286a418f817b2b82546956d120e6d6", size = 14209, upload-time = "2026-04-09T14:40:02.987Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-sqlalchemy" +version = "0.62b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/3d/40adc8c38e5be017ceb230a28ca57ca81981d4dc0c4b902cc930c77fd14f/opentelemetry_instrumentation_sqlalchemy-0.62b0.tar.gz", hash = "sha256:d02f85b83f349e9ef70a34cb3f4c3a3481fa15b11747f09209818663e161cac4", size = 18539, upload-time = "2026-04-09T14:40:50.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e0/77954ac593f34740dc32e28a15fe7170e90f6ba6398eaaa5c88b34c05ed1/opentelemetry_instrumentation_sqlalchemy-0.62b0-py3-none-any.whl", hash = "sha256:ec576e0660080d9d15ce4fa44d2a07fff8cb4b796a84344cb0f2c9e5d6e26f79", size = 15534, upload-time = "2026-04-09T14:40:03.957Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.41.0"