From c7c197109b7d7bb7a77dc43a0b9d84873f36b847 Mon Sep 17 00:00:00 2001 From: avantol Date: Tue, 25 Aug 2026 13:42:46 -0500 Subject: [PATCH 1/2] feat(observability): port generalized observability support from Gen3 AI into common library --- .gitignore | 7 + README.md | 8 + cdispyutils/metrics.py | 83 +- cdispyutils/observability/README.md | 208 ++++ cdispyutils/observability/__init__.py | 7 + cdispyutils/observability/_config.py | 112 +++ cdispyutils/observability/constants.py | 33 + .../observability/continuous_profiling.py | 159 +++ cdispyutils/observability/request_metrics.py | 404 ++++++++ cdispyutils/observability/tracing.py | 448 +++++++++ poetry.lock | 948 +++++++++++++++++- pyproject.toml | 31 +- tests/observability/__init__.py | 0 .../test_continuous_profiling.py | 142 +++ tests/observability/test_request_metrics.py | 409 ++++++++ tests/observability/test_tracing.py | 372 +++++++ tests/test_metrics.py | 155 ++- 17 files changed, 3507 insertions(+), 19 deletions(-) create mode 100644 cdispyutils/observability/README.md create mode 100644 cdispyutils/observability/__init__.py create mode 100644 cdispyutils/observability/_config.py create mode 100644 cdispyutils/observability/constants.py create mode 100644 cdispyutils/observability/continuous_profiling.py create mode 100644 cdispyutils/observability/request_metrics.py create mode 100644 cdispyutils/observability/tracing.py create mode 100644 tests/observability/__init__.py create mode 100644 tests/observability/test_continuous_profiling.py create mode 100644 tests/observability/test_request_metrics.py create mode 100644 tests/observability/test_tracing.py diff --git a/.gitignore b/.gitignore index cce92b0..d5a7d5d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,12 @@ coverage.xml *.mo *.pot +# AI +.claude/ +.serena/ +AGENTS.md +CLAUDE.md + # Django stuff: *.log local_settings.py @@ -93,3 +99,4 @@ ENV/ # Other .DS_Store +.vscode/ diff --git a/README.md b/README.md index 039a079..d3181a6 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ supplementary tools and resources that are reusable and not exclusive to any spe - Prometheus +## observability + +OpenTelemetry tracing, Pyroscope continuous profiling, and cardinality-safe request metrics for +FastAPI services. Requires the `observability` extra. + +See https://github.com/uc-cdis/cdis-python-utils/tree/master/cdispyutils/observability + + ## profiling See https://github.com/uc-cdis/cdis-python-utils/tree/master/cdispyutils/profiling diff --git a/cdispyutils/metrics.py b/cdispyutils/metrics.py index ca70332..d636269 100644 --- a/cdispyutils/metrics.py +++ b/cdispyutils/metrics.py @@ -11,7 +11,7 @@ from collections.abc import Callable import os import pathlib -from typing import Dict, Tuple +from typing import Dict from cdislogging import get_logger from prometheus_client import ( @@ -19,10 +19,12 @@ CollectorRegistry, Counter, Gauge, + Histogram, generate_latest, multiprocess, make_wsgi_app, make_asgi_app, + values, ) @@ -49,7 +51,7 @@ def get_wsgi_app(self) -> Callable: raise NotImplementedError() @abstractmethod - def get_latest_metrics(self) -> Tuple[str, str]: + def get_latest_metrics(self) -> tuple[bytes, str]: """ Generate the latest metrics. @@ -110,23 +112,35 @@ def __init__(self, enabled=True, prometheus_dir="/var/tmp/prometheus_metrics"): doesn't have to check, it always tries to log a metric. prometheus_dir (str): Directory to use when setting PROMETHEUS_MULTIPROC_DIR env var (which prometheus requires for multiprocess metrics collection). Note that this the prometheus client is very - finicky about when the ENV var is set. + finicky about when the ENV var is set: any metric created before this constructor + runs keeps storing its value in process memory and will not appear in a + multiprocess registry. """ self.enabled = enabled self.prometheus_metrics = {} + + # Created even when disabled, so `get_asgi_app`, `get_wsgi_app` and `get_latest_metrics` + # serve an empty 200 rather than raising AttributeError. A caller mounting the endpoint + # should not have to know whether metrics are on. + self._registry = CollectorRegistry() if not enabled: return pathlib.Path(prometheus_dir).mkdir(parents=True, exist_ok=True) os.environ["PROMETHEUS_MULTIPROC_DIR"] = prometheus_dir + # prometheus_client chooses between its in-memory and its multiprocess value class once, + # when prometheus_client.values is first imported, from PROMETHEUS_MULTIPROC_DIR - and + # this module's own import wins that race against a caller setting the variable here. + # Re-running the choice is what keeps counters out of process memory; without it they + # stay in memory while /metrics serves a multiprocess registry over an empty directory, + # returning 200 with no data. Metrics built before this runs keep the class they got. + values.ValueClass = values.get_value_class() + logger.info( f"PROMETHEUS_MULTIPROC_DIR is {os.environ['PROMETHEUS_MULTIPROC_DIR']}" ) - self._registry = CollectorRegistry() - multiprocess.MultiProcessCollector(self._registry, path=prometheus_dir) - def get_metrics_app(self, **kwargs) -> Callable: """ Required for Prometheus multiprocess setup @@ -152,17 +166,19 @@ def get_wsgi_app(self) -> Callable: """ return make_wsgi_app(self._registry) - def get_latest_metrics(self) -> Tuple[str, str]: + def get_latest_metrics(self) -> tuple[bytes, str]: """ Generate the latest Prometheus metrics Returns: - str: Latest Prometheus metrics + bytes: Latest Prometheus metrics, in the exposition format `generate_latest` emits. + Bytes in both branches, so a caller handing this to a response does not have + to know whether metrics were enabled. str: Content type of the latest Prometheus metrics """ # When metrics gathering is not enabled, the metrics endpoint should not error, but it should # not return any data. if not self.enabled: - return "", CONTENT_TYPE_LATEST + return b"", CONTENT_TYPE_LATEST return generate_latest(self._registry), CONTENT_TYPE_LATEST @@ -260,3 +276,52 @@ def _create_gauge_if_not_exist(self, name, labels, value, description) -> None: raise ValueError( f"Trying to create gauge '{name}' but a {type(self.prometheus_metrics[name])} with this name already exists" ) + + def observe_histogram( + self, name, labels, value, description="", buckets=None + ) -> None: + """ + Record one observation in a Prometheus histogram metric. + + Not part of AbstractBaseMetrics: adding an abstract method to that contract would stop + any existing implementation of it from instantiating. Check with `hasattr` before + calling this on something typed as the abstract base. + + Args: + name (str): Name of the metric. + labels (dict): Dictionary of labels for the metric. A histogram stores one bucket + series per label combination, so it multiplies the cost of every label far + faster than a counter does. + value (float): The observation, for example a duration in seconds. + description (str): Help text, used only when the histogram is first created. + buckets (Sequence[float] | None): Upper bounds of the buckets. None takes + prometheus_client's defaults, which span 5ms to 10s and suit request latency. + + Raises: + ValueError: If a metric of a different type already exists under this name. + """ + if not self.enabled: + return + + # create the histogram if it doesn't already exist + if name not in self.prometheus_metrics: + logger.info( + f"Creating histogram '{name}' with description '{description}' and labels: {labels}" + ) + extra_kwargs = {} if buckets is None else {"buckets": buckets} + self.prometheus_metrics[name] = Histogram( + name, + description, + [*labels.keys()], + registry=self._registry, + **extra_kwargs, + ) + elif type(self.prometheus_metrics[name]) is not Histogram: + raise ValueError( + f"Trying to create histogram '{name}' but a {type(self.prometheus_metrics[name])} with this name already exists" + ) + + logger.debug( + f"Observing '{value}' for histogram '{name}' with labels: {labels}" + ) + self.prometheus_metrics[name].labels(*labels.values()).observe(value) diff --git a/cdispyutils/observability/README.md b/cdispyutils/observability/README.md new file mode 100644 index 0000000..9154e94 --- /dev/null +++ b/cdispyutils/observability/README.md @@ -0,0 +1,208 @@ +# observability + +Tracing, continuous profiling, and request metrics for Gen3 FastAPI services. + +```bash +poetry add 'cdispyutils[observability]' +``` + +Four signals and how they leave the process: + +| Signal | Path out | Configured by | +| -------- | ------------------------------------------- | -------------------------------------------------------- | +| Traces | OTLP to a collector, or the console | `tracing.configure_tracing` | +| Profiles | Pyroscope ingest API | `continuous_profiling.configure_profiling` | +| Metrics | Scraped from an endpoint the service mounts | `request_metrics.add_request_metrics_middleware` | +| Logs | JSON on stdout, carrying the trace id | `cdislogging`, correlated by the logging instrumentation | + +Import the submodules directly. Importing them through the package would drag OpenTelemetry, +Pyroscope, and FastAPI in together even when only one is wanted. + +## Wiring a service + +Order matters: `configure_profiling` runs first, because `configure_tracing` only links spans to +profiles when it can see that an agent is already running. + +```python +from cdispyutils.metrics import BaseMetrics +from cdispyutils.observability.continuous_profiling import configure_profiling +from cdispyutils.observability.request_metrics import add_request_metrics_middleware +from cdispyutils.observability.tracing import configure_tracing, instrument_class + + +def get_app() -> FastAPI: + app = FastAPI() + + configure_profiling("my_service") + configure_tracing(app, "my_service") + + instrument_class(DataAccessLayer) + + metrics = BaseMetrics(enabled=True) + app.mount("/metrics", metrics.get_metrics_app(path="/var/tmp/prometheus_metrics")) + add_request_metrics_middleware( + app, metrics, counter_name="my_service_api_requests" + ) + return app +``` + +Every setting has a keyword argument and an environment variable behind it. Leave the argument +out to take the environment's value; pass it to override. A service with its own config object +passes its values in explicitly. + +### Choosing instrumentors + +`configure_tracing` enables HTTPX, requests, and log correlation by default. Pass `instrumentors` +to change that - a service with no `requests` calls and a pile of boto3 ones wants a different +set, and database instrumentation is deliberately not in the default because the right one +differs per service: + +```python +from opentelemetry.instrumentation.botocore import BotocoreInstrumentor +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + +from cdispyutils.observability.tracing import LoggingInstrumentorWithContext + +configure_tracing( + app, + "my_service", + instrumentors=[ + HTTPXClientInstrumentor(), + BotocoreInstrumentor(), + LoggingInstrumentorWithContext(), + ], +) +``` + +Use `LoggingInstrumentorWithContext`, not a bare `LoggingInstrumentor()`. The latter does not +inject trace context, so logs carry no trace id and nothing joins them to spans. + +## Configuration + +| Variable | Default | What it does | +| ------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------- | +| `ENABLE_OPENTELEMETRY_TRACES` | `true` | Whether to install a tracer provider at all | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `""` | Collector base URL. Empty prints spans to the console | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | `http/protobuf` | `grpc` or `http/protobuf` | +| `FORCE_DISABLE_CUSTOM_TRACING` | `false` | Diagnostic switch turning off `@traced` and the instrument helpers while leaving request spans alone | +| `ENABLE_CONTINUOUS_PROFILING` | `false` | Whether to start the Pyroscope agent | +| `PYROSCOPE_SERVER_ADDRESS` | `""` | Pyroscope ingest base URL. Empty leaves the agent stopped | +| `PYROSCOPE_SAMPLE_RATE` | `100` | Samples per second | +| `PYROSCOPE_UPLOAD_INTERVAL` | `10` | Seconds between pushes | +| `PROFILE_CPU` | `true` | Collect CPU profiles | +| `PROFILE_MEMORY` | `false` | Collect memory profiles | +| `PROFILE_ON_CPU_ONLY` | `true` | Measure CPU time rather than wall-clock time | +| `PYROSCOPE_BASIC_AUTH_USERNAME` | `""` | | +| `PYROSCOPE_BASIC_AUTH_PASSWORD` | `""` | | +| `PYROSCOPE_TENANT_ID` | `""` | | + +`FORCE_DISABLE_CUSTOM_TRACING` exists to answer "is our own instrumentation causing this?" in one +deploy. It is not a setting to leave on. + +## Tracing your own functions + +A few ways: + +**`@traced`** on a definition, roughly 10µs per call: + +```python +@traced +async def parse_and_auth_request(request: Request) -> None: ... +``` + +**`instrument_class(SomeClass)`** where the app is built, not at import. Traces the methods the +class itself defines, skipping inherited ones, dunders, and anything defined with +`@staticmethod`, `@classmethod`, or `@property` - the class dict holds a descriptor for those, so +there is no plain function to wrap. Decorate those where they are defined, with `@traced` +innermost. + +**`get_tracer(__name__).start_as_current_span(...)`** when the span should cover part of a +function rather than all of it. + +`configure_tracing(enabled=...)` gates `instrument_class` and `instrument_module`, which run from +the app factory after it. It cannot gate `@traced`: that runs when the decorated module is +imported, before any setup call, so only `ENABLE_OPENTELEMETRY_TRACES` in the environment reaches +it. `FORCE_DISABLE_CUSTOM_TRACING` turns off all three regardless. + +`instrument_module(module)` exists too, but only calls that look the function up on the module are +affected. A caller that did `from x import work` holds the original and keeps calling it untraced, +so a module whose consumers import it that way needs `@traced` at each definition. + +### What not to trace + +Anything that runs per row or per loop iteration. A span costs more than the work it reports +there; mark it `@no_trace` so the class and module walks skip it. + +Generators and async generators. A span around one ends when the generator object is created, +before any of the body runs, and wrapping hides the function's generator-ness from FastAPI's +dependency injection. `traced` raises `TypeError` rather than let that through, even when tracing +is disabled. Open a span inside the function instead. + +## Request metrics + +`add_request_metrics_middleware` counts every request, labelled with the route *template* that +matched it. That is the point of it: labelling with the URL would mint a Prometheus time series +per path parameter value, and a scanner walking the URL space would multiply that indefinitely. +Anything served without a matching route collapses to a single `` label. + +The metrics endpoint is always excluded, whatever `excluded_paths` says, so a scrape can never +count itself. + +Pass `duration_histogram_name` to record request latency as well: + +```python +add_request_metrics_middleware( + app, + metrics, + counter_name="my_service_api_requests", + duration_histogram_name="my_service_api_request_duration_seconds", +) +``` + +The timing covers the whole response, including a streamed body, because the middleware is pure +ASGI and the downstream call returns only after the last body message. A histogram stores one +bucket series per label combination, so it multiplies the cost of every label far faster than a +counter - be sparing with `extra_label_names` when one is enabled. `duration_buckets` overrides +the defaults, which span 5ms to 10s. + +Extra labels come from an async provider paired with the names it supplies: + +```python +async def user_id_label(request: Request) -> dict[str, str]: + return {"user_id": await get_user_id(request=request)} + +add_request_metrics_middleware( + app, + metrics, + counter_name="my_service_api_requests", + extra_label_names=("user_id",), + extra_labels=user_id_label, +) +``` + +The declared names are the canonical order, so a provider whose dict iterates differently cannot +swap values between labels. A name it omits, and every name if it raises, records as `Unknown`. +Each name multiplies the counter's cardinality, so declare them deliberately. + +## Checking it locally + +Spans, with no collector running: + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT= python -m uvicorn my_service.main:app +``` + +Profiles: + +```bash +docker run -p 4040:4040 grafana/pyroscope +ENABLE_CONTINUOUS_PROFILING=true PYROSCOPE_SERVER_ADDRESS=http://localhost:4040 \ + python -m uvicorn my_service.main:app +``` + +`PYROSCOPE_SERVER_ADDRESS` is Pyroscope's own ingest API, `POST /push.v1.PusherService/Push`, not +an OTLP receiver. Pointing it at 4317 or 4318 gets a 404 at push time, well after startup has +appeared to succeed. + +One agent runs per process, started when the app is built. Running uvicorn with `--workers` forks +after that point and leaves the children unprofiled. So *****follow our guidelines of 1 uvicorn process per container!***** diff --git a/cdispyutils/observability/__init__.py b/cdispyutils/observability/__init__.py new file mode 100644 index 0000000..8b85f6b --- /dev/null +++ b/cdispyutils/observability/__init__.py @@ -0,0 +1,7 @@ +""" +Tracing, continuous profiling, and request metrics for Gen3 FastAPI services. + +Requires the `observability` extra. Import the submodules directly rather than through this +package: each one pulls a different slice of that extra, and re-exporting here would make any +single import drag in OpenTelemetry, Pyroscope, and FastAPI together. +""" diff --git a/cdispyutils/observability/_config.py b/cdispyutils/observability/_config.py new file mode 100644 index 0000000..27c4e9a --- /dev/null +++ b/cdispyutils/observability/_config.py @@ -0,0 +1,112 @@ +""" +Environment-variable fallbacks for the observability helpers' keyword arguments. +""" + +import os +from collections.abc import Callable + +# What `starlette.config.Config(cast=bool)` accepts, so a deployment that already sets +# ENABLE_OPENTELEMETRY_TRACES=false for a Starlette-configured service keeps the same meaning +# here. +_TRUE_VALUES = frozenset({"true", "1", "on", "yes", "y", "t"}) +_FALSE_VALUES = frozenset({"false", "0", "off", "no", "n", "f", ""}) + + +def env_bool(name: str, default: bool) -> bool: + """ + Read a boolean setting from the environment. + + Args: + name (str): The environment variable to read. + default (bool): Returned when the variable is not set. + + Returns: + bool: The parsed value, or `default`. + + Raises: + ValueError: If the variable is set to something that is neither true-ish nor false-ish. + Rejected rather than read as false, so a typo in a deployment's configuration turns + observability off loudly instead of silently. + """ + raw = os.environ.get(name) + if raw is None: + return default + + value = raw.strip().lower() + if value in _TRUE_VALUES: + return True + if value in _FALSE_VALUES: + return False + + raise ValueError( + f"environment variable {name}={raw!r} is not a boolean; expected one of " + f"{sorted(_TRUE_VALUES | _FALSE_VALUES)}" + ) + + +def env_str(name: str, default: str) -> str: + """ + Read a string setting from the environment. + + Args: + name (str): The environment variable to read. + default (str): Returned when the variable is not set. + + Returns: + str: The value, or `default`. An empty variable reads as the empty string, which the + callers treat as "no endpoint configured" rather than falling back to `default`. + """ + raw = os.environ.get(name) + return default if raw is None else raw + + +def env_int(name: str, default: int) -> int: + """ + Read an integer setting from the environment. + + Args: + name (str): The environment variable to read. + default (int): Returned when the variable is not set. + + Returns: + int: The parsed value, or `default`. + + Raises: + ValueError: If the variable is set to something that is not an integer. + """ + raw = os.environ.get(name) + if raw is None: + return default + + try: + return int(raw.strip()) + except ValueError: + raise ValueError( + f"environment variable {name}={raw!r} is not an integer" + ) from None + + +def resolve[ + Setting +]( + value: Setting | None, + name: str, + default: Setting, + reader: Callable[[str, Setting], Setting], +) -> Setting: + """ + Return an explicit argument, or the environment's value for it. + + Args: + value (Setting | None): The argument as the caller passed it. Anything other than None is + used as-is, so an explicit False or empty string overrides the environment. + name (str): The environment variable to fall back to. + default (Setting): Returned when the argument is None and the variable is not set. + reader (Callable[[str, Setting], Setting]): One of `env_bool`, `env_str`, or `env_int`. + + Returns: + Setting: The resolved setting, with the same type as `default`. + """ + if value is not None: + return value + return reader(name, default) diff --git a/cdispyutils/observability/constants.py b/cdispyutils/observability/constants.py new file mode 100644 index 0000000..97bc81e --- /dev/null +++ b/cdispyutils/observability/constants.py @@ -0,0 +1,33 @@ +""" +Path sets and label values shared by the observability helpers. +""" + +# Recorded in place of the path of a request that matched no route, so that a scanner walking +# the URL space adds one time series instead of one per URL it tries. +UNMATCHED_PATH = "" + +# Recorded in place of a label the caller's provider could not produce, so the counter keeps the +# label set it was created with. +UNKNOWN_LABEL_VALUE = "Unknown" + +# Endpoints that exist to be polled or fetched by a browser. +# +# Do NOT add "/" here. `tracing.excluded_url_patterns` turns each of these into a regex anchored +# at the end of the URL, so a bare "/" becomes "/$", which matches every URL ending in a slash +# and would drop the trailing-slash form of every real route from tracing. The site root, the +# docs, and the OpenAPI spec are left out for the same reason they are cheap to record: traffic +# to them is low volume and worth seeing. +DEFAULT_UNMONITORED_PATHS = frozenset( + { + "/_status", + "/_status/", + "/_version", + "/_version/", + "/favicon.ico", + "/favicon.ico/", + } +) + +DEFAULT_ENDPOINTS_WITHOUT_METRICS = DEFAULT_UNMONITORED_PATHS | frozenset( + {"/metrics", "/metrics/"} +) diff --git a/cdispyutils/observability/continuous_profiling.py b/cdispyutils/observability/continuous_profiling.py new file mode 100644 index 0000000..e17c83c --- /dev/null +++ b/cdispyutils/observability/continuous_profiling.py @@ -0,0 +1,159 @@ +""" +Continuous profiling with Pyroscope. + +Requires the `observability` extra. +""" + +import os +from collections.abc import Mapping + +import pyroscope +from cdislogging import get_logger + +from cdispyutils.observability._config import env_bool, env_int, env_str, resolve + +logger = get_logger(__name__) + +# Whether `configure_profiling` has started the agent in this process. The SDK holds one global +# agent, and a second `pyroscope.configure` only logs `Agent already running` and returns, so this +# is what keeps that error out of a test suite that builds the app repeatedly. +_agent_running = False + + +def configure_profiling( + service_name: str, + *, + enabled: bool | None = None, + server_address: str | None = None, + sample_rate: int | None = None, + upload_interval: int | None = None, + profile_cpu: bool | None = None, + profile_memory: bool | None = None, + on_cpu_only: bool | None = None, + basic_auth_username: str | None = None, + basic_auth_password: str | None = None, + tenant_id: str | None = None, + tags: Mapping[str, str] | None = None, +) -> None: + """ + Start the Pyroscope agent so this process pushes CPU and memory profiles. + + Does nothing when profiling is disabled, when no server address is configured, or when the + agent is already running in this process. Call this before + `cdispyutils.observability.tracing.configure_tracing`, which asks `profiling_active` whether + to link spans to profiles. + + Every argument left as None is read from the environment variable named beside it below, so a + service can configure this entirely through its deployment. + + Args: + service_name (str): Pyroscope's application name, which is what the profiles are grouped + and queried under. + enabled (bool | None): Whether to start the agent at all. + Env ENABLE_CONTINUOUS_PROFILING, default False. + server_address (str | None): Base URL of the Pyroscope ingest API. Note this is + Pyroscope's own `POST /push.v1.PusherService/Push` endpoint, not an OTLP receiver, so + an OTLP port such as 4317 or 4318 fails at push time rather than here. + Env PYROSCOPE_SERVER_ADDRESS, default "" (the agent is not started). + sample_rate (int | None): Samples per second. Env PYROSCOPE_SAMPLE_RATE, default 100. + upload_interval (int | None): Seconds between pushes. + Env PYROSCOPE_UPLOAD_INTERVAL, default 10. + profile_cpu (bool | None): Collect CPU profiles. Env PROFILE_CPU, default True. + profile_memory (bool | None): Collect memory profiles. Env PROFILE_MEMORY, default False. + on_cpu_only (bool | None): Measure CPU time rather than wall-clock time, so time spent + awaiting I/O is left out of the flamegraph. + Env PROFILE_ON_CPU_ONLY, default True. + basic_auth_username (str | None): Env PYROSCOPE_BASIC_AUTH_USERNAME, default "". + basic_auth_password (str | None): Env PYROSCOPE_BASIC_AUTH_PASSWORD, default "". + tenant_id (str | None): Env PYROSCOPE_TENANT_ID, default "". + tags (Mapping[str, str] | None): Extra tags, merged over the default `pod` tag. + """ + global _agent_running + + if not resolve(enabled, "ENABLE_CONTINUOUS_PROFILING", False, env_bool): + logger.info("Continuous profiling is disabled, skipping Pyroscope setup") + return + + address = resolve(server_address, "PYROSCOPE_SERVER_ADDRESS", "", env_str) + if not address: + logger.warning( + "Continuous profiling is enabled but no Pyroscope server address is configured, " + "so the agent was not started" + ) + return + + if _agent_running: + logger.info( + "The Pyroscope agent is already running in this process, leaving it alone" + ) + return + + pyroscope.configure( + application_name=service_name, + server_address=address, + sample_rate=resolve(sample_rate, "PYROSCOPE_SAMPLE_RATE", 100, env_int), + upload_interval=resolve( + upload_interval, "PYROSCOPE_UPLOAD_INTERVAL", 10, env_int + ), + cpu_enabled=resolve(profile_cpu, "PROFILE_CPU", True, env_bool), + mem_enabled=resolve(profile_memory, "PROFILE_MEMORY", False, env_bool), + oncpu=resolve(on_cpu_only, "PROFILE_ON_CPU_ONLY", True, env_bool), + # Sample only the thread holding the GIL. Every request is handled on the one event loop + # thread, so that is the thread doing the work; a service that pushed work into a + # threadpool would need False to see any of it. + gil_only=True, + tags=_agent_tags(tags), + report_pid=True, + basic_auth_username=resolve( + basic_auth_username, "PYROSCOPE_BASIC_AUTH_USERNAME", "", env_str + ), + basic_auth_password=resolve( + basic_auth_password, "PYROSCOPE_BASIC_AUTH_PASSWORD", "", env_str + ), + tenant_id=resolve(tenant_id, "PYROSCOPE_TENANT_ID", "", env_str), + ) + _agent_running = True + + logger.info(f"Pyroscope agent started for '{service_name}', pushing to {address}") + + +def profiling_active() -> bool: + """ + Report whether the Pyroscope agent is running in this process. + + Returns: + bool: True only after `configure_profiling` has started the agent. Distinct from the + enabled setting, because the tagging that links traces to profiles is wasted work + unless there is an agent to receive the tags. + """ + return _agent_running + + +def stop_profiling() -> None: + """ + Stop the agent and allow `configure_profiling` to start it again. + + Exists for tests, which would otherwise leak one process-wide agent into every test that + follows the first one to enable profiling. + """ + global _agent_running + + if not _agent_running: + return + + pyroscope.shutdown() + _agent_running = False + + +def _agent_tags(tags: Mapping[str, str] | None) -> dict[str, str]: + """ + Build the tag set the agent reports profiles under. + + Args: + tags (Mapping[str, str] | None): Caller-supplied tags, which win over the defaults. + + Returns: + dict[str, str]: The merged tags. Kubernetes sets HOSTNAME to the pod name, which is the + only thing distinguishing one replica's flamegraph from another's. + """ + return {"pod": os.environ.get("HOSTNAME", ""), **(tags or {})} diff --git a/cdispyutils/observability/request_metrics.py b/cdispyutils/observability/request_metrics.py new file mode 100644 index 0000000..d6d7405 --- /dev/null +++ b/cdispyutils/observability/request_metrics.py @@ -0,0 +1,404 @@ +""" +Prometheus metrics for the HTTP requests a FastAPI application serves. + +Requires the `observability` extra. +""" + +import time +from collections.abc import Awaitable, Callable, Collection, Mapping +from typing import Protocol, cast + +from cdislogging import get_logger +from fastapi import FastAPI +from starlette.requests import Request +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from cdispyutils.metrics import AbstractBaseMetrics +from cdispyutils.observability.constants import ( + DEFAULT_ENDPOINTS_WITHOUT_METRICS, + UNKNOWN_LABEL_VALUE, + UNMATCHED_PATH, +) + +logger = get_logger(__name__) + +# Labels the middleware records itself, which an extra-label provider may not shadow. +RESERVED_LABEL_NAMES = frozenset({"method", "path", "status_code"}) + +ExtraLabelsProvider = Callable[[Request], Awaitable[Mapping[str, str]]] + + +class HistogramRecorder(Protocol): + """ + A metrics client that can record histograms. + + `observe_histogram` is on `BaseMetrics` rather than on `AbstractBaseMetrics`, so this names + the extra capability that `add_request_metrics_middleware` checks for before accepting a + `duration_histogram_name`. + """ + + def observe_histogram( + self, + name: str, + labels: Mapping[str, str], + value: float, + description: str = "", + buckets: Collection[float] | None = None, + ) -> None: + """Record one observation.""" + + +def add_request_metrics_middleware( + app: FastAPI, + metrics: AbstractBaseMetrics | None, + *, + counter_name: str, + counter_description: str = "", + duration_histogram_name: str | None = None, + duration_histogram_description: str = "", + duration_buckets: Collection[float] | None = None, + excluded_paths: Collection[str] = DEFAULT_ENDPOINTS_WITHOUT_METRICS, + metrics_path: str | None = "/metrics", + extra_unrouted_paths: Collection[str] = (), + extra_label_names: Collection[str] = (), + extra_labels: ExtraLabelsProvider | None = None, +) -> None: + """ + Count every HTTP request the app serves, labelled by route template. + + Requests are labelled with the template that matched them, for example `/things/{thing_id}`, + never the URL that was requested, so a path parameter does not mint one Prometheus time + series per value. A request matching no route is labelled UNMATCHED_PATH. + + Installs nothing when metrics are disabled, so callers never have to guard this call. + + Args: + app (FastAPI): The application to instrument. Must not have started serving yet; + Starlette refuses new middleware once it has. + metrics (AbstractBaseMetrics | None): The metrics client to record through. None, or a + client reporting itself disabled, installs nothing. + counter_name (str): Name of the counter, for example `gen3_workflow_api_requests`. + Prometheus exposes it with a `_total` suffix. + counter_description (str): Help text, used only when the counter is first created. + duration_histogram_name (str | None): Name of a histogram to record request duration in + seconds under, or None to record no timings. Timing covers the whole response, + including a streamed body. The histogram carries the same labels as the counter and + stores one bucket series per label combination, so `extra_label_names` costs far + more with one of these enabled. + duration_histogram_description (str): Help text for the histogram. + duration_buckets (Collection[float] | None): Upper bounds of the histogram's buckets. + None takes prometheus_client's defaults, which span 5ms to 10s. + excluded_paths (Collection[str]): Path labels to record nothing for. Matched against the + resolved label, so against a route template rather than a URL. `metrics_path` is + always excluded whether or not it appears here. + metrics_path (str | None): Where the Prometheus endpoint is mounted, or None if the app + has none. A mounted sub-application leaves no route on the request scope, so the + middleware can only recognise it by path. + extra_unrouted_paths (Collection[str]): Further paths the app serves without an API + route, such as other mounted sub-applications. Anything served without a route and + not listed here is labelled UNMATCHED_PATH. + extra_label_names (Collection[str]): Names of the labels `extra_labels` supplies. The + counter is created with exactly these, in this order, on top of `method`, `path`, and + `status_code`. Every name multiplies the counter's cardinality. + extra_labels (ExtraLabelsProvider | None): Async callable returning values for + `extra_label_names`, called once per counted request after the response. It receives + a Request with no receive channel, so it can read headers, cookies, and query + parameters, but reaching for the body raises rather than hanging on a channel whose + content is already consumed. Any exception it raises, and any name it omits, yields + UNKNOWN_LABEL_VALUE for that label. + + Raises: + ValueError: If `extra_label_names` and `extra_labels` are not either both given or both + omitted, if a name in `extra_label_names` is one of `method`, `path`, or + `status_code`, or if `duration_histogram_name` is given for a metrics client that + cannot record histograms. + """ + label_names = tuple(extra_label_names) + + if bool(label_names) != bool(extra_labels): + raise ValueError( + "extra_label_names and extra_labels must be given together; got names=" + f"{sorted(label_names)} and provider={extra_labels!r}" + ) + + reserved = RESERVED_LABEL_NAMES.intersection(label_names) + if reserved: + raise ValueError( + f"extra_label_names may not reuse the built-in labels: {sorted(reserved)}" + ) + + # `observe_histogram` is on BaseMetrics rather than on the abstract base, so checking is the + # only honest option. Failing here makes it a startup error rather than an AttributeError on + # the first request. + if duration_histogram_name and not hasattr(metrics, "observe_histogram"): + raise ValueError( + f"{type(metrics).__name__} cannot record histograms, so " + f"duration_histogram_name={duration_histogram_name!r} cannot be honoured" + ) + + # `enabled` is on BaseMetrics rather than on the abstract base, so a third-party + # implementation of the contract need not carry it. + if metrics is None or not getattr(metrics, "enabled", True): + logger.info( + f"Metrics are disabled, so requests will not be counted under '{counter_name}'" + ) + return + + # A scrape must never be able to count itself, however the caller configured exclusions: + # that makes the counter climb on its own and turns any rate() over it into noise. + always_excluded = ( + {metrics_path, metrics_path.rstrip("/") + "/"} if metrics_path else set() + ) + + # Endpoints FastAPI and Starlette serve without an APIRoute: the docs, the spec, and any + # mounted sub-application. None of them leave a route on the scope, so the middleware has to + # recognise them by path or collapse them into UNMATCHED_PATH. + unrouted_paths = frozenset( + path + for path in ( + app.docs_url, + app.redoc_url, + app.openapi_url, + metrics_path, + *extra_unrouted_paths, + ) + if path + ) + + app.add_middleware( + _RequestMetricsMiddleware, + metrics=metrics, + # The hasattr check above is what establishes this; the cast only says so in the types. + histogram_metrics=( + cast(HistogramRecorder, metrics) if duration_histogram_name else None + ), + counter_name=counter_name, + counter_description=counter_description, + duration_histogram_name=duration_histogram_name, + duration_histogram_description=duration_histogram_description, + duration_buckets=duration_buckets, + excluded_paths=frozenset(excluded_paths) | always_excluded, + unrouted_paths=unrouted_paths, + extra_label_names=label_names, + extra_labels=extra_labels, + ) + + +class _RequestMetricsMiddleware: + """ + ASGI middleware recording one counter increment per served request. + + Written against the raw ASGI interface rather than as a BaseHTTPMiddleware subclass: that + base allocates an anyio task group and a memory object stream per request and buffers + streaming responses, which is more interference than a helper needing only the response + status should impose on a service that streams. + """ + + def __init__( + self, + app: ASGIApp, + *, + metrics: AbstractBaseMetrics, + histogram_metrics: HistogramRecorder | None, + counter_name: str, + counter_description: str, + duration_histogram_name: str | None, + duration_histogram_description: str, + duration_buckets: Collection[float] | None, + excluded_paths: frozenset[str], + unrouted_paths: frozenset[str], + extra_label_names: tuple[str, ...], + extra_labels: ExtraLabelsProvider | None, + ) -> None: + self.app = app + self._metrics = metrics + self._histogram_metrics = histogram_metrics + self._counter_name = counter_name + self._counter_description = counter_description + self._duration_histogram_name = duration_histogram_name + self._duration_histogram_description = duration_histogram_description + self._duration_buckets = duration_buckets + self._excluded_paths = excluded_paths + self._unrouted_paths = unrouted_paths + self._extra_label_names = extra_label_names + self._extra_labels = extra_labels + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """ + Serve one ASGI event, counting it if it is an HTTP request. + + Args: + scope (Scope): The connection scope. + receive (Receive): The ASGI receive channel. + send (Send): The ASGI send channel. + """ + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + # Stands until an http.response.start goes past. If the app raises instead, this is the + # status the error handler above this middleware is about to send. + status_code = 500 + + async def send_wrapper(message: Message) -> None: + nonlocal status_code + if message["type"] == "http.response.start": + status_code = message["status"] + await send(message) + + # perf_counter around the whole downstream call, which for a streaming response returns + # only after the last body message: the duration therefore covers the transfer, not just + # the time to headers. + started = time.perf_counter() + try: + await self.app(scope, receive, send_wrapper) + finally: + # Routing runs inside the call above and updates this same scope dict in place, so + # the matched route is only readable afterwards. + await self._record(scope, status_code, time.perf_counter() - started) + + async def _record( + self, scope: Scope, status_code: int, duration_seconds: float + ) -> None: + """ + Record one finished request. + + Args: + scope (Scope): The scope of a request that has already been routed. + status_code (int): The status the response went out with. + duration_seconds (float): How long the whole response took. + """ + path = _path_label(scope, self._unrouted_paths) + if path in self._excluded_paths: + return + + # Assembled in a fixed order every time: increment_counter creates the counter from the + # first call's key order and thereafter passes values positionally, so a dict that + # iterates differently on a later request would file values under the wrong labels. + labels = { + "method": scope.get("method", ""), + "path": path, + "status_code": str(status_code), + } + labels.update(await self._resolve_extra_labels(scope)) + + try: + self._metrics.increment_counter( + name=self._counter_name, + labels=labels, + description=self._counter_description, + ) + if self._histogram_metrics and self._duration_histogram_name: + self._histogram_metrics.observe_histogram( + name=self._duration_histogram_name, + labels=labels, + value=duration_seconds, + description=self._duration_histogram_description, + buckets=self._duration_buckets, + ) + except Exception as exc: + # A metric must never be the reason a response fails. + logger.warning( + f"Could not record counter '{self._counter_name}' for '{path}'. Error: '{exc}'" + ) + + async def _resolve_extra_labels(self, scope: Scope) -> dict[str, str]: + """ + Ask the caller's provider for its labels. + + Args: + scope (Scope): The scope of the request being counted. + + Returns: + dict[str, str]: One entry per declared name, in declared order. A name the provider + did not supply, and every name if the provider raised, gets UNKNOWN_LABEL_VALUE, + which keeps the counter's label set the shape it was created with. + """ + if not self._extra_label_names: + return {} + + provided: Mapping[str, str] = {} + if self._extra_labels: + try: + # No receive channel: the body is already consumed, so a provider reaching for it + # gets Starlette's RuntimeError rather than awaiting a channel that never yields. + provided = await self._extra_labels(Request(scope)) + except Exception as exc: + logger.debug( + f"Extra metric labels unavailable. Error: '{exc}'. " + f"Using '{UNKNOWN_LABEL_VALUE}'" + ) + + return { + name: str(provided.get(name, UNKNOWN_LABEL_VALUE)) + for name in self._extra_label_names + } + + +def _path_label(scope: Scope, unrouted_paths: frozenset[str]) -> str: + """ + Return the label to record a request's path under. + + Args: + scope (Scope): The scope of a request that has already been routed. + unrouted_paths (frozenset[str]): Paths the app serves without an APIRoute, which + therefore have no template to be labelled with. + + Returns: + str: The matched route's template, for example `/things/{thing_id}`, one of + `unrouted_paths`, or UNMATCHED_PATH. Never the request's own URL, whose path + parameters would each become a separate Prometheus time series. + """ + # Only fastapi.routing.APIRoute puts `route` on the scope. A plain Starlette route, which is + # what /docs, /redoc and /openapi.json are, does not, hence the fallbacks below. + template = getattr(scope.get("route"), "path", None) + if template: + return template + + for candidate in (_route_path(scope), _mount_prefix(scope)): + if candidate in unrouted_paths: + return candidate + + return UNMATCHED_PATH + + +def _route_path(scope: Scope) -> str: + """ + Return the path the router matched against, with the app's own prefix removed. + + Args: + scope (Scope): The scope of a request that has already been routed. + + Returns: + str: The path as the app's routes declare it. An app deployed behind a prefix carries + that prefix in both `path` and `root_path`, so the raw path never equals a declared + path such as `/docs`. + """ + path = scope.get("path", "") + root_path = scope.get("root_path", "") + if root_path and path.startswith(root_path): + return path.removeprefix(root_path) or "/" + return path + + +def _mount_prefix(scope: Scope) -> str: + """ + Return the prefix of the mount that served this request. + + Args: + scope (Scope): The scope of a request that has already been routed. + + Returns: + str: The mount's prefix as the app declares it, or `root_path` when no mount handled the + request. Starlette moves a mount's prefix off the path and onto `root_path` before + handing the request to the sub-application, and records the app's own prefix in + `app_root_path`, so a `/metrics` mount under an app at `/svc` arrives as + `root_path="/svc/metrics"`. Without subtracting the app's own prefix the label falls + through to UNMATCHED_PATH, misses the exclusion check, and every scrape counts + itself. + """ + root_path = scope.get("root_path", "") + app_root_path = scope.get("app_root_path", "") + if app_root_path and root_path.startswith(app_root_path): + return root_path.removeprefix(app_root_path) + return root_path diff --git a/cdispyutils/observability/tracing.py b/cdispyutils/observability/tracing.py new file mode 100644 index 0000000..768e4d4 --- /dev/null +++ b/cdispyutils/observability/tracing.py @@ -0,0 +1,448 @@ +""" +OpenTelemetry tracing for FastAPI services. + +Requires the `observability` extra. +""" + +import functools +import inspect +import re +from collections.abc import Collection, Iterable +from types import FunctionType, ModuleType +from typing import Any, Protocol, cast + +from cdislogging import get_logger +from fastapi import FastAPI +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as GrpcSpanExporter, +) +from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as HttpSpanExporter, +) +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, + SpanExporter, +) + +from cdispyutils.observability._config import env_bool, env_str, resolve +from cdispyutils.observability.constants import DEFAULT_ENDPOINTS_WITHOUT_METRICS +from cdispyutils.observability.continuous_profiling import profiling_active + +logger = get_logger(__name__) + +GRPC_PROTOCOL = "grpc" + + +class Instrumentor(Protocol): + """ + What `configure_tracing` needs from a library instrumentation. + + Structural rather than `BaseInstrumentor`, so an adapter that merely wraps one - such as + `LoggingInstrumentorWithContext` - satisfies it without subclassing an ABC whose + `_instrument` and `_uninstrument` contract it has no use for. + """ + + def instrument(self) -> None: + """Enable this instrumentation.""" + + +# A marker to be set on already `traced` items. Instrumenting the same class or module twice in one +# process - which every `get_app()` in a test suite does - would otherwise nest a second span +# around every call. +# +# The value is historical and shared with the copy of this code in gen3-ai. A service part way +# through migrating from that copy to this one may have a function decorated by one and inspected +# by the other; a different marker here would wrap it twice. +_TRACED_MARKER = "_gen3_traced" + +# Set by `no_trace` to keep the module and class walks off a function. +_NO_TRACE_MARKER = "_gen3_no_trace" + +# What `configure_tracing` resolved `enabled` to, or None before it has run. Lets a service +# that configures tracing from a config file rather than the environment have that choice +# reach `instrument_class` and `instrument_module`, which run after it. +_tracing_enabled_override: bool | None = None + + +def configure_tracing( + app: FastAPI, + service_name: str, + *, + enabled: bool | None = None, + otlp_endpoint: str | None = None, + otlp_protocol: str | None = None, + excluded_urls: Collection[str] = DEFAULT_ENDPOINTS_WITHOUT_METRICS, + instrumentors: Iterable[Instrumentor] | None = None, +) -> None: + """ + Install a tracer provider and instrument the app to emit request spans. + + When the Pyroscope agent is already running, spans are also tagged so Grafana can jump from a + span to the profile for that request. Call + `cdispyutils.observability.continuous_profiling.configure_profiling` first, otherwise this + cannot know the agent exists and the link is left out. + + Args: + app (FastAPI): The application to instrument. + service_name (str): Value for the `service.name` resource attribute. + enabled (bool | None): Whether to install anything at all. + Env ENABLE_OPENTELEMETRY_TRACES, default True. + otlp_endpoint (str | None): Signal-agnostic base URL of the OTLP collector. An empty + value selects the console exporter, which is how local development inspects spans + without a collector. Env OTEL_EXPORTER_OTLP_ENDPOINT, default "". + otlp_protocol (str | None): Either `grpc` or `http/protobuf`. + Env OTEL_EXPORTER_OTLP_PROTOCOL, default `http/protobuf`. + excluded_urls (Collection[str]): Paths to emit no request spans for. Liveness and + readiness probes hit these every few seconds per replica, and a span each would swamp + real traffic. + instrumentors (Iterable[Instrumentor] | None): Library instrumentations to enable + alongside the app's own. Defaults to HTTPX, requests, and logging. Database + instrumentation is not included, because the right one differs per service; pass + `AsyncPGInstrumentor()` or the equivalent to add it. + """ + global _tracing_enabled_override + + _tracing_enabled_override = resolve( + enabled, "ENABLE_OPENTELEMETRY_TRACES", True, env_bool + ) + if not _tracing_enabled_override: + logger.info("OpenTelemetry traces are disabled, skipping setup") + return + + if _tracer_provider_is_set(): + # A provider installed by something else (e.g. the `opentelemetry-instrument` + # wrapper) wins: set_tracer_provider ignores the second call and only warns. + logger.info("A tracer provider is already installed, reusing it") + else: + endpoint = resolve(otlp_endpoint, "OTEL_EXPORTER_OTLP_ENDPOINT", "", env_str) + protocol = resolve( + otlp_protocol, "OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf", env_str + ) + + provider = TracerProvider( + resource=Resource.create(attributes={"service.name": service_name}) + ) + provider.add_span_processor( + BatchSpanProcessor(_span_exporter(endpoint, protocol)) + ) + trace.set_tracer_provider(provider) + + _link_spans_to_profiles() + + FastAPIInstrumentor.instrument_app( + app, excluded_urls=excluded_url_patterns(excluded_urls) + ) + + for instrumentor in _resolve_instrumentors(instrumentors): + instrumentor.instrument() + + +def get_tracer(name: str) -> trace.Tracer: + """ + Return a tracer for one instrumentation scope. + + Args: + name (str): The scope, conventionally the calling module's `__name__`. + + Returns: + trace.Tracer: A tracer. Safe to hold from import time: until a provider is installed + this is a proxy, and it starts recording once one is. + """ + return trace.get_tracer(name) + + +def traced[Function: FunctionType](fn: Function) -> Function: + """ + Wrap a function so each call emits a span named `.`. + + Qualified name includes anything inside the module the named function is *in* (e.g. + if it's in a class, then the qualname is "SomeClass.some_function") + + Args: + fn (Function): A sync or async function. Bound to `FunctionType` rather than a callable, + because this reads `fn.__module__` and `fn.__qualname__` and hands `fn` to + `functools.wraps`, none of which an arbitrary callable object carries. + + Returns: + Function: A wrapped function, or `fn` itself when custom tracing is disabled or `fn` is + already wrapped. + + Raises: + TypeError: If `fn` is a generator or async generator function. A span around one of + those ends when the generator object is created, so it measures nothing, and the + wrapper also hides the function's generator-ness from callers that introspect it, + such as FastAPI's dependency injection. The message says what to do instead, which + is to open a span inside the function with `get_tracer`. + """ + if inspect.isgeneratorfunction(fn) or inspect.isasyncgenfunction(fn): + # Raised even when tracing is off, so the mistake cannot hide behind a config value. + raise TypeError( + f"cannot trace generator function {fn.__qualname__}: the span would end when the " + "generator object is created, before any of the body runs, and wrapping also hides " + "the function's generator-ness from FastAPI's dependency injection. Instead, open " + "the span inside the function around the work you want measured: " + "`with get_tracer(__name__).start_as_current_span('name'): ...`." + ) + + if not _custom_tracing_enabled() or getattr(fn, _TRACED_MARKER, False): + return fn + + tracer = trace.get_tracer(fn.__module__) + span_name = f"{fn.__module__}.{fn.__qualname__}" + + if inspect.iscoroutinefunction(fn): + + @functools.wraps(fn) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + with tracer.start_as_current_span(span_name): + return await fn(*args, **kwargs) + + wrapper = async_wrapper + else: + + @functools.wraps(fn) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + with tracer.start_as_current_span(span_name): + return fn(*args, **kwargs) + + wrapper = sync_wrapper + + setattr(wrapper, _TRACED_MARKER, True) + return cast(Function, wrapper) + + +def no_trace[Function: FunctionType](fn: Function) -> Function: + """ + Mark a function for `instrument_module` and `instrument_class` to skip. + + Use this on a function called once per row or per loop iteration inside a module that is + otherwise worth tracing, where a span per call would cost more than it reports. + + Args: + fn (Function): The function to leave alone. + + Returns: + Function: `fn`, unchanged. + """ + setattr(fn, _NO_TRACE_MARKER, True) + return fn + + +def instrument_class(cls: type) -> None: + """ + Replace the methods a class defines with traced versions, in place. + + Only the class's own attributes are considered, so inherited methods are left to the class + that defines them, and dunder methods are skipped. + + A method defined with `@staticmethod`, `@classmethod` or `@property` is skipped too: the + class dict holds a descriptor for those, not the underlying function, so there is nothing + here to wrap. To trace one, decorate it where it is defined and keep `@traced` innermost, + directly above the `def`. + + Args: + cls (type): The class to instrument. + """ + if not _custom_tracing_enabled(): + return + + for name, attr in list(vars(cls).items()): + if not name.startswith("__") and _is_traceable(attr): + setattr(cls, name, traced(attr)) + + +def instrument_module(module: ModuleType) -> None: + """ + Replace the functions a module defines with traced versions, in place. + + Functions the module merely imported are skipped, so instrumenting one module cannot + silently instrument another's code, or a third-party library's. + + Only calls that look the function up on the module are traced. A caller that did + `from x import work` holds the original function and keeps calling it untraced, so a + module whose consumers import it that way needs `@traced` at each definition instead. + + Args: + module (ModuleType): The module to instrument. + """ + if not _custom_tracing_enabled(): + return + + for name, attr in list(vars(module).items()): + if _is_traceable(attr) and attr.__module__ == module.__name__: + setattr(module, name, traced(attr)) + + +def excluded_url_patterns(paths: Collection[str]) -> str: + """ + Build the URL exclusion list for the request instrumentation. + + The instrumentation matches these against a whole URL, `scheme://host/path`, using a search + rather than a full match, so each pattern is anchored at the end. The ASGI path already + carries any root_path, which a suffix match tolerates. + + Args: + paths (Collection[str]): The paths to exempt. Must not contain `"/"`, which anchored as + `/$` would match every URL ending in a slash. + + Returns: + str: A comma-separated list of regexes, in the form + `opentelemetry.util.http.parse_excluded_urls` expects. + """ + return ",".join(sorted(re.escape(path) + "$" for path in paths)) + + +def _resolve_instrumentors( + instrumentors: Iterable[Instrumentor] | None, +) -> list[Instrumentor]: + """ + Return the library instrumentations to enable. + + Args: + instrumentors (Iterable[Instrumentor] | None): The caller's choice, or None for the + default set. + + Returns: + list[Instrumentor]: Outbound HTTP in both its async and sync forms, plus log + correlation, which puts otelTraceID/otelSpanID/otelServiceName on every record for + the logging formatters to render. + """ + if instrumentors is not None: + return list(instrumentors) + + # Imported here rather than at module scope so that installing a narrower set of the + # instrumentation packages does not break importing this module. + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + from opentelemetry.instrumentation.requests import RequestsInstrumentor + + return [ + HTTPXClientInstrumentor(), + RequestsInstrumentor(), + LoggingInstrumentorWithContext(), + ] + + +class LoggingInstrumentorWithContext: + """ + LoggingInstrumentor with the one argument that makes log correlation happen. + + A bare `LoggingInstrumentor()` does not inject trace context, so logs carry no trace id and + nothing joins them to spans. Pass this instead when supplying your own `instrumentors` list. + """ + + def __init__(self, instrumentor: BaseInstrumentor | None = None) -> None: + if instrumentor is None: + from opentelemetry.instrumentation.logging import LoggingInstrumentor + + instrumentor = LoggingInstrumentor() + self._instrumentor = instrumentor + + def instrument(self) -> None: + """Enable log correlation.""" + self._instrumentor.instrument(inject_trace_context=True) + + +def _span_exporter(endpoint: str, protocol: str) -> SpanExporter: + """ + Build the span exporter selected by the OTLP protocol. + + Args: + endpoint (str): Signal-agnostic base URL of the collector, or "" for the console. + protocol (str): Either `grpc` or `http/protobuf`. + + Returns: + SpanExporter: A gRPC, HTTP, or console exporter. + """ + if not endpoint: + return ConsoleSpanExporter() + + if protocol == GRPC_PROTOCOL: + return GrpcSpanExporter(endpoint=endpoint) + + # The endpoint is a signal-agnostic base per the OTLP spec, and the HTTP exporter only + # appends `/v1/traces` when it reads that variable itself. An `endpoint` passed to the + # constructor is used verbatim, so the path has to be added here. + return HttpSpanExporter(endpoint=f"{endpoint.rstrip('/')}/v1/traces") + + +def _link_spans_to_profiles() -> None: + """ + Add the span processor that joins traces to Pyroscope profiles, when both are running. + + Skipped unless the profiler is active: the processor tags the profiler's thread on every root + span, which with no agent to receive the tags is cost on the request path for nothing. + """ + if not profiling_active(): + return + + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + # A provider installed by something else need not be the SDK's, and only the SDK's has + # add_span_processor. Traces and profiles both keep working, just unlinked. + logger.warning( + "The installed tracer provider takes no span processors, so profiles will not link " + "to traces" + ) + return + + from pyroscope.otel import PyroscopeSpanProcessor + + provider.add_span_processor(PyroscopeSpanProcessor()) + + +def _custom_tracing_enabled() -> bool: + """ + Report whether the per-function span helpers should wrap anything. + + Prefers what `configure_tracing` resolved, falling back to the environment. The fallback is + what `@traced` gets: it runs when the decorated module is imported, before any + `configure_tracing` call, so only the environment can gate it. `instrument_class` and + `instrument_module` run from an app factory afterwards and do see the resolved value. + + Returns: + bool: True when tracing is on and the kill switch is off. The kill switch is read from + the environment either way, so it can disable custom tracing in a deployment whose + service configures `enabled` in code. + """ + if env_bool("FORCE_DISABLE_CUSTOM_TRACING", False): + return False + + if _tracing_enabled_override is not None: + return _tracing_enabled_override + + return env_bool("ENABLE_OPENTELEMETRY_TRACES", True) + + +def reset_tracing_state() -> None: + """ + Forget what `configure_tracing` resolved. + + Exists for tests: the override is process-wide, so without this the first test to configure + tracing decides the answer for every test after it. + """ + global _tracing_enabled_override + + _tracing_enabled_override = None + + +def _is_traceable(attr: object) -> bool: + """Return whether a class or module attribute is a function `traced` can wrap.""" + return ( + inspect.isfunction(attr) + and not inspect.isgeneratorfunction(attr) + and not inspect.isasyncgenfunction(attr) + and not getattr(attr, _NO_TRACE_MARKER, False) + # This would indicate it's already traced + and not getattr(attr, _TRACED_MARKER, False) + ) + + +def _tracer_provider_is_set() -> bool: + """Return whether a real tracer provider has already replaced the default proxy.""" + return not isinstance(trace.get_tracer_provider(), trace.ProxyTracerProvider) diff --git a/poetry.lock b/poetry.lock index c5549f9..8677571 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,67 @@ # This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. +[[package]] +name = "annotated-doc" +version = "0.0.5" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101"}, + {file = "annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb"}, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, +] + +[[package]] +name = "anyio" +version = "4.14.2" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"}, + {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"}, +] +markers = {main = "extra == \"observability\""} + +[package.dependencies] +idna = ">=2.8" + +[package.extras] +trio = ["trio (>=0.32.0)"] + +[[package]] +name = "asgiref" +version = "3.12.1" +description = "ASGI specs, helper code, and adapters" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094"}, + {file = "asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340"}, +] + +[package.extras] +mypy = ["mypy (>=1.14.0)"] +tests = ["pytest", "pytest-asyncio"] + [[package]] name = "blinker" version = "1.9.0" @@ -49,7 +111,7 @@ version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, @@ -523,6 +585,31 @@ colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} packaging = ">=23.2" requirements-parser = ">=0.11.0,<1" +[[package]] +name = "fastapi" +version = "0.141.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3"}, + {file = "fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +pydantic = ">=2.9.0" +starlette = ">=0.46.0" +typing-extensions = ">=4.8.0" +typing-inspection = ">=0.4.2" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.32)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.32)", "fastar (>=0.9.0)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.32)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + [[package]] name = "flask" version = "3.1.2" @@ -621,13 +708,159 @@ type1 = ["xattr ; sys_platform == \"darwin\""] unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""] woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] +[[package]] +name = "googleapis-common-protos" +version = "1.75.1" +description = "Common protobufs used in Google APIs" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79"}, + {file = "googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071"}, +] + +[package.dependencies] +protobuf = ">=6.33.5,<8.0.0" + +[package.extras] +grpc = ["grpcio (>=1.59.0,<2.0.0)"] + +[[package]] +name = "grpcio" +version = "1.83.0" +description = "HTTP/2-based RPC framework" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "grpcio-1.83.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:fba099b716e73512d61b97f71ea3c31a72abb36904036e316bf4dd148ca8dcc8"}, + {file = "grpcio-1.83.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6755ed67cc3e454d51ae9f6e1915b80d3942fa4de956ef48dacd45ab7f40b727"}, + {file = "grpcio-1.83.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5882c1a721b50ce0123ee5e839e1ab059ad72a7ade76cdf2d5bd833b56791acf"}, + {file = "grpcio-1.83.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e3eedfc92b6b9f2960115e7e620cf0cbf80bb7849a51ce3820dc54dfd88b6b9"}, + {file = "grpcio-1.83.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fcaa7c45c45b4a89e2867d1f1785d9481a788399d915e341ed2eb49aeef9dd4"}, + {file = "grpcio-1.83.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6b6c666a1d5613ff360c9e90f44665e3a88b25a815209ddbc0917eec281931cb"}, + {file = "grpcio-1.83.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6be5c807b717be3dd649446f021301fd7907e376318675d2147823071034112a"}, + {file = "grpcio-1.83.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c834e86d8fd2f03d7e4db49a027f7c5b89c5b88eed305543a5295bd6fee61e40"}, + {file = "grpcio-1.83.0-cp310-cp310-win32.whl", hash = "sha256:35a5b1c192496b6c25956eebfa963468935612206fd2543ac3ce981e6a5e0f03"}, + {file = "grpcio-1.83.0-cp310-cp310-win_amd64.whl", hash = "sha256:8f6c395e493d20c39b29392ca200e9aaeb78d0bc2f04db0c0a7da7ddc939aa57"}, + {file = "grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f"}, + {file = "grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223"}, + {file = "grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0"}, + {file = "grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d"}, + {file = "grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9"}, + {file = "grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745"}, + {file = "grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617"}, + {file = "grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969"}, + {file = "grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45"}, + {file = "grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49"}, + {file = "grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930"}, + {file = "grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da"}, + {file = "grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16"}, + {file = "grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf"}, + {file = "grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd"}, + {file = "grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c"}, + {file = "grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5"}, + {file = "grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5"}, + {file = "grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc"}, + {file = "grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df"}, + {file = "grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0"}, + {file = "grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1"}, + {file = "grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867"}, + {file = "grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881"}, + {file = "grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f"}, + {file = "grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61"}, + {file = "grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45"}, + {file = "grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c"}, + {file = "grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf"}, + {file = "grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735"}, + {file = "grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa"}, + {file = "grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c"}, + {file = "grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b"}, + {file = "grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c"}, + {file = "grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df"}, + {file = "grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b"}, + {file = "grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404"}, + {file = "grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af"}, + {file = "grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33"}, + {file = "grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9"}, + {file = "grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.83.0)"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -1069,6 +1302,324 @@ files = [ {file = "numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0"}, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +description = "OpenTelemetry Python API" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef"}, + {file = "opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a"}, +] + +[package.dependencies] +typing-extensions = ">=4.5.0" + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.44.0" +description = "OpenTelemetry Collector Exporters" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_exporter_otlp-1.44.0-py3-none-any.whl", hash = "sha256:4a498fa8d8fd8be9e8e2d175fe5524a3fe581ccffadd8509db86526a5fb97051"}, + {file = "opentelemetry_exporter_otlp-1.44.0.tar.gz", hash = "sha256:af1cde7c33ea8ed624bf04ac49a885730fe44c1f1ad698656e592c38f70ce106"}, +] + +[package.dependencies] +opentelemetry-exporter-otlp-proto-grpc = "1.44.0" +opentelemetry-exporter-otlp-proto-http = "1.44.0" + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +description = "OpenTelemetry Protobuf encoding" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac"}, +] + +[package.dependencies] +opentelemetry-proto = "1.44.0" + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.44.0" +description = "OpenTelemetry Collector Protobuf over gRPC Exporter" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e"}, + {file = "opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.57,<2.0" +grpcio = [ + {version = ">=1.66.2,<2.0.0", markers = "python_version == \"3.13\""}, + {version = ">=1.75.1,<2.0.0", markers = "python_version >= \"3.14\""}, +] +opentelemetry-api = ">=1.15,<2.0" +opentelemetry-exporter-otlp-proto-common = "1.44.0" +opentelemetry-proto = "1.44.0" +opentelemetry-sdk = ">=1.44.0,<1.45.0" +typing-extensions = ">=4.6.0" + +[package.extras] +gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +description = "OpenTelemetry Collector Protobuf over HTTP Exporter" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.52,<2.0" +opentelemetry-api = ">=1.15,<2.0" +opentelemetry-exporter-otlp-proto-common = "1.44.0" +opentelemetry-proto = "1.44.0" +opentelemetry-sdk = ">=1.44.0,<1.45.0" +requests = ">=2.7,<3.0" +typing-extensions = ">=4.5.0" + +[package.extras] +gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.65b0" +description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137"}, + {file = "opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.4,<2.0" +opentelemetry-semantic-conventions = "0.65b0" +packaging = ">=18.0" +wrapt = ">=1.0.0,<3.0.0" + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.65b0" +description = "ASGI instrumentation for OpenTelemetry" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_instrumentation_asgi-0.65b0-py3-none-any.whl", hash = "sha256:3a845a8ebd1c4ef0d8263401e6545f5b219b2feee612090d50f578a87e71fd65"}, + {file = "opentelemetry_instrumentation_asgi-0.65b0.tar.gz", hash = "sha256:892bca67c56522ffa85a8a83cf934d7b50b3be2132e45cbee705825f0a5ba426"}, +] + +[package.dependencies] +asgiref = ">=3.0,<4.0" +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.65b0" +opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-util-http = "0.65b0" + +[package.extras] +instruments = ["asgiref (>=3.0,<4.0)"] + +[[package]] +name = "opentelemetry-instrumentation-asyncpg" +version = "0.65b0" +description = "OpenTelemetry instrumentation for AsyncPG" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_instrumentation_asyncpg-0.65b0-py3-none-any.whl", hash = "sha256:9156d2501021b968da0f317eb508d3efb3e7506bf50e00ee477ce0636a12772a"}, + {file = "opentelemetry_instrumentation_asyncpg-0.65b0.tar.gz", hash = "sha256:0b14338886f17c18a9899d6764117a4c163a9826f6ed1577f68ee773f95ebbc2"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.65b0" +opentelemetry-semantic-conventions = "0.65b0" + +[package.extras] +instruments = ["asyncpg (>=0.12.0)"] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.65b0" +description = "OpenTelemetry FastAPI Instrumentation" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_instrumentation_fastapi-0.65b0-py3-none-any.whl", hash = "sha256:cda2610a0ec1b22d19886f33e4d861e9f5dbb886aeaa3a1263b47aff82c36943"}, + {file = "opentelemetry_instrumentation_fastapi-0.65b0.tar.gz", hash = "sha256:10a3a95486036230413a58fe4fdf4a83fa6bba46918407e527476994bd92bd97"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.65b0" +opentelemetry-instrumentation-asgi = "0.65b0" +opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-util-http = "0.65b0" + +[package.extras] +instruments = ["fastapi (>=0.92,<1.0)"] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.65b0" +description = "OpenTelemetry HTTPX Instrumentation" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_instrumentation_httpx-0.65b0-py3-none-any.whl", hash = "sha256:400f1b78afa4ee2332b5debe58e1ed1b317913d58812c952576be76660aeadb1"}, + {file = "opentelemetry_instrumentation_httpx-0.65b0.tar.gz", hash = "sha256:4627aa9c6bb99bf4462c8b565b0ef6aeb9ffad95c6c92868be1ef7895de112ee"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.65b0" +opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-util-http = "0.65b0" +wrapt = ">=1.0.0,<3.0.0" + +[package.extras] +instruments-any = ["httpx (>=0.18.0)", "httpx2 (>=2.0.0)"] + +[[package]] +name = "opentelemetry-instrumentation-logging" +version = "0.65b0" +description = "OpenTelemetry Logging instrumentation" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_instrumentation_logging-0.65b0-py3-none-any.whl", hash = "sha256:68365b31755c844f1e85f07dcd217839ff92f2d278a214bdf02d4dc806f9d915"}, + {file = "opentelemetry_instrumentation_logging-0.65b0.tar.gz", hash = "sha256:c0a50cade5d54db6c6af12e2c69227ecd26f2b3b779e99ff850561d3d8dd77e3"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.65b0" +opentelemetry-semantic-conventions = "0.65b0" + +[[package]] +name = "opentelemetry-instrumentation-requests" +version = "0.65b0" +description = "OpenTelemetry requests instrumentation" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_instrumentation_requests-0.65b0-py3-none-any.whl", hash = "sha256:91688ec0d4d1fed75ea8d026ef2c66274ed9868c22b6be211ef85d832d16f957"}, + {file = "opentelemetry_instrumentation_requests-0.65b0.tar.gz", hash = "sha256:1d601548f89236d5ab373c7208a2e1e162a8d6462b5b972f9ad8fb0ed82d7438"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.65b0" +opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-util-http = "0.65b0" + +[package.extras] +instruments = ["requests (>=2.0,<3.0)"] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +description = "OpenTelemetry Python Proto" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56"}, + {file = "opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3"}, +] + +[package.dependencies] +protobuf = ">=5.0,<8.0" + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +description = "OpenTelemetry Python SDK" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad"}, + {file = "opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b"}, +] + +[package.dependencies] +opentelemetry-api = "1.44.0" +opentelemetry-semantic-conventions = "0.65b0" +typing-extensions = ">=4.5.0" + +[package.extras] +file-configuration = ["opentelemetry-configuration (==0.65b0)"] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +description = "OpenTelemetry Semantic Conventions" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb"}, + {file = "opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60"}, +] + +[package.dependencies] +opentelemetry-api = "1.44.0" +typing-extensions = ">=4.5.0" + +[[package]] +name = "opentelemetry-util-http" +version = "0.65b0" +description = "Web util for OpenTelemetry" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "opentelemetry_util_http-0.65b0-py3-none-any.whl", hash = "sha256:7553b606f963097cb190536dc30556cce85090692e471a422fff30ca29b04348"}, + {file = "opentelemetry_util_http-0.65b0.tar.gz", hash = "sha256:84f82d826978bba416ab453460ff6a7391cdc3534c93a786595e4068680016b7"}, +] + [[package]] name = "packaging" version = "25.0" @@ -1080,7 +1631,7 @@ files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] -markers = {main = "extra == \"profiling\""} +markers = {main = "extra == \"profiling\" or extra == \"observability\""} [[package]] name = "pillow" @@ -1223,6 +1774,25 @@ files = [ [package.extras] twisted = ["twisted"] +[[package]] +name = "protobuf" +version = "7.36.0" +description = "" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37"}, + {file = "protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44"}, + {file = "protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16"}, + {file = "protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b"}, + {file = "protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071"}, + {file = "protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488"}, + {file = "protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c"}, + {file = "protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea"}, +] + [[package]] name = "pycparser" version = "2.23" @@ -1236,6 +1806,163 @@ files = [ {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] +[[package]] +name = "pydantic" +version = "2.13.4" +description = "Data validation using Python type hints" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, + {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.46.4" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +description = "Core functionality for Pydantic validation and serialization" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, + {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + [[package]] name = "pygments" version = "2.19.2" @@ -1285,6 +2012,66 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pyroscope-io" +version = "1.2.1" +description = "Pyroscope Python integration" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "pyroscope_io-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e2ddf9ddbecea6625fce29c1abd4add9dbf17ae2d0cb4dece05110de91f1cddb"}, + {file = "pyroscope_io-1.2.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:acdc935c60e5f84917e47b14a6fe14a8dbb93735b89e0b1a85eab5c27e4be134"}, + {file = "pyroscope_io-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:71278a8ca7babd5b21dfb8ff7a0ba37c35a82e754f42bcceb96c8a0e1b7f44f4"}, + {file = "pyroscope_io-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b2b2c6833c95881208222a8675bf0d8a90a1225748a185e73811b956f055b45f"}, + {file = "pyroscope_io-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd7e1963ba0075ab8f8dd2877e059e1c02d9ba903f55696541b3cdb3b9df5378"}, + {file = "pyroscope_io-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d5f59a62785c1ce56247bfacf1e3f80c2af14df227585bc738dc6d2baab7c315"}, + {file = "pyroscope_io-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8387470016135a590f43da21a4dfcd47c238e4dfacabccc3e527da6cf61898f7"}, + {file = "pyroscope_io-1.2.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:0c8eb803eed4ae7247adb18999afcfb82894c28d67c0b99c63c16fc6443c63cc"}, + {file = "pyroscope_io-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bebcccb717abddeb59b7df12e693fd0f85b946a20a38bdcfef3491fbac7d827c"}, + {file = "pyroscope_io-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4327785c6087afc084a230aa27107b53070c67555438caf3fce3ac73f821c787"}, + {file = "pyroscope_io-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d18f843ef60503f15c9d189dc1aa7f06d8edac9760f252ae6e825805fc7d21e3"}, + {file = "pyroscope_io-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:736d569a465edafc5c271d702bfef24e9afd25c06e5ea2e6746bd01ef622e62e"}, + {file = "pyroscope_io-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:692091ae7d020ee76a16d9da2e213b69c20367d20032ecb643c23e3a5831f87d"}, + {file = "pyroscope_io-1.2.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:ceabd42b2d61876eb81668cb76bb04b911d62369645c1c4eb63c9d4ed5106b3c"}, + {file = "pyroscope_io-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e64e2f34f340b163013b2942017d98bb0a4584e26ae64dfdba3f85304eea8efe"}, + {file = "pyroscope_io-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16a39b80e5032fe978eaac6abf907e7b79128357adc0f0d8e7921e68bd57c4ea"}, + {file = "pyroscope_io-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:45f5d7ed4a7a158732d3c1634846011c382309720d891200f64aee3a11173418"}, + {file = "pyroscope_io-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7bc01f8b5c49efca59987194c28667559b1fbded965943d020d12aadc40d0c5c"}, + {file = "pyroscope_io-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2ce28485fc46390c31300abde8c93b7dd6c042960deb1b729c2d001a8795516"}, + {file = "pyroscope_io-1.2.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:10b0e7a8040ed6b953c9920f2c463d6dddd4f26c01dcb1ffb696879c15cab218"}, + {file = "pyroscope_io-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:655f8629f3f8c5b8c2bec105f0c19a69be4666ae5e452e4c910b2fa3de3452d6"}, + {file = "pyroscope_io-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e232ea23cc756f6990a325230c6f22d7c380b012a3bd9dac476a338b1edd7d26"}, + {file = "pyroscope_io-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35e57ab13d40d8af15821bc30720eb5c74949c169b649379ad114f7e1f75cd8f"}, + {file = "pyroscope_io-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:31a2a694f507280a451fe082789599e68af48389c74b2c15d99715bd0d0f9f77"}, + {file = "pyroscope_io-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:152be5e004ca87da17a91c9b80726b78b32d3d4ae3ed0e31117924f76c665c7a"}, + {file = "pyroscope_io-1.2.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:95d535c377c43631b1a4d24ac42f3fa81ece7baca4749f23a6cc9db5ff98e477"}, + {file = "pyroscope_io-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8673378adcf7985b6824cefbdfc8ee2567576d1e70f710b8dda649cb3a90b1ae"}, + {file = "pyroscope_io-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ec8e2a8954de298000811af743060c2ec85295c452a8b3e0226be11ffaf1c80"}, + {file = "pyroscope_io-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:014c6f222cec75eebb12b85ea8ced49efedd29f027dae915e4257822b2815272"}, + {file = "pyroscope_io-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:83867ba5f46f1d0946524407d5627c1bb06f32dfd35fc06c7258f756d9e80c08"}, + {file = "pyroscope_io-1.2.1.tar.gz", hash = "sha256:c3236136dc086845d283fbeb434f5e22f5a7ddc0ff5a5b328752335d61ee1aaf"}, +] + +[[package]] +name = "pyroscope-otel" +version = "1.1.0" +description = "A library providing profiling functionalities related to OpenTelemetry" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "pyroscope_otel-1.1.0-py3-none-any.whl", hash = "sha256:a10accd2a60fa00a18445f8e95df91500630f8f6b177133693792785f57637e5"}, + {file = "pyroscope_otel-1.1.0.tar.gz", hash = "sha256:552e3401446d0406407f4c8c9487d36604949324cd240599af58a69d0d634175"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.27.0,<2.0.0" +opentelemetry-sdk = ">=1.27.0,<2.0.0" +pyroscope-io = ">=1.0.0,<2.0.0" + [[package]] name = "pytest" version = "9.0.2" @@ -1393,6 +2180,54 @@ files = [ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] +[[package]] +name = "starlette" +version = "1.6.0" +description = "The little ASGI library that shines." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c"}, + {file = "starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +description = "Runtime typing introspection tools" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, +] + +[package.dependencies] +typing-extensions = ">=4.15.0" + [[package]] name = "urllib3" version = "2.6.3" @@ -1441,11 +2276,116 @@ markupsafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] +[[package]] +name = "wrapt" +version = "2.3.0" +description = "Module for decorators, wrappers and monkey patching." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"observability\"" +files = [ + {file = "wrapt-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0bb2797048db0956348cb3058c33bc4184614f13231389cfbccc16a5d32780a7"}, + {file = "wrapt-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce9f398f868d2b3b27aa2ea4de79645ef9077aeeac8dfc2814b0d542c6a2b87f"}, + {file = "wrapt-2.3.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad71df7a04dd3497e9302e81f4a7c91bd401ea0e15a9df9029527900f94bee43"}, + {file = "wrapt-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc82c2ccc8e234c844f5303d9f2984b346dcdd53e94823ce8420d2c75b4b9023"}, + {file = "wrapt-2.3.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6e19531ae33c508cea7d84a7edfda01fa86e51b8d1a93a77712c55e6e469152"}, + {file = "wrapt-2.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df4ce31150bcd5d9f36f816aac3010ab4f4bf8672ac1d3b0ac7d539ec61c7c02"}, + {file = "wrapt-2.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e2e692bc0d63f881cf7006730a56bd4e0c2fab5dc318466942805d692b166276"}, + {file = "wrapt-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c8388ba7faf5dbf9ee106bb70d66f257629b1bd98091123e19e8a4553a319199"}, + {file = "wrapt-2.3.0-cp310-cp310-win32.whl", hash = "sha256:e045ff75d7d94900fc32896ed93c45ce2d2cac28c9dead582ff9a5a49d446e35"}, + {file = "wrapt-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b4fc96b159af0a3e0faa72475a69d66292bea72a5bed1e1aca1bffbddc3cb2b0"}, + {file = "wrapt-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:1236fa25173ca964c97422470482e9011b9e3c7ed0d75798b40b3da3b0e0e760"}, + {file = "wrapt-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ab559e1b2551d23d54db2a0001c6d73bad022a254639561c5f6c382a9d6c2fe"}, + {file = "wrapt-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bff9a671bc00709cab5a7f745c592b5671873449db0ee2a569af994f16b29a4d"}, + {file = "wrapt-2.3.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc648a335d7e01adb3640b25f02fd0ea05886cf04d0af7f4ee902bc7b5e466e8"}, + {file = "wrapt-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0077f3d65541925fa83002f967b22ad6550d24813ac64cb905f717194128d9c"}, + {file = "wrapt-2.3.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9790ea25190a4e0fe4cdf4eeb868e9d75f8a024a70a5b6bf9c348a3a2b72e731"}, + {file = "wrapt-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:816877aa749253149f9ecfd2635d4d948ecfa338e1a0311d187b1acb1bb8a3eb"}, + {file = "wrapt-2.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d1c2c1b808600d2ea808e6360910a60ed5f409a4011655e10f9164ba0a414a6"}, + {file = "wrapt-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5ba1e5e08ddc46130e9682b2c249f2d1dd39bda9106ed4bd401b7519f18f41bd"}, + {file = "wrapt-2.3.0-cp311-cp311-win32.whl", hash = "sha256:45c9279b373d15649dfa2c2077cb3408ea1a6d3125afbdab9d6b809a66f68e14"}, + {file = "wrapt-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:195b1842b4122fb54e3cd3dd5b2b4aa49302a5a61da901df0481f5c97aedde84"}, + {file = "wrapt-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:6db604ef0c67bdb2042ecdfd7b7f037cf09733557ca42360d1018285634f7b98"}, + {file = "wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525"}, + {file = "wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d"}, + {file = "wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8"}, + {file = "wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb"}, + {file = "wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60"}, + {file = "wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02"}, + {file = "wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3"}, + {file = "wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d"}, + {file = "wrapt-2.3.0-cp312-cp312-win32.whl", hash = "sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1"}, + {file = "wrapt-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8"}, + {file = "wrapt-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab"}, + {file = "wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f"}, + {file = "wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f"}, + {file = "wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5"}, + {file = "wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0"}, + {file = "wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609"}, + {file = "wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8"}, + {file = "wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae"}, + {file = "wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3"}, + {file = "wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f"}, + {file = "wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838"}, + {file = "wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579"}, + {file = "wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944"}, + {file = "wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360"}, + {file = "wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614"}, + {file = "wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a"}, + {file = "wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687"}, + {file = "wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570"}, + {file = "wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41"}, + {file = "wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4"}, + {file = "wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3"}, + {file = "wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98"}, + {file = "wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6"}, + {file = "wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc"}, + {file = "wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1"}, + {file = "wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945"}, + {file = "wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5"}, + {file = "wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3"}, + {file = "wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07"}, + {file = "wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f"}, + {file = "wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23"}, + {file = "wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b"}, + {file = "wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d"}, + {file = "wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab"}, + {file = "wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84"}, + {file = "wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7"}, + {file = "wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2"}, + {file = "wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c"}, + {file = "wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295"}, + {file = "wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd"}, + {file = "wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df"}, + {file = "wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109"}, + {file = "wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501"}, + {file = "wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5"}, + {file = "wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51"}, + {file = "wrapt-2.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c3b476ae63b4a3b4da681aafcb25ff3542d289fbda8b5da7caf76aaffafafdbb"}, + {file = "wrapt-2.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:932dced0a7b2950ed58a3325536a1dcb7b58e7330af54e8552d2e566b5328b99"}, + {file = "wrapt-2.3.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0db083387d6e75ec0be8173ecbf0e811cf60bae1cc75a815feb104167ea10d4d"}, + {file = "wrapt-2.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abc71504669d126d91f89fc0e388c6295d8fbd2439be884f175133fda8aa403c"}, + {file = "wrapt-2.3.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b767a9566f165dd14decf8f4194c6bb0ce3a8420cec213824e05a99400c9260a"}, + {file = "wrapt-2.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:73d0b10b64620a2cf4bc3d31775c4d9527e309a5549e4379e3bf71e8d2dc193e"}, + {file = "wrapt-2.3.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:e31734c5077f29f892b2565eee5106d610278151ad49fc6a9d69a647cd5730e2"}, + {file = "wrapt-2.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:628f3ba8ec793a5b10a6cd8c6c6b7b55eb552abd1f3bd301336acb74c7a82dfe"}, + {file = "wrapt-2.3.0-cp39-cp39-win32.whl", hash = "sha256:3873c3c5ca9f4ef91f693602eca19d1f1e7c410338df82a4ff11d826b5896a8f"}, + {file = "wrapt-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:8f8a1c6472675956cece9a8f403f43c3594f1681319eed2dd56f60877397c636"}, + {file = "wrapt-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c8858d8ff9822a081e3cc49ae1b3b22f0f789c14001cdac8f94564010d9c9d66"}, + {file = "wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2"}, + {file = "wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107"}, +] + +[package.extras] +dev = ["pytest", "setuptools"] + [extras] +observability = ["fastapi", "opentelemetry-api", "opentelemetry-exporter-otlp", "opentelemetry-instrumentation-asyncpg", "opentelemetry-instrumentation-fastapi", "opentelemetry-instrumentation-httpx", "opentelemetry-instrumentation-logging", "opentelemetry-instrumentation-requests", "opentelemetry-sdk", "pyroscope-io", "pyroscope-otel", "starlette"] profiling = ["Werkzeug", "matplotlib", "numpy"] uwsgi = ["uwsgi"] [metadata] lock-version = "2.1" python-versions = ">=3.13,<4" -content-hash = "3f11c05ca85662d73b2743d106c63388abed6aa70583c1db6e5ca1a0f5c658b7" +content-hash = "c7ea0ebcb2b7ec0a98b55ffe1435379486b19787f8ddad2ab85b81027d1f4f6b" diff --git a/pyproject.toml b/pyproject.toml index 93d1729..2d54da3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cdispyutils" -version = "2.5.1" +version = "2.6.0" description = "This package includes several utility Python tools for the Gen3 stack." authors = ["CTDS UChicago "] license = "Apache-2.0" @@ -20,6 +20,19 @@ matplotlib = { version = "*", optional = true } numpy = { version = "*", optional = true } uwsgi = { version = "*", optional = true } Werkzeug = { version = "*", optional = true } +opentelemetry-api = { version = "*", optional = true } +opentelemetry-sdk = { version = "*", optional = true } +opentelemetry-exporter-otlp = { version = "*", optional = true } +opentelemetry-instrumentation-fastapi = { version = "*", optional = true } +opentelemetry-instrumentation-httpx = { version = "*", optional = true } +opentelemetry-instrumentation-requests = { version = "*", optional = true } +opentelemetry-instrumentation-logging = { version = "*", optional = true } +opentelemetry-instrumentation-asyncpg = { version = "*", optional = true } +pyroscope-io = { version = "*", optional = true } +pyroscope-otel = { version = "*", optional = true } +fastapi = { version = "*", optional = true } +# Imported directly for the ASGI types and Request, rather than relied on through FastAPI. +starlette = { version = "*", optional = true } [tool.poetry.group.dev.dependencies] pytest = "*" @@ -27,10 +40,26 @@ pytest-flask = "*" mock = "*" deptry = "^0.24.0" cryptography = "*" +# fastapi.testclient needs it; the fastapi core distribution does not pull it in. +httpx = "*" [tool.poetry.extras] profiling = ["Werkzeug", "matplotlib", "numpy"] uwsgi = ["uwsgi"] +observability = [ + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-exporter-otlp", + "opentelemetry-instrumentation-fastapi", + "opentelemetry-instrumentation-httpx", + "opentelemetry-instrumentation-requests", + "opentelemetry-instrumentation-logging", + "opentelemetry-instrumentation-asyncpg", + "pyroscope-io", + "pyroscope-otel", + "fastapi", + "starlette", +] [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/observability/__init__.py b/tests/observability/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/observability/test_continuous_profiling.py b/tests/observability/test_continuous_profiling.py new file mode 100644 index 0000000..dc502ca --- /dev/null +++ b/tests/observability/test_continuous_profiling.py @@ -0,0 +1,142 @@ +""" +Tests for the Pyroscope continuous-profiling lifecycle. +""" + +import pytest + +from cdispyutils.observability import continuous_profiling +from cdispyutils.observability.continuous_profiling import ( + configure_profiling, + profiling_active, + stop_profiling, +) + +SERVICE_NAME = "test_service" +SERVER_ADDRESS = "http://pyroscope.test:4040" + + +class FakeAgent: + """Stands in for the pyroscope SDK, recording what it was asked to do.""" + + def __init__(self) -> None: + self.configure_calls: list[dict] = [] + self.shutdown_calls = 0 + + def configure(self, **kwargs) -> None: + """Record a request to start the agent.""" + self.configure_calls.append(kwargs) + + def shutdown(self) -> None: + """Record a request to stop the agent.""" + self.shutdown_calls += 1 + + +@pytest.fixture(autouse=True) +def agent(monkeypatch): + """Replace the SDK with a fake, and leave no agent running behind the test.""" + fake = FakeAgent() + monkeypatch.setattr(continuous_profiling, "pyroscope", fake) + yield fake + stop_profiling() + + +@pytest.fixture +def profiling_on(monkeypatch): + """Turn profiling on through the environment, as a deployment would.""" + monkeypatch.setenv("ENABLE_CONTINUOUS_PROFILING", "true") + monkeypatch.setenv("PYROSCOPE_SERVER_ADDRESS", SERVER_ADDRESS) + + +def test_profiling_is_off_by_default(agent, monkeypatch): + """With nothing configured, no agent is started.""" + monkeypatch.delenv("ENABLE_CONTINUOUS_PROFILING", raising=False) + configure_profiling(SERVICE_NAME) + + assert agent.configure_calls == [] + assert not profiling_active() + + +def test_enabling_starts_the_agent_under_the_service_name(agent, profiling_on): + """An enabled service starts the agent under its own name.""" + configure_profiling(SERVICE_NAME) + + assert profiling_active() + assert agent.configure_calls[0]["application_name"] == SERVICE_NAME + assert agent.configure_calls[0]["server_address"] == SERVER_ADDRESS + + +def test_cpu_is_profiled_and_memory_is_not_by_default(agent, profiling_on): + """CPU profiling is on and memory profiling is off unless asked for.""" + configure_profiling(SERVICE_NAME) + + assert agent.configure_calls[0]["cpu_enabled"] is True + assert agent.configure_calls[0]["mem_enabled"] is False + + +def test_memory_profiling_can_be_requested(agent, profiling_on): + """Asking for memory profiling reaches the SDK.""" + configure_profiling(SERVICE_NAME, profile_memory=True) + + assert agent.configure_calls[0]["mem_enabled"] is True + + +def test_wall_clock_profiling_can_be_requested(agent, profiling_on): + """Turning off on-CPU-only profiling reaches the SDK.""" + configure_profiling(SERVICE_NAME, on_cpu_only=False) + + assert agent.configure_calls[0]["oncpu"] is False + + +def test_explicit_argument_overrides_the_environment(agent, profiling_on): + """An explicitly disabled service does not start the agent despite the environment.""" + configure_profiling(SERVICE_NAME, enabled=False) + + assert agent.configure_calls == [] + assert not profiling_active() + + +def test_missing_server_address_does_not_start_the_agent(agent, monkeypatch): + """Profiling enabled with nowhere to push does not start the agent.""" + monkeypatch.setenv("ENABLE_CONTINUOUS_PROFILING", "true") + monkeypatch.setenv("PYROSCOPE_SERVER_ADDRESS", "") + configure_profiling(SERVICE_NAME) + + assert agent.configure_calls == [] + assert not profiling_active() + + +def test_caller_tags_are_merged_over_the_defaults(agent, profiling_on): + """Caller-supplied tags reach the SDK alongside the default pod tag.""" + configure_profiling(SERVICE_NAME, tags={"region": "us-east-1"}) + + tags = agent.configure_calls[0]["tags"] + assert tags["region"] == "us-east-1" + assert "pod" in tags + + +def test_configuring_twice_leaves_the_running_agent_alone(agent, profiling_on): + """A second call does not start a second agent.""" + configure_profiling(SERVICE_NAME) + configure_profiling(SERVICE_NAME) + + assert len(agent.configure_calls) == 1 + + +def test_stopping_shuts_the_agent_down_and_allows_restarting(agent, profiling_on): + """Stopping the agent reports it inactive and lets it be configured again.""" + configure_profiling(SERVICE_NAME) + stop_profiling() + + assert agent.shutdown_calls == 1 + assert not profiling_active() + + configure_profiling(SERVICE_NAME) + assert profiling_active() + assert len(agent.configure_calls) == 2 + + +def test_stopping_an_unstarted_agent_does_nothing(agent): + """Stopping when nothing is running never reaches the SDK.""" + stop_profiling() + + assert agent.shutdown_calls == 0 diff --git a/tests/observability/test_request_metrics.py b/tests/observability/test_request_metrics.py new file mode 100644 index 0000000..15358a0 --- /dev/null +++ b/tests/observability/test_request_metrics.py @@ -0,0 +1,409 @@ +""" +Tests for the FastAPI request-metrics middleware. +""" + +import tempfile + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette.responses import StreamingResponse + +from cdispyutils.metrics import BaseMetrics +from cdispyutils.observability.request_metrics import add_request_metrics_middleware + +COUNTER_NAME = "test_api_requests" +HISTOGRAM_NAME = "test_api_request_duration_seconds" + + +@pytest.fixture +def metrics(): + """A metrics client writing to a throwaway directory.""" + with tempfile.TemporaryDirectory() as prometheus_dir: + yield BaseMetrics(enabled=True, prometheus_dir=prometheus_dir) + + +def build_app(metrics, **kwargs) -> FastAPI: + """Build an instrumented app exposing one route of each interesting shape.""" + app = FastAPI(**kwargs.pop("app_kwargs", {})) + + @app.get("/") + def root(): + return {"ok": True} + + @app.get("/_status") + def status(): + return {"ok": True} + + @app.get("/things/{thing_id}") + def thing(thing_id: str): + return {"thing_id": thing_id} + + @app.get("/boom") + def boom(): + raise RuntimeError("kaboom") + + @app.get("/stream") + def stream(): + return StreamingResponse(iter([b"chunk-a", b"chunk-b"])) + + if metrics is not None: + app.mount("/metrics", metrics.get_asgi_app()) + + add_request_metrics_middleware(app, metrics, counter_name=COUNTER_NAME, **kwargs) + return app + + +def client(app) -> TestClient: + """A test client that reports server errors as 500s instead of re-raising them.""" + return TestClient(app, raise_server_exceptions=False) + + +def samples(test_client) -> list[str]: + """Return the counter's sample lines from a scrape of the metrics endpoint.""" + body = test_client.get("/metrics").text + return [ + line for line in body.splitlines() if line.startswith(f"{COUNTER_NAME}_total{{") + ] + + +def label_sets(test_client) -> list[str]: + """Return just the label portion of each of the counter's sample lines.""" + return [ + line[line.index("{") : line.rindex("}") + 1] for line in samples(test_client) + ] + + +def test_served_request_is_counted(metrics): + """A served request is counted with its method, path and status.""" + c = client(build_app(metrics)) + c.get("/") + + assert any( + 'method="GET"' in s and 'path="/"' in s and 'status_code="200"' in s + for s in label_sets(c) + ) + + +def test_path_parameter_is_recorded_as_its_route_template(metrics): + """A path parameter is labelled with its template, not the value that was requested.""" + c = client(build_app(metrics)) + c.get("/things/abc") + c.get("/things/def") + + recorded = label_sets(c) + assert any('path="/things/{thing_id}"' in s for s in recorded) + assert not any("abc" in s or "def" in s for s in recorded) + + +def test_unmatched_requests_share_one_path_label(metrics): + """Requests matching no route collapse into a single time series.""" + c = client(build_app(metrics)) + c.get("/nope/one") + c.get("/nope/two") + + unmatched = [s for s in label_sets(c) if 'status_code="404"' in s] + assert len(unmatched) == 1 + assert not any("nope" in s for s in label_sets(c)) + + +def test_method_not_allowed_is_recorded_under_its_route_template(metrics): + """A 405 is labelled with the template it partially matched.""" + c = client(build_app(metrics)) + c.post("/things/abc") + + assert any( + 'path="/things/{thing_id}"' in s and 'status_code="405"' in s + for s in label_sets(c) + ) + + +@pytest.mark.parametrize("path", ["/docs", "/openapi.json"]) +def test_documentation_endpoints_are_counted_under_their_own_label(metrics, path): + """Docs and spec traffic is counted under its own path label, not as unmatched.""" + c = client(build_app(metrics)) + c.get(path) + + assert any(f'path="{path}"' in s for s in label_sets(c)) + + +def test_scraping_the_metrics_endpoint_records_nothing(metrics): + """A Prometheus scrape does not count itself.""" + c = client(build_app(metrics)) + c.get("/") + before = samples(c) + after = samples(c) + + assert before == after + + +def test_scrape_records_nothing_even_with_no_configured_exclusions(metrics): + """The metrics endpoint is excluded even when the caller excludes nothing.""" + c = client(build_app(metrics, excluded_paths=())) + c.get("/") + before = samples(c) + + assert samples(c) == before + + +def test_scrape_records_nothing_when_the_app_is_behind_a_root_path(metrics): + """A scrape does not count itself when the app is served under a prefix.""" + app = build_app(metrics, app_kwargs={"root_path": "/prefix"}) + c = TestClient(app, root_path="/prefix", raise_server_exceptions=False) + c.get("/prefix/") + before = [ + line + for line in c.get("/prefix/metrics").text.splitlines() + if line.startswith(f"{COUNTER_NAME}_total{{") + ] + after = [ + line + for line in c.get("/prefix/metrics").text.splitlines() + if line.startswith(f"{COUNTER_NAME}_total{{") + ] + + assert before == after + + +def test_excluded_path_is_not_counted(metrics): + """A path in excluded_paths records nothing.""" + c = client(build_app(metrics)) + c.get("/_status") + + assert not any('path="/_status"' in s for s in label_sets(c)) + + +def test_extra_label_from_provider_is_recorded(metrics): + """A provider's value is recorded under its declared label name.""" + + async def provider(request): + return {"user_id": "someone"} + + c = client( + build_app(metrics, extra_label_names=("user_id",), extra_labels=provider) + ) + c.get("/") + + assert any('user_id="someone"' in s for s in label_sets(c)) + + +def test_failing_provider_yields_unknown_and_leaves_the_response_alone(metrics): + """A provider that raises does not affect the response and labels the request Unknown.""" + + async def provider(request): + raise RuntimeError("no idea") + + c = client( + build_app(metrics, extra_label_names=("user_id",), extra_labels=provider) + ) + response = c.get("/") + + assert response.status_code == 200 + assert any('user_id="Unknown"' in s for s in label_sets(c)) + + +def test_provider_omitting_a_declared_name_yields_unknown(metrics): + """A declared label the provider did not supply is recorded as Unknown.""" + + async def provider(request): + return {"user_id": "someone"} + + c = client( + build_app( + metrics, + extra_label_names=("user_id", "tenant"), + extra_labels=provider, + ) + ) + c.get("/") + + assert any( + 'user_id="someone"' in s and 'tenant="Unknown"' in s for s in label_sets(c) + ) + + +def test_undeclared_name_from_provider_is_dropped(metrics): + """A label the provider returns but did not declare does not reach the counter.""" + + async def provider(request): + return {"user_id": "someone", "sneaky": "value"} + + c = client( + build_app(metrics, extra_label_names=("user_id",), extra_labels=provider) + ) + c.get("/") + + assert not any("sneaky" in s for s in label_sets(c)) + + +def test_label_values_stay_with_their_own_labels_across_requests(metrics): + """Values are not swapped when the provider's dict iterates in a different order.""" + calls = {"n": 0} + + async def provider(request): + calls["n"] += 1 + if calls["n"] == 1: + return {"first": "one", "second": "two"} + return {"second": "two", "first": "one"} + + c = client( + build_app( + metrics, + extra_label_names=("first", "second"), + extra_labels=provider, + ) + ) + c.get("/") + c.get("/") + + recorded = label_sets(c) + assert all('first="one"' in s and 'second="two"' in s for s in recorded) + + +def test_provider_reading_the_body_does_not_affect_the_response(metrics): + """A provider that reaches for the request body fails without breaking the request.""" + + async def provider(request): + return {"user_id": (await request.body()).decode()} + + c = client( + build_app(metrics, extra_label_names=("user_id",), extra_labels=provider) + ) + response = c.get("/") + + assert response.status_code == 200 + assert any('user_id="Unknown"' in s for s in label_sets(c)) + + +@pytest.mark.parametrize( + "names,provider", + [ + (("user_id",), None), + ((), lambda request: {}), + (("method",), lambda request: {}), + (("path", "user_id"), lambda request: {}), + ], +) +def test_invalid_label_configuration_is_rejected_at_install_time( + metrics, names, provider +): + """A mismatched or reserved label configuration raises when the middleware is added.""" + with pytest.raises(ValueError): + build_app(metrics, extra_label_names=names, extra_labels=provider) + + +def test_unhandled_error_is_counted_as_a_server_error(metrics): + """A route that raises is counted with status 500.""" + c = client(build_app(metrics)) + response = c.get("/boom") + + assert response.status_code == 500 + assert any('path="/boom"' in s and 'status_code="500"' in s for s in label_sets(c)) + + +def test_streaming_response_is_counted_and_arrives_intact(metrics): + """A streaming response is counted without its body being disturbed.""" + c = client(build_app(metrics)) + response = c.get("/stream") + + assert response.content == b"chunk-achunk-b" + assert any('path="/stream"' in s for s in label_sets(c)) + + +def test_response_is_passed_through_unchanged(metrics): + """The middleware does not alter the status, body or headers of a response.""" + c = client(build_app(metrics)) + response = c.get("/things/abc") + + assert response.status_code == 200 + assert response.json() == {"thing_id": "abc"} + assert response.headers["content-type"] == "application/json" + + +def test_disabled_metrics_client_installs_nothing(): + """A disabled client leaves requests working and creates no counter.""" + with tempfile.TemporaryDirectory() as prometheus_dir: + disabled = BaseMetrics(enabled=False, prometheus_dir=prometheus_dir) + app = FastAPI() + + @app.get("/") + def root(): + return {"ok": True} + + add_request_metrics_middleware(app, disabled, counter_name=COUNTER_NAME) + response = TestClient(app).get("/") + + assert response.status_code == 200 + assert disabled.prometheus_metrics == {} + + +def test_absent_metrics_client_installs_nothing(): + """A None client leaves requests working.""" + app = FastAPI() + + @app.get("/") + def root(): + return {"ok": True} + + add_request_metrics_middleware(app, None, counter_name=COUNTER_NAME) + + assert TestClient(app).get("/").status_code == 200 + + +def histogram_lines(test_client) -> list[str]: + """Return the duration histogram's bucket lines from a scrape.""" + body = test_client.get("/metrics").text + return [ + line + for line in body.splitlines() + if line.startswith(f"{HISTOGRAM_NAME}_bucket") + ] + + +def test_request_duration_is_recorded_when_a_histogram_is_named(metrics): + """A duration histogram is recorded alongside the counter when one is requested.""" + c = client(build_app(metrics, duration_histogram_name=HISTOGRAM_NAME)) + c.get("/things/abc") + + assert any('path="/things/{thing_id}"' in line for line in histogram_lines(c)) + assert any('path="/things/{thing_id}"' in s for s in label_sets(c)) + + +def test_no_histogram_is_recorded_by_default(metrics): + """Without a histogram name, only the counter is recorded.""" + c = client(build_app(metrics)) + c.get("/things/abc") + + assert histogram_lines(c) == [] + + +def test_streaming_response_duration_covers_the_body(metrics): + """A streamed response is timed to the last chunk, not to its headers.""" + c = client(build_app(metrics, duration_histogram_name=HISTOGRAM_NAME)) + c.get("/stream") + + observed = [ + sample.value + for metric in metrics.prometheus_metrics[HISTOGRAM_NAME].collect() + for sample in metric.samples + if sample.name.endswith("_sum") and sample.labels["path"] == "/stream" + ] + assert observed and observed[0] > 0 + + +def test_histogram_on_a_client_that_cannot_record_one_is_rejected(metrics): + """Asking for a histogram from a client without the capability fails at install time.""" + + class CounterOnlyMetrics: + enabled = True + + def increment_counter(self, name, labels, description=""): + """Record nothing.""" + + with pytest.raises(ValueError): + add_request_metrics_middleware( + FastAPI(), + CounterOnlyMetrics(), + counter_name=COUNTER_NAME, + duration_histogram_name=HISTOGRAM_NAME, + ) diff --git a/tests/observability/test_tracing.py b/tests/observability/test_tracing.py new file mode 100644 index 0000000..c9618a4 --- /dev/null +++ b/tests/observability/test_tracing.py @@ -0,0 +1,372 @@ +""" +Tests for the OpenTelemetry tracing helpers. +""" + +import asyncio +import time +from types import ModuleType + +import pytest +from fastapi import FastAPI +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import StatusCode +from opentelemetry.util.http import parse_excluded_urls + +from cdispyutils.observability.constants import DEFAULT_ENDPOINTS_WITHOUT_METRICS +from cdispyutils.observability.tracing import ( + configure_tracing, + excluded_url_patterns, + get_tracer, + instrument_class, + instrument_module, + no_trace, + reset_tracing_state, + traced, +) + +SLEEP_SECONDS = 0.01 + + +@pytest.fixture(scope="session") +def exporter(): + """ + Install one SDK tracer provider for the whole session. + + Session-scoped because `set_tracer_provider` only warns on a second call, so a narrower + fixture would silently keep using the first provider it installed. + """ + in_memory = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(in_memory)) + trace.set_tracer_provider(provider) + return in_memory + + +@pytest.fixture(autouse=True) +def spans(exporter): + """Give each test an empty span buffer.""" + exporter.clear() + return exporter + + +@pytest.fixture(autouse=True) +def tracing_enabled(monkeypatch): + """Turn custom tracing on, as a default deployment has it, and leave no state behind.""" + monkeypatch.setenv("ENABLE_OPENTELEMETRY_TRACES", "true") + monkeypatch.delenv("FORCE_DISABLE_CUSTOM_TRACING", raising=False) + reset_tracing_state() + yield + reset_tracing_state() + + +def build_module(source: str, name: str = "sample_module") -> ModuleType: + """Build a module from source so module-level instrumentation can be exercised.""" + module = ModuleType(name) + exec(compile(source, name, "exec"), module.__dict__) + return module + + +def test_span_is_named_for_the_function(spans): + """A traced function emits one span named for its module and qualified name.""" + + @traced + def work(): + return "done" + + assert work() == "done" + + finished = spans.get_finished_spans() + assert len(finished) == 1 + assert finished[0].name.endswith( + "test_span_is_named_for_the_function..work" + ) + + +def test_async_function_is_traced_across_its_await(spans): + """An async function returns its value and its span covers the awaited work.""" + + @traced + async def work(): + await asyncio.sleep(SLEEP_SECONDS) + return "done" + + assert asyncio.run(work()) == "done" + + finished = spans.get_finished_spans() + assert len(finished) == 1 + assert finished[0].end_time - finished[0].start_time >= SLEEP_SECONDS * 1e9 + + +def test_nested_calls_are_linked_as_parent_and_child(spans): + """A traced function called from another is recorded as its child.""" + + @traced + def inner(): + return 1 + + @traced + def outer(): + return inner() + + outer() + + finished = spans.get_finished_spans() + child = next(s for s in finished if s.name.endswith("inner")) + parent = next(s for s in finished if s.name.endswith("outer")) + assert child.parent.span_id == parent.context.span_id + + +def test_exception_is_recorded_and_still_propagates(spans): + """A raising function marks its span as an error and does not swallow the exception.""" + + @traced + def work(): + raise ValueError("nope") + + with pytest.raises(ValueError): + work() + + span = spans.get_finished_spans()[0] + assert span.status.status_code is StatusCode.ERROR + assert len([event for event in span.events if event.name == "exception"]) == 1 + + +@pytest.mark.parametrize("kill_switch", ["true", "false"]) +def test_generator_functions_are_rejected(monkeypatch, kill_switch): + """Tracing a generator raises whether or not the kill switch is on.""" + monkeypatch.setenv("FORCE_DISABLE_CUSTOM_TRACING", kill_switch) + + def gen(): + yield 1 + + async def agen(): + yield 1 + + with pytest.raises(TypeError): + traced(gen) + with pytest.raises(TypeError): + traced(agen) + + +def test_instrument_class_traces_the_methods_it_defines(spans): + """Instrumenting a class traces its own methods, including private ones.""" + + class Service: + def public(self): + return self._private() + + def _private(self): + return 1 + + instrument_class(Service) + Service().public() + + names = [span.name for span in spans.get_finished_spans()] + assert any(name.endswith("Service.public") for name in names) + assert any(name.endswith("Service._private") for name in names) + + +def test_instrument_class_is_idempotent(spans): + """Instrumenting a class twice does not nest a second span around every call.""" + + class Service: + def work(self): + return 1 + + instrument_class(Service) + instrument_class(Service) + Service().work() + + assert len(spans.get_finished_spans()) == 1 + + +def test_instrument_class_skips_dunders_and_descriptors(spans): + """Constructors, static methods, class methods and properties are left alone.""" + + class Service: + def __init__(self): + self.value = 1 + + @staticmethod + def helper(): + return 1 + + @classmethod + def build(cls): + return cls() + + @property + def doubled(self): + return self.value * 2 + + instrument_class(Service) + Service.helper() + Service.build().doubled + + assert spans.get_finished_spans() == () + + +def test_instrument_module_traces_only_its_own_functions(spans): + """A module's own functions are traced and the ones it imported are not.""" + module = build_module( + "from json import dumps\n" "def own():\n" " return dumps({})\n" + ) + instrument_module(module) + module.own() + + names = [span.name for span in spans.get_finished_spans()] + assert len(names) == 1 + assert names[0].endswith("own") + + +def test_instrument_module_leaves_generators_alone(spans): + """A module holding a generator can be instrumented without raising.""" + module = build_module( + "def gen():\n" " yield 1\n" "def work():\n" " return list(gen())\n" + ) + instrument_module(module) + + assert module.work() == [1] + assert len(spans.get_finished_spans()) == 1 + + +def test_no_trace_excludes_a_function(spans): + """A function marked no_trace is skipped by the module walk.""" + module = build_module( + "from cdispyutils.observability.tracing import no_trace\n" + "@no_trace\n" + "def skipped():\n" + " return 1\n" + "def traced_one():\n" + " return skipped()\n" + ) + instrument_module(module) + module.traced_one() + + names = [span.name for span in spans.get_finished_spans()] + assert len(names) == 1 + assert names[0].endswith("traced_one") + + +@pytest.mark.parametrize( + "env", + [ + {"ENABLE_OPENTELEMETRY_TRACES": "false"}, + {"FORCE_DISABLE_CUSTOM_TRACING": "true"}, + ], +) +def test_disabled_tracing_returns_the_original_function(monkeypatch, env): + """With tracing off, the decorator hands back the function untouched.""" + for name, value in env.items(): + monkeypatch.setenv(name, value) + + def work(): + return 1 + + assert traced(work) is work + + +def test_get_tracer_produces_usable_spans(spans): + """A tracer taken by name records the spans opened through it.""" + with get_tracer(__name__).start_as_current_span("manual"): + pass + + assert [span.name for span in spans.get_finished_spans()] == ["manual"] + + +@pytest.mark.parametrize( + "url", + [ + "http://host/_status", + "http://host/_status/", + "http://host/metrics", + "http://host/ai/embeddings/_status", + ], +) +def test_polled_endpoints_are_excluded(url): + """Probe and scrape endpoints produce no request spans, including behind a prefix.""" + excluded = parse_excluded_urls( + excluded_url_patterns(DEFAULT_ENDPOINTS_WITHOUT_METRICS) + ) + + assert excluded.url_disabled(url) + + +@pytest.mark.parametrize( + "url", + [ + "http://host/", + "http://host/docs", + "http://host/openapi.json", + "http://host/collections", + "http://host/collections/", + "http://host/collections/a-name", + ], +) +def test_real_traffic_is_not_excluded(url): + """Ordinary routes, including trailing-slash forms, still produce request spans.""" + excluded = parse_excluded_urls( + excluded_url_patterns(DEFAULT_ENDPOINTS_WITHOUT_METRICS) + ) + + assert not excluded.url_disabled(url) + + +def test_configure_tracing_respects_the_environment(monkeypatch): + """With traces disabled in the environment, the app is left uninstrumented.""" + monkeypatch.setenv("ENABLE_OPENTELEMETRY_TRACES", "false") + app = FastAPI() + configure_tracing(app, "test_service") + + assert not hasattr(app, "_is_instrumented_by_opentelemetry") or not getattr( + app, "_is_instrumented_by_opentelemetry" + ) + + +def test_configure_tracing_instruments_the_app(monkeypatch): + """An enabled service gets its FastAPI app instrumented.""" + app = FastAPI() + configure_tracing(app, "test_service", instrumentors=()) + + assert getattr(app, "_is_instrumented_by_opentelemetry") + + +def test_configure_tracing_choice_reaches_the_instrument_helpers(monkeypatch): + """A service configuring tracing in code, not the environment, still gates instrument_class.""" + monkeypatch.delenv("ENABLE_OPENTELEMETRY_TRACES", raising=False) + configure_tracing(FastAPI(), "test_service", enabled=False, instrumentors=()) + + class Service: + def work(self): + """Do nothing.""" + + original = Service.work + instrument_class(Service) + + assert Service.work is original + + +def test_kill_switch_overrides_a_configured_choice(monkeypatch): + """FORCE_DISABLE_CUSTOM_TRACING wins over a service that enabled tracing explicitly.""" + configure_tracing(FastAPI(), "test_service", enabled=True, instrumentors=()) + monkeypatch.setenv("FORCE_DISABLE_CUSTOM_TRACING", "true") + + def work(): + return 1 + + assert traced(work) is work + + +def test_reset_sends_the_helpers_back_to_the_environment(monkeypatch): + """Resetting forgets the configured choice so the environment decides again.""" + configure_tracing(FastAPI(), "test_service", enabled=False, instrumentors=()) + reset_tracing_state() + monkeypatch.setenv("ENABLE_OPENTELEMETRY_TRACES", "true") + + def work(): + return 1 + + assert traced(work) is not work diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 573bdbe..44e22b0 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,7 +1,10 @@ import pytest import os +import subprocess +import sys import tempfile -from prometheus_client import Counter, Gauge +import textwrap +from prometheus_client import Counter, Gauge, Histogram from unittest.mock import patch, MagicMock from cdispyutils.metrics import BaseMetrics @@ -41,11 +44,11 @@ def test_get_latest_metrics_disabled(): we've incremented something """ metrics = BaseMetrics(enabled=False) - metrics_data, content_type = metrics.get_latest_metrics() name = "test_counter1" labels = {"label1": "value1"} metrics.increment_counter(name, labels) - assert metrics_data == "" + metrics_data, content_type = metrics.get_latest_metrics() + assert metrics_data == b"" def test_get_latest_metrics_enabled(prometheus_dir): @@ -53,11 +56,11 @@ def test_get_latest_metrics_enabled(prometheus_dir): Test that you get metrics data when it's enabled we've incremented something """ metrics = BaseMetrics(enabled=True, prometheus_dir=prometheus_dir) - metrics_data, content_type = metrics.get_latest_metrics() name = "test_counter2" labels = {"label1": "value1"} metrics.increment_counter(name, labels) - assert metrics_data != "" + metrics_data, content_type = metrics.get_latest_metrics() + assert name.encode() in metrics_data def test_increment_counter(prometheus_dir): @@ -139,3 +142,145 @@ def test_set_gauge_disabled(): value = 5 metrics.set_gauge(name, labels, value) assert name not in metrics.prometheus_metrics + + +def test_metrics_are_written_for_multiprocess_collection(prometheus_dir): + """ + Test that counters are stored where a multiprocess registry can read them. + + Run in a subprocess because prometheus_client resolves its storage backend once per + interpreter, from PROMETHEUS_MULTIPROC_DIR, and the test suite's own imports have already + settled that choice for this process. + """ + source = textwrap.dedent( + f""" + from cdispyutils.metrics import BaseMetrics + + metrics = BaseMetrics(enabled=True, prometheus_dir={prometheus_dir!r}) + metrics.increment_counter("subprocess_counter", {{"label1": "value1"}}) + """ + ) + env = {k: v for k, v in os.environ.items() if k != "PROMETHEUS_MULTIPROC_DIR"} + + result = subprocess.run( + [sys.executable, "-c", source], env=env, capture_output=True, text=True + ) + + assert result.returncode == 0, result.stderr + assert [name for name in os.listdir(prometheus_dir) if name.endswith(".db")] + + +def test_latest_metrics_reports_each_metric_once(prometheus_dir): + """ + Test that a metric family is exposed once, not once per collector in the registry. + + A duplicated `# HELP` line for one metric name is a parse error to Prometheus. + """ + metrics = BaseMetrics(enabled=True, prometheus_dir=prometheus_dir) + metrics.increment_counter("test_counter_once", {"label1": "value1"}) + + text, _ = metrics.get_latest_metrics() + lines = text.decode() + + assert lines.count("# HELP test_counter_once_total") == 1 + + +def test_asgi_app_is_available_when_metrics_are_disabled(): + """ + Test that the ASGI metrics endpoint can be built while metrics are disabled. + + A caller mounts the endpoint before knowing whether metrics are on, so this must not raise. + """ + metrics = BaseMetrics(enabled=False) + + assert callable(metrics.get_asgi_app()) + + +def test_wsgi_app_is_available_when_metrics_are_disabled(): + """ + Test that the WSGI metrics endpoint can be built while metrics are disabled. + """ + metrics = BaseMetrics(enabled=False) + + assert callable(metrics.get_wsgi_app()) + + +def test_disabled_wsgi_app_serves_an_empty_response(): + """ + Test that scraping a disabled metrics endpoint succeeds and returns no metrics. + """ + metrics = BaseMetrics(enabled=False) + metrics.increment_counter("disabled_counter", {"label1": "value1"}) + + captured = {} + + def start_response(status, headers): + captured["status"] = status + + body = b"".join( + metrics.get_wsgi_app()( + {"REQUEST_METHOD": "GET", "PATH_INFO": "/", "QUERY_STRING": ""}, + start_response, + ) + ) + + assert captured["status"].startswith("200") + assert b"disabled_counter" not in body + + +def test_observe_histogram(prometheus_dir): + """ + Test that observing a histogram records the value in the right bucket. + """ + metrics = BaseMetrics(enabled=True, prometheus_dir=prometheus_dir) + name = "test_histogram1" + labels = {"label1": "value1"} + metrics.observe_histogram(name, labels, 0.3, buckets=[0.1, 0.5, 1.0]) + + assert isinstance(metrics.prometheus_metrics[name], Histogram) + + samples = { + sample.labels["le"]: sample.value + for metric in metrics.prometheus_metrics[name].collect() + for sample in metric.samples + if sample.name.endswith("_bucket") + } + assert samples["0.1"] == 0.0 + assert samples["0.5"] == 1.0 + assert samples["1.0"] == 1.0 + + +def test_observe_histogram_uses_default_buckets(prometheus_dir): + """ + Test that omitting buckets falls back to the client's defaults rather than failing. + """ + metrics = BaseMetrics(enabled=True, prometheus_dir=prometheus_dir) + name = "test_histogram2" + metrics.observe_histogram(name, {"label1": "value1"}, 0.3) + + text, _ = metrics.get_latest_metrics() + lines = text.decode() + assert lines.count(f"{name}_bucket") > 3 + + +def test_observe_histogram_rejects_a_name_used_by_another_type(prometheus_dir): + """ + Test that reusing a counter's name for a histogram raises. + """ + metrics = BaseMetrics(enabled=True, prometheus_dir=prometheus_dir) + name = "test_histogram3" + metrics.increment_counter(name, {"label1": "value1"}) + + with pytest.raises(ValueError): + metrics.observe_histogram(name, {"label1": "value1"}, 0.3) + + +def test_observe_histogram_disabled(): + """ + Test that observing a histogram does nothing when metrics are disabled. + """ + metrics = BaseMetrics(enabled=False) + name = "test_histogram4" + metrics.observe_histogram(name, {"label1": "value1"}, 0.3) + + assert name not in metrics.prometheus_metrics From 5f3141a835693605b3843bfe819484bd0e61608e Mon Sep 17 00:00:00 2001 From: avantol Date: Wed, 26 Aug 2026 08:58:09 -0500 Subject: [PATCH 2/2] feat(logging): update to latest cdislogging for tracing --- cdispyutils/metrics.py | 2 +- cdispyutils/observability/README.md | 2 +- .../observability/continuous_profiling.py | 2 +- cdispyutils/observability/request_metrics.py | 2 +- cdispyutils/observability/tracing.py | 2 +- poetry.lock | 18 +++++++++++++++++- pyproject.toml | 3 ++- 7 files changed, 24 insertions(+), 7 deletions(-) diff --git a/cdispyutils/metrics.py b/cdispyutils/metrics.py index d636269..c76b877 100644 --- a/cdispyutils/metrics.py +++ b/cdispyutils/metrics.py @@ -13,7 +13,7 @@ import pathlib from typing import Dict -from cdislogging import get_logger +from gen3logging import get_logger from prometheus_client import ( CONTENT_TYPE_LATEST, CollectorRegistry, diff --git a/cdispyutils/observability/README.md b/cdispyutils/observability/README.md index 9154e94..dd9af33 100644 --- a/cdispyutils/observability/README.md +++ b/cdispyutils/observability/README.md @@ -13,7 +13,7 @@ Four signals and how they leave the process: | Traces | OTLP to a collector, or the console | `tracing.configure_tracing` | | Profiles | Pyroscope ingest API | `continuous_profiling.configure_profiling` | | Metrics | Scraped from an endpoint the service mounts | `request_metrics.add_request_metrics_middleware` | -| Logs | JSON on stdout, carrying the trace id | `cdislogging`, correlated by the logging instrumentation | +| Logs | JSON on stdout, carrying the trace id | `gen3logging`, correlated by the logging instrumentation | Import the submodules directly. Importing them through the package would drag OpenTelemetry, Pyroscope, and FastAPI in together even when only one is wanted. diff --git a/cdispyutils/observability/continuous_profiling.py b/cdispyutils/observability/continuous_profiling.py index e17c83c..f1c78c8 100644 --- a/cdispyutils/observability/continuous_profiling.py +++ b/cdispyutils/observability/continuous_profiling.py @@ -8,7 +8,7 @@ from collections.abc import Mapping import pyroscope -from cdislogging import get_logger +from gen3logging import get_logger from cdispyutils.observability._config import env_bool, env_int, env_str, resolve diff --git a/cdispyutils/observability/request_metrics.py b/cdispyutils/observability/request_metrics.py index d6d7405..5e0a81f 100644 --- a/cdispyutils/observability/request_metrics.py +++ b/cdispyutils/observability/request_metrics.py @@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable, Collection, Mapping from typing import Protocol, cast -from cdislogging import get_logger +from gen3logging import get_logger from fastapi import FastAPI from starlette.requests import Request from starlette.types import ASGIApp, Message, Receive, Scope, Send diff --git a/cdispyutils/observability/tracing.py b/cdispyutils/observability/tracing.py index 768e4d4..fdb9f78 100644 --- a/cdispyutils/observability/tracing.py +++ b/cdispyutils/observability/tracing.py @@ -11,7 +11,7 @@ from types import FunctionType, ModuleType from typing import Any, Protocol, cast -from cdislogging import get_logger +from gen3logging import get_logger from fastapi import FastAPI from opentelemetry import trace from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( diff --git a/poetry.lock b/poetry.lock index 8677571..88e0fda 100644 --- a/poetry.lock +++ b/poetry.lock @@ -708,6 +708,22 @@ type1 = ["xattr ; sys_platform == \"darwin\""] unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""] woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] +[[package]] +name = "gen3logging" +version = "2.0.0" +description = "Standardized logging tool and format for Gen3" +optional = false +python-versions = ">=3.13,<4" +groups = ["main"] +files = [] +develop = false + +[package.source] +type = "git" +url = "https://github.com/uc-cdis/cdislogging.git" +reference = "feat/json" +resolved_reference = "66e6d96eff1b8d73279ab1256fb4c811c9043722" + [[package]] name = "googleapis-common-protos" version = "1.75.1" @@ -2388,4 +2404,4 @@ uwsgi = ["uwsgi"] [metadata] lock-version = "2.1" python-versions = ">=3.13,<4" -content-hash = "c7ea0ebcb2b7ec0a98b55ffe1435379486b19787f8ddad2ab85b81027d1f4f6b" +content-hash = "0d6cee36e0ca1bf7787a52e072e1decd6fbed5ec7d005efaaf279bc1b8a79cec" diff --git a/pyproject.toml b/pyproject.toml index 2d54da3..8237308 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,8 @@ license = "Apache-2.0" [tool.poetry.dependencies] python = ">=3.13,<4" cdiserrors = "*" -cdislogging = "*" +# `gen3logging` is the renamed `cdislogging`; the repository still has the old name. +gen3logging = { git = "https://github.com/uc-cdis/cdislogging.git", rev = "feat/json" } # unless this library explicitly uses features known only to a library version # above a certain version, allow flexibility for consumers of this library to # limit the version as necessary