Metadata-Version: 2.4
Name: azpaddypy
Version: 1.1.0
Summary: Azure cloud services SDK with Storage (blob, file share, queue), Key Vault, Cosmos DB, AI Foundry Projects (agents, deployments, evaluations), Document Intelligence, Speech, Azure ML Studio (jobs, model registry, endpoints, data, compute, MLflow), OpenTelemetry tracing, AI Foundry GenAI tracing, and builder patterns.
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: <=3.14,>=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: azure-monitor-opentelemetry==1.8.7
Requires-Dist: azure-identity==1.25.3
Requires-Dist: azure-keyvault-secrets==4.10.0
Requires-Dist: azure-keyvault-keys==4.11.0
Requires-Dist: azure-keyvault-certificates==4.10.0
Requires-Dist: azure-storage-blob>=12.27.1
Requires-Dist: azure-storage-file-share==12.24.0
Requires-Dist: azure-storage-queue==12.15.0
Requires-Dist: azure-cosmos==4.15.0
Requires-Dist: chardet==7.4.0.post2
Requires-Dist: azure-mgmt-resource==25.0.0
Requires-Dist: azure-mgmt-subscription==3.1.1
Requires-Dist: azure-mgmt-storage==24.0.1
Requires-Dist: azure-mgmt-cosmosdb==9.9.0
Requires-Dist: azure-ai-projects==2.0.1
Requires-Dist: azure-ai-documentintelligence>=1.0.2
Requires-Dist: azure-cognitiveservices-speech>=1.48.2
Requires-Dist: azure-ai-ml>=1.26.0
Requires-Dist: opentelemetry-instrumentation-openai-v2>=2.0
Provides-Extra: dev
Requires-Dist: azure-cosmos==4.15.0; extra == "dev"
Requires-Dist: pytest==9.0.2; extra == "dev"
Requires-Dist: pytest-cov==7.1.0; extra == "dev"
Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
Requires-Dist: anyio==4.13.0; extra == "dev"
Requires-Dist: jupyter==1.1.1; extra == "dev"
Requires-Dist: ipykernel==7.2.0; extra == "dev"
Requires-Dist: pytest-profiling==1.8.1; extra == "dev"
Dynamic: license-file

# azpaddypy

Azure cloud services SDK with Storage (blob, append blob, file share, queue), Key Vault, Cosmos DB, AI Foundry Projects (agents, deployments, evaluations), Document Intelligence, Speech, Azure ML Studio (jobs, model registry, endpoints, data, compute, MLflow), OpenTelemetry tracing, AI Foundry GenAI tracing, and builder patterns.

Designed for Dockerized Azure Function Apps and Web Apps.

## Installation

Requires Python 3.11–3.13.

```bash
uv add azpaddypy
```

## Quick Start

Minimal: bootstrap telemetry, get a logger, create a resource client.

```python
from azpaddypy import AzureIdentity, AzureStorage
from azpaddypy.mgmt.logging import AzureLogger, bootstrap_azure_monitor

# 1. Wire Azure Monitor + GenAI instrumentors once (idempotent)
bootstrap_azure_monitor(service_name="my_service", service_version="1.0.0")

# 2. Get a logger anywhere -- auto-enriches records with correlation_id (when
#    active) and any flattened OTel baggage entries. Trace/span IDs and
#    cloud_RoleName are populated by the exporter on the standard App Insights
#    columns, so we don't duplicate them into customDimensions.
logger = AzureLogger(__name__)
logger.info("service starting")

# 3. Create resource clients (factory caches instances; identity must be injected)
storage = AzureStorage(
    account_url="https://myaccount.blob.core.windows.net/",
    azure_identity=AzureIdentity(service_name="my_service"),
    enable_file_storage=True,
)
```

Prefer the builder for multi-resource apps -- see [Builder Pattern](#builder-pattern).
For the full telemetry surface (`trace_function`, correlation IDs, GenAI
tracing), see [Logging & Tracing](#logging--tracing).

## Storage Operations

### Blob Storage

```python
# Upload
storage.upload_blob(
    container_name="documents",
    blob_name="report.pdf",
    data=pdf_bytes,
    content_type="application/pdf",
    metadata={"author": "team"},
)

# Download (returns None if not found)
data = storage.download_blob(container_name="documents", blob_name="report.pdf")

# Upload and get SAS URL
sas_url = storage.upload_blob_with_sas(
    container_name="documents",
    blob_name="report.pdf",
    data=pdf_bytes,
    sas_permission="r",
    sas_expiry_delta=timedelta(hours=3),
)

# List, exists, delete
blobs = storage.list_blobs(container_name="documents", name_starts_with="reports/")
exists = storage.blob_exists(container_name="documents", blob_name="report.pdf")
storage.delete_blob(container_name="documents", blob_name="report.pdf")

# Metadata upsert (merges with existing)
storage.upsert_blob_metadata(
    container_name="documents",
    blob_name="report.pdf",
    metadata={"status": "processed"},
)

# SAS token generation
blob_sas = storage.get_blob_sas(container_name="docs", blob_name="file.pdf")
container_sas = storage.get_container_sas(container_name="docs", permission="r")
```

### Append Blob Storage

Append blobs are optimized for append operations such as logging, auditing, or streaming data.
Each append block can be up to 4 MiB. Unlike block blobs, append blobs do not support overwriting existing content.

```python
# Create an empty append blob
storage.create_append_blob(
    container_name="logs",
    blob_name="app-2026-04-05.log",
    content_type="text/plain; charset=utf-8",
    metadata={"source": "web-app"},
)

# Append data blocks
storage.append_block(
    container_name="logs",
    blob_name="app-2026-04-05.log",
    data="2026-04-05T10:00:00Z INFO Application started\n",
)

storage.append_block(
    container_name="logs",
    blob_name="app-2026-04-05.log",
    data=b"2026-04-05T10:00:01Z DEBUG Connection pool initialized\n",
)

# Convenience: create-if-missing + append in one call
storage.append_blob_from_text(
    container_name="logs",
    blob_name="app-2026-04-05.log",
    text="2026-04-05T10:05:00Z WARN High memory usage\n",
    create_if_not_exists=True,  # default, skips creation if blob already exists
)
```

### File Share Storage

Requires `enable_file_storage=True`. Uses Azure File Shares (SMB/NFS), not blob storage.

```python
storage = AzureStorage(
    account_url="https://myaccount.blob.core.windows.net/",
    azure_identity=identity,
    enable_file_storage=True,
)

# Upload (auto-creates parent directories)
storage.upload_share_file(
    share_name="myshare",
    file_path="reports/2026/q1.pdf",
    data=pdf_bytes,
    content_type="application/pdf",
)

# Download (returns None if not found)
data = storage.download_share_file(share_name="myshare", file_path="reports/2026/q1.pdf")

# List files and directories
items = storage.list_share_files(share_name="myshare", directory_path="reports/2026")
# Returns: [{"name": "q1.pdf", "is_directory": False, "size": 1024}, ...]

# Exists, properties, delete
exists = storage.share_file_exists(share_name="myshare", file_path="reports/2026/q1.pdf")
props = storage.get_share_file_properties(share_name="myshare", file_path="reports/2026/q1.pdf")
storage.delete_share_file(share_name="myshare", file_path="reports/2026/q1.pdf")

# Directory management
storage.create_share_directory(share_name="myshare", directory_path="reports/2026/q2")
storage.delete_share_directory(share_name="myshare", directory_path="reports/2026/q2")

# Metadata upsert (merges with existing)
storage.upsert_share_file_metadata(
    share_name="myshare",
    file_path="reports/2026/q1.pdf",
    metadata={"reviewed": "true"},
)
```

### Queue Storage

```python
# Send
storage.send_message(
    queue_name="tasks",
    content='{"task": "process"}',
    visibility_timeout=30,
    time_to_live=3600,
)

# Receive
messages = storage.receive_messages(queue_name="tasks", messages_per_page=5)
for msg in messages:
    print(msg["id"], msg["content"])
    storage.delete_message(
        queue_name="tasks",
        message_id=msg["id"],
        pop_receipt=msg["pop_receipt"],
    )
```

## Builder Pattern

Recommended for multi-resource setups: the builders read service name, version,
connection string, and log level from the environment configuration you pass
them, so you don't repeat that information in each `with_*` call.

```python
from azpaddypy.builder import (
    AzureManagementBuilder,
    AzureResourceBuilder,
    ConfigurationSetupBuilder,
)

# 1. Environment configuration (reads REFLECTION_NAME, LOGGER_LOG_LEVEL,
#    APPLICATIONINSIGHTS_CONNECTION_STRING, IDENTITY_* etc. from env/.env)
env_config = (
    ConfigurationSetupBuilder()
    .with_local_env_management()
    .with_environment_detection()
    .with_service_configuration()
    .with_logging_configuration()
    .with_identity_configuration()
    .build()
)

# 2. Management services: bootstraps Azure Monitor + creates the service logger
mgmt = (
    AzureManagementBuilder(env_config)
    .with_logger()                      # bootstraps Azure Monitor, returns AzureLogger
    .with_identity()
    .with_keyvault(vault_url="https://myvault.vault.azure.net/", name="main")
    .build()
)

logger = mgmt.logger                    # AzureLogger, already wired to App Insights
identity = mgmt.identity
keyvaults = mgmt.keyvaults

# 3. Resource clients -- share the logger and identity via the mgmt config
resources = (
    AzureResourceBuilder(mgmt, env_config)
    .with_storage(name="default", account_url="https://mystg.blob.core.windows.net/",
                  enable_blob=True, enable_file=True, enable_queue=True)
    .with_storage(name="archive", account_url="https://archive.blob.core.windows.net/")
    .with_cosmosdb(name="main", endpoint="https://mycosmos.documents.azure.com:443/")
    .with_ai_project(name="aiservices", endpoint="https://my-ai.services.ai.azure.com/",
                     enable_agents=True, enable_deployments=True, enable_connections=True)
    .with_document_intelligence(name="docs", endpoint="https://my-ai.cognitiveservices.azure.com/",
                                 enable_analysis=True, enable_administration=True)
    .with_speech(
        name="speech",
        region="westeurope",
        resource_id="/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<ai>",
        default_speech_recognition_language="en-US",
        default_speech_synthesis_voice_name="en-US-JennyNeural",
    )
    .with_ml_workspace(
        name="mlw-main-myproj-prod",
        subscription_id="<subscription-id>",
        resource_group="<resource-group>",
        workspace_name="mlw-main-myproj-prod",
        enable_jobs=True,
        enable_model_registry=True,
        enable_endpoints=True,
        enable_data=True,
        enable_compute=True,
        enable_mlflow=True,
    )
    .build()
)

storage = resources.storage_accounts["default"]
ai_project = resources.ai_project_clients["aiservices"]
doc_intel = resources.document_intelligence_clients["docs"]
speech = resources.speech_clients["speech"]
ml_workspace = resources.ml_workspace_clients["mlw-main-myproj-prod"]
```

See [`examples/mgmt_config_template.py`](examples/mgmt_config_template.py) for a
full production-shaped example including Key Vault-sourced resource names and
the `ConfigurationManager` + `LogExecution` tool wiring.

The template's module body is **side-effect-free**: the bootstrap (Azure
Monitor, identity, Key Vault calls, resource clients) runs lazily on the
first attribute access and is cached **process-wide** via a sentinel in
`sys.modules`. So `import mgmt_config` is cheap, and even if the file is
loaded under multiple module names in one process, the bootstrap runs
exactly once. Either access style works:

```python
# Lazy module-level (existing style — unchanged):
from mgmt_config import logger, configuration_manager, storage_accounts

# Explicit accessor (returns the same singleton):
from mgmt_config import get_config
cfg = get_config()
cfg.logger.info("ready")
```

> **Note:** `Document Intelligence` and `Speech` are configured exclusively through `mgmt_config` (typically from Key Vault secrets). They have no environment-variable fallbacks — pass `endpoint` (and for Speech, `region` + `resource_id`) explicitly.

## Key Vault

```python
from azpaddypy import AzureKeyVault, create_azure_keyvault, create_azure_identity

identity = create_azure_identity(service_name="my_service")
kv = create_azure_keyvault(
    vault_url="https://myvault.vault.azure.net/",
    azure_identity=identity,
    service_name="my_service",
)

secret = kv.get_secret("database-connection-string")
```

## AI Foundry Projects

Manage Azure AI Foundry agents, deployments, and connections with integrated OpenAI client support.

```python
from azpaddypy import AzureAIProject, create_azure_ai_project, create_azure_identity

# Factory function (cached instances; identity must be injected explicitly)
identity = create_azure_identity(service_name="my_service")
ai = create_azure_ai_project(
    endpoint="https://my-ai.services.ai.azure.com/api/projects/my-project",
    azure_identity=identity,
    service_name="my_service",
)

# List deployments
deployments = ai.list_deployments()

# Get an authenticated OpenAI client
openai_client = ai.get_openai_client()

# Agent operations
from azure.ai.projects.models import PromptAgentDefinition

agent = ai.create_agent(
    agent_name="my-agent",
    definition=PromptAgentDefinition(model="gpt-4o", instructions="You are helpful"),
)

agents = ai.list_agents()
details = ai.get_agent(agent_name="my-agent")

# Invoke an agent via OpenAI responses API
result = ai.invoke_agent(agent_name="my-agent", user_message="Hello")
print(result["response"])

# Connections
connections = ai.list_connections()
connection = ai.get_connection(name="my-openai-connection", include_credentials=True)

# Fetch the Application Insights connection string linked to this Foundry project
# (requires the linkage to have been configured once via portal -> Project -> Tracing).
# Returns None if no App Insights resource is linked.
conn_str = ai.get_application_insights_connection_string()
```

### Feature Flags

```python
ai = AzureAIProject(
    endpoint="https://my-ai.services.ai.azure.com/api/projects/my-project",
    azure_identity=identity,
    enable_agents=True,       # Agent CRUD + invocation
    enable_deployments=True,  # List/get model deployments
    enable_connections=False,  # Disable connection enumeration
)
```

### Evaluation

Run AI quality and safety evaluations against your model outputs. Results appear in the AI Foundry portal under **Evaluation** with average scores and per-row details.

```python
from mgmt_config import ai_projects

ai = ai_projects["aiservices"]

# One-shot: create eval + run + poll + return results
result = ai.evaluate(
    name="my-eval",
    evaluator_names=[
        # Quality (need a judge model)
        "builtin.coherence",
        "builtin.groundedness",
        "builtin.relevance",
        # Safety (no judge model needed)
        "builtin.violence",
        "builtin.hate_unfairness",
    ],
    data=[
        {
            "query": "What is Azure?",
            "response": "Azure is Microsoft's cloud platform.",
            "context": "Azure documentation overview.",
        },
        {
            "query": "How do I deploy?",
            "response": "Use az webapp deploy.",
            "context": "CLI deployment docs.",
        },
    ],
    judge_model="gpt-4o",  # required for quality evaluators
    cleanup=True,           # delete eval definition after getting results
)

print(result["status"])        # "completed" or "failed"
print(result["report_url"])    # portal link to the evaluation report
print(result["result_counts"]) # aggregate pass/fail counts
for item in result["output_items"]:
    print(item)                # per-row evaluator scores
```

Available built-in evaluators:

| Category | Evaluators | Judge model required |
|---|---|---|
| Quality | `builtin.coherence`, `builtin.fluency`, `builtin.groundedness`, `builtin.relevance`, `builtin.similarity`, `builtin.task_adherence` | Yes |
| NLP | `builtin.f1_score`, `builtin.bleu_score`, `builtin.rouge_score`, `builtin.meteor_score`, `builtin.gleu_score` | No |
| Safety | `builtin.violence`, `builtin.sexual`, `builtin.self_harm`, `builtin.hate_unfairness`, `builtin.prohibited_actions`, `builtin.sensitive_data_leakage` | No |

For finer control, use the individual methods: `create_evaluation()`, `run_evaluation()`, `get_evaluation_run()`, `get_evaluation_run_output_items()`, `list_evaluations()`, `delete_evaluation()`.

## Document Intelligence

Analyze documents using Azure AI Document Intelligence (formerly Form Recognizer). Shares the same Cognitive Services / AI Services account as AI Foundry.

```python
from azpaddypy import AzureDocumentIntelligence, create_azure_document_intelligence, create_azure_identity

identity = create_azure_identity(service_name="my_service")
di = create_azure_document_intelligence(
    endpoint="https://my-ai.cognitiveservices.azure.com/",
    azure_identity=identity,
    service_name="my_service",
    enable_administration=True,  # opt in to model management
)

# Analyze from URL with a prebuilt model
result = di.analyze_document_from_url(
    model_id="prebuilt-layout",
    url_source="https://example.com/invoice.pdf",
)
print(f"Pages: {len(result.pages)}")

# Analyze from bytes
with open("contract.pdf", "rb") as f:
    result = di.analyze_document_from_bytes(model_id="prebuilt-read", document=f.read())

# Manage custom models
models = di.list_models()
model = di.get_model(model_id="my-custom-model")
di.delete_model(model_id="my-custom-model")
```

## Speech

Azure Cognitive Services Speech with Entra ID authentication. Unlike most Azure SDKs, the Speech SDK does not accept `TokenCredential` directly — it requires the special `aad#<resource-id>#<token>` auth string. `azpaddypy` handles token acquisition, format, and refresh.

You must provide both the Azure region and the full ARM resource ID of the Speech / AI Services account.

```python
from azpaddypy import AzureSpeech, create_azure_speech, create_azure_identity

identity = create_azure_identity(service_name="my_service")
speech = create_azure_speech(
    region="westeurope",
    resource_id=(
        "/subscriptions/<sub>/resourceGroups/<rg>"
        "/providers/Microsoft.CognitiveServices/accounts/<ai-services>"
    ),
    azure_identity=identity,
    service_name="my_service",
    default_speech_synthesis_voice_name="en-US-JennyNeural",
)

# Synthesize text to in-memory bytes (server / container scenarios)
audio: bytes = speech.synthesize_text_to_bytes("Hello from azpaddypy")

# Synthesize and write directly to a file
speech.synthesize_text_to_file("Hello from azpaddypy", file_path="out.wav")

# Synthesize and play on the default speaker (interactive / local dev)
speech.synthesize_text_to_speaker("Hello from azpaddypy")
```

### Custom synthesizers and recognizers

For full control (streaming, recognition, custom audio configs, event callbacks), get a fresh `SpeechConfig` and build your own:

```python
import azure.cognitiveservices.speech as speechsdk

speech_config = speech.get_speech_config()
synthesizer = speechsdk.SpeechSynthesizer(
    speech_config=speech_config,
    audio_config=speechsdk.audio.AudioOutputConfig(filename="out.wav"),
)
synthesizer.speak_text_async("Hello from azpaddypy").get()

# Refresh AAD token on long-lived synthesizers/recognizers
# (Speech tokens expire after ~10 minutes)
speech.refresh_authorization_token(synthesizer)
```

## Azure ML Studio

Manage Azure Machine Learning workloads — jobs, model registry, online endpoints, data assets, compute, and MLflow tracking — with integrated OpenTelemetry tracing.

```python
from azpaddypy import AzureMLWorkspace, create_azure_ml_workspace, create_azure_identity

identity = create_azure_identity(service_name="my_service")
ml = create_azure_ml_workspace(
    subscription_id="<subscription-id>",
    resource_group="<resource-group>",
    workspace_name="mlw-main-myproj-prod",
    azure_identity=identity,
    service_name="my_service",
    enable_jobs=True,
    enable_model_registry=True,
    enable_endpoints=True,
    enable_data=True,
    enable_compute=True,
    enable_mlflow=True,  # requires: pip install azureml-mlflow==1.62.0
)
```

### Jobs

```python
from azure.ai.ml import command
from azure.ai.ml.entities import Job

# Submit a command job
job = ml.submit_job(
    command(
        code="./src",
        command="python train.py --epochs 10",
        environment="azureml:AzureML-sklearn-1.0-ubuntu20.04-py38-cpu:1",
        compute="cpu-cluster",
    )
)
print(job.name, job.status)

# Poll a running job
job = ml.get_job(job_name=job.name)

# List recent jobs
jobs = ml.list_jobs(max_results=20)

# Cancel a job
ml.cancel_job(job_name=job.name)
```

### Model Registry

```python
from azure.ai.ml.entities import Model

# Register a model
model = ml.register_model(
    Model(
        name="fraud-detector",
        version="3",
        path="./outputs/model.pkl",
        description="XGBoost fraud detector",
    )
)

# Get a specific version (or latest if version omitted)
model = ml.get_model(name="fraud-detector", version="3")
latest = ml.get_model(name="fraud-detector")

# List all versions of a model
versions = ml.list_models(name="fraud-detector")

# List all models
all_models = ml.list_models()

# Delete a version
ml.delete_model(name="fraud-detector", version="1")
```

### Online Endpoints

```python
# Get endpoint details
endpoint = ml.get_online_endpoint(endpoint_name="fraud-detector-endpoint")
print(endpoint.scoring_uri)

# List all online endpoints
endpoints = ml.list_online_endpoints()
```

### Data Assets

```python
from azure.ai.ml.entities import Data

# Get a specific version (or latest if version omitted)
dataset = ml.get_data_asset(name="training-data", version="2")
latest = ml.get_data_asset(name="training-data")

# List all versions of a data asset
versions = ml.list_data_assets(name="training-data")

# List all data assets
all_assets = ml.list_data_assets()
```

### Compute

```python
# Get compute target details
compute = ml.get_compute(compute_name="cpu-cluster")

# List all compute targets
computes = ml.list_compute()
```

### MLflow

`enable_mlflow=True` wires the workspace's MLflow tracking URI so you can log experiments and query runs directly. Requires `pip install azureml-mlflow==1.62.0` (see [note below](#mlflow-dependency)).

```python
# Get the azureml:// tracking URI for this workspace
uri = ml.get_mlflow_tracking_uri()

# Get a fully configured MlflowClient
client = ml.get_mlflow_client()
runs = client.search_runs(experiment_ids=["0"])
```

#### MLflow dependency

`azureml-mlflow==1.62.0` caps `azure-storage-blob<=12.27.1`, which conflicts with azpaddypy's `azure-storage-blob>=12.27.1`. Install it separately in your project after azpaddypy:

```bash
pip install azureml-mlflow==1.62.0
```

### Feature Flags

| Flag | Default | Effect |
|---|---|---|
| `enable_jobs` | `True` | Job submission, polling, listing, cancellation |
| `enable_model_registry` | `True` | Register, get, list, delete model versions |
| `enable_endpoints` | `True` | Get and list online endpoints |
| `enable_data` | `True` | Get and list data assets |
| `enable_compute` | `True` | Get and list compute targets |
| `enable_mlflow` | `True` | `get_mlflow_tracking_uri()` and `get_mlflow_client()` |

## Logging & Tracing

`azpaddypy` splits telemetry into two entry points with one concern each.

### 1. Bootstrap once, at process start

`bootstrap_azure_monitor()` configures the Azure Monitor exporter + GenAI
instrumentors for the whole process. It's **idempotent** — call it as many
times as you like. Without a connection string it logs a warning and disables
telemetry cleanly.

```python
from azpaddypy.mgmt.logging import bootstrap_azure_monitor

bootstrap_azure_monitor(
    service_name="crawler-api",       # cloud role name in App Insights
    service_version="0.5.1",
    # connection_string falls back to APPLICATIONINSIGHTS_CONNECTION_STRING
)
```

If you use `AzureManagementBuilder.with_logger()`, it calls `bootstrap_azure_monitor`
for you with the values from `EnvironmentConfiguration`.

#### Sampling

By default, `bootstrap_azure_monitor` does **not** override the trace sampler
— whatever the distro (or the Functions host, when running inside one)
chooses applies. Pass `sampling_ratio=` only when you explicitly want a
fixed-percentage sampler:

```python
bootstrap_azure_monitor(
    service_name="crawler-api",
    sampling_ratio=0.25,                          # ~25% of traces
    enable_trace_based_sampling_for_logs=True,    # drop logs whose trace was sampled out
)
```

When `sampling_ratio` is set, bootstrap sets
`OTEL_TRACES_SAMPLER=microsoft.fixed_percentage` and
`OTEL_TRACES_SAMPLER_ARG=<ratio>` via `os.environ.setdefault`, so explicit
deployment-time overrides via App Settings continue to win.

> ⚠️ **Don't pass `sampling_ratio` inside an Azure Functions worker.** When
> `PYTHON_APPLICATIONINSIGHTS_ENABLE_TELEMETRY=true` the host has already
> wired the OTel pipeline before your worker code runs, and changing the
> sampler env var mid-flight can leave the SDK falling back to NoOp via an
> entry-point lookup race — silently dropping every span and log. Configure
> the sampler at the host level via app settings (`OTEL_TRACES_SAMPLER`,
> `OTEL_TRACES_SAMPLER_ARG`) instead.

> **About the `azure-monitor-opentelemetry` v1.8.6 sampler change.** Some
> blog posts claim the distro default flipped from 100% to a 5 trace/sec
> rate-limited sampler in v1.8.6. The official Microsoft Learn docs still
> state `ApplicationInsightsSampler` is the default. If you observe heavy
> drop rates after upgrading the distro, set `OTEL_TRACES_SAMPLER` /
> `OTEL_TRACES_SAMPLER_ARG` explicitly via App Settings — that's the
> Microsoft-recommended path on Functions, and it sidesteps any worker-side
> initialization race.

#### Custom processors

Pass `span_processors=[...]` and `log_record_processors=[...]` to attach
global enrichers (e.g. tenant ID on every span, redaction on every log). These
forward to the v1.8.4+ distro pass-throughs on `configure_azure_monitor`.

```python
from opentelemetry.sdk.trace import SpanProcessor

class TenantSpanProcessor(SpanProcessor):
    def on_end(self, span):
        span._attributes["tenant.id"] = current_tenant_id()
    def on_start(self, span, parent_context=None): ...
    def shutdown(self): ...
    def force_flush(self, timeout_millis=30000): return True

bootstrap_azure_monitor(
    service_name="crawler-api",
    span_processors=[TenantSpanProcessor()],
)
```

### 2. Create loggers anywhere

`AzureLogger(name)` is a thin `logging.Logger`-shaped wrapper that auto-enriches
every record with `correlation_id` (when active) and any OTel baggage entries
(flattened to `baggage.<key>`). It deliberately does **not** copy `service_name`,
`timestamp`, `trace_id`, or `span_id` into `customDimensions` — the Azure Monitor
exporter already populates those columns (`cloud_RoleName`, `timestamp`,
`operation_Id`, `operation_ParentId`), so duplicating them would just inflate
ingestion. Pass `__name__` so logs are properly namespaced per module.

```python
from azpaddypy.mgmt.logging import AzureLogger

logger = AzureLogger(__name__)

# stdlib-compatible API: msg + %-style args + extra
logger.info("field filled: selector=%s", selector)
logger.warning("retrying: attempt=%d", attempt, extra={"backoff_ms": 250})
logger.error("field failed: selector=%s", selector, exc_info=True)

# Inside an except block, logger.exception() attaches the active traceback
try:
    do_thing()
except RuntimeError:
    logger.exception("operation failed")
```

Since it matches `logging.Logger`, `AzureLogger` drops into any third-party
code (including `logging.LoggerAdapter`) that expects a stdlib logger.

### 3. Decorate functions for distributed tracing

`trace_function` creates an OpenTelemetry span around the call. It manages a
correlation ID scoped to the span (UUID4 if none is active, reset on exit) so
long-running workers don't leak IDs across requests.

```python
from azpaddypy.mgmt.logging import AzureLogger, trace_function

@trace_function()                         # default: span named {module}.{qualname}
async def crawl(url: str) -> dict:
    return await do_crawl(url)

# Custom span name + record the return value as a span attribute
@trace_function(name="summarize", record_result=True)
def summarize(text: str) -> str:
    return text[:100]

# Also available as a method on AzureLogger for ergonomics
logger = AzureLogger(__name__)

@logger.trace_function(record_args=True)
def process(user_id: int, payload: dict) -> None: ...
```

`record_args` / `record_result` add the arguments / return value as span
attributes (stringified, truncated to 1000 chars). Default is off so you don't
accidentally ship sensitive values. When applied to bound methods or
classmethods, `self` and `cls` are skipped automatically — only the call's
own parameters are recorded.

Each decorated function emits its span under its own module's instrumentation
scope (`trace.get_tracer(func.__module__)`), so spans are attributed to your
code rather than to `azpaddypy.mgmt.logging`. The span's start/end timestamps
already drive App Insights' `duration` column, so no separate `duration_ms`
attribute is emitted.

#### Span kind

`kind` defaults to `SpanKind.INTERNAL`, which lands in App Insights'
**`dependencies`** table. App Insights' Performance / end-to-end transaction
tree only renders when there's a `SpanKind.SERVER` span at the root (the
`requests` table). Use it on entry-point handlers that aren't auto-instrumented
by a framework:

```python
from opentelemetry.trace import SpanKind

@trace_function(kind=SpanKind.SERVER)
async def consume_message(msg):  # custom Service Bus consumer outside FastAPI
    ...
```

##### Inside an Azure Function App

**Don't pass `kind=SpanKind.SERVER`** on Functions handlers. The Functions
host already emits a request row for every trigger invocation (HTTP, Timer,
Queue, Service Bus, ...) — via the legacy AppInsights SDK in the recommended
configuration, or via OTel when `host.json` sets `"telemetryMode": "OpenTelemetry"`
(currently incompatible with azpaddypy's worker bootstrap; see
[Azure Function Apps](#azure-function-apps)). Either way, a worker-side SERVER
span would produce a duplicate row in the `requests` table with a different
`operation_Id`, breaking the end-to-end transaction tree.

The right pattern: **leave `kind` at its default (`INTERNAL`)** on every
worker-side decoration — the trigger handler, any helpers it calls, any
`Blueprint`-registered function. The host-emitted request anchors the tree
and your `@trace_function` spans render as children of it under
`dependencies`.

```python
import azure.functions as func
from mgmt_config import logger, log_execution_config

bp_orders: func.Blueprint = func.Blueprint()

# Trigger handler — INTERNAL (default). The host emits the SERVER request
# span; this becomes a child dependency under it.
@bp_orders.timer_trigger(arg_name="mytimer", schedule="0 */5 * * * *")
@logger.trace_function(**log_execution_config.to_dict())
async def reconcile_orders(mytimer: func.TimerRequest) -> None:
    await _load_orders()
    await _persist_results()

# Helpers called from the handler — also INTERNAL. They appear as nested
# dependencies underneath reconcile_orders, which itself sits under the
# host's request span.
@logger.trace_function()
async def _load_orders() -> list[Order]: ...

@logger.trace_function()
async def _persist_results() -> None: ...
```

The resulting tree in App Insights' Performance blade:

```
[REQUEST]   reconcile_orders             ← emitted by the Functions host
  └─ [DEPENDENCY] reconcile_orders        ← @logger.trace_function (INTERNAL)
       ├─ [DEPENDENCY] _load_orders       ← INTERNAL
       │    └─ [DEPENDENCY] CosmosDB query   ← azpaddypy span
       └─ [DEPENDENCY] _persist_results   ← INTERNAL
            └─ [DEPENDENCY] HTTP PUT ...    ← requests instrumentor
```

The builder also auto-disables the FastAPI / Django / Flask instrumentors
inside Functions (it detects the function context via `reflection_kind`), so
even if you embed FastAPI behind `azure.functions.AsgiMiddleware` you won't
get duplicate request rows. See [Azure Function Apps](#azure-function-apps)
for the full host configuration (`host.json`, app settings).

### Correlation IDs

Correlation IDs are stored in a `contextvars.ContextVar`, so every logger in
the same task tree sees the same ID, and async tasks do not contaminate each
other. You can set one manually at the edge of a request:

```python
from azpaddypy.mgmt.logging import AzureLogger

token = AzureLogger.set_correlation_id("request-abc-123")
try:
    await handle_request(...)
finally:
    AzureLogger.reset_correlation_id(token)
```

If you don't set one, the first `@trace_function`-decorated call will generate
a UUID4 for the duration of that span.

Resource clients expose `set_correlation_id` / `reset_correlation_id` /
`correlation_id_scope` / `get_correlation_id` on the instance for convenience.
**`set_correlation_id` returns a `contextvars.Token`** — keep it and pass it
to `reset_correlation_id` so the value is scoped to one request. Long-lived
workers (Functions, ASGI) that never reset will leak a single correlation ID
across every subsequent request:

```python
# Manual scoping
token = storage.set_correlation_id("request-abc-123")
try:
    storage.upload_blob(...)
finally:
    storage.reset_correlation_id(token)

# Or use the context manager (recommended)
with storage.correlation_id_scope("request-abc-123"):
    storage.upload_blob(...)
```

All three delegate to the same process-wide contextvar that `AzureLogger` and
`@trace_function` consult.

### Flushing at shutdown

`logger.flush()` flushes console handlers and force-flushes **all three**
configured OTel providers (tracer, logger, meter). Call it at the end of an
Azure Functions invocation or before container exit so batched traces, logs,
and metrics are pushed before the worker terminates:

```python
try:
    await main(...)
finally:
    logger.flush()
```

### AI Foundry Tracing

`bootstrap_azure_monitor()` installs two instrumentors so that traces from both direct OpenAI SDK calls and AI Foundry agent invocations flow into Application Insights and the AI Foundry Tracing UI:

1. **`opentelemetry-instrumentation-openai-v2`** — instruments `openai.chat.completions.create()`, `embeddings.create()`, etc. Emits OTel GenAI spans with model, token usage, latency, and optional prompt/completion content.
2. **`azure.ai.projects.telemetry.AIProjectInstrumentor`** — instruments the OpenAI Responses API so that `agent_reference` calls attach agent metadata, tool-call spans, and the `gen_ai.*` attributes the AI Foundry Tracing UI groups traces on. Requires the `AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true` feature gate, which the bootstrap sets for you when `install_genai_instrumentors=True` (the default).

Both instrumentors are harmless for non-Foundry apps: if your code never calls `responses.create()` or `chat.completions.create()`, neither instrumentor has any runtime effect.

#### Configuration kwargs

Pass these to `bootstrap_azure_monitor()` or, equivalently, to `AzureManagementBuilder.with_logger()`:

| Kwarg | Default | Effect |
|---|---|---|
| `capture_gen_ai_content` | `False` | When `True`, sets **both** `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (honored by `opentelemetry-instrumentation-openai-v2` and `AIProjectInstrumentor`) and `AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED` (honored by `azure-ai-inference`'s `AIInferenceInstrumentor`) **before** the instrumentors activate. Off by default so prompts/completions don't ship to App Insights unexpectedly -- opt in per deployment. |
| `install_genai_instrumentors` | `True` | Installs `opentelemetry-instrumentation-openai-v2` and `AIProjectInstrumentor`, and sets `AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true`. Required for the AI Foundry Tracing UI to render agent metadata, tool-call spans, and `gen_ai.agent.*` attributes on Responses API traces. |
| `enable_gen_ai_trace_propagation` | `True` | Sets `AZURE_TRACING_GEN_AI_ENABLE_TRACE_CONTEXT_PROPAGATION=true` so outbound OpenAI SDK HTTP calls carry W3C `traceparent`/`tracestate` headers. Server-side spans in Foundry correlate with your client spans. |

Log export attaches to the root logger so third-party library logs (e.g. `azure.core.pipeline`, `urllib3`) also flow to Application Insights and contribute to Application Map dependency edges. `bootstrap_azure_monitor` raises the root logger to `INFO` (only when it would otherwise be coarser) so those records aren't filtered before reaching the handler, and silences two known-noisy loggers (`azure.core.pipeline.policies.http_logging_policy`, `urllib3.connectionpool`) at `WARNING`. Apply additional level filters with `logging.getLogger("azure").setLevel(...)` if a particular library is still too chatty for your taste.

### Azure Function Apps

Function Apps run a two-process model: a **host** (the Functions runtime) and a
**worker** (your Python code). The host emits request telemetry for every
trigger invocation (HTTP, Timer, Queue, Service Bus, ...) regardless of mode.
Your worker spans, emitted by `bootstrap_azure_monitor` via the OTel pipeline,
become children of the host's request via `operation_Id` correlation.

Recommended configuration (verified working with azpaddypy):

1. `host.json` — leave the host on the **legacy AppInsights SDK** (omit
   `telemetryMode`):

   ```json
   {
     "version": "2.0",
     "logging": {
       "logLevel": {
         "default": "Information"
       },
       "applicationInsights": {
         "samplingSettings": { "isEnabled": false }
       }
     }
   }
   ```

   - The keys under `logging.logLevel` are **logger category names**
     (`default`, `Function`, `Function.<name>`, `Host.Results`, ...) — *not*
     level names. A common pitfall is writing `{"trace": "Information"}`,
     which the schema accepts as a free-form key but matches no real category.
     The implicit `default` then falls back to `Warning`, dropping every
     `Information`-level worker log before it reaches App Insights. `requests`
     still appear (separate pipeline) but `traces` / `dependencies` /
     `exceptions` look empty. Always set `default` so there's a fallback for
     every category.
   - `applicationInsights.samplingSettings.isEnabled = false` disables the
     legacy adaptive sampling so high-volume telemetry types aren't capped
     at the host level.

2. App settings:

   ```
   APPLICATIONINSIGHTS_CONNECTION_STRING=<your conn string>
   ```

   - **Don't** set `PYTHON_APPLICATIONINSIGHTS_ENABLE_TELEMETRY=true`. With
     azpaddypy's `bootstrap_azure_monitor` calling `configure_azure_monitor()`
     in the worker, that setting hands pipeline ownership to the host which
     races with the worker's bootstrap on cold starts and silently drops
     every log record.

3. In the worker, just call `bootstrap_azure_monitor()` (or use the builder).
   `AzureManagementBuilder.with_logger()` detects the function context via
   `reflection_kind` and **automatically disables** the FastAPI / Django / Flask
   auto-instrumentors so the worker doesn't emit competing SERVER spans for the
   same invocation. Use `@trace_function()` (default `INTERNAL`) on handlers and
   nested calls — they'll appear as dependencies under the host's request.

#### Why not `telemetryMode: OpenTelemetry`?

The `host.json` setting `"telemetryMode": "OpenTelemetry"` is in **public
preview** and currently incompatible with worker-side `configure_azure_monitor()`
calls. Symptoms reported in production: `traces`, `dependencies`, and
`exceptions` tables silently empty while `requests` continues to populate
(host pipeline), or 4× duplicated log records (see [Azure/azure-functions-python-worker#1342][gh1342]).
The Microsoft fix — setting `PYTHON_APPLICATIONINSIGHTS_ENABLE_TELEMETRY=true` —
hands pipeline ownership to the host and conflicts with azpaddypy's worker
bootstrap. Until the upstream conflict is resolved, omit `telemetryMode`. The
host will use the legacy AppInsights SDK to emit requests, App Insights still
correlates the host's request with your worker spans via `operation_Id`, and
the end-to-end transaction tree renders in both the Performance and Failures
blades. You lose nothing functionally compared to OTel mode for this use case.

[gh1342]: https://github.com/Azure/azure-functions-python-worker/issues/1342

Outside Function Apps (App Service, Container Apps, VM), the FastAPI / Django /
Flask auto-instrumentors stay enabled because there is no host emitter and the
framework instrumentor is the only source of the SERVER request span.

> **Per-function vs. process-wide:** the `trace_function(record_result=True)` flag controls only whether the decorated function's return value is attached as a span attribute. GenAI prompt/completion content capture is a separate, process-level concern controlled by `bootstrap_azure_monitor(capture_gen_ai_content=True)` — the two are deliberately decoupled because GenAI instrumentors read the env var at install time, not per-call.

#### Example: tracing a chat completion

```python
from mgmt_config import logger, ai_projects, log_execution_config

@logger.trace_function()
async def generate_summary(document_text: str) -> str:
    ai_project = ai_projects.get("aiservices")
    openai_client = ai_project.get_openai_client()

    response = openai_client.chat.completions.create(
        model="gpt-5",
        messages=[
            {"role": "system", "content": "Summarize the document."},
            {"role": "user", "content": document_text},
        ],
    )
    return response.choices[0].message.content
```

The trace in AI Foundry shows a parent span for `generate_summary` with a child `chat gpt-5` span containing model, token counts, latency, and (when the logger was constructed with `capture_gen_ai_content=True`) the full prompt/completion content.

#### Example: tracing a Foundry agent invocation

```python
# Agent trace flows through AIProjectInstrumentor -> AI Foundry Tracing UI
result = ai_projects["aiservices"].invoke_agent(
    agent_name="doc-summarizer",
    user_message="Summarize the attached document",
)
```

The trace shows `AzureAIProject.invoke_agent` with `gen_ai.system=az.ai.projects`, `gen_ai.operation.name=invoke_agent`, `gen_ai.agent.name=doc-summarizer`, and (via `AIProjectInstrumentor`) nested spans for the Responses API call, tool calls, and model invocation — all grouped under the agent in the Foundry Tracing UI.

#### Linking Application Insights to your Foundry project

The AI Foundry Tracing tab in the portal reads directly from the Application Insights resource **linked** to your Foundry project (Project → Tracing → "Manage data source"). Setting `APPLICATIONINSIGHTS_CONNECTION_STRING` is not enough on its own — the resource must be linked once via the portal for the Tracing UI to find the traces.

If your app wants to fetch the linked connection string at runtime instead of hand-wiring it:

```python
from mgmt_config import ai_projects

ai = ai_projects["aiservices"]
conn_str = ai.get_application_insights_connection_string()  # returns None if not linked
```

This wraps `azure-ai-projects`' `client.telemetry.get_application_insights_connection_string()` and is the recommended bootstrap path when you want the logger to always target whatever App Insights is currently linked to your Foundry resource.

## Feature Flags

Each resource client exposes feature flags that gate operation groups. All flags default to the values shown in `mgmt_config_template.py` (all `True`). Disabled operations raise `RuntimeError` with a clear message.

### Storage

| Flag | Default | Service |
|---|---|---|
| `enable_blob` | `True` | BlobServiceClient (blob upload, download, SAS, metadata) |
| `enable_file` | `True` | ShareServiceClient (file share; requires `Storage File Data SMB Share Contributor` RBAC) |
| `enable_queue` | `True` | QueueServiceClient (send, receive, delete messages) |

### AI Foundry Projects

| Flag | Default | Effect |
|---|---|---|
| `enable_agents` | `True` | Agent CRUD and invocation |
| `enable_deployments` | `True` | List and get model deployments |
| `enable_connections` | `True` | List and get project connections |

### Document Intelligence

| Flag | Default | Effect |
|---|---|---|
| `enable_analysis` | `True` | Document analysis (prebuilt and custom models) |
| `enable_administration` | `True` | Model management (list, get, delete); requires `Cognitive Services Contributor` |

### Speech

No feature flags — all Speech operations (synthesis, recognition) are always available when the client is created.

### Azure ML Studio

| Flag | Default | Effect |
|---|---|---|
| `enable_jobs` | `True` | Job submission, polling, listing, cancellation |
| `enable_model_registry` | `True` | Register, get, list, delete model versions |
| `enable_endpoints` | `True` | Get and list online endpoints |
| `enable_data` | `True` | Get and list data assets |
| `enable_compute` | `True` | Get and list compute targets |
| `enable_mlflow` | `True` | MLflow tracking URI and client (requires `azureml-mlflow`) |

## Dependencies

- `azure-storage-blob` - Blob operations
- `azure-storage-file-share` - File share operations
- `azure-storage-queue` - Queue operations
- `azure-identity` - Credential management
- `azure-keyvault-secrets` / `keys` / `certificates` - Key Vault
- `azure-cosmos` - Cosmos DB
- `azure-ai-projects` - AI Foundry Projects (agents, deployments, connections, `AIProjectInstrumentor` for Responses API tracing)
- `azure-ai-documentintelligence` - Document Intelligence (analyze, model management)
- `azure-cognitiveservices-speech` - Speech (synthesis, recognition with Entra ID)
- `azure-ai-ml` - Azure ML Studio (jobs, model registry, endpoints, data, compute)
- `azure-monitor-opentelemetry` - Telemetry
- `opentelemetry-instrumentation-openai-v2` - AI Foundry tracing for OpenAI SDK calls
- `azureml-mlflow` *(optional, install separately)* - MLflow tracking URI and client for Azure ML
