From d5784141b9b4d0c0bb63f782c7548e25e0aa0d06 Mon Sep 17 00:00:00 2001 From: Igor <62580819+igor-ctrl@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:19:55 -0500 Subject: [PATCH 1/3] perf(telemetry): spool events locally and ship them from a detached process The azure_monitor backend went through azure-monitor-opentelemetry, whose exporter shut down synchronously at process exit. That added ~2 s to every command (0.3 s -> 2.3 s for `bcli endpoint list`), and its Azure VM resource detector printed an IMDS timeout traceback on any machine that isn't an Azure VM. emit() now appends one Application Insights envelope per event to ~/.config/bcli/telemetry/spool.jsonl. flush(), called once at exit, starts a detached `python -m bcli.telemetry._ship` that posts the spool to the connection string's ingestion endpoint with httpx, after the command has returned. Commands run in the same time with telemetry on as with it off. The shipper holds an O_EXCL lock (taken over once stale), claims the spool by renaming it, posts in batches, deletes what Application Insights has answered for, and keeps the unsent remainder on a network error or a retryable status. The spool is capped at 5 MB. Events now arrive as custom events (AppEvents) with string-valued properties. The [telemetry] extra is kept empty so existing install commands still resolve. --- CHANGELOG.md | 22 ++ pyproject.toml | 6 +- src/bcli/config/_defaults.py | 4 + src/bcli/config/_model.py | 5 +- src/bcli/telemetry/__init__.py | 3 +- src/bcli/telemetry/_azure_monitor.py | 185 ++++++++---- src/bcli/telemetry/_ship.py | 117 ++++++++ tests/test_telemetry/test_ship.py | 120 ++++++++ tests/test_telemetry/test_sink.py | 94 ++++-- uv.lock | 427 --------------------------- 10 files changed, 466 insertions(+), 517 deletions(-) create mode 100644 src/bcli/telemetry/_ship.py create mode 100644 tests/test_telemetry/test_ship.py 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..ba39503 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._spooled = False + 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,48 @@ 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: 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._spooled = 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 this process spooled anything. - 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._spooled: 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..73a81d8 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,80 @@ 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_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" From 46cc498b1c06f3dbc9d5a5329cac838f0680dd69 Mon Sep 17 00:00:00 2001 From: Igor <62580819+igor-ctrl@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:31:19 -0500 Subject: [PATCH 2/3] fix(telemetry): start the shipper when the spool is full At the 5 MB cap emit() dropped the event without marking work pending, so flush() never started the shipper and a full spool could never drain: telemetry stopped for good on that machine even after it came back online. Hitting the cap now marks the backlog pending like a successful write does. --- .gitignore | 3 +++ .ignore | 5 +++++ src/bcli/telemetry/_azure_monitor.py | 11 +++++++---- tests/test_telemetry/test_sink.py | 13 +++++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .ignore diff --git a/.gitignore b/.gitignore index 1cd41a6..a70f583 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ bcli-site/.astro/ bcli-site/pnpm-lock.yaml bcli-site/package-lock.json bcli-site/yarn.lock + +# graft's local graph cache — regenerable, not committed (run `graft build`). +/graft/ diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..615e581 --- /dev/null +++ b/.ignore @@ -0,0 +1,5 @@ +# graft's cards are gitignored but should stay greppable: ripgrep reads +# .ignore before .gitignore, so this re-admits the tree to search only. +!graft/ +graft/.cache/ +graft/.graph/ diff --git a/src/bcli/telemetry/_azure_monitor.py b/src/bcli/telemetry/_azure_monitor.py index ba39503..3d451da 100644 --- a/src/bcli/telemetry/_azure_monitor.py +++ b/src/bcli/telemetry/_azure_monitor.py @@ -98,7 +98,7 @@ def __init__( spool_dir = TELEMETRY_DIR self._spool_dir = spool_dir - self._spooled = False + self._pending = False # events are waiting on disk to be shipped self._unavailable = not self._ikey if self._unavailable: _diag_logger.debug( @@ -121,23 +121,26 @@ def emit(self, name: str, properties: dict[str, Any]) -> None: 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 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._spooled = True + self._pending = True except Exception: # noqa: BLE001 _diag_logger.debug("Failed to spool telemetry event %s", name, exc_info=True) def flush(self, timeout: float = 2.0) -> None: # noqa: ARG002 - """Start a detached shipper if this process spooled anything. + """Start a detached shipper if events are waiting on disk. Never waits for it: the command has finished, and the shipper owns the network round trip from here. """ - if not self._spooled: + if not self._pending: return try: kwargs: dict[str, Any] = { diff --git a/tests/test_telemetry/test_sink.py b/tests/test_telemetry/test_sink.py index 73a81d8..c8eedd7 100644 --- a/tests/test_telemetry/test_sink.py +++ b/tests/test_telemetry/test_sink.py @@ -193,6 +193,19 @@ def test_spool_cap_drops_new_events(self, tmp_path, monkeypatch): 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("") From de3df00fa27d66586abb85e58ed6c7a91deacd95 Mon Sep 17 00:00:00 2001 From: Igor <62580819+igor-ctrl@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:31:40 -0500 Subject: [PATCH 3/3] chore: drop local tool ignore files committed by mistake --- .gitignore | 3 --- .ignore | 5 ----- 2 files changed, 8 deletions(-) delete mode 100644 .ignore diff --git a/.gitignore b/.gitignore index a70f583..1cd41a6 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,3 @@ bcli-site/.astro/ bcli-site/pnpm-lock.yaml bcli-site/package-lock.json bcli-site/yarn.lock - -# graft's local graph cache — regenerable, not committed (run `graft build`). -/graft/ diff --git a/.ignore b/.ignore deleted file mode 100644 index 615e581..0000000 --- a/.ignore +++ /dev/null @@ -1,5 +0,0 @@ -# graft's cards are gitignored but should stay greppable: ripgrep reads -# .ignore before .gitignore, so this re-admits the tree to search only. -!graft/ -graft/.cache/ -graft/.graph/