diff --git a/CHANGELOG.md b/CHANGELOG.md index f2021bc..7705cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Client-credentials profiles are unaffected: a service principal mints tokens from its secret on demand and has no refresh token to cache. +### Changed + +- The `azure_monitor` telemetry backend no longer slows commands down. It used + `azure-monitor-opentelemetry`, whose exporter shut down synchronously at + process exit — about 2 s added to every command (0.3 s → 2.3 s for + `bcli endpoint list`) — and whose Azure VM resource detector printed an IMDS + timeout traceback on every machine that isn't an Azure VM. + + Events are now appended as Application Insights envelopes to + `~/.config/bcli/telemetry/spool.jsonl` (microseconds, no network), and at + exit a detached `python -m bcli.telemetry._ship` posts them to the + connection string's ingestion endpoint with `httpx`. Commands with telemetry + on now run in the same time as with it off. A single shipper runs at a time + (lock file, taken over when stale); events that fail with a network error or + a retryable status stay on disk for the next run, and the spool is capped at + 5 MB so an offline machine can't grow it without bound. + + Events now land as real custom events (`customEvents` / `AppEvents` in a + workspace-based resource) with string-valued properties; read numbers back + with `todouble(...)`. The `[telemetry]` extra is now empty and kept only so + existing install commands still resolve. No config change is needed. + ## [0.8.1] - 2026-08-11 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 65d79c5..3fba5f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,9 +62,9 @@ cli = [] etl = [ "dlt[parquet,filesystem,s3]>=1.0", ] -telemetry = [ - "azure-monitor-opentelemetry>=1.6", -] +# Backward-compatible no-op extra: the azure_monitor backend now posts with +# httpx (a core dependency) instead of azure-monitor-opentelemetry. +telemetry = [] extract = [ "bc-cli[extract-claude]", "bc-cli[extract-openai]", diff --git a/src/bcli/config/_defaults.py b/src/bcli/config/_defaults.py index 29dfcf5..764525a 100644 --- a/src/bcli/config/_defaults.py +++ b/src/bcli/config/_defaults.py @@ -38,6 +38,10 @@ # is durability, not secrecy. See ``bcli.telemetry.events._install_id``. INSTALL_ID_FILE = CONFIG_DIR / "install-id" +# Telemetry spool: events are appended here during a command and shipped +# by a detached background process. See ``bcli.telemetry._ship``. +TELEMETRY_DIR = CONFIG_DIR / "telemetry" + # Custom registries directory REGISTRIES_DIR = CONFIG_DIR / "registries" diff --git a/src/bcli/config/_model.py b/src/bcli/config/_model.py index 0cfc9c2..90314e9 100644 --- a/src/bcli/config/_model.py +++ b/src/bcli/config/_model.py @@ -122,8 +122,9 @@ class TelemetryConfig(BaseModel): Built-in backends: * ``"null"`` — drop everything (default; effectively disabled) * ``"console"`` — pretty-print events to stderr (handy for dev) - * ``"azure_monitor"`` — Azure Application Insights via - ``azure-monitor-opentelemetry`` (extra: ``[telemetry]``) + * ``"azure_monitor"`` — Azure Application Insights. Events are spooled + to ``~/.config/bcli/telemetry/`` and shipped by a + detached process, so commands never wait on it. Custom backends: * ``"my_pkg.module:MySink"`` — any importable class implementing the diff --git a/src/bcli/telemetry/__init__.py b/src/bcli/telemetry/__init__.py index 8b0d376..589acf8 100644 --- a/src/bcli/telemetry/__init__.py +++ b/src/bcli/telemetry/__init__.py @@ -6,7 +6,8 @@ * ``"null"`` (default) — drop everything * ``"console"`` — pretty-print to stderr (dev / debugging) -* ``"azure_monitor"`` — Azure Application Insights (extra: ``[telemetry]``) +* ``"azure_monitor"`` — Azure Application Insights (spooled locally, shipped + by a detached background process; no extra dependencies) Custom backends are loaded by ``module.path:ClassName`` import spec and must satisfy the :class:`TelemetrySink` protocol — i.e. expose diff --git a/src/bcli/telemetry/_azure_monitor.py b/src/bcli/telemetry/_azure_monitor.py index 7467983..3d451da 100644 --- a/src/bcli/telemetry/_azure_monitor.py +++ b/src/bcli/telemetry/_azure_monitor.py @@ -1,24 +1,32 @@ """Azure Application Insights backend (``backend = "azure_monitor"``). -Wraps ``azure-monitor-opentelemetry`` so events show up under -``customEvents`` in Log Analytics. Selected by the -:func:`bcli.telemetry.get_sink` factory when the user sets -``[telemetry] backend = "azure_monitor"``. - -The Azure SDK lives in the optional ``[telemetry]`` extra. If the import -fails (extra not installed, broken environment), this sink falls back to -a no-op state — telemetry must never crash the CLI. - -The sink piggybacks on Python's logging module: the Azure exporter -attaches a handler to the ``bcli.telemetry.events`` logger and ships -records on a background thread, so :meth:`emit` is non-blocking. +Events show up as ``customEvents`` (``AppEvents`` in a workspace-based +resource). Selected by the :func:`bcli.telemetry.get_sink` factory when the +user sets ``[telemetry] backend = "azure_monitor"``. + +Nothing touches the network while a command runs. :meth:`emit` appends one +Application Insights envelope per event to a local spool file (a few +microseconds), and :meth:`flush` — called once at process exit — starts a +detached ``python -m bcli.telemetry._ship`` that posts the spool to the +ingestion endpoint after the command has already returned. An offline +laptop keeps its events on disk and ships them on a later run. + +Sending through ``azure-monitor-opentelemetry`` instead cost ~2 s per +command (exporter shutdown at exit) and printed an IMDS-probe traceback on +every machine that isn't an Azure VM, so the envelope is built here and +posted with ``httpx``. """ from __future__ import annotations +import json import logging +import os import random -import threading +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -26,9 +34,49 @@ _diag_logger = logging.getLogger("bcli.telemetry") -_event_logger = logging.getLogger("bcli.telemetry.events") -_event_logger.setLevel(logging.INFO) -_event_logger.propagate = False +# Default from Azure's connection-string spec, used when the string carries +# only an InstrumentationKey. +_DEFAULT_INGESTION = "https://dc.services.visualstudio.com/" + +# Stop spooling once this much is waiting to ship (an offline laptop must +# not grow the directory without bound). Events past the cap are dropped. +MAX_SPOOL_BYTES = 5 * 1024 * 1024 + +SPOOL_NAME = "spool.jsonl" + + +def parse_connection_string(connection_string: str) -> tuple[str, str]: + """Return ``(instrumentation_key, track_url)``; key is ``""`` if absent.""" + parts = {} + for item in connection_string.split(";"): + key, sep, value = item.partition("=") + if sep: + parts[key.strip().lower()] = value.strip() + endpoint = parts.get("ingestionendpoint") or _DEFAULT_INGESTION + return parts.get("instrumentationkey", ""), endpoint.rstrip("/") + "/v2.1/track" + + +def _envelope(ikey: str, name: str, properties: dict[str, Any]) -> dict[str, Any]: + # App Insights properties are string-valued; KQL reads numbers back + # with todouble(). + props = {k: "" if v is None else str(v) for k, v in properties.items()} + return { + "name": "Microsoft.ApplicationInsights.Event", + "time": datetime.now(timezone.utc).isoformat(), + "iKey": ikey, + "tags": {"ai.cloud.role": "bcli"}, + "data": { + "baseType": "EventData", + "baseData": {"ver": 2, "name": name, "properties": props}, + }, + } + + +def _spool_bytes(spool_dir: Path) -> int: + try: + return sum(p.stat().st_size for p in spool_dir.glob("*.jsonl")) + except OSError: + return 0 class AzureMonitorSink: @@ -36,12 +84,27 @@ class AzureMonitorSink: is_active: bool = True - def __init__(self, *, connection_string: str, sample_rate: float = 1.0) -> None: - self._connection_string = connection_string + def __init__( + self, + *, + connection_string: str, + sample_rate: float = 1.0, + spool_dir: Path | None = None, + ) -> None: + self._ikey, self._track_url = parse_connection_string(connection_string) self._sample_rate = max(0.0, min(1.0, sample_rate)) - self._configured = False - self._unavailable = False - self._lock = threading.Lock() + if spool_dir is None: + from bcli.config._defaults import TELEMETRY_DIR + + spool_dir = TELEMETRY_DIR + self._spool_dir = spool_dir + self._pending = False # events are waiting on disk to be shipped + self._unavailable = not self._ikey + if self._unavailable: + _diag_logger.debug( + "azure_monitor backend selected but the connection string has no " + "InstrumentationKey; events will be dropped." + ) @classmethod def from_config(cls, config: "TelemetryConfig") -> "AzureMonitorSink": @@ -50,54 +113,51 @@ def from_config(cls, config: "TelemetryConfig") -> "AzureMonitorSink": sample_rate=config.sample_rate, ) - def _ensure_configured(self) -> None: - if self._configured or self._unavailable: + def emit(self, name: str, properties: dict[str, Any]) -> None: + if self._unavailable: return - with self._lock: - if self._configured or self._unavailable: - return - if not self._connection_string.strip(): - _diag_logger.debug( - "azure_monitor backend selected but connection_string is empty; " - "treating as unavailable." - ) - self._unavailable = True + if self._sample_rate < 1.0 and random.random() > self._sample_rate: + return + try: + self._spool_dir.mkdir(parents=True, exist_ok=True) + if _spool_bytes(self._spool_dir) >= MAX_SPOOL_BYTES: + # Drop this event, but the backlog still needs a shipper — + # otherwise a full spool would never drain. + self._pending = True return - try: - from azure.monitor.opentelemetry import configure_azure_monitor + line = json.dumps(_envelope(self._ikey, name, properties), default=str) + # One write() of one line in append mode: concurrent bcli + # processes don't interleave within a line. + with open(self._spool_dir / SPOOL_NAME, "a", encoding="utf-8") as f: + f.write(line + "\n") + self._pending = True + except Exception: # noqa: BLE001 + _diag_logger.debug("Failed to spool telemetry event %s", name, exc_info=True) - configure_azure_monitor( - connection_string=self._connection_string, - logger_name="bcli.telemetry.events", - ) - self._configured = True - _diag_logger.debug("Azure Monitor configured for bcli telemetry.") - except ImportError as e: - _diag_logger.debug( - "azure-monitor-opentelemetry not installed (%s); " - "events will be dropped. Install bcli[telemetry] to enable.", - e, - ) - self._unavailable = True - except Exception as e: # noqa: BLE001 - _diag_logger.debug("Azure Monitor configure failed: %s", e) - self._unavailable = True + def flush(self, timeout: float = 2.0) -> None: # noqa: ARG002 + """Start a detached shipper if events are waiting on disk. - def emit(self, name: str, properties: dict[str, Any]) -> None: - if self._sample_rate < 1.0 and random.random() > self._sample_rate: - return - self._ensure_configured() - if self._unavailable: + Never waits for it: the command has finished, and the shipper owns + the network round trip from here. + """ + if not self._pending: return try: - _event_logger.info( - name, - extra={"custom_dimensions": {**properties, "event_name": name}}, + kwargs: dict[str, Any] = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "close_fds": True, + } + if os.name == "nt": + kwargs["creationflags"] = ( + subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + ) + else: + kwargs["start_new_session"] = True + subprocess.Popen( # noqa: S603 — fixed argv, our own module + [sys.executable, "-m", "bcli.telemetry._ship", str(self._spool_dir), self._track_url], + **kwargs, ) except Exception: # noqa: BLE001 - _diag_logger.debug("Failed to enqueue telemetry event %s", name, exc_info=True) - - def flush(self, timeout: float = 2.0) -> None: # noqa: ARG002 - # OpenTelemetry's BatchLogProcessor flushes on its periodic timer - # and again on shutdown; nothing to do here for v1. - return None + _diag_logger.debug("Failed to start telemetry shipper", exc_info=True) diff --git a/src/bcli/telemetry/_ship.py b/src/bcli/telemetry/_ship.py new file mode 100644 index 0000000..45d7b3b --- /dev/null +++ b/src/bcli/telemetry/_ship.py @@ -0,0 +1,117 @@ +"""Ship spooled telemetry to Application Insights. Runs detached. + +Started by :meth:`AzureMonitorSink.flush` as +``python -m bcli.telemetry._ship `` after the command +has returned, so the network round trip never delays the user. + +* One shipper at a time, via an ``O_EXCL`` lock file. A lock older than + :data:`LOCK_STALE_SECONDS` belongs to a shipper that died and is taken over. +* The live spool is claimed by renaming it to an ``outbox-*.jsonl`` file, so + commands that finish meanwhile start a fresh spool. The shipper loops until + no spool or outbox is left. +* An outbox is deleted once Application Insights has answered. On a network + error or a retryable status the unsent remainder stays on disk for the + next run. +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path + +import httpx + +from bcli.telemetry._azure_monitor import SPOOL_NAME + +LOCK_NAME = "ship.lock" +LOCK_STALE_SECONDS = 300 +BATCH_SIZE = 500 +TIMEOUT_SECONDS = 10.0 +# Application Insights' documented retryable statuses. Anything else (200, +# 206 partial accept, 400 malformed) is final: retrying can't change it. +RETRYABLE = frozenset({408, 429, 439, 500, 502, 503, 504}) + + +def _acquire(lock: Path) -> bool: + for _ in range(2): + try: + os.close(os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)) + return True + except FileExistsError: + try: + age = time.time() - lock.stat().st_mtime + except FileNotFoundError: + continue # released between our open and stat; try again + if age < LOCK_STALE_SECONDS: + return False + lock.unlink(missing_ok=True) + return False + + +def _valid_lines(path: Path) -> list[str]: + # A malformed line would make the whole batch a 400, losing the good + # events with it, so drop unparseable lines up front. + lines = [] + for line in path.read_text(encoding="utf-8").splitlines(): + try: + json.loads(line) + except ValueError: + continue + lines.append(line) + return lines + + +def _send(client: httpx.Client, url: str, lines: list[str]) -> bool: + """True when Application Insights has given a final answer for these lines.""" + try: + resp = client.post( + url, + content="[" + ",".join(lines) + "]", + headers={"Content-Type": "application/json"}, + ) + except httpx.HTTPError: + return False + return resp.status_code not in RETRYABLE + + +def ship(spool_dir: Path, url: str, client: httpx.Client | None = None) -> None: + lock = spool_dir / LOCK_NAME + if not _acquire(lock): + return + try: + with client or httpx.Client(timeout=TIMEOUT_SECONDS) as http: + while True: + spool = spool_dir / SPOOL_NAME + if spool.exists(): + os.replace(spool, spool_dir / f"outbox-{time.time_ns()}.jsonl") + # A writer that opened the spool just before the rename + # appends to the outbox instead; give that write a moment. + time.sleep(0.2) + outboxes = sorted(spool_dir.glob("outbox-*.jsonl")) + if not outboxes: + return + for outbox in outboxes: + lines = _valid_lines(outbox) + for start in range(0, len(lines), BATCH_SIZE): + os.utime(lock) # still alive: keep the lock fresh + if not _send(http, url, lines[start:start + BATCH_SIZE]): + outbox.write_text("\n".join(lines[start:]) + "\n", encoding="utf-8") + return + outbox.unlink(missing_ok=True) + finally: + lock.unlink(missing_ok=True) + + +def main(argv: list[str]) -> int: + try: + ship(Path(argv[0]), argv[1]) + except Exception: # noqa: BLE001 — detached, nobody to report to + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_telemetry/test_ship.py b/tests/test_telemetry/test_ship.py new file mode 100644 index 0000000..1df868c --- /dev/null +++ b/tests/test_telemetry/test_ship.py @@ -0,0 +1,120 @@ +"""Tests for the detached telemetry shipper (bcli.telemetry._ship).""" + +from __future__ import annotations + +import json +import os +import time + +import httpx +import pytest + +from bcli.telemetry import _ship + +URL = "https://example.in.applicationinsights.azure.com/v2.1/track" + + +def _event(n: int) -> str: + return json.dumps({"name": "Microsoft.ApplicationInsights.Event", "n": n}) + + +def _client(statuses, received): + """httpx client answering with `statuses` in order; `received` collects accepted batches.""" + it = iter(statuses) + + def handler(request: httpx.Request) -> httpx.Response: + status = next(it) + if isinstance(status, Exception): + raise status + if 200 <= status < 300: + received.append([item["n"] for item in json.loads(request.content)]) + return httpx.Response(status) + + return httpx.Client(transport=httpx.MockTransport(handler)) + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch): + monkeypatch.setattr(_ship.time, "sleep", lambda s: None) + + +def _write_spool(tmp_path, ns): + (tmp_path / "spool.jsonl").write_text("".join(_event(n) + "\n" for n in ns)) + + +def _leftovers(tmp_path): + return sorted(p.name for p in tmp_path.iterdir()) + + +def test_ships_spool_and_cleans_up(tmp_path): + _write_spool(tmp_path, [1, 2, 3]) + received = [] + _ship.ship(tmp_path, URL, _client([200], received)) + assert received == [[1, 2, 3]] + assert _leftovers(tmp_path) == [] # spool, outbox and lock all gone + + +def test_batches_large_spools(tmp_path, monkeypatch): + monkeypatch.setattr(_ship, "BATCH_SIZE", 2) + _write_spool(tmp_path, [1, 2, 3, 4, 5]) + received = [] + _ship.ship(tmp_path, URL, _client([200, 200, 200], received)) + assert received == [[1, 2], [3, 4], [5]] + + +@pytest.mark.parametrize("failure", [503, 429, httpx.ConnectError("offline")]) +def test_retryable_failure_keeps_unsent_events(tmp_path, monkeypatch, failure): + monkeypatch.setattr(_ship, "BATCH_SIZE", 2) + _write_spool(tmp_path, [1, 2, 3, 4]) + received = [] + _ship.ship(tmp_path, URL, _client([200, failure], received)) + assert received == [[1, 2]] + (outbox,) = tmp_path.glob("outbox-*.jsonl") + assert [json.loads(line)["n"] for line in outbox.read_text().splitlines()] == [3, 4] + assert not (tmp_path / "ship.lock").exists() + + # The next run picks the leftover up. + received = [] + _ship.ship(tmp_path, URL, _client([200], received)) + assert received == [[3, 4]] + assert _leftovers(tmp_path) == [] + + +def test_final_rejection_is_not_retried(tmp_path): + _write_spool(tmp_path, [1]) + received = [] + _ship.ship(tmp_path, URL, _client([400], received)) + assert received == [] + assert _leftovers(tmp_path) == [] # dropped, not kept for a retry that would fail again + + +def test_malformed_lines_are_dropped_not_sent(tmp_path): + (tmp_path / "spool.jsonl").write_text(_event(1) + "\n{truncated\n" + _event(2) + "\n") + received = [] + _ship.ship(tmp_path, URL, _client([200], received)) + assert received == [[1, 2]] + + +def test_fresh_lock_means_another_shipper_is_running(tmp_path): + _write_spool(tmp_path, [1]) + (tmp_path / "ship.lock").touch() + received = [] + _ship.ship(tmp_path, URL, _client([], received)) + assert received == [] + assert (tmp_path / "spool.jsonl").exists() + + +def test_stale_lock_is_taken_over(tmp_path): + _write_spool(tmp_path, [1]) + lock = tmp_path / "ship.lock" + lock.touch() + old = time.time() - _ship.LOCK_STALE_SECONDS - 1 + os.utime(lock, (old, old)) + received = [] + _ship.ship(tmp_path, URL, _client([200], received)) + assert received == [[1]] + assert _leftovers(tmp_path) == [] + + +def test_main_never_raises(tmp_path): + assert _ship.main([str(tmp_path / "missing"), URL]) == 0 diff --git a/tests/test_telemetry/test_sink.py b/tests/test_telemetry/test_sink.py index c9098fa..c8eedd7 100644 --- a/tests/test_telemetry/test_sink.py +++ b/tests/test_telemetry/test_sink.py @@ -2,6 +2,10 @@ from __future__ import annotations +import json +import os +import subprocess + import pytest from pydantic import ValidationError @@ -11,9 +15,11 @@ ConsoleSink, NullSink, TelemetrySink, + _azure_monitor, events, get_sink, ) +from bcli.telemetry._azure_monitor import parse_connection_string # ── get_sink dispatch ──────────────────────────────────────────────── @@ -135,32 +141,93 @@ def test_from_config(self): assert isinstance(sink, ConsoleSink) -class TestAzureMonitorSink: - """Azure SDK is the optional [telemetry] extra; sink must soft-fail without it.""" - - def test_emit_when_sdk_missing(self, monkeypatch): - # Simulate SDK absence by making its import raise - import builtins - real_import = builtins.__import__ +CS = ( + "InstrumentationKey=00000000-0000-0000-0000-000000000001;" + "IngestionEndpoint=https://example.in.applicationinsights.azure.com/;" + "LiveEndpoint=https://example.livediagnostics.monitor.azure.com/" +) - def bomb(name, *args, **kwargs): - if name.startswith("azure.monitor.opentelemetry"): - raise ImportError("simulated missing dep") - return real_import(name, *args, **kwargs) - monkeypatch.setattr(builtins, "__import__", bomb) +class TestAzureMonitorSink: + """Spools envelopes locally; never touches the network in-process.""" + + def _spooled(self, spool_dir): + path = spool_dir / "spool.jsonl" + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines()] + + def test_connection_string_parsing(self): + assert parse_connection_string(CS) == ( + "00000000-0000-0000-0000-000000000001", + "https://example.in.applicationinsights.azure.com/v2.1/track", + ) + # Key only → Azure's documented default ingestion endpoint. + assert parse_connection_string("InstrumentationKey=k")[1] == ( + "https://dc.services.visualstudio.com/v2.1/track" + ) - sink = AzureMonitorSink(connection_string="InstrumentationKey=fake;") + def test_emit_spools_an_app_insights_event(self, tmp_path): + sink = AzureMonitorSink(connection_string=CS, spool_dir=tmp_path) + sink.emit(*events.command(command="get vendors", profile="p", duration_ms=12.5)) + (env,) = self._spooled(tmp_path) + assert env["name"] == "Microsoft.ApplicationInsights.Event" + assert env["iKey"] == "00000000-0000-0000-0000-000000000001" + base = env["data"]["baseData"] + assert env["data"]["baseType"] == "EventData" + assert base["name"] == "bcli.command" + # App Insights properties are string-valued. + assert base["properties"]["duration_ms"] == "12.5" + assert all(isinstance(v, str) for v in base["properties"].values()) + + def test_missing_instrumentation_key_drops_events(self, tmp_path): + sink = AzureMonitorSink(connection_string="", spool_dir=tmp_path) + sink.emit(*events.startup(profile="x")) + assert self._spooled(tmp_path) == [] + sink.flush() # nothing spooled → nothing to start + + def test_spool_cap_drops_new_events(self, tmp_path, monkeypatch): + monkeypatch.setattr(_azure_monitor, "MAX_SPOOL_BYTES", 1) + sink = AzureMonitorSink(connection_string=CS, spool_dir=tmp_path) + sink.emit(*events.startup(profile="x")) # first write fits (0 bytes waiting) + sink.emit(*events.startup(profile="x")) # over the cap → dropped + assert len(self._spooled(tmp_path)) == 1 + + def test_full_spool_still_starts_the_shipper(self, tmp_path, monkeypatch): + # A later command that only hits the cap must still drain the backlog, + # or a full spool would never ship again. + monkeypatch.setattr(_azure_monitor, "MAX_SPOOL_BYTES", 1) + (tmp_path / "outbox-1.jsonl").write_text("{}\n") + calls = [] + monkeypatch.setattr(_azure_monitor.subprocess, "Popen", lambda argv, **kw: calls.append(argv)) + sink = AzureMonitorSink(connection_string=CS, spool_dir=tmp_path) + sink.emit(*events.startup(profile="x")) + assert self._spooled(tmp_path) == [] # dropped + sink.flush() + assert len(calls) == 1 + + def test_unwritable_spool_never_raises(self, tmp_path): + blocker = tmp_path / "not-a-dir" + blocker.write_text("") + sink = AzureMonitorSink(connection_string=CS, spool_dir=blocker / "spool") sink.emit(*events.startup(profile="x")) # must not raise - assert sink._unavailable is True - - # Subsequent emits no-op without re-trying the import - sink.emit(*events.query(endpoint="vendors", has_filter=False)) - def test_empty_connection_string_marks_unavailable(self): - sink = AzureMonitorSink(connection_string="") + def test_flush_starts_detached_shipper_only_after_spooling(self, tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr(_azure_monitor.subprocess, "Popen", lambda argv, **kw: calls.append((argv, kw))) + sink = AzureMonitorSink(connection_string=CS, spool_dir=tmp_path) + sink.flush() + assert calls == [] sink.emit(*events.startup(profile="x")) - assert sink._unavailable is True + sink.flush() + (argv, kw), = calls + assert argv[1:] == [ + "-m", "bcli.telemetry._ship", str(tmp_path), + "https://example.in.applicationinsights.azure.com/v2.1/track", + ] + assert kw["stdout"] is subprocess.DEVNULL and kw["stderr"] is subprocess.DEVNULL + if os.name != "nt": + assert kw["start_new_session"] is True # ── TelemetryConfig validation ─────────────────────────────────────── diff --git a/uv.lock b/uv.lock index 9929b4b..495f07e 100644 --- a/uv.lock +++ b/uv.lock @@ -209,15 +209,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "asgiref" -version = "3.11.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, -] - [[package]] name = "attrs" version = "26.1.0" @@ -227,89 +218,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "azure-core" -version = "1.39.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/34/83/bbde3faa84ddcb8eb0eca4b3ffb3221252281db4ce351300fe248c5c70b1/azure_core-1.39.0.tar.gz", hash = "sha256:8a90a562998dd44ce84597590fff6249701b98c0e8797c95fcdd695b54c35d74", size = 367531, upload-time = "2026-03-19T01:31:29.461Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, -] - -[[package]] -name = "azure-core-tracing-opentelemetry" -version = "1.0.0b12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "azure-core" }, - { name = "opentelemetry-api" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/7f/5de13a331a5f2919417819cc37dcf7c897018f02f83aa82b733e6629a6a6/azure_core_tracing_opentelemetry-1.0.0b12.tar.gz", hash = "sha256:bb454142440bae11fd9d68c7c1d67ae38a1756ce808c5e4d736730a7b4b04144", size = 26010, upload-time = "2025-03-21T00:18:37.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/5e/97a471f66935e7f89f521d0e11ae49c7f0871ca38f5c319dccae2155c8d8/azure_core_tracing_opentelemetry-1.0.0b12-py3-none-any.whl", hash = "sha256:38fd42709f1cc4bbc4f2797008b1c30a6a01617e49910c05daa3a0d0c65053ac", size = 11962, upload-time = "2025-03-21T00:18:38.581Z" }, -] - -[[package]] -name = "azure-identity" -version = "1.25.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "azure-core" }, - { name = "cryptography" }, - { name = "msal" }, - { name = "msal-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, -] - -[[package]] -name = "azure-monitor-opentelemetry" -version = "1.8.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "azure-core" }, - { name = "azure-core-tracing-opentelemetry" }, - { name = "azure-monitor-opentelemetry-exporter" }, - { name = "opentelemetry-instrumentation-django" }, - { name = "opentelemetry-instrumentation-fastapi" }, - { name = "opentelemetry-instrumentation-flask" }, - { name = "opentelemetry-instrumentation-logging" }, - { name = "opentelemetry-instrumentation-psycopg2" }, - { name = "opentelemetry-instrumentation-requests" }, - { name = "opentelemetry-instrumentation-urllib" }, - { name = "opentelemetry-instrumentation-urllib3" }, - { name = "opentelemetry-resource-detector-azure" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/89/42/ea67bebb400a7561b1ad1dd59d06b67e880daf8081ec0d41d3b0ce8fcc26/azure_monitor_opentelemetry-1.8.7.tar.gz", hash = "sha256:d0a430c69451f8fa09362769d2d65471713989fb78e4ad0f50832b597921efbb", size = 76970, upload-time = "2026-03-19T21:43:57.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/22/245a4f75a834430759a6fab9c5ab10e18719786ae684cf234c7bb6a693d1/azure_monitor_opentelemetry-1.8.7-py3-none-any.whl", hash = "sha256:0d3a228a183d76cf22698a3eed6e836d1cf57608b8ee879c634609b26f384eb2", size = 41268, upload-time = "2026-03-19T21:43:58.188Z" }, -] - -[[package]] -name = "azure-monitor-opentelemetry-exporter" -version = "1.0.0b51" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "azure-core" }, - { name = "azure-identity" }, - { name = "msrest" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "psutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/a4/a6cd2d389bc1009300bcd57c9e2ace4b7e7ae1e5dc0bda415ee803629cf2/azure_monitor_opentelemetry_exporter-1.0.0b51.tar.gz", hash = "sha256:a6171c34326bcd6216938bb40d715c15f1f22984ac1986fc97231336d8ac4c3c", size = 319837, upload-time = "2026-04-06T21:45:46.378Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/1a/6b0b7a6181b42709103a65a676c89fd5055cb1d1b281ebe10c49254a170f/azure_monitor_opentelemetry_exporter-1.0.0b51-py2.py3-none-any.whl", hash = "sha256:6572cac11f96e3b18ae1187cb35cf3b40d0004655dae8048896c41c765bea530", size = 242104, upload-time = "2026-04-06T21:45:47.856Z" }, -] - [[package]] name = "backports-tarfile" version = "1.2.0" @@ -380,15 +288,11 @@ polaris = [ { name = "pyarrow" }, { name = "pyiceberg", extra = ["s3fs"] }, ] -telemetry = [ - { name = "azure-monitor-opentelemetry" }, -] [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'ask-claude'", specifier = ">=0.40" }, { name = "anthropic", marker = "extra == 'extract-claude'", specifier = ">=0.40" }, - { name = "azure-monitor-opentelemetry", marker = "extra == 'telemetry'", specifier = ">=1.6" }, { name = "bc-cli", extras = ["ask"], marker = "extra == 'dev'" }, { name = "bc-cli", extras = ["ask-claude"], marker = "extra == 'ask'" }, { name = "bc-cli", extras = ["ask-openai"], marker = "extra == 'ask'" }, @@ -992,15 +896,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "isodate" -version = "0.7.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, -] - [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1369,34 +1264,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" }, ] -[[package]] -name = "msal-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "msal" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, -] - -[[package]] -name = "msrest" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "azure-core" }, - { name = "certifi" }, - { name = "isodate" }, - { name = "requests" }, - { name = "requests-oauthlib" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2022-06-13T22:41:25.111Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/cf/f2966a2638144491f8696c27320d5219f48a072715075d168b31d3237720/msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32", size = 85384, upload-time = "2022-06-13T22:41:22.42Z" }, -] - [[package]] name = "multidict" version = "6.7.1" @@ -1514,15 +1381,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] -[[package]] -name = "oauthlib" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, -] - [[package]] name = "openai" version = "2.36.0" @@ -1542,250 +1400,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, ] -[[package]] -name = "opentelemetry-api" -version = "1.40.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "importlib-metadata" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-asgi" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asgiref" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/3e/143cf5c034e58037307e6a24f06e0dd64b2c49ae60a965fc580027581931/opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5", size = 26691, upload-time = "2026-03-04T14:20:21.038Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/78/154470cf9d741a7487fbb5067357b87386475bbb77948a6707cae982e158/opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4", size = 16980, upload-time = "2026-03-04T14:19:10.894Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-dbapi" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d6/ed/ba91c9e4a3ec65781e9c59982109f0a36de9fa574f622596b33d1985dab5/opentelemetry_instrumentation_dbapi-0.61b0.tar.gz", hash = "sha256:02fa800682c1de87dcad0e59f2092b3b6fb8b8ea0636518f989e1166b418dcb9", size = 16761, upload-time = "2026-03-04T14:20:29.782Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/a5/d26c68f3fd33eb7410985cef7700bb426e2c4a26de9207902cbbffb19a3f/opentelemetry_instrumentation_dbapi-0.61b0-py3-none-any.whl", hash = "sha256:8f762c39c8edd20c6aef3282550a2cfbfec76c3f431bf5c36327dcf9ece2e5a0", size = 14134, upload-time = "2026-03-04T14:19:24.718Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-django" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-wsgi" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/ef/6bc1a6560630f26b1c010af86b28f42bfbe6a601bd1647d1436e0d3436aa/opentelemetry_instrumentation_django-0.61b0.tar.gz", hash = "sha256:9885154dc128578de0e6b5ce49e965c786f8ab071175bec005dcd454510be951", size = 25996, upload-time = "2026-03-04T14:20:30.453Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/3b/74dad6d98fdee1d137f1c2748548d4159578508f21e3aef581c110e64041/opentelemetry_instrumentation_django-0.61b0-py3-none-any.whl", hash = "sha256:26c1b0b325a9783d4a2f4df660ba05cf929c3eda2ae9b07916b649bb44e1c5b6", size = 20773, upload-time = "2026-03-04T14:19:25.675Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-fastapi" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-asgi" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/37/35/aa727bb6e6ef930dcdc96a617b83748fece57b43c47d83ba8d83fbeca657/opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f", size = 24800, upload-time = "2026-03-04T14:20:32.759Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/05/acfeb2cccd434242a0a7d0ea29afaf077e04b42b35b485d89aee4e0d9340/opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c", size = 13485, upload-time = "2026-03-04T14:19:30.351Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-flask" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-wsgi" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/33/d6852d8f2c3eef86f2f8c858d6f5315983c7063e07e595519e96d4c31c06/opentelemetry_instrumentation_flask-0.61b0.tar.gz", hash = "sha256:e9faf58dfd9860a1868442d180142645abdafc1a652dd73d469a5efd106a7d49", size = 24071, upload-time = "2026-03-04T14:20:33.437Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/41/619f3530324a58491f2d20f216a10dd7393629b29db4610dda642a27f4ed/opentelemetry_instrumentation_flask-0.61b0-py3-none-any.whl", hash = "sha256:e8ce474d7ce543bfbbb3e93f8a6f8263348af9d7b45502f387420cf3afa71253", size = 15996, upload-time = "2026-03-04T14:19:31.304Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-logging" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/69473f925acfe2d4edf5c23bcced36906ac3627aa7c5722a8e3f60825f3b/opentelemetry_instrumentation_logging-0.61b0.tar.gz", hash = "sha256:feaa30b700acd2a37cc81db5f562ab0c3a5b6cc2453595e98b72c01dcf649584", size = 17906, upload-time = "2026-03-04T14:20:37.398Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/0e/2137db5239cc5e564495549a4d11488a7af9b48fc76520a0eea20e69ddae/opentelemetry_instrumentation_logging-0.61b0-py3-none-any.whl", hash = "sha256:6d87e5ded6a0128d775d41511f8380910a1b610671081d16efb05ac3711c0074", size = 17076, upload-time = "2026-03-04T14:19:36.765Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-psycopg2" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-instrumentation-dbapi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/28/f28d52b1088e7a09761566f8700507b54d3d83a6f9c93c0ce02f53619e83/opentelemetry_instrumentation_psycopg2-0.61b0.tar.gz", hash = "sha256:863ccf9687b71e73dd489c7bb117278768bdf26aa0dafe7dc974a2425e05b5d7", size = 11676, upload-time = "2026-03-04T14:20:41.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/f1/4341d0584c288765c73e28c30ba58e7aedb50c01108f17f947b872657f79/opentelemetry_instrumentation_psycopg2-0.61b0-py3-none-any.whl", hash = "sha256:36b96983beda05c927179bb66b6c72f07a8d9a591f76ce9da88b1dd1587cb083", size = 11491, upload-time = "2026-03-04T14:19:42.018Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-requests" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/c7/7a47cb85c7aa93a9c820552e414889185bcf91245271d12e5d443e5f834d/opentelemetry_instrumentation_requests-0.61b0.tar.gz", hash = "sha256:15f879ce8fb206bd7e6fdc61663ea63481040a845218c0cf42902ce70bd7e9d9", size = 18379, upload-time = "2026-03-04T14:20:46.959Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/a1/a7a133b273d1f53950f16a370fc94367eff472c9c2576e8e9e28c62dcc9f/opentelemetry_instrumentation_requests-0.61b0-py3-none-any.whl", hash = "sha256:cce19b379949fe637eb73ba39b02c57d2d0805447ca6d86534aa33fcb141f683", size = 14207, upload-time = "2026-03-04T14:19:51.765Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-urllib" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/37/77cd326b083390e74280c08bbd585153809619dad068e2d1b253fec1164d/opentelemetry_instrumentation_urllib-0.61b0.tar.gz", hash = "sha256:6a15ff862fc1603e0ea5ea75558f76f36436b02e0ae48daecedcb5e574cce160", size = 16894, upload-time = "2026-03-04T14:20:52.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/fc/a88fbfd8b9eb16ba1c21f0514c12696441be7fc42c7e319f3ee793bf9e96/opentelemetry_instrumentation_urllib-0.61b0-py3-none-any.whl", hash = "sha256:d7e409876580fb41102e3522ce81a756e53a74073c036a267a1c280cc0fa09b0", size = 13970, upload-time = "2026-03-04T14:20:01.24Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-urllib3" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fa/80/7ad8da30f479c6117768e72d6f2f3f0bd3495338707d6f61de042149578a/opentelemetry_instrumentation_urllib3-0.61b0.tar.gz", hash = "sha256:f00037bc8ff813153c4b79306f55a14618c40469a69c6c03a3add29dc7e8b928", size = 19325, upload-time = "2026-03-04T14:20:53.386Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/0c/01359e55b9f2fb2b1d4d9e85e77773a96697207895118533f3be718a3326/opentelemetry_instrumentation_urllib3-0.61b0-py3-none-any.whl", hash = "sha256:9644f8c07870266e52f129e6226859ff3a35192555abe46fa0ef9bbbf5b6b46d", size = 14339, upload-time = "2026-03-04T14:20:02.681Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-wsgi" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/89/e5/189f2845362cfe78e356ba127eab21456309def411c6874aa4800c3de816/opentelemetry_instrumentation_wsgi-0.61b0.tar.gz", hash = "sha256:380f2ae61714e5303275a80b2e14c58571573cd1fddf496d8c39fb9551c5e532", size = 19898, upload-time = "2026-03-04T14:20:54.068Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/75/d6b42ba26f3c921be6d01b16561b7bb863f843bad7ac3a5011f62617bcab/opentelemetry_instrumentation_wsgi-0.61b0-py3-none-any.whl", hash = "sha256:bd33b0824166f24134a3400648805e8d2e6a7951f070241294e8b8866611d7fa", size = 14628, upload-time = "2026-03-04T14:20:03.934Z" }, -] - -[[package]] -name = "opentelemetry-resource-detector-azure" -version = "0.1.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/e4/0d359d48d03d447225b30c3dd889d5d454e3b413763ff721f9b0e4ac2e59/opentelemetry_resource_detector_azure-0.1.5.tar.gz", hash = "sha256:e0ba658a87c69eebc806e75398cd0e9f68a8898ea62de99bc1b7083136403710", size = 11503, upload-time = "2024-05-16T21:54:58.994Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/ae/c26d8da88ba2e438e9653a408b0c2ad6f17267801250a8f3cc6405a93a72/opentelemetry_resource_detector_azure-0.1.5-py3-none-any.whl", hash = "sha256:4dcc5d54ab5c3b11226af39509bc98979a8b9e0f8a24c1b888783755d3bf00eb", size = 14252, upload-time = "2024-05-16T21:54:57.208Z" }, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.40.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, -] - -[[package]] -name = "opentelemetry-util-http" -version = "0.61b0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/3c/f0196223efc5c4ca19f8fad3d5462b171ac6333013335ce540c01af419e9/opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572", size = 11361, upload-time = "2026-03-04T14:20:57.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/e5/c08aaaf2f64288d2b6ef65741d2de5454e64af3e050f34285fb1907492fe/opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33", size = 9281, upload-time = "2026-03-04T14:20:08.364Z" }, -] - [[package]] name = "orjson" version = "3.11.8" @@ -2049,34 +1663,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] -[[package]] -name = "psutil" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, - { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, - { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, - { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, -] - [[package]] name = "pyarrow" version = "23.0.1" @@ -2612,19 +2198,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] -[[package]] -name = "requests-oauthlib" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "oauthlib" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, -] - [[package]] name = "requirements-parser" version = "0.13.0"