Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down
4 changes: 4 additions & 0 deletions src/bcli/config/_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
5 changes: 3 additions & 2 deletions src/bcli/config/_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/bcli/telemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
188 changes: 124 additions & 64 deletions src/bcli/telemetry/_azure_monitor.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,110 @@
"""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:
from bcli.config._model import TelemetryConfig

_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:
"""Telemetry backend that ships events to Azure Application Insights."""

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":
Expand All @@ -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:
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
# 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)
Loading
Loading