From d01673e23f873829af0ac95b0f10c8f403b1ede1 Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Sat, 14 Mar 2026 12:10:11 -0600 Subject: [PATCH 01/11] docs: Add design for Spectra Collector exporter integration Design document and brainstorm outputs for adding SpectraExporterOptions to observability-core, enabling Weave/Copilot Cowork to export traces via OTLP to Spectra Collector sidecars instead of the A365 API. Key decisions: - New SpectraExporterOptions class with K8s sidecar defaults - Union type dispatch on configure() exporter_options parameter - Move suppress_invoke_agent_input to enrichment layer for exporter-agnostic suppression - Protocol validation, module-level gRPC import, export surface symmetry Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ARCHITECTURE.md | 255 +++++++++ .../BRAINSTORM.md | 163 ++++++ .../spectra-collector-integration/TLDR.md | 32 ++ docs/design/spectra-exporter-options.md | 537 ++++++++++++++++++ 4 files changed, 987 insertions(+) create mode 100644 docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md create mode 100644 docs/brainstorm/spectra-collector-integration/BRAINSTORM.md create mode 100644 docs/brainstorm/spectra-collector-integration/TLDR.md create mode 100644 docs/design/spectra-exporter-options.md diff --git a/docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md b/docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md new file mode 100644 index 00000000..43a01604 --- /dev/null +++ b/docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md @@ -0,0 +1,255 @@ +# Spectra Collector Integration — Architecture Proposal + +**Date:** 2026-03-14 +**Status:** Ready to build + +--- + +## Overview + +Integrate Spectra Collector as an optional export destination in the `microsoft-agents-a365-observability-core` package. Consumers deploying with Spectra sidecars in K8s pass `SpectraExporterOptions` to `configure()` instead of `Agent365ExporterOptions`. Under the hood, this creates an `OTLPSpanExporter` pointed at the Spectra sidecar. + +--- + +## Architecture + +### Exporter Selection Flow (after change) + +``` +configure(exporter_options=SpectraExporterOptions(...)) + │ + ├─ if isinstance(exporter_options, SpectraExporterOptions): + │ → OTLPSpanExporter(endpoint, protocol, insecure) + │ → ENABLE_A365_OBSERVABILITY_EXPORTER env var is IGNORED + │ + ├─ elif isinstance(exporter_options, Agent365ExporterOptions): + │ → if ENABLE_A365_OBSERVABILITY_EXPORTER + token_resolver: + │ │ → _Agent365Exporter (custom HTTP) + │ → else: + │ → ConsoleSpanExporter (fallback) + │ + └─ if ENABLE_OTLP_EXPORTER=true: (unchanged, additive) + → OTLPSpanExporter (auto-configured from OTEL env vars) +``` + +### Component Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Consumer Application │ +│ │ +│ configure(exporter_options=SpectraExporterOptions()) │ +│ or │ +│ configure(exporter_options=Agent365ExporterOptions(...)) │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ TelemetryManager._configure_internal() │ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────────┐ │ +│ │ SpectraExporterOpts │ │ Agent365ExporterOptions │ │ +│ │ endpoint (4317) │ │ token_resolver │ │ +│ │ protocol (gRPC) │ │ cluster_category │ │ +│ │ insecure (false) │ │ use_s2s_endpoint │ │ +│ │ batch settings │ │ batch settings │ │ +│ └────────┬────────────┘ └────────┬────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌──────────────────┐ │ +│ │ OTLPSpanExporter│ │ _Agent365Exporter│ │ +│ │ (standard OTEL) │ │ (custom HTTP) │ │ +│ └────────┬────────┘ └────────┬─────────┘ │ +│ │ │ │ +│ └──────────┬───────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────┐ │ +│ │ _EnrichingBatchSpan- │ │ +│ │ Processor │ ← enrichers from │ +│ │ (exporter-agnostic) │ framework exts │ +│ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌────────────┴────────────┐ + ▼ ▼ + Spectra Sidecar A365 API + (localhost:4317) (agent365.svc.cloud.microsoft) +``` + +--- + +## New File: `spectra_exporter_options.py` + +Location: `libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py` + +```python +class SpectraExporterOptions: + """ + Configuration for exporting traces to a Spectra Collector sidecar via OTLP. + """ + + def __init__( + self, + endpoint: str = "http://localhost:4317", + protocol: str = "grpc", + insecure: bool = False, + max_queue_size: int = 2048, + scheduled_delay_ms: int = 5000, + exporter_timeout_ms: int = 30000, + max_export_batch_size: int = 512, + ): + self.endpoint = endpoint + self.protocol = protocol # "grpc" or "http" + self.insecure = insecure + self.max_queue_size = max_queue_size + self.scheduled_delay_ms = scheduled_delay_ms + self.exporter_timeout_ms = exporter_timeout_ms + self.max_export_batch_size = max_export_batch_size +``` + +### Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `endpoint` | `str` | `http://localhost:4317` | Spectra sidecar OTLP endpoint | +| `protocol` | `str` | `grpc` | OTLP protocol: `"grpc"` or `"http"` | +| `insecure` | `bool` | `False` | Whether to use insecure (no TLS) connection | +| `max_queue_size` | `int` | `2048` | Batch processor queue size | +| `scheduled_delay_ms` | `int` | `5000` | Export interval (ms) | +| `exporter_timeout_ms` | `int` | `30000` | Export timeout (ms) | +| `max_export_batch_size` | `int` | `512` | Max spans per export batch | + +--- + +## Changes to `config.py` + +### `configure()` signature + +```python +def configure( + service_name: str, + service_namespace: str, + logger_name: str = DEFAULT_LOGGER_NAME, + token_resolver: Callable[[str, str], str | None] | None = None, + cluster_category: str = "prod", + exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, + suppress_invoke_agent_input: bool = False, + **kwargs: Any, +) -> bool: +``` + +### `_configure_internal()` exporter selection + +```python +# Type-based dispatch +if isinstance(exporter_options, SpectraExporterOptions): + # Spectra path — OTLP exporter to sidecar + # ENABLE_A365_OBSERVABILITY_EXPORTER is ignored + if exporter_options.protocol == "grpc": + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GrpcExporter + exporter = GrpcExporter( + endpoint=exporter_options.endpoint, + insecure=exporter_options.insecure, + ) + else: + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HttpExporter + exporter = HttpExporter( + endpoint=exporter_options.endpoint, + ) + +elif isinstance(exporter_options, Agent365ExporterOptions): + # A365 path (existing logic, unchanged) + if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: + exporter = _Agent365Exporter(...) + else: + exporter = ConsoleSpanExporter() + +else: + # No options provided — legacy fallback + exporter_options = Agent365ExporterOptions( + cluster_category=cluster_category, + token_resolver=token_resolver, + ) + # ... existing logic +``` + +--- + +## Export Surface Changes + +### `exporters/__init__.py` + +```python +from .agent365_exporter_options import Agent365ExporterOptions +from .spectra_exporter_options import SpectraExporterOptions + +__all__ = ["Agent365ExporterOptions", "SpectraExporterOptions"] +``` + +### `core/__init__.py` + +Add `SpectraExporterOptions` to imports and `__all__`. + +--- + +## Consumer Usage + +### Spectra deployment (Weave/Copilot Cowork) + +```python +from microsoft_agents_a365.observability.core import configure, SpectraExporterOptions + +# Zero-config — defaults to localhost:4317, gRPC +configure( + service_name="weave-agent", + service_namespace="copilot-cowork", + exporter_options=SpectraExporterOptions(), +) +``` + +### A365 deployment (existing consumers, unchanged) + +```python +from microsoft_agents_a365.observability.core import configure, Agent365ExporterOptions + +configure( + service_name="my-agent", + service_namespace="my-namespace", + exporter_options=Agent365ExporterOptions( + token_resolver=my_token_resolver, + cluster_category="prod", + ), +) +``` + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `exporters/spectra_exporter_options.py` | **New** — `SpectraExporterOptions` class | +| `exporters/__init__.py` | Add `SpectraExporterOptions` export | +| `core/__init__.py` | Add `SpectraExporterOptions` to `__all__` | +| `config.py` | Union type on `exporter_options`, type-based dispatch in `_configure_internal()` | +| `tests/observability/core/test_spectra_exporter.py` | **New** — tests for Spectra exporter path (mocked `OTLPSpanExporter`) | + +## Files NOT Changed + +- Scope classes (`invoke_agent_scope.py`, `execute_tool_scope.py`, etc.) +- Enrichment pipeline (`enriching_span_processor.py`) +- Framework extensions (all `*-observability-extensions-*` packages) +- Constants (`constants.py`) +- `Agent365ExporterOptions` class +- `_Agent365Exporter` class + +--- + +## Risks & Mitigations + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Sidecar not running → silent failure | Medium | OTLP exporter logs connection errors; document deployment prereqs | +| Consumer passes both A365 env var + Spectra options | Low | Spectra options take precedence; env var ignored. Document. | +| gRPC dependency not installed | Low | `opentelemetry-exporter-otlp` (core dep) includes both gRPC and HTTP | +| `insecure=False` default may fail on plain HTTP localhost | Low | Document: set `insecure=True` if sidecar doesn't have TLS | diff --git a/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md b/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md new file mode 100644 index 00000000..1768b393 --- /dev/null +++ b/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md @@ -0,0 +1,163 @@ +# Spectra Collector Integration — Brainstorm Working Document + +**Status:** Complete +**Started:** 2026-03-14 +**Last Updated:** 2026-03-14 + +--- + +## Problem Frame + +**Trigger:** Weave/Copilot Cowork team needs to use Spectra Collector instead of A365 API directly. This is net-new — no consumer currently uses Spectra through this SDK. + +**Scope:** Create a dedicated `SpectraExporterOptions` so consumers deploying with Spectra sidecars in K8s can configure the observability-core package to export traces to Spectra instead of A365. + +**Key Context:** +- Spectra Collector is a sidecar OTEL Collector that accepts **standard OTLP** (gRPC on :4317, HTTP on :4318) +- Spectra is a **replacement** for A365 in certain deployments (not additive) +- No Spectra-specific attributes needed — tenant_id is already required by this library +- Spectra dependencies should remain optional + +**Stakeholders:** Weave/Copilot Cowork team (first consumer) +**Constraints:** Optional dependencies, K8s sidecar deployment only + +--- + +## Current State + +### Exporter Pipeline Architecture + +The `TelemetryManager.configure()` method (`config.py`) creates the exporter pipeline: + +1. **Primary exporter** (lines 159-171): Either `_Agent365Exporter` (if `ENABLE_A365_OBSERVABILITY_EXPORTER=true` AND `token_resolver` provided) or `ConsoleSpanExporter` fallback +2. **Optional OTLP exporter** (lines 187-192): If `ENABLE_OTLP_EXPORTER=true`, adds an `OTLPSpanExporter` (auto-configured from OTEL env vars) +3. Both use `_EnrichingBatchSpanProcessor` which applies span enrichers before export + +### Key Components + +| Component | Role | Public? | +|-----------|------|---------| +| `configure()` | Entry point — takes `Agent365ExporterOptions` | Yes | +| `Agent365ExporterOptions` | Config for A365 exporter (token_resolver, cluster_category, batch settings) | Yes | +| `_Agent365Exporter` | Custom HTTP exporter — partitions by identity, POSTs to A365 API | No (internal) | +| `_EnrichingBatchSpanProcessor` | Batch processor with span enrichment hook | No (internal) | +| `OTLPSpanExporter` | Standard OTEL OTLP exporter (used for optional OTLP path) | From otel-sdk | + +### What Works Well (Keep) +- `_EnrichingBatchSpanProcessor` — enrichment pipeline is exporter-agnostic +- `SpanProcessor` for agent span processing — independent of export destination +- All scope classes (`InvokeAgentScope`, `ExecuteToolScope`, etc.) — independent of exporter +- Extension instrumentors — they just register enrichers/processors, don't care about export + +### What's Painful (Change Candidates) +- `configure()` is hardcoded to either A365 exporter or console fallback — no way to select Spectra +- `exporter_options` parameter is typed as `Agent365ExporterOptions` — not generic +- The OTLP exporter path (lines 187-192) is a bolt-on with no configuration surface + +### What's Missing (Build) +- `SpectraExporterOptions` — configuration class for Spectra-specific settings +- Selection logic in `configure()` to choose Spectra exporter when configured +- Environment variable for enabling Spectra exporter + +### Dependencies +- `opentelemetry-exporter-otlp` is already a **core dependency** (not optional) — used for the existing OTLP path +- Since Spectra accepts OTLP, no new dependencies needed for the exporter itself + +--- + +## Requirements & Gaps + +| # | Requirement | Layer | Status | +|---|-------------|-------|--------| +| R1 | Consumer can configure Spectra as the export destination instead of A365 | Configuration | **Gap** | +| R2 | Spectra exporter sends traces via OTLP to a sidecar endpoint (default `localhost:4317`) | Infrastructure | **Partial** | +| R3 | Span enrichment pipeline works identically regardless of exporter | Infrastructure | **Met** | +| R4 | Framework extensions work with Spectra without changes | Intelligence | **Met** | +| R5 | Spectra and A365 are mutually exclusive per deployment | Configuration | **Gap** | +| R6 | Spectra dependencies remain optional (no new deps needed) | Infrastructure | **Met** | +| R7 | Batch processor settings configurable for Spectra (client-side batching) | Configuration | **Gap** | + +### What Changes vs What Stays + +**MUST NOT change:** Scope classes, span enrichment pipeline, framework extensions, constants, `Agent365ExporterOptions` API + +**MUST change:** `configure()` to accept Spectra options; exporter selection logic in `_configure_internal()` + +**COULD change:** Extract shared batch settings into base class (deferred — only 2 exporters) + +--- + +## Key Decisions + +### Decision 1: Exporter configuration model +**Chosen: Separate `SpectraExporterOptions` class** alongside `Agent365ExporterOptions`. No shared base class — duplication of 4 batch fields is acceptable. Avoids changing existing class hierarchy. + +### Decision 2: Exporter selection mechanism +**Chosen: Type-based dispatch.** Consumer passes `SpectraExporterOptions` or `Agent365ExporterOptions` to `configure()`. No env var magic — explicit in code. + +### Decision 3: Relationship to `ENABLE_OTLP_EXPORTER` +**Chosen: Keep separate.** `ENABLE_OTLP_EXPORTER` remains a generic escape hatch. Spectra is a dedicated, opinionated integration. Different purposes, no conflict. + +### Decision 4: Default endpoint and protocol +**Chosen: `SpectraExporterOptions` defaults to `http://localhost:4317` (gRPC).** Zero-config for K8s sidecar deployments. Consumer can override but shouldn't need to in the common case. + +--- + +## Risks & Open Questions + +### Risks +| Risk | Severity | Mitigation | +|------|----------|------------| +| Sidecar not running → silent failure | Medium | OTLP exporter logs connection errors; document deployment prereqs | +| Consumer passes both A365 env var + Spectra options | Low | Spectra options take precedence; env var ignored | +| `insecure=False` default may fail on plain HTTP localhost | Low | Document: set `insecure=True` if sidecar doesn't have TLS | + +### Open Questions (Resolved) +1. **Union type vs separate param** → Union type on `exporter_options` +2. **A365 env var behavior** → Ignored entirely when SpectraExporterOptions provided +3. **Insecure config** → Exposed, defaults to `False` +4. **Protocol config** → Exposed (`"grpc"` or `"http"`), defaults to `"grpc"` +5. **Package exports** → `SpectraExporterOptions` added to `__init__.py` +6. **Test strategy** → Mock `OTLPSpanExporter` + +## Doc Map +- [TLDR.md](TLDR.md) — 1-page summary for stakeholders +- [ARCHITECTURE.md](ARCHITECTURE.md) — Full technical architecture proposal + +--- + +## Session Log + +### 2026-03-14 — Phase 1: Problem Framing +- Explored observability-core architecture: TelemetryManager singleton, A365 exporter, OTLP exporter, enriching batch processor +- Explored spectra-collector: OTLP-based sidecar, accepts standard OTEL traces/logs/metrics on localhost:4317/4318 +- Key finding: existing `ENABLE_OTLP_EXPORTER` path already creates an `OTLPSpanExporter` — Spectra accepts OTLP natively +- User confirmed: Spectra replaces A365 in certain deployments (Weave/Copilot Cowork) +- User confirmed: No Spectra-specific attributes needed, dependencies should be optional + +### 2026-03-14 — Phase 2: Current State Mapped +- Mapped exporter pipeline: configure() → A365Exporter or Console, plus optional OTLP +- Key gap: configure() is hardcoded to A365 exporter options — no way to select Spectra +- Key advantage: `opentelemetry-exporter-otlp` is already a core dep, enrichment pipeline is exporter-agnostic +- All scope classes, extensions, and instrumentors are independent of export destination + +### 2026-03-14 — Phase 3: Requirements & Gaps +- 7 requirements identified; 3 met, 1 partial, 3 gaps +- Key gaps: configure() dispatch, exporter selection, batch settings for Spectra +- No changes needed to scopes, enrichment, extensions, or constants + +### 2026-03-14 — Phase 4: Key Decisions +- Decision 1: Separate `SpectraExporterOptions` class (no shared base) +- Decision 2: Type-based dispatch in `configure()` +- Decision 3: Keep `ENABLE_OTLP_EXPORTER` separate from Spectra +- Decision 4: Default endpoint `http://localhost:4317` (gRPC) — zero-config for K8s sidecar +- User clarified: defaults are critical since Spectra is always a K8s sidecar + +### 2026-03-14 — Phase 5: Risks & Open Questions +- All 6 open questions resolved by user +- Key decisions: union type on exporter_options, env var ignored for Spectra, insecure=False default, gRPC default, mock tests +- No blocking risks identified + +### 2026-03-14 — Phase 6: Outputs +- Created TLDR.md and ARCHITECTURE.md +- Brainstorm complete diff --git a/docs/brainstorm/spectra-collector-integration/TLDR.md b/docs/brainstorm/spectra-collector-integration/TLDR.md new file mode 100644 index 00000000..aadeed05 --- /dev/null +++ b/docs/brainstorm/spectra-collector-integration/TLDR.md @@ -0,0 +1,32 @@ +# Spectra Collector Integration — TLDR + +**Date:** 2026-03-14 +**Status:** Ready to build + +## What + +Add `SpectraExporterOptions` to the observability-core package so consumers deploying with Spectra Collector sidecars in K8s can export traces via OTLP instead of the A365 API. + +## Why + +Weave/Copilot Cowork needs Spectra Collector as their telemetry destination. Spectra replaces A365 in their deployment topology. The SDK currently only supports the A365 exporter or a raw OTLP bolt-on with no configuration surface. + +## How + +- New `SpectraExporterOptions` class with sensible defaults for K8s sidecar (`http://localhost:4317`, gRPC, insecure=false) +- `configure(exporter_options=...)` accepts `Agent365ExporterOptions | SpectraExporterOptions` — type-based dispatch selects the exporter +- When `SpectraExporterOptions` is provided, `ENABLE_A365_OBSERVABILITY_EXPORTER` env var is ignored +- Under the hood, creates an `OTLPSpanExporter` pointed at the Spectra endpoint — no new dependencies +- Enrichment pipeline, scope classes, and framework extensions work unchanged + +## Key Decisions + +1. **Separate options class** (not a shared base) — keeps things simple with only 2 exporters +2. **Type-based dispatch** — no env var magic, consumer explicitly chooses in code +3. **`ENABLE_OTLP_EXPORTER` stays separate** — generic escape hatch, different purpose +4. **Zero-config defaults** — endpoint `http://localhost:4317`, gRPC protocol, configurable but shouldn't need to change + +## Scope + +- **Changes:** `SpectraExporterOptions` (new file), `config.py` (exporter selection), `exporters/__init__.py` and `core/__init__.py` (exports), tests +- **No changes:** Scope classes, enrichment pipeline, framework extensions, constants, `Agent365ExporterOptions` diff --git a/docs/design/spectra-exporter-options.md b/docs/design/spectra-exporter-options.md new file mode 100644 index 00000000..0c76c56d --- /dev/null +++ b/docs/design/spectra-exporter-options.md @@ -0,0 +1,537 @@ +# Design: Spectra Collector Exporter Integration + +**Author:** Agent365 SDK Team +**Date:** 2026-03-14 +**Status:** Reviewed +**Brainstorm:** [docs/brainstorm/spectra-collector-integration/](../brainstorm/spectra-collector-integration/) + +--- + +## 1. Problem Statement + +The Weave/Copilot Cowork team deploys with Spectra Collector sidecars in Kubernetes and needs to export traces to the Spectra sidecar instead of the A365 observability API. Today, the `configure()` function in `observability-core` only supports `Agent365ExporterOptions`, which creates an `_Agent365Exporter` that POSTs to the A365 API with custom HTTP semantics (identity partitioning, token resolution, etc.). + +There is an existing `ENABLE_OTLP_EXPORTER` env var path (`config.py:187-192`) that creates a bare `OTLPSpanExporter`, but it has no configuration surface and is designed as a generic bolt-on, not a Spectra-aware integration. + +**Evidence:** +- `config.py:54-63` — `configure()` signature accepts only `Agent365ExporterOptions` +- `config.py:159-171` — exporter selection is hardcoded: A365 or console fallback +- `config.py:187-192` — OTLP bolt-on with zero configuration +- Spectra Collector accepts standard OTLP on `localhost:4317` (gRPC) / `localhost:4318` (HTTP) — confirmed from `D:\spectra-collector` + +--- + +## 2. Current Architecture + +### Affected Files Inventory + +| File | Path (relative to observability-core package root) | Role | Changes | +|------|------|------|---------| +| `config.py` | `microsoft_agents_a365/observability/core/config.py` | TelemetryManager singleton, `configure()` entry point | Modify | +| `agent365_exporter_options.py` | `microsoft_agents_a365/observability/core/exporters/agent365_exporter_options.py` | A365 exporter config | No change | +| `agent365_exporter.py` | `microsoft_agents_a365/observability/core/exporters/agent365_exporter.py` | A365 exporter — remove suppression logic from `_map_span` | Modify | +| `enriching_span_processor.py` | `microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py` | Batch processor — add suppression logic to `on_end` | Modify | +| `enriched_span.py` | `microsoft_agents_a365/observability/core/exporters/enriched_span.py` | Enriched span wrapper — add `excluded_attribute_keys` support | Modify | +| `exporters/__init__.py` | `microsoft_agents_a365/observability/core/exporters/__init__.py` | Exporter public exports | Modify | +| `core/__init__.py` | `microsoft_agents_a365/observability/core/__init__.py` | Package public API | Modify | +| `spectra_exporter_options.py` | `microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py` | **New** — Spectra config | Create | +| `test_spectra_exporter.py` | `tests/observability/core/test_spectra_exporter.py` | **New** — Spectra tests | Create | +| `test_agent365.py` | `tests/observability/core/test_agent365.py` | Existing config tests | Modify (add Spectra tests) | + +### Current Exporter Selection (`config.py:144-192`) + +```python +# Lines 144-149: Legacy fallback +if exporter_options is None: + exporter_options = Agent365ExporterOptions( + cluster_category=cluster_category, + token_resolver=token_resolver, + ) + +# Lines 159-171: A365 or console +if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: + exporter = _Agent365Exporter(...) +else: + exporter = ConsoleSpanExporter() + +# Lines 187-192: Optional OTLP bolt-on +if os.environ.get("ENABLE_OTLP_EXPORTER", "").lower() == "true": + otlp_exporter = OTLPSpanExporter() + tracer_provider.add_span_processor( + _EnrichingBatchSpanProcessor(otlp_exporter, **batch_processor_kwargs) + ) +``` + +### Unchanged Components + +These components are independent of the export destination and require **zero changes**: +- Scope classes: `InvokeAgentScope`, `ExecuteToolScope`, `InferenceScope` +- Span processor: `SpanProcessor` (copies baggage to span attributes) +- All framework extension packages (`*-observability-extensions-*`) +- Constants (`constants.py`) +- `Agent365ExporterOptions` class + +### Components Requiring Modification for `suppress_invoke_agent_input` + +The `suppress_invoke_agent_input` feature currently lives inside `_Agent365Exporter._map_span()` (`agent365_exporter.py:274-284`), where it strips `gen_ai.input.messages` from InvokeAgent spans during JSON serialization. This is exporter-specific — the `OTLPSpanExporter` never calls `_map_span`, so suppression would be lost in the Spectra path. + +**This must be moved to an exporter-agnostic layer** so it works with both A365 and Spectra exporters. See Section 5.4 for the approach. + +--- + +## 3. Requirements + +### Must-Have + +| ID | Requirement | +|----|------------| +| M1 | Consumer can pass `SpectraExporterOptions` to `configure()` to export traces via OTLP to a Spectra sidecar | +| M2 | Default endpoint is `http://localhost:4317` with gRPC protocol — zero-config for K8s sidecar | +| M3 | When `SpectraExporterOptions` is provided, `ENABLE_A365_OBSERVABILITY_EXPORTER` env var is ignored entirely | +| M4 | Span enrichment pipeline works identically regardless of which exporter is active | +| M5 | No new package dependencies — `opentelemetry-exporter-otlp` is already a core dep | +| M6 | `SpectraExporterOptions` is exported from `microsoft_agents_a365.observability.core` | + +### Nice-to-Have + +| ID | Requirement | +|----|------------| +| N1 | Protocol field (`"grpc"` or `"http"`) for consumers who need HTTP/protobuf instead of gRPC | +| N2 | `insecure` field for TLS configuration (defaults to `True` for localhost sidecar) | + +### Constraints + +| ID | Constraint | +|----|-----------| +| C1 | `Agent365ExporterOptions` class and its API must not change | +| C2 | Existing consumers using `Agent365ExporterOptions` must see zero behavioral change | +| C3 | `ENABLE_OTLP_EXPORTER` bolt-on path remains separate and unchanged | +| C4 | No shared base class between `Agent365ExporterOptions` and `SpectraExporterOptions` (decided in brainstorm) | + +--- + +## 4. Options Evaluation + +### Option A: Type-based dispatch with union parameter (Recommended) + +`configure()` accepts `exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None`. The `_configure_internal()` method uses `isinstance` to select the exporter. + +**Pros:** Explicit, type-safe, no env var ambiguity, consumer controls exporter in code +**Cons:** Union type is slightly more complex than a single type + +### Option B: Separate `spectra_exporter_options` parameter + +Add a new keyword arg `spectra_exporter_options: SpectraExporterOptions | None` alongside the existing `exporter_options`. + +**Pros:** No type change on existing parameter +**Cons:** Two params for the same concept, awkward if both are passed, more args on an already-long signature + +### Option C: Env-var-driven selection + +New `ENABLE_SPECTRA_EXPORTER` env var. Consumer sets env vars instead of passing options in code. + +**Pros:** Consistent with existing `ENABLE_A365_OBSERVABILITY_EXPORTER` pattern +**Cons:** Two env vars could conflict, harder to reason about, no type safety + +### Comparison Matrix + +| Criterion | Option A (Union) | Option B (Separate param) | Option C (Env var) | +|-----------|-----------------|--------------------------|-------------------| +| Type safety | High | Medium (mutual exclusion not enforced at type level) | Low | +| Backward compat | Full | Full | Full | +| Consumer clarity | High — one param, one choice | Medium — which param do I use? | Low — env var precedence unclear | +| Implementation complexity | Low | Low | Medium (precedence logic) | + +**Decision: Option A** — confirmed in brainstorm with stakeholder input. + +--- + +## 5. Recommended Approach + +### 5.1 New File: `spectra_exporter_options.py` + +**Location:** `libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py` + +**Estimated size:** ~40 lines + +```python +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Literal + + +class SpectraExporterOptions: + """ + Configuration for exporting traces to a Spectra Collector sidecar via OTLP. + + Spectra Collector is deployed as a Kubernetes sidecar that accepts + standard OTLP telemetry on localhost. Defaults are tuned for this + deployment topology — most consumers should not need to override them. + + Note: Batch processor fields (max_queue_size, scheduled_delay_ms, etc.) + are duplicated from Agent365ExporterOptions intentionally — these two + options classes have no shared base class per design decision C4. + """ + + def __init__( + self, + endpoint: str = "http://localhost:4317", + protocol: Literal["grpc", "http"] = "grpc", + insecure: bool = True, + max_queue_size: int = 2048, + scheduled_delay_ms: int = 5000, + exporter_timeout_ms: int = 30000, + max_export_batch_size: int = 512, + ): + """ + Args: + endpoint: Spectra sidecar OTLP endpoint. Default: http://localhost:4317. + protocol: OTLP protocol — "grpc" or "http". Default: grpc. + insecure: Use insecure (no TLS) connection. Default: True (localhost sidecar). + max_queue_size: Batch processor queue size. Default: 2048. + scheduled_delay_ms: Export interval in milliseconds. Default: 5000. + exporter_timeout_ms: Export timeout in milliseconds. Default: 30000. + max_export_batch_size: Max spans per export batch. Default: 512. + """ + if protocol not in ("grpc", "http"): + raise ValueError( + f"protocol must be 'grpc' or 'http', got '{protocol}'" + ) + self.endpoint = endpoint + self.protocol = protocol + self.insecure = insecure + self.max_queue_size = max_queue_size + self.scheduled_delay_ms = scheduled_delay_ms + self.exporter_timeout_ms = exporter_timeout_ms + self.max_export_batch_size = max_export_batch_size +``` + +### 5.2 Changes to `config.py` + +#### Import additions (module level in `config.py`) + +```python +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as GrpcOTLPSpanExporter, +) +from .exporters.spectra_exporter_options import SpectraExporterOptions +``` + +The gRPC exporter is imported at module level (matching the existing HTTP `OTLPSpanExporter` import at `config.py:11`). The `opentelemetry-exporter-otlp` core dependency pulls in both gRPC and HTTP sub-packages transitively, so the import is safe. This also provides a clean mock target for tests: `@patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter")`. + +#### Signature change — all three functions + +The `exporter_options` parameter type changes on **all three** function signatures that must stay in sync: + +1. Public `configure()` at `config.py:245` +2. `TelemetryManager.configure()` at `config.py:54` +3. `TelemetryManager._configure_internal()` at `config.py:96` + +From: +```python +exporter_options: Optional[Agent365ExporterOptions] = None +``` +To: +```python +exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None +``` + +#### Exporter selection in `_configure_internal()` (replaces lines 144-171) + +The existing early-resolve pattern is preserved — `None` resolves to `Agent365ExporterOptions` first, then `batch_processor_kwargs` are extracted once, then `isinstance` dispatch: + +```python +# Resolve None to default Agent365ExporterOptions (legacy fallback) +if exporter_options is None: + exporter_options = Agent365ExporterOptions( + cluster_category=cluster_category, + token_resolver=token_resolver, + ) + +# Extract batch processor kwargs — works for both options types +# (both have identical field names: max_queue_size, scheduled_delay_ms, etc.) +batch_processor_kwargs = { + "max_queue_size": exporter_options.max_queue_size, + "schedule_delay_millis": exporter_options.scheduled_delay_ms, + "export_timeout_millis": exporter_options.exporter_timeout_ms, + "max_export_batch_size": exporter_options.max_export_batch_size, +} + +# Type-based exporter dispatch +if isinstance(exporter_options, SpectraExporterOptions): + # Spectra path — OTLP exporter to sidecar + # ENABLE_A365_OBSERVABILITY_EXPORTER is intentionally ignored. + # suppress_invoke_agent_input is handled by _EnrichingBatchSpanProcessor + # (see Section 5.3), so it works with both A365 and Spectra exporters. + if exporter_options.protocol == "grpc": + exporter = GrpcOTLPSpanExporter( + endpoint=exporter_options.endpoint, + insecure=exporter_options.insecure, + ) + else: + exporter = OTLPSpanExporter( + endpoint=exporter_options.endpoint, + ) + +else: + # A365 path (existing logic, unchanged) + if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: + exporter = _Agent365Exporter( + token_resolver=exporter_options.token_resolver, + cluster_category=exporter_options.cluster_category, + use_s2s_endpoint=exporter_options.use_s2s_endpoint, + ) + else: + exporter = ConsoleSpanExporter() +``` + +**Design notes:** +- `None` resolves to `Agent365ExporterOptions` early, preserving the existing single-path pattern and eliminating code duplication. +- `batch_processor_kwargs` are extracted once — both options classes share identical field names and defaults. +- `suppress_invoke_agent_input` is moved from `_Agent365Exporter._map_span()` to `_EnrichingBatchSpanProcessor.on_end()` so it works with any exporter (see Section 5.3). The flag is passed to the batch processor, not the exporter. + +### 5.3 Moving `suppress_invoke_agent_input` to the enrichment layer + +Currently, input message suppression lives inside `_Agent365Exporter._map_span()` (`agent365_exporter.py:274-284`). It checks if a span is an InvokeAgent span and removes `gen_ai.input.messages` from the serialized attributes. This only works for the A365 exporter because the `OTLPSpanExporter` never calls `_map_span`. + +To make suppression work with any exporter, we move it into `_EnrichingBatchSpanProcessor.on_end()`, which runs before all exporters. + +#### Step 1: Extend `EnrichedReadableSpan` to support attribute exclusion + +`EnrichedReadableSpan` (`enriched_span.py`) currently only supports *adding* attributes. Add an `excluded_attribute_keys` parameter so attributes can also be *removed*: + +```python +class EnrichedReadableSpan(ReadableSpan): + def __init__( + self, + span: ReadableSpan, + extra_attributes: dict, + excluded_attribute_keys: set[str] | None = None, + ): + self._span = span + self._extra_attributes = extra_attributes + self._excluded_attribute_keys = excluded_attribute_keys or set() + + @property + def attributes(self) -> types.Attributes: + original = dict(self._span.attributes or {}) + original.update(self._extra_attributes) + for key in self._excluded_attribute_keys: + original.pop(key, None) + return original +``` + +The new parameter is optional and defaults to empty — existing callers are unaffected. + +#### Step 2: Pass `suppress_invoke_agent_input` to `_EnrichingBatchSpanProcessor` + +```python +class _EnrichingBatchSpanProcessor(BatchSpanProcessor): + def __init__( + self, + *args: object, + suppress_invoke_agent_input: bool = False, + **kwargs: object, + ): + super().__init__(*args, **kwargs) + self._suppress_invoke_agent_input = suppress_invoke_agent_input + + def on_end(self, span: ReadableSpan) -> None: + enriched_span = span + + # Apply registered enricher (framework extensions) + enricher = get_span_enricher() + if enricher is not None: + try: + enriched_span = enricher(span) + except Exception: + logger.exception(...) + + # Apply input message suppression for InvokeAgent spans + if self._suppress_invoke_agent_input: + attrs = enriched_span.attributes or {} + operation_name = attrs.get(GEN_AI_OPERATION_NAME_KEY) + if ( + enriched_span.name.startswith(INVOKE_AGENT_OPERATION_NAME) + and operation_name == INVOKE_AGENT_OPERATION_NAME + ): + enriched_span = EnrichedReadableSpan( + enriched_span, + extra_attributes={}, + excluded_attribute_keys={GEN_AI_INPUT_MESSAGES_KEY}, + ) + + super().on_end(enriched_span) +``` + +#### Step 3: Wire it up in `config.py` + +In `_configure_internal()`, pass `suppress_invoke_agent_input` to the batch processor: + +```python +batch_processor = _EnrichingBatchSpanProcessor( + exporter, + suppress_invoke_agent_input=suppress_invoke_agent_input, + **batch_processor_kwargs, +) +``` + +This works for both the A365 and Spectra exporter paths. + +#### Step 4: Remove suppression from `_Agent365Exporter._map_span()` + +Remove lines 274-284 from `agent365_exporter.py`. The suppression is now handled by the batch processor before the exporter sees the span. Also remove the `suppress_invoke_agent_input` parameter from `_Agent365Exporter.__init__()`. + +### 5.4 Export surface changes + +#### `exporters/__init__.py` + +Add `SpectraExporterOptions` to exports: +```python +from .agent365_exporter_options import Agent365ExporterOptions +from .spectra_exporter_options import SpectraExporterOptions + +__all__ = ["Agent365ExporterOptions", "SpectraExporterOptions"] +``` + +#### `core/__init__.py` + +Add both options classes to the public API for import symmetry. Currently `Agent365ExporterOptions` is only exported from `exporters/__init__.py` — consumers must import from the deeper path. Adding it here alongside `SpectraExporterOptions` ensures consistent import ergonomics: + +```python +from .exporters.agent365_exporter_options import Agent365ExporterOptions +from .exporters.spectra_exporter_options import SpectraExporterOptions +# ... in __all__: +"Agent365ExporterOptions", +"SpectraExporterOptions", +``` + +--- + +## 6. Compliance Checklist + +| Check | Status | +|-------|--------| +| Copyright header on new files | Required | +| No `typing.Any` usage | Will follow — `SpectraExporterOptions` uses concrete types | +| No `_async` suffix on async methods | N/A — no async methods | +| Type hints on all parameters and return types | Yes | +| Explicit `None` checks (`is not None`) | Yes | +| Line length ≤ 100 characters | Yes | +| `Agent365ExporterOptions` API unchanged | Yes | +| Existing test suite passes without modification | Yes | +| No new package dependencies | Yes — `opentelemetry-exporter-otlp` already in core deps | + +--- + +## 7. Test Strategy + +### New test file: `tests/observability/core/test_spectra_exporter.py` + +Tests follow the existing pattern in `test_agent365.py` (unittest.TestCase, Mock, patch): + +**Core functionality:** + +| Test | What it verifies | +|------|-----------------| +| `test_configure_with_spectra_options_default` | `configure()` succeeds with `SpectraExporterOptions()` (all defaults) via public API | +| `test_configure_with_spectra_options_creates_grpc_exporter` | gRPC `OTLPSpanExporter` created with correct endpoint and `insecure=True` | +| `test_configure_with_spectra_options_creates_http_exporter` | HTTP `OTLPSpanExporter` created when `protocol="http"` | +| `test_configure_with_spectra_options_custom_endpoint` | Custom endpoint is passed through to exporter | +| `test_configure_with_spectra_options_ignores_a365_env_var` | `ENABLE_A365_OBSERVABILITY_EXPORTER=true` does not create `_Agent365Exporter`; `is_agent365_exporter_enabled` is not called | +| `test_configure_with_spectra_options_batch_settings` | Batch processor kwargs extracted from `SpectraExporterOptions` | +| `test_configure_with_agent365_options_unchanged` | Existing A365 path still works identically (regression) | +| `test_spectra_exporter_options_defaults` | All default values are correct (`endpoint`, `protocol`, `insecure=True`, batch settings) | + +**Edge cases and interactions:** + +| Test | What it verifies | +|------|-----------------| +| `test_spectra_options_invalid_protocol_raises` | `SpectraExporterOptions(protocol="websocket")` raises `ValueError` | +| `test_configure_spectra_with_otlp_bolt_on` | With `SpectraExporterOptions` + `ENABLE_OTLP_EXPORTER=true`, two exporters are created (documented behavior) | +| `test_configure_spectra_with_suppress_invoke_agent_input` | `suppress_invoke_agent_input=True` with Spectra options creates batch processor with suppression enabled | +| `test_suppress_invoke_agent_input_strips_attribute_in_enriching_processor` | `_EnrichingBatchSpanProcessor` strips `gen_ai.input.messages` from InvokeAgent spans when flag is set | +| `test_enriched_span_excluded_attribute_keys` | `EnrichedReadableSpan` with `excluded_attribute_keys` removes specified attributes | + +### Mocking strategy + +- Mock `microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter` for gRPC tests (module-level import) +- Mock `microsoft_agents_a365.observability.core.config.OTLPSpanExporter` for HTTP tests (module-level import) +- Mock `_EnrichingBatchSpanProcessor` to verify batch kwargs +- Reset `_telemetry_manager` singleton in setUp/tearDown (existing pattern from `test_agent365.py:22-27`) + +--- + +## 8. Consumer Usage + +### Spectra deployment (Weave/Copilot Cowork) — zero config + +```python +from microsoft_agents_a365.observability.core import configure, SpectraExporterOptions + +configure( + service_name="weave-agent", + service_namespace="copilot-cowork", + exporter_options=SpectraExporterOptions(), +) +``` + +### Spectra with custom settings + +```python +configure( + service_name="weave-agent", + service_namespace="copilot-cowork", + exporter_options=SpectraExporterOptions( + endpoint="http://spectra-sidecar:4317", + protocol="http", + max_export_batch_size=1024, + ), +) +``` + +### A365 deployment (unchanged) + +```python +from microsoft_agents_a365.observability.core import configure, Agent365ExporterOptions + +configure( + service_name="my-agent", + service_namespace="my-namespace", + exporter_options=Agent365ExporterOptions( + token_resolver=my_token_resolver, + cluster_category="prod", + ), +) +``` + +--- + +## 9. Risk Assessment + +| Risk | Severity | Likelihood | Mitigation | +|------|----------|------------|------------| +| Spectra sidecar not running → traces silently dropped | Medium | Low (deployment issue) | OTLP exporter logs connection errors with retry. Document sidecar as deployment prereq. | +| Consumer accidentally passes both A365 env var + Spectra options | Low | Medium | Type-based dispatch means Spectra path is taken regardless. No ambiguity. | +| Consumer sets `insecure=False` for remote Spectra endpoint but forgets TLS setup | Low | Low | Only relevant for non-sidecar deployments. Default `insecure=True` is correct for localhost. | +| gRPC import fails if grpc extras not installed | Low | Low | `opentelemetry-exporter-otlp` (core dep) pulls in both gRPC and HTTP sub-packages | +| Union type confuses consumers | Low | Low | Clear docstrings. Only two concrete types. | + +--- + +## 10. Interactions and Notes + +### `ENABLE_OTLP_EXPORTER` + +When `SpectraExporterOptions` is provided and `ENABLE_OTLP_EXPORTER=true` is also set, two OTLP exporters will be active simultaneously: the Spectra exporter and the generic OTLP bolt-on. This doubles memory queues and export I/O. This is documented and tested but is unlikely to be intentional in practice. Consumers should not set `ENABLE_OTLP_EXPORTER` when using `SpectraExporterOptions`. + +--- + +## 11. Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0 | 2026-03-14 | Initial design from brainstorm | +| 1.1 | 2026-03-14 | Review feedback: fixed `insecure` default to `True`, eliminated fallback duplication, added `protocol` validation, module-level gRPC import, export surface symmetry, added edge case tests | +| 1.2 | 2026-03-14 | Moved `suppress_invoke_agent_input` from `_Agent365Exporter._map_span()` to `_EnrichingBatchSpanProcessor.on_end()` so it works with both A365 and Spectra exporters. Extended `EnrichedReadableSpan` with `excluded_attribute_keys`. | From 93242e77eadabb2a9936f16dd6457bfe148c8da1 Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Sat, 14 Mar 2026 12:22:42 -0600 Subject: [PATCH 02/11] feat: Add SpectraExporterOptions class for Spectra Collector sidecar export Introduces SpectraExporterOptions with fields for endpoint, protocol (grpc/http), insecure flag, and batch settings. Defaults are tuned for K8s sidecar topology (localhost:4317, gRPC, insecure). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../exporters/spectra_exporter_options.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py new file mode 100644 index 00000000..96b1f0fc --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Literal + + +class SpectraExporterOptions: + """ + Configuration for exporting traces to a Spectra Collector sidecar via OTLP. + + Spectra Collector is deployed as a Kubernetes sidecar that accepts + standard OTLP telemetry on localhost. Defaults are tuned for this + deployment topology — most consumers should not need to override them. + + Note: Batch processor fields (max_queue_size, scheduled_delay_ms, etc.) + are duplicated from Agent365ExporterOptions intentionally — these two + options classes have no shared base class per design decision C4. + """ + + def __init__( + self, + endpoint: str = "http://localhost:4317", + protocol: Literal["grpc", "http"] = "grpc", + insecure: bool = True, + max_queue_size: int = 2048, + scheduled_delay_ms: int = 5000, + exporter_timeout_ms: int = 30000, + max_export_batch_size: int = 512, + ): + """ + Args: + endpoint: Spectra sidecar OTLP endpoint. Default: http://localhost:4317. + protocol: OTLP protocol — "grpc" or "http". Default: grpc. + insecure: Use insecure (no TLS) connection. Default: True (localhost sidecar). + max_queue_size: Batch processor queue size. Default: 2048. + scheduled_delay_ms: Export interval in milliseconds. Default: 5000. + exporter_timeout_ms: Export timeout in milliseconds. Default: 30000. + max_export_batch_size: Max spans per export batch. Default: 512. + """ + if protocol not in ("grpc", "http"): + raise ValueError(f"protocol must be 'grpc' or 'http', got '{protocol}'") + self.endpoint = endpoint + self.protocol = protocol + self.insecure = insecure + self.max_queue_size = max_queue_size + self.scheduled_delay_ms = scheduled_delay_ms + self.exporter_timeout_ms = exporter_timeout_ms + self.max_export_batch_size = max_export_batch_size From bed464cad5fbf7bfd2902784446c20cb08d474f0 Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Sat, 14 Mar 2026 12:23:09 -0600 Subject: [PATCH 03/11] feat: Add excluded_attribute_keys to EnrichedReadableSpan Extends EnrichedReadableSpan with an optional excluded_attribute_keys parameter that removes specified keys after merging extras. Existing callers are unaffected (defaults to empty set). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../observability/core/exporters/enriched_span.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py index b57bd4e7..a6b4e8c1 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py @@ -19,22 +19,31 @@ class EnrichedReadableSpan(ReadableSpan): the original span. """ - def __init__(self, span: ReadableSpan, extra_attributes: dict): + def __init__( + self, + span: ReadableSpan, + extra_attributes: dict, + excluded_attribute_keys: set[str] | None = None, + ): """ Initialize the enriched span wrapper. Args: span: The original ReadableSpan to wrap. extra_attributes: Additional attributes to merge with the original. + excluded_attribute_keys: Attribute keys to remove after merging. """ self._span = span self._extra_attributes = extra_attributes + self._excluded_attribute_keys = excluded_attribute_keys or set() @property def attributes(self) -> types.Attributes: """Return merged attributes from original span and extra attributes.""" original = dict(self._span.attributes or {}) original.update(self._extra_attributes) + for key in self._excluded_attribute_keys: + original.pop(key, None) return original @property From 225aac9ea44139ddc51b6a82c070c9f0ced4ceca Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Sat, 14 Mar 2026 12:24:05 -0600 Subject: [PATCH 04/11] refactor: Move suppress_invoke_agent_input from exporter to batch processor Moves the InvokeAgent input message suppression from _Agent365Exporter._map_span() to _EnrichingBatchSpanProcessor.on_end() so it works with any exporter (A365, Spectra, or OTLP bolt-on). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../observability/core/config.py | 7 +++-- .../core/exporters/agent365_exporter.py | 20 ------------- .../exporters/enriching_span_processor.py | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py index 68acc389..05bf78e1 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py @@ -161,7 +161,6 @@ def _configure_internal( token_resolver=exporter_options.token_resolver, cluster_category=exporter_options.cluster_category, use_s2s_endpoint=exporter_options.use_s2s_endpoint, - suppress_invoke_agent_input=suppress_invoke_agent_input, ) else: @@ -174,7 +173,11 @@ def _configure_internal( # Create _EnrichingBatchSpanProcessor with optimized settings # This allows extensions to enrich spans before export - batch_processor = _EnrichingBatchSpanProcessor(exporter, **batch_processor_kwargs) + batch_processor = _EnrichingBatchSpanProcessor( + exporter, + suppress_invoke_agent_input=suppress_invoke_agent_input, + **batch_processor_kwargs, + ) agent_processor = SpanProcessor() tracer_provider.add_span_processor(batch_processor) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py index 27b54e23..dd04e285 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py @@ -17,11 +17,6 @@ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult from opentelemetry.trace import StatusCode -from ..constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - INVOKE_AGENT_OPERATION_NAME, -) from .utils import ( build_export_url, get_validated_domain_override, @@ -59,7 +54,6 @@ def __init__( token_resolver: Callable[[str, str], str | None], cluster_category: str = "prod", use_s2s_endpoint: bool = False, - suppress_invoke_agent_input: bool = False, ): if token_resolver is None: raise ValueError("token_resolver must be provided.") @@ -69,7 +63,6 @@ def __init__( self._token_resolver = token_resolver self._cluster_category = cluster_category self._use_s2s_endpoint = use_s2s_endpoint - self._suppress_invoke_agent_input = suppress_invoke_agent_input # Read domain override once at initialization self._domain_override = get_validated_domain_override() @@ -270,19 +263,6 @@ def _map_span(self, sp: ReadableSpan) -> dict[str, Any]: # attributes attrs = dict(sp.attributes or {}) - # Suppress input messages if configured and current span is an InvokeAgent span - if self._suppress_invoke_agent_input: - # Check if current span is an InvokeAgent span by: - # 1. Span name starts with "invoke_agent" - # 2. Has attribute gen_ai.operation.name set to INVOKE_AGENT_OPERATION_NAME - operation_name = attrs.get(GEN_AI_OPERATION_NAME_KEY) - if ( - sp.name.startswith(INVOKE_AGENT_OPERATION_NAME) - and operation_name == INVOKE_AGENT_OPERATION_NAME - ): - # Remove input messages attribute - attrs.pop(GEN_AI_INPUT_MESSAGES_KEY, None) - # events events = [] for ev in sp.events: diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py index 03c54775..b324b0d8 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py @@ -10,6 +10,13 @@ from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace.export import BatchSpanProcessor +from ..constants import ( + GEN_AI_INPUT_MESSAGES_KEY, + GEN_AI_OPERATION_NAME_KEY, + INVOKE_AGENT_OPERATION_NAME, +) +from .enriched_span import EnrichedReadableSpan + logger = logging.getLogger(__name__) # Single span enricher - only one platform instrumentor should be active at a time @@ -65,6 +72,15 @@ def get_span_enricher() -> Callable[[ReadableSpan], ReadableSpan] | None: class _EnrichingBatchSpanProcessor(BatchSpanProcessor): """BatchSpanProcessor that applies the registered enricher before batching.""" + def __init__( + self, + *args: object, + suppress_invoke_agent_input: bool = False, + **kwargs: object, + ): + super().__init__(*args, **kwargs) + self._suppress_invoke_agent_input = suppress_invoke_agent_input + def on_end(self, span: ReadableSpan) -> None: """Apply the span enricher and pass to parent for batching. @@ -83,4 +99,18 @@ def on_end(self, span: ReadableSpan) -> None: enricher.__name__, ) + # Apply input message suppression for InvokeAgent spans + if self._suppress_invoke_agent_input: + attrs = enriched_span.attributes or {} + operation_name = attrs.get(GEN_AI_OPERATION_NAME_KEY) + if ( + enriched_span.name.startswith(INVOKE_AGENT_OPERATION_NAME) + and operation_name == INVOKE_AGENT_OPERATION_NAME + ): + enriched_span = EnrichedReadableSpan( + enriched_span, + extra_attributes={}, + excluded_attribute_keys={GEN_AI_INPUT_MESSAGES_KEY}, + ) + super().on_end(enriched_span) From ce34a506c8c11331ab286007ef57a7e436486bfa Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Sat, 14 Mar 2026 12:25:28 -0600 Subject: [PATCH 05/11] feat: Add Spectra exporter dispatch to configure() Adds isinstance-based dispatch in _configure_internal() to create GrpcOTLPSpanExporter or OTLPSpanExporter when SpectraExporterOptions is provided. Updates exporter_options type to union on all three configure() signatures. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../observability/core/config.py | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py index 05bf78e1..28d78e77 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py @@ -8,6 +8,9 @@ from typing import Any, Optional from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as GrpcOTLPSpanExporter, +) from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_NAMESPACE, Resource from opentelemetry.sdk.trace import TracerProvider @@ -18,6 +21,7 @@ from .exporters.enriching_span_processor import ( _EnrichingBatchSpanProcessor, ) +from .exporters.spectra_exporter_options import SpectraExporterOptions from .exporters.utils import is_agent365_exporter_enabled from .trace_processor.span_processor import SpanProcessor @@ -58,7 +62,7 @@ def configure( logger_name: str = DEFAULT_LOGGER_NAME, token_resolver: Callable[[str, str], str | None] | None = None, cluster_category: str = "prod", - exporter_options: Optional[Agent365ExporterOptions] = None, + exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, suppress_invoke_agent_input: bool = False, **kwargs: Any, ) -> bool: @@ -72,9 +76,10 @@ def configure( Use exporter_options instead. :param cluster_category: (Deprecated) Environment / cluster category (e.g. "prod"). Use exporter_options instead. - :param exporter_options: Agent365ExporterOptions instance for configuring the exporter. - If provided, exporter_options takes precedence. If exporter_options is None, the token_resolver and cluster_category parameters are used as fallback/legacy support to construct a default Agent365ExporterOptions instance. - :param suppress_invoke_agent_input: If True, suppress input messages for spans that are children of InvokeAgent spans. + :param exporter_options: Exporter configuration. Pass Agent365ExporterOptions for A365 API + export, SpectraExporterOptions for Spectra Collector sidecar export, or None (default) + to construct Agent365ExporterOptions from legacy parameters. + :param suppress_invoke_agent_input: If True, suppress input messages for InvokeAgent spans. :return: True if configuration succeeded, False otherwise. """ try: @@ -100,7 +105,7 @@ def _configure_internal( logger_name: str, token_resolver: Callable[[str, str], str | None] | None = None, cluster_category: str = "prod", - exporter_options: Optional[Agent365ExporterOptions] = None, + exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, suppress_invoke_agent_input: bool = False, **kwargs: Any, ) -> bool: @@ -156,7 +161,21 @@ def _configure_internal( "max_export_batch_size": exporter_options.max_export_batch_size, } - if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: + # Type-based exporter dispatch + if isinstance(exporter_options, SpectraExporterOptions): + # Spectra path — OTLP exporter to sidecar + # ENABLE_A365_OBSERVABILITY_EXPORTER is intentionally ignored. + if exporter_options.protocol == "grpc": + exporter = GrpcOTLPSpanExporter( + endpoint=exporter_options.endpoint, + insecure=exporter_options.insecure, + ) + else: + exporter = OTLPSpanExporter( + endpoint=exporter_options.endpoint, + ) + + elif is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: exporter = _Agent365Exporter( token_resolver=exporter_options.token_resolver, cluster_category=exporter_options.cluster_category, @@ -166,7 +185,8 @@ def _configure_internal( else: exporter = ConsoleSpanExporter() self._logger.warning( - "is_agent365_exporter_enabled() not enabled or token_resolver not set. Falling back to console exporter." + "is_agent365_exporter_enabled() not enabled or token_resolver not set." + " Falling back to console exporter." ) # Add span processors @@ -251,7 +271,7 @@ def configure( logger_name: str = DEFAULT_LOGGER_NAME, token_resolver: Callable[[str, str], str | None] | None = None, cluster_category: str = "prod", - exporter_options: Optional[Agent365ExporterOptions] = None, + exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, **kwargs: Any, ) -> bool: """ @@ -264,8 +284,9 @@ def configure( Use exporter_options instead. :param cluster_category: (Deprecated) Environment / cluster category (e.g. "prod"). Use exporter_options instead. - :param exporter_options: Agent365ExporterOptions instance for configuring the exporter. - If provided, exporter_options takes precedence. If exporter_options is None, the token_resolver and cluster_category parameters are used as fallback/legacy support to construct a default Agent365ExporterOptions instance. + :param exporter_options: Exporter configuration. Pass Agent365ExporterOptions for A365 API + export, SpectraExporterOptions for Spectra Collector sidecar export, or None (default) + to construct Agent365ExporterOptions from legacy parameters. :return: True if configuration succeeded, False otherwise. """ return _telemetry_manager.configure( From e1ed62652638a596cddb7020789e430324785f96 Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Sat, 14 Mar 2026 12:26:03 -0600 Subject: [PATCH 06/11] feat: Export SpectraExporterOptions and Agent365ExporterOptions from public API Adds both exporter options classes to the core package's public API for consistent import ergonomics. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../microsoft_agents_a365/observability/core/__init__.py | 5 +++++ .../observability/core/exporters/__init__.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py index 0ea6776a..a3915b6b 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py @@ -12,12 +12,14 @@ ) from .execute_tool_scope import ExecuteToolScope from .execution_type import ExecutionType +from .exporters.agent365_exporter_options import Agent365ExporterOptions from .exporters.enriched_span import EnrichedReadableSpan from .exporters.enriching_span_processor import ( get_span_enricher, register_span_enricher, unregister_span_enricher, ) +from .exporters.spectra_exporter_options import SpectraExporterOptions from .inference_call_details import InferenceCallDetails, ServiceEndpoint from .inference_operation_type import InferenceOperationType from .inference_scope import InferenceScope @@ -38,6 +40,9 @@ "is_configured", "get_tracer", "get_tracer_provider", + # Exporter options + "Agent365ExporterOptions", + "SpectraExporterOptions", # Span enrichment "register_span_enricher", "unregister_span_enricher", diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__.py index b0e584f5..9b0ab065 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__.py @@ -2,7 +2,8 @@ # Licensed under the MIT License. from .agent365_exporter_options import Agent365ExporterOptions +from .spectra_exporter_options import SpectraExporterOptions # Agent365Exporter is not exported intentionally. # It should only be used internally by the observability core module. -__all__ = ["Agent365ExporterOptions"] +__all__ = ["Agent365ExporterOptions", "SpectraExporterOptions"] From 1e7aa8a81c54666d100b1437ef65aac8a453d593 Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Sat, 14 Mar 2026 12:28:53 -0600 Subject: [PATCH 07/11] test: Add Spectra exporter tests and update A365 regression test Adds test_spectra_exporter.py with 13 tests covering: - SpectraExporterOptions defaults and validation - configure() with Spectra options (gRPC, HTTP, custom endpoint) - A365 env var ignored when Spectra options provided - Batch settings extraction, OTLP bolt-on interaction - suppress_invoke_agent_input in batch processor - EnrichedReadableSpan excluded_attribute_keys Updates test_agent365.py to reflect suppress_invoke_agent_input being moved from _Agent365Exporter to _EnrichingBatchSpanProcessor. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/observability/core/test_agent365.py | 2 +- .../core/test_spectra_exporter.py | 283 ++++++++++++++++++ 2 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 tests/observability/core/test_spectra_exporter.py diff --git a/tests/observability/core/test_agent365.py b/tests/observability/core/test_agent365.py index f8c4869c..ac8adaed 100644 --- a/tests/observability/core/test_agent365.py +++ b/tests/observability/core/test_agent365.py @@ -115,11 +115,11 @@ def test_batch_span_processor_and_exporter_called_with_correct_values( self.assertTrue(result, "configure() should return True") # Verify Agent365Exporter was called with correct parameters + # (suppress_invoke_agent_input is now handled by _EnrichingBatchSpanProcessor) mock_exporter.assert_called_once_with( token_resolver=self.mock_token_resolver, cluster_category="staging", use_s2s_endpoint=True, - suppress_invoke_agent_input=False, ) # Verify BatchSpanProcessor was called with correct parameters from exporter_options diff --git a/tests/observability/core/test_spectra_exporter.py b/tests/observability/core/test_spectra_exporter.py new file mode 100644 index 00000000..4f77dbd6 --- /dev/null +++ b/tests/observability/core/test_spectra_exporter.py @@ -0,0 +1,283 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import unittest +from unittest.mock import Mock, patch + +from microsoft_agents_a365.observability.core import configure +from microsoft_agents_a365.observability.core.constants import ( + GEN_AI_INPUT_MESSAGES_KEY, + GEN_AI_OPERATION_NAME_KEY, + INVOKE_AGENT_OPERATION_NAME, +) +from microsoft_agents_a365.observability.core.exporters.agent365_exporter_options import ( + Agent365ExporterOptions, +) +from microsoft_agents_a365.observability.core.exporters.enriched_span import ( + EnrichedReadableSpan, +) +from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( + _EnrichingBatchSpanProcessor, +) +from microsoft_agents_a365.observability.core.exporters.spectra_exporter_options import ( + SpectraExporterOptions, +) +from opentelemetry.sdk.trace import ReadableSpan + + +class TestSpectraExporterOptions(unittest.TestCase): + """Tests for SpectraExporterOptions class.""" + + def test_spectra_exporter_options_defaults(self): + """All default values are correct.""" + opts = SpectraExporterOptions() + self.assertEqual(opts.endpoint, "http://localhost:4317") + self.assertEqual(opts.protocol, "grpc") + self.assertTrue(opts.insecure) + self.assertEqual(opts.max_queue_size, 2048) + self.assertEqual(opts.scheduled_delay_ms, 5000) + self.assertEqual(opts.exporter_timeout_ms, 30000) + self.assertEqual(opts.max_export_batch_size, 512) + + def test_spectra_options_invalid_protocol_raises(self): + """ValueError for invalid protocol.""" + with self.assertRaises(ValueError) as ctx: + SpectraExporterOptions(protocol="websocket") + self.assertIn("websocket", str(ctx.exception)) + + +class TestConfigureWithSpectraOptions(unittest.TestCase): + """Tests for configure() with SpectraExporterOptions.""" + + def setUp(self): + from microsoft_agents_a365.observability.core.config import _telemetry_manager + from microsoft_agents_a365.observability.core.opentelemetry_scope import ( + OpenTelemetryScope, + ) + + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + OpenTelemetryScope._tracer = None + + def tearDown(self): + from microsoft_agents_a365.observability.core.config import _telemetry_manager + from microsoft_agents_a365.observability.core.opentelemetry_scope import ( + OpenTelemetryScope, + ) + + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + OpenTelemetryScope._tracer = None + + def test_configure_with_spectra_options_default(self): + """configure() succeeds with SpectraExporterOptions() defaults.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(), + ) + self.assertTrue(result) + + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + def test_configure_with_spectra_options_creates_grpc_exporter(self, mock_grpc): + """gRPC OTLPSpanExporter created with correct endpoint and insecure args.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(), + ) + self.assertTrue(result) + mock_grpc.assert_called_once_with( + endpoint="http://localhost:4317", + insecure=True, + ) + + @patch("microsoft_agents_a365.observability.core.config.OTLPSpanExporter") + def test_configure_with_spectra_options_creates_http_exporter(self, mock_http): + """HTTP OTLPSpanExporter created when protocol='http'.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(protocol="http"), + ) + self.assertTrue(result) + mock_http.assert_called_once_with( + endpoint="http://localhost:4317", + ) + + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + def test_configure_with_spectra_options_custom_endpoint(self, mock_grpc): + """Custom endpoint passed through to exporter.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(endpoint="http://spectra-sidecar:4317"), + ) + self.assertTrue(result) + mock_grpc.assert_called_once_with( + endpoint="http://spectra-sidecar:4317", + insecure=True, + ) + + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") + @patch.dict("os.environ", {"ENABLE_A365_OBSERVABILITY_EXPORTER": "true"}) + def test_configure_with_spectra_options_ignores_a365_env_var(self, mock_is_enabled, mock_grpc): + """ENABLE_A365_OBSERVABILITY_EXPORTER=true doesn't create _Agent365Exporter.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(), + ) + self.assertTrue(result) + mock_grpc.assert_called_once() + # is_agent365_exporter_enabled should not be called when Spectra path is taken + mock_is_enabled.assert_not_called() + + @patch("microsoft_agents_a365.observability.core.config._EnrichingBatchSpanProcessor") + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + def test_configure_with_spectra_options_batch_settings(self, mock_grpc, mock_batch): + """Batch processor kwargs extracted from SpectraExporterOptions.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions( + max_queue_size=1024, + scheduled_delay_ms=2000, + exporter_timeout_ms=15000, + max_export_batch_size=256, + ), + ) + self.assertTrue(result) + mock_batch.assert_called_once() + call_kwargs = mock_batch.call_args.kwargs + self.assertEqual(call_kwargs["max_queue_size"], 1024) + self.assertEqual(call_kwargs["schedule_delay_millis"], 2000) + self.assertEqual(call_kwargs["export_timeout_millis"], 15000) + self.assertEqual(call_kwargs["max_export_batch_size"], 256) + + @patch("microsoft_agents_a365.observability.core.config._Agent365Exporter") + @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") + def test_configure_with_agent365_options_unchanged(self, mock_is_enabled, mock_exporter): + """A365 regression test — existing path still works identically.""" + mock_is_enabled.return_value = True + mock_token_resolver = Mock() + mock_token_resolver.return_value = "token" + + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=Agent365ExporterOptions( + cluster_category="staging", + token_resolver=mock_token_resolver, + use_s2s_endpoint=True, + ), + ) + self.assertTrue(result) + mock_exporter.assert_called_once_with( + token_resolver=mock_token_resolver, + cluster_category="staging", + use_s2s_endpoint=True, + ) + + @patch("microsoft_agents_a365.observability.core.config.OTLPSpanExporter") + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + @patch.dict("os.environ", {"ENABLE_OTLP_EXPORTER": "true"}) + def test_configure_spectra_with_otlp_bolt_on(self, mock_grpc, mock_http_otlp): + """Spectra + ENABLE_OTLP_EXPORTER=true creates two exporters.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(), + ) + self.assertTrue(result) + # gRPC for Spectra + mock_grpc.assert_called_once() + # HTTP OTLP for bolt-on + mock_http_otlp.assert_called_once() + + @patch("microsoft_agents_a365.observability.core.config._EnrichingBatchSpanProcessor") + @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") + def test_configure_spectra_with_suppress_invoke_agent_input(self, mock_grpc, mock_batch): + """suppress_invoke_agent_input=True passed to batch processor.""" + result = configure( + service_name="test-service", + service_namespace="test-namespace", + exporter_options=SpectraExporterOptions(), + suppress_invoke_agent_input=True, + ) + self.assertTrue(result) + mock_batch.assert_called_once() + call_kwargs = mock_batch.call_args.kwargs + self.assertTrue(call_kwargs["suppress_invoke_agent_input"]) + + +class TestEnrichedSpanExcludedAttributes(unittest.TestCase): + """Tests for EnrichedReadableSpan excluded_attribute_keys.""" + + def test_enriched_span_excluded_attribute_keys(self): + """EnrichedReadableSpan with exclusions removes specified attributes.""" + mock_span = Mock(spec=ReadableSpan) + mock_span.attributes = { + "key1": "value1", + "key2": "value2", + "key3": "value3", + } + + enriched = EnrichedReadableSpan( + mock_span, + extra_attributes={"key4": "value4"}, + excluded_attribute_keys={"key2"}, + ) + + attrs = enriched.attributes + self.assertEqual(attrs["key1"], "value1") + self.assertNotIn("key2", attrs) + self.assertEqual(attrs["key3"], "value3") + self.assertEqual(attrs["key4"], "value4") + + +class TestSuppressInvokeAgentInputInProcessor(unittest.TestCase): + """Tests for suppress_invoke_agent_input in _EnrichingBatchSpanProcessor.""" + + def test_suppress_invoke_agent_input_strips_attribute_in_enriching_processor(self): + """Processor strips gen_ai.input.messages from InvokeAgent spans.""" + mock_exporter = Mock() + + processor = _EnrichingBatchSpanProcessor( + mock_exporter, + suppress_invoke_agent_input=True, + ) + + mock_span = Mock(spec=ReadableSpan) + mock_span.name = "invoke_agent test-agent" + mock_span.attributes = { + GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME, + GEN_AI_INPUT_MESSAGES_KEY: '[{"role": "user", "content": "hello"}]', + "other_key": "other_value", + } + + with patch.object(_EnrichingBatchSpanProcessor, "on_end", wraps=processor.on_end): + # Call on_end directly — the parent's on_end will queue the span + # We patch super().on_end to capture what gets queued + with patch( + "microsoft_agents_a365.observability.core.exporters" + ".enriching_span_processor.BatchSpanProcessor.on_end" + ) as mock_super_on_end: + processor.on_end(mock_span) + + # Verify super().on_end was called with an EnrichedReadableSpan + mock_super_on_end.assert_called_once() + enriched_span = mock_super_on_end.call_args[0][0] + self.assertIsInstance(enriched_span, EnrichedReadableSpan) + + # Verify input messages were stripped + attrs = enriched_span.attributes + self.assertNotIn(GEN_AI_INPUT_MESSAGES_KEY, attrs) + self.assertEqual(attrs["other_key"], "other_value") + + processor.shutdown() + + +if __name__ == "__main__": + unittest.main() From 900af51b660a499d91427f340b9a2a06fd21999b Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Sat, 14 Mar 2026 12:30:15 -0600 Subject: [PATCH 08/11] fix: Update prompt suppression tests for processor-based suppression Updates test_prompt_suppression.py to test suppress_invoke_agent_input on _EnrichingBatchSpanProcessor instead of _Agent365Exporter, reflecting the refactor that moved suppression to the processor layer. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../openai/test_prompt_suppression.py | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/tests/observability/extensions/openai/test_prompt_suppression.py b/tests/observability/extensions/openai/test_prompt_suppression.py index 2c8e0ffe..1d198885 100644 --- a/tests/observability/extensions/openai/test_prompt_suppression.py +++ b/tests/observability/extensions/openai/test_prompt_suppression.py @@ -2,37 +2,42 @@ # Licensed under the MIT License. import unittest +from unittest.mock import Mock -from microsoft_agents_a365.observability.core.exporters.agent365_exporter import _Agent365Exporter +from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( + _EnrichingBatchSpanProcessor, +) class TestPromptSuppressionConfiguration(unittest.TestCase): """Unit tests for prompt suppression configuration in the core SDK.""" - def test_exporter_default_suppression_is_false(self): - """Test that the default value for suppress_invoke_agent_input is False in exporter.""" - exporter = _Agent365Exporter(token_resolver=lambda x, y: "test") + def test_processor_default_suppression_is_false(self): + """Test that the default value for suppress_invoke_agent_input is False in processor.""" + mock_exporter = Mock() + processor = _EnrichingBatchSpanProcessor(mock_exporter) self.assertFalse( - exporter._suppress_invoke_agent_input, + processor._suppress_invoke_agent_input, "Default value for suppress_invoke_agent_input should be False", ) + processor.shutdown() - def test_exporter_can_enable_suppression(self): - """Test that suppression can be enabled via exporter constructor.""" - exporter = _Agent365Exporter( - token_resolver=lambda x, y: "test", suppress_invoke_agent_input=True - ) + def test_processor_can_enable_suppression(self): + """Test that suppression can be enabled via processor constructor.""" + mock_exporter = Mock() + processor = _EnrichingBatchSpanProcessor(mock_exporter, suppress_invoke_agent_input=True) self.assertTrue( - exporter._suppress_invoke_agent_input, + processor._suppress_invoke_agent_input, "suppress_invoke_agent_input should be True when explicitly set", ) + processor.shutdown() def run_tests(): """Run all prompt suppression configuration tests.""" - print("🧪 Running prompt suppression configuration tests...") + print("Running prompt suppression configuration tests...") print("=" * 80) loader = unittest.TestLoader() @@ -42,16 +47,16 @@ def run_tests(): result = runner.run(suite) print("\n" + "=" * 80) - print("🏁 Test Summary:") + print("Test Summary:") print(f"Tests run: {result.testsRun}") print(f"Failures: {len(result.failures)}") print(f"Errors: {len(result.errors)}") if result.wasSuccessful(): - print("🎉 All tests passed!") + print("All tests passed!") return True else: - print("🔧 Some tests failed. Check output above.") + print("Some tests failed. Check output above.") return False From 398b34557f0cea4bc2cfb389d05df6a1cd3ea2ab Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Sat, 14 Mar 2026 12:49:54 -0600 Subject: [PATCH 09/11] fix: Address PR review feedback from Copilot - Make default endpoint protocol-aware: 4317 for gRPC, 4318 for HTTP - Add suppress_invoke_agent_input as explicit param on public configure() - Fix insecure default from False to True in brainstorm docs (BRAINSTORM.md, TLDR.md, ARCHITECTURE.md) to match implementation - Add tests for HTTP default endpoint and explicit endpoint override Co-Authored-By: Claude Opus 4.6 (1M context) --- .../spectra-collector-integration/ARCHITECTURE.md | 8 ++++---- .../spectra-collector-integration/BRAINSTORM.md | 4 ++-- .../brainstorm/spectra-collector-integration/TLDR.md | 2 +- .../observability/core/config.py | 3 +++ .../core/exporters/spectra_exporter_options.py | 12 ++++++++++-- tests/observability/core/test_spectra_exporter.py | 12 +++++++++++- 6 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md b/docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md index 43a01604..34a87c2a 100644 --- a/docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md +++ b/docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md @@ -51,7 +51,7 @@ configure(exporter_options=SpectraExporterOptions(...)) │ │ SpectraExporterOpts │ │ Agent365ExporterOptions │ │ │ │ endpoint (4317) │ │ token_resolver │ │ │ │ protocol (gRPC) │ │ cluster_category │ │ -│ │ insecure (false) │ │ use_s2s_endpoint │ │ +│ │ insecure (true) │ │ use_s2s_endpoint │ │ │ │ batch settings │ │ batch settings │ │ │ └────────┬────────────┘ └────────┬────────────────┘ │ │ │ │ │ @@ -92,7 +92,7 @@ class SpectraExporterOptions: self, endpoint: str = "http://localhost:4317", protocol: str = "grpc", - insecure: bool = False, + insecure: bool = True, max_queue_size: int = 2048, scheduled_delay_ms: int = 5000, exporter_timeout_ms: int = 30000, @@ -113,7 +113,7 @@ class SpectraExporterOptions: |-------|------|---------|-------------| | `endpoint` | `str` | `http://localhost:4317` | Spectra sidecar OTLP endpoint | | `protocol` | `str` | `grpc` | OTLP protocol: `"grpc"` or `"http"` | -| `insecure` | `bool` | `False` | Whether to use insecure (no TLS) connection | +| `insecure` | `bool` | `True` | Whether to use insecure (no TLS) connection | | `max_queue_size` | `int` | `2048` | Batch processor queue size | | `scheduled_delay_ms` | `int` | `5000` | Export interval (ms) | | `exporter_timeout_ms` | `int` | `30000` | Export timeout (ms) | @@ -252,4 +252,4 @@ configure( | Sidecar not running → silent failure | Medium | OTLP exporter logs connection errors; document deployment prereqs | | Consumer passes both A365 env var + Spectra options | Low | Spectra options take precedence; env var ignored. Document. | | gRPC dependency not installed | Low | `opentelemetry-exporter-otlp` (core dep) includes both gRPC and HTTP | -| `insecure=False` default may fail on plain HTTP localhost | Low | Document: set `insecure=True` if sidecar doesn't have TLS | +| Consumer sets `insecure=False` for remote Spectra endpoint but forgets TLS setup | Low | Only relevant for non-sidecar deployments. Default `insecure=True` is correct for localhost. | diff --git a/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md b/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md index 1768b393..95ab8462 100644 --- a/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md +++ b/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md @@ -110,12 +110,12 @@ The `TelemetryManager.configure()` method (`config.py`) creates the exporter pip |------|----------|------------| | Sidecar not running → silent failure | Medium | OTLP exporter logs connection errors; document deployment prereqs | | Consumer passes both A365 env var + Spectra options | Low | Spectra options take precedence; env var ignored | -| `insecure=False` default may fail on plain HTTP localhost | Low | Document: set `insecure=True` if sidecar doesn't have TLS | +| Consumer sets `insecure=False` for remote Spectra endpoint but forgets TLS setup | Low | Only relevant for non-sidecar deployments. Default `insecure=True` is correct for localhost. | ### Open Questions (Resolved) 1. **Union type vs separate param** → Union type on `exporter_options` 2. **A365 env var behavior** → Ignored entirely when SpectraExporterOptions provided -3. **Insecure config** → Exposed, defaults to `False` +3. **Insecure config** → Exposed, defaults to `True` (localhost sidecar) 4. **Protocol config** → Exposed (`"grpc"` or `"http"`), defaults to `"grpc"` 5. **Package exports** → `SpectraExporterOptions` added to `__init__.py` 6. **Test strategy** → Mock `OTLPSpanExporter` diff --git a/docs/brainstorm/spectra-collector-integration/TLDR.md b/docs/brainstorm/spectra-collector-integration/TLDR.md index aadeed05..6bef4603 100644 --- a/docs/brainstorm/spectra-collector-integration/TLDR.md +++ b/docs/brainstorm/spectra-collector-integration/TLDR.md @@ -13,7 +13,7 @@ Weave/Copilot Cowork needs Spectra Collector as their telemetry destination. Spe ## How -- New `SpectraExporterOptions` class with sensible defaults for K8s sidecar (`http://localhost:4317`, gRPC, insecure=false) +- New `SpectraExporterOptions` class with sensible defaults for K8s sidecar (`http://localhost:4317`, gRPC, insecure=true) - `configure(exporter_options=...)` accepts `Agent365ExporterOptions | SpectraExporterOptions` — type-based dispatch selects the exporter - When `SpectraExporterOptions` is provided, `ENABLE_A365_OBSERVABILITY_EXPORTER` env var is ignored - Under the hood, creates an `OTLPSpanExporter` pointed at the Spectra endpoint — no new dependencies diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py index 28d78e77..ce451328 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py @@ -272,6 +272,7 @@ def configure( token_resolver: Callable[[str, str], str | None] | None = None, cluster_category: str = "prod", exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, + suppress_invoke_agent_input: bool = False, **kwargs: Any, ) -> bool: """ @@ -287,6 +288,7 @@ def configure( :param exporter_options: Exporter configuration. Pass Agent365ExporterOptions for A365 API export, SpectraExporterOptions for Spectra Collector sidecar export, or None (default) to construct Agent365ExporterOptions from legacy parameters. + :param suppress_invoke_agent_input: If True, suppress input messages for InvokeAgent spans. :return: True if configuration succeeded, False otherwise. """ return _telemetry_manager.configure( @@ -296,6 +298,7 @@ def configure( token_resolver, cluster_category, exporter_options, + suppress_invoke_agent_input, **kwargs, ) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py index 96b1f0fc..826076a3 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py @@ -17,9 +17,12 @@ class SpectraExporterOptions: options classes have no shared base class per design decision C4. """ + _DEFAULT_GRPC_ENDPOINT = "http://localhost:4317" + _DEFAULT_HTTP_ENDPOINT = "http://localhost:4318" + def __init__( self, - endpoint: str = "http://localhost:4317", + endpoint: str | None = None, protocol: Literal["grpc", "http"] = "grpc", insecure: bool = True, max_queue_size: int = 2048, @@ -29,7 +32,8 @@ def __init__( ): """ Args: - endpoint: Spectra sidecar OTLP endpoint. Default: http://localhost:4317. + endpoint: Spectra sidecar OTLP endpoint. Defaults to + http://localhost:4317 for gRPC or http://localhost:4318 for HTTP. protocol: OTLP protocol — "grpc" or "http". Default: grpc. insecure: Use insecure (no TLS) connection. Default: True (localhost sidecar). max_queue_size: Batch processor queue size. Default: 2048. @@ -39,6 +43,10 @@ def __init__( """ if protocol not in ("grpc", "http"): raise ValueError(f"protocol must be 'grpc' or 'http', got '{protocol}'") + if endpoint is None: + endpoint = ( + self._DEFAULT_GRPC_ENDPOINT if protocol == "grpc" else self._DEFAULT_HTTP_ENDPOINT + ) self.endpoint = endpoint self.protocol = protocol self.insecure = insecure diff --git a/tests/observability/core/test_spectra_exporter.py b/tests/observability/core/test_spectra_exporter.py index 4f77dbd6..e4d19603 100644 --- a/tests/observability/core/test_spectra_exporter.py +++ b/tests/observability/core/test_spectra_exporter.py @@ -39,6 +39,16 @@ def test_spectra_exporter_options_defaults(self): self.assertEqual(opts.exporter_timeout_ms, 30000) self.assertEqual(opts.max_export_batch_size, 512) + def test_spectra_exporter_options_http_default_endpoint(self): + """HTTP protocol defaults to port 4318.""" + opts = SpectraExporterOptions(protocol="http") + self.assertEqual(opts.endpoint, "http://localhost:4318") + + def test_spectra_exporter_options_explicit_endpoint_overrides_default(self): + """Explicit endpoint overrides protocol-based default.""" + opts = SpectraExporterOptions(protocol="http", endpoint="http://custom:9999") + self.assertEqual(opts.endpoint, "http://custom:9999") + def test_spectra_options_invalid_protocol_raises(self): """ValueError for invalid protocol.""" with self.assertRaises(ValueError) as ctx: @@ -102,7 +112,7 @@ def test_configure_with_spectra_options_creates_http_exporter(self, mock_http): ) self.assertTrue(result) mock_http.assert_called_once_with( - endpoint="http://localhost:4317", + endpoint="http://localhost:4318", ) @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") From 6b342f3a1f9a8d266b3d1d2f85a74a04c1bd97d6 Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Sat, 14 Mar 2026 12:53:08 -0600 Subject: [PATCH 10/11] fix: Update remaining insecure=False reference in brainstorm changelog Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/brainstorm/spectra-collector-integration/BRAINSTORM.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md b/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md index 95ab8462..253d601b 100644 --- a/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md +++ b/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md @@ -155,7 +155,7 @@ The `TelemetryManager.configure()` method (`config.py`) creates the exporter pip ### 2026-03-14 — Phase 5: Risks & Open Questions - All 6 open questions resolved by user -- Key decisions: union type on exporter_options, env var ignored for Spectra, insecure=False default, gRPC default, mock tests +- Key decisions: union type on exporter_options, env var ignored for Spectra, insecure=True default, gRPC default, mock tests - No blocking risks identified ### 2026-03-14 — Phase 6: Outputs From ab17a388e30f2aea76ac8af66c59990093edef92 Mon Sep 17 00:00:00 2001 From: Julio Menendez Gonzalez Date: Mon, 16 Mar 2026 07:36:40 -0600 Subject: [PATCH 11/11] chore: Remove brainstorm and design documents for Spectra exporter Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ARCHITECTURE.md | 255 --------- .../BRAINSTORM.md | 163 ------ .../spectra-collector-integration/TLDR.md | 32 -- docs/design/spectra-exporter-options.md | 537 ------------------ 4 files changed, 987 deletions(-) delete mode 100644 docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md delete mode 100644 docs/brainstorm/spectra-collector-integration/BRAINSTORM.md delete mode 100644 docs/brainstorm/spectra-collector-integration/TLDR.md delete mode 100644 docs/design/spectra-exporter-options.md diff --git a/docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md b/docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md deleted file mode 100644 index 34a87c2a..00000000 --- a/docs/brainstorm/spectra-collector-integration/ARCHITECTURE.md +++ /dev/null @@ -1,255 +0,0 @@ -# Spectra Collector Integration — Architecture Proposal - -**Date:** 2026-03-14 -**Status:** Ready to build - ---- - -## Overview - -Integrate Spectra Collector as an optional export destination in the `microsoft-agents-a365-observability-core` package. Consumers deploying with Spectra sidecars in K8s pass `SpectraExporterOptions` to `configure()` instead of `Agent365ExporterOptions`. Under the hood, this creates an `OTLPSpanExporter` pointed at the Spectra sidecar. - ---- - -## Architecture - -### Exporter Selection Flow (after change) - -``` -configure(exporter_options=SpectraExporterOptions(...)) - │ - ├─ if isinstance(exporter_options, SpectraExporterOptions): - │ → OTLPSpanExporter(endpoint, protocol, insecure) - │ → ENABLE_A365_OBSERVABILITY_EXPORTER env var is IGNORED - │ - ├─ elif isinstance(exporter_options, Agent365ExporterOptions): - │ → if ENABLE_A365_OBSERVABILITY_EXPORTER + token_resolver: - │ │ → _Agent365Exporter (custom HTTP) - │ → else: - │ → ConsoleSpanExporter (fallback) - │ - └─ if ENABLE_OTLP_EXPORTER=true: (unchanged, additive) - → OTLPSpanExporter (auto-configured from OTEL env vars) -``` - -### Component Diagram - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Consumer Application │ -│ │ -│ configure(exporter_options=SpectraExporterOptions()) │ -│ or │ -│ configure(exporter_options=Agent365ExporterOptions(...)) │ -└──────────────────────┬──────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ TelemetryManager._configure_internal() │ -│ │ -│ ┌─────────────────────┐ ┌─────────────────────────┐ │ -│ │ SpectraExporterOpts │ │ Agent365ExporterOptions │ │ -│ │ endpoint (4317) │ │ token_resolver │ │ -│ │ protocol (gRPC) │ │ cluster_category │ │ -│ │ insecure (true) │ │ use_s2s_endpoint │ │ -│ │ batch settings │ │ batch settings │ │ -│ └────────┬────────────┘ └────────┬────────────────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌─────────────────┐ ┌──────────────────┐ │ -│ │ OTLPSpanExporter│ │ _Agent365Exporter│ │ -│ │ (standard OTEL) │ │ (custom HTTP) │ │ -│ └────────┬────────┘ └────────┬─────────┘ │ -│ │ │ │ -│ └──────────┬───────────────┘ │ -│ ▼ │ -│ ┌──────────────────────────┐ │ -│ │ _EnrichingBatchSpan- │ │ -│ │ Processor │ ← enrichers from │ -│ │ (exporter-agnostic) │ framework exts │ -│ └──────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ - ┌────────────┴────────────┐ - ▼ ▼ - Spectra Sidecar A365 API - (localhost:4317) (agent365.svc.cloud.microsoft) -``` - ---- - -## New File: `spectra_exporter_options.py` - -Location: `libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py` - -```python -class SpectraExporterOptions: - """ - Configuration for exporting traces to a Spectra Collector sidecar via OTLP. - """ - - def __init__( - self, - endpoint: str = "http://localhost:4317", - protocol: str = "grpc", - insecure: bool = True, - max_queue_size: int = 2048, - scheduled_delay_ms: int = 5000, - exporter_timeout_ms: int = 30000, - max_export_batch_size: int = 512, - ): - self.endpoint = endpoint - self.protocol = protocol # "grpc" or "http" - self.insecure = insecure - self.max_queue_size = max_queue_size - self.scheduled_delay_ms = scheduled_delay_ms - self.exporter_timeout_ms = exporter_timeout_ms - self.max_export_batch_size = max_export_batch_size -``` - -### Fields - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `endpoint` | `str` | `http://localhost:4317` | Spectra sidecar OTLP endpoint | -| `protocol` | `str` | `grpc` | OTLP protocol: `"grpc"` or `"http"` | -| `insecure` | `bool` | `True` | Whether to use insecure (no TLS) connection | -| `max_queue_size` | `int` | `2048` | Batch processor queue size | -| `scheduled_delay_ms` | `int` | `5000` | Export interval (ms) | -| `exporter_timeout_ms` | `int` | `30000` | Export timeout (ms) | -| `max_export_batch_size` | `int` | `512` | Max spans per export batch | - ---- - -## Changes to `config.py` - -### `configure()` signature - -```python -def configure( - service_name: str, - service_namespace: str, - logger_name: str = DEFAULT_LOGGER_NAME, - token_resolver: Callable[[str, str], str | None] | None = None, - cluster_category: str = "prod", - exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, - suppress_invoke_agent_input: bool = False, - **kwargs: Any, -) -> bool: -``` - -### `_configure_internal()` exporter selection - -```python -# Type-based dispatch -if isinstance(exporter_options, SpectraExporterOptions): - # Spectra path — OTLP exporter to sidecar - # ENABLE_A365_OBSERVABILITY_EXPORTER is ignored - if exporter_options.protocol == "grpc": - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GrpcExporter - exporter = GrpcExporter( - endpoint=exporter_options.endpoint, - insecure=exporter_options.insecure, - ) - else: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HttpExporter - exporter = HttpExporter( - endpoint=exporter_options.endpoint, - ) - -elif isinstance(exporter_options, Agent365ExporterOptions): - # A365 path (existing logic, unchanged) - if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: - exporter = _Agent365Exporter(...) - else: - exporter = ConsoleSpanExporter() - -else: - # No options provided — legacy fallback - exporter_options = Agent365ExporterOptions( - cluster_category=cluster_category, - token_resolver=token_resolver, - ) - # ... existing logic -``` - ---- - -## Export Surface Changes - -### `exporters/__init__.py` - -```python -from .agent365_exporter_options import Agent365ExporterOptions -from .spectra_exporter_options import SpectraExporterOptions - -__all__ = ["Agent365ExporterOptions", "SpectraExporterOptions"] -``` - -### `core/__init__.py` - -Add `SpectraExporterOptions` to imports and `__all__`. - ---- - -## Consumer Usage - -### Spectra deployment (Weave/Copilot Cowork) - -```python -from microsoft_agents_a365.observability.core import configure, SpectraExporterOptions - -# Zero-config — defaults to localhost:4317, gRPC -configure( - service_name="weave-agent", - service_namespace="copilot-cowork", - exporter_options=SpectraExporterOptions(), -) -``` - -### A365 deployment (existing consumers, unchanged) - -```python -from microsoft_agents_a365.observability.core import configure, Agent365ExporterOptions - -configure( - service_name="my-agent", - service_namespace="my-namespace", - exporter_options=Agent365ExporterOptions( - token_resolver=my_token_resolver, - cluster_category="prod", - ), -) -``` - ---- - -## Files Changed - -| File | Change | -|------|--------| -| `exporters/spectra_exporter_options.py` | **New** — `SpectraExporterOptions` class | -| `exporters/__init__.py` | Add `SpectraExporterOptions` export | -| `core/__init__.py` | Add `SpectraExporterOptions` to `__all__` | -| `config.py` | Union type on `exporter_options`, type-based dispatch in `_configure_internal()` | -| `tests/observability/core/test_spectra_exporter.py` | **New** — tests for Spectra exporter path (mocked `OTLPSpanExporter`) | - -## Files NOT Changed - -- Scope classes (`invoke_agent_scope.py`, `execute_tool_scope.py`, etc.) -- Enrichment pipeline (`enriching_span_processor.py`) -- Framework extensions (all `*-observability-extensions-*` packages) -- Constants (`constants.py`) -- `Agent365ExporterOptions` class -- `_Agent365Exporter` class - ---- - -## Risks & Mitigations - -| Risk | Severity | Mitigation | -|------|----------|------------| -| Sidecar not running → silent failure | Medium | OTLP exporter logs connection errors; document deployment prereqs | -| Consumer passes both A365 env var + Spectra options | Low | Spectra options take precedence; env var ignored. Document. | -| gRPC dependency not installed | Low | `opentelemetry-exporter-otlp` (core dep) includes both gRPC and HTTP | -| Consumer sets `insecure=False` for remote Spectra endpoint but forgets TLS setup | Low | Only relevant for non-sidecar deployments. Default `insecure=True` is correct for localhost. | diff --git a/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md b/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md deleted file mode 100644 index 253d601b..00000000 --- a/docs/brainstorm/spectra-collector-integration/BRAINSTORM.md +++ /dev/null @@ -1,163 +0,0 @@ -# Spectra Collector Integration — Brainstorm Working Document - -**Status:** Complete -**Started:** 2026-03-14 -**Last Updated:** 2026-03-14 - ---- - -## Problem Frame - -**Trigger:** Weave/Copilot Cowork team needs to use Spectra Collector instead of A365 API directly. This is net-new — no consumer currently uses Spectra through this SDK. - -**Scope:** Create a dedicated `SpectraExporterOptions` so consumers deploying with Spectra sidecars in K8s can configure the observability-core package to export traces to Spectra instead of A365. - -**Key Context:** -- Spectra Collector is a sidecar OTEL Collector that accepts **standard OTLP** (gRPC on :4317, HTTP on :4318) -- Spectra is a **replacement** for A365 in certain deployments (not additive) -- No Spectra-specific attributes needed — tenant_id is already required by this library -- Spectra dependencies should remain optional - -**Stakeholders:** Weave/Copilot Cowork team (first consumer) -**Constraints:** Optional dependencies, K8s sidecar deployment only - ---- - -## Current State - -### Exporter Pipeline Architecture - -The `TelemetryManager.configure()` method (`config.py`) creates the exporter pipeline: - -1. **Primary exporter** (lines 159-171): Either `_Agent365Exporter` (if `ENABLE_A365_OBSERVABILITY_EXPORTER=true` AND `token_resolver` provided) or `ConsoleSpanExporter` fallback -2. **Optional OTLP exporter** (lines 187-192): If `ENABLE_OTLP_EXPORTER=true`, adds an `OTLPSpanExporter` (auto-configured from OTEL env vars) -3. Both use `_EnrichingBatchSpanProcessor` which applies span enrichers before export - -### Key Components - -| Component | Role | Public? | -|-----------|------|---------| -| `configure()` | Entry point — takes `Agent365ExporterOptions` | Yes | -| `Agent365ExporterOptions` | Config for A365 exporter (token_resolver, cluster_category, batch settings) | Yes | -| `_Agent365Exporter` | Custom HTTP exporter — partitions by identity, POSTs to A365 API | No (internal) | -| `_EnrichingBatchSpanProcessor` | Batch processor with span enrichment hook | No (internal) | -| `OTLPSpanExporter` | Standard OTEL OTLP exporter (used for optional OTLP path) | From otel-sdk | - -### What Works Well (Keep) -- `_EnrichingBatchSpanProcessor` — enrichment pipeline is exporter-agnostic -- `SpanProcessor` for agent span processing — independent of export destination -- All scope classes (`InvokeAgentScope`, `ExecuteToolScope`, etc.) — independent of exporter -- Extension instrumentors — they just register enrichers/processors, don't care about export - -### What's Painful (Change Candidates) -- `configure()` is hardcoded to either A365 exporter or console fallback — no way to select Spectra -- `exporter_options` parameter is typed as `Agent365ExporterOptions` — not generic -- The OTLP exporter path (lines 187-192) is a bolt-on with no configuration surface - -### What's Missing (Build) -- `SpectraExporterOptions` — configuration class for Spectra-specific settings -- Selection logic in `configure()` to choose Spectra exporter when configured -- Environment variable for enabling Spectra exporter - -### Dependencies -- `opentelemetry-exporter-otlp` is already a **core dependency** (not optional) — used for the existing OTLP path -- Since Spectra accepts OTLP, no new dependencies needed for the exporter itself - ---- - -## Requirements & Gaps - -| # | Requirement | Layer | Status | -|---|-------------|-------|--------| -| R1 | Consumer can configure Spectra as the export destination instead of A365 | Configuration | **Gap** | -| R2 | Spectra exporter sends traces via OTLP to a sidecar endpoint (default `localhost:4317`) | Infrastructure | **Partial** | -| R3 | Span enrichment pipeline works identically regardless of exporter | Infrastructure | **Met** | -| R4 | Framework extensions work with Spectra without changes | Intelligence | **Met** | -| R5 | Spectra and A365 are mutually exclusive per deployment | Configuration | **Gap** | -| R6 | Spectra dependencies remain optional (no new deps needed) | Infrastructure | **Met** | -| R7 | Batch processor settings configurable for Spectra (client-side batching) | Configuration | **Gap** | - -### What Changes vs What Stays - -**MUST NOT change:** Scope classes, span enrichment pipeline, framework extensions, constants, `Agent365ExporterOptions` API - -**MUST change:** `configure()` to accept Spectra options; exporter selection logic in `_configure_internal()` - -**COULD change:** Extract shared batch settings into base class (deferred — only 2 exporters) - ---- - -## Key Decisions - -### Decision 1: Exporter configuration model -**Chosen: Separate `SpectraExporterOptions` class** alongside `Agent365ExporterOptions`. No shared base class — duplication of 4 batch fields is acceptable. Avoids changing existing class hierarchy. - -### Decision 2: Exporter selection mechanism -**Chosen: Type-based dispatch.** Consumer passes `SpectraExporterOptions` or `Agent365ExporterOptions` to `configure()`. No env var magic — explicit in code. - -### Decision 3: Relationship to `ENABLE_OTLP_EXPORTER` -**Chosen: Keep separate.** `ENABLE_OTLP_EXPORTER` remains a generic escape hatch. Spectra is a dedicated, opinionated integration. Different purposes, no conflict. - -### Decision 4: Default endpoint and protocol -**Chosen: `SpectraExporterOptions` defaults to `http://localhost:4317` (gRPC).** Zero-config for K8s sidecar deployments. Consumer can override but shouldn't need to in the common case. - ---- - -## Risks & Open Questions - -### Risks -| Risk | Severity | Mitigation | -|------|----------|------------| -| Sidecar not running → silent failure | Medium | OTLP exporter logs connection errors; document deployment prereqs | -| Consumer passes both A365 env var + Spectra options | Low | Spectra options take precedence; env var ignored | -| Consumer sets `insecure=False` for remote Spectra endpoint but forgets TLS setup | Low | Only relevant for non-sidecar deployments. Default `insecure=True` is correct for localhost. | - -### Open Questions (Resolved) -1. **Union type vs separate param** → Union type on `exporter_options` -2. **A365 env var behavior** → Ignored entirely when SpectraExporterOptions provided -3. **Insecure config** → Exposed, defaults to `True` (localhost sidecar) -4. **Protocol config** → Exposed (`"grpc"` or `"http"`), defaults to `"grpc"` -5. **Package exports** → `SpectraExporterOptions` added to `__init__.py` -6. **Test strategy** → Mock `OTLPSpanExporter` - -## Doc Map -- [TLDR.md](TLDR.md) — 1-page summary for stakeholders -- [ARCHITECTURE.md](ARCHITECTURE.md) — Full technical architecture proposal - ---- - -## Session Log - -### 2026-03-14 — Phase 1: Problem Framing -- Explored observability-core architecture: TelemetryManager singleton, A365 exporter, OTLP exporter, enriching batch processor -- Explored spectra-collector: OTLP-based sidecar, accepts standard OTEL traces/logs/metrics on localhost:4317/4318 -- Key finding: existing `ENABLE_OTLP_EXPORTER` path already creates an `OTLPSpanExporter` — Spectra accepts OTLP natively -- User confirmed: Spectra replaces A365 in certain deployments (Weave/Copilot Cowork) -- User confirmed: No Spectra-specific attributes needed, dependencies should be optional - -### 2026-03-14 — Phase 2: Current State Mapped -- Mapped exporter pipeline: configure() → A365Exporter or Console, plus optional OTLP -- Key gap: configure() is hardcoded to A365 exporter options — no way to select Spectra -- Key advantage: `opentelemetry-exporter-otlp` is already a core dep, enrichment pipeline is exporter-agnostic -- All scope classes, extensions, and instrumentors are independent of export destination - -### 2026-03-14 — Phase 3: Requirements & Gaps -- 7 requirements identified; 3 met, 1 partial, 3 gaps -- Key gaps: configure() dispatch, exporter selection, batch settings for Spectra -- No changes needed to scopes, enrichment, extensions, or constants - -### 2026-03-14 — Phase 4: Key Decisions -- Decision 1: Separate `SpectraExporterOptions` class (no shared base) -- Decision 2: Type-based dispatch in `configure()` -- Decision 3: Keep `ENABLE_OTLP_EXPORTER` separate from Spectra -- Decision 4: Default endpoint `http://localhost:4317` (gRPC) — zero-config for K8s sidecar -- User clarified: defaults are critical since Spectra is always a K8s sidecar - -### 2026-03-14 — Phase 5: Risks & Open Questions -- All 6 open questions resolved by user -- Key decisions: union type on exporter_options, env var ignored for Spectra, insecure=True default, gRPC default, mock tests -- No blocking risks identified - -### 2026-03-14 — Phase 6: Outputs -- Created TLDR.md and ARCHITECTURE.md -- Brainstorm complete diff --git a/docs/brainstorm/spectra-collector-integration/TLDR.md b/docs/brainstorm/spectra-collector-integration/TLDR.md deleted file mode 100644 index 6bef4603..00000000 --- a/docs/brainstorm/spectra-collector-integration/TLDR.md +++ /dev/null @@ -1,32 +0,0 @@ -# Spectra Collector Integration — TLDR - -**Date:** 2026-03-14 -**Status:** Ready to build - -## What - -Add `SpectraExporterOptions` to the observability-core package so consumers deploying with Spectra Collector sidecars in K8s can export traces via OTLP instead of the A365 API. - -## Why - -Weave/Copilot Cowork needs Spectra Collector as their telemetry destination. Spectra replaces A365 in their deployment topology. The SDK currently only supports the A365 exporter or a raw OTLP bolt-on with no configuration surface. - -## How - -- New `SpectraExporterOptions` class with sensible defaults for K8s sidecar (`http://localhost:4317`, gRPC, insecure=true) -- `configure(exporter_options=...)` accepts `Agent365ExporterOptions | SpectraExporterOptions` — type-based dispatch selects the exporter -- When `SpectraExporterOptions` is provided, `ENABLE_A365_OBSERVABILITY_EXPORTER` env var is ignored -- Under the hood, creates an `OTLPSpanExporter` pointed at the Spectra endpoint — no new dependencies -- Enrichment pipeline, scope classes, and framework extensions work unchanged - -## Key Decisions - -1. **Separate options class** (not a shared base) — keeps things simple with only 2 exporters -2. **Type-based dispatch** — no env var magic, consumer explicitly chooses in code -3. **`ENABLE_OTLP_EXPORTER` stays separate** — generic escape hatch, different purpose -4. **Zero-config defaults** — endpoint `http://localhost:4317`, gRPC protocol, configurable but shouldn't need to change - -## Scope - -- **Changes:** `SpectraExporterOptions` (new file), `config.py` (exporter selection), `exporters/__init__.py` and `core/__init__.py` (exports), tests -- **No changes:** Scope classes, enrichment pipeline, framework extensions, constants, `Agent365ExporterOptions` diff --git a/docs/design/spectra-exporter-options.md b/docs/design/spectra-exporter-options.md deleted file mode 100644 index 0c76c56d..00000000 --- a/docs/design/spectra-exporter-options.md +++ /dev/null @@ -1,537 +0,0 @@ -# Design: Spectra Collector Exporter Integration - -**Author:** Agent365 SDK Team -**Date:** 2026-03-14 -**Status:** Reviewed -**Brainstorm:** [docs/brainstorm/spectra-collector-integration/](../brainstorm/spectra-collector-integration/) - ---- - -## 1. Problem Statement - -The Weave/Copilot Cowork team deploys with Spectra Collector sidecars in Kubernetes and needs to export traces to the Spectra sidecar instead of the A365 observability API. Today, the `configure()` function in `observability-core` only supports `Agent365ExporterOptions`, which creates an `_Agent365Exporter` that POSTs to the A365 API with custom HTTP semantics (identity partitioning, token resolution, etc.). - -There is an existing `ENABLE_OTLP_EXPORTER` env var path (`config.py:187-192`) that creates a bare `OTLPSpanExporter`, but it has no configuration surface and is designed as a generic bolt-on, not a Spectra-aware integration. - -**Evidence:** -- `config.py:54-63` — `configure()` signature accepts only `Agent365ExporterOptions` -- `config.py:159-171` — exporter selection is hardcoded: A365 or console fallback -- `config.py:187-192` — OTLP bolt-on with zero configuration -- Spectra Collector accepts standard OTLP on `localhost:4317` (gRPC) / `localhost:4318` (HTTP) — confirmed from `D:\spectra-collector` - ---- - -## 2. Current Architecture - -### Affected Files Inventory - -| File | Path (relative to observability-core package root) | Role | Changes | -|------|------|------|---------| -| `config.py` | `microsoft_agents_a365/observability/core/config.py` | TelemetryManager singleton, `configure()` entry point | Modify | -| `agent365_exporter_options.py` | `microsoft_agents_a365/observability/core/exporters/agent365_exporter_options.py` | A365 exporter config | No change | -| `agent365_exporter.py` | `microsoft_agents_a365/observability/core/exporters/agent365_exporter.py` | A365 exporter — remove suppression logic from `_map_span` | Modify | -| `enriching_span_processor.py` | `microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py` | Batch processor — add suppression logic to `on_end` | Modify | -| `enriched_span.py` | `microsoft_agents_a365/observability/core/exporters/enriched_span.py` | Enriched span wrapper — add `excluded_attribute_keys` support | Modify | -| `exporters/__init__.py` | `microsoft_agents_a365/observability/core/exporters/__init__.py` | Exporter public exports | Modify | -| `core/__init__.py` | `microsoft_agents_a365/observability/core/__init__.py` | Package public API | Modify | -| `spectra_exporter_options.py` | `microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py` | **New** — Spectra config | Create | -| `test_spectra_exporter.py` | `tests/observability/core/test_spectra_exporter.py` | **New** — Spectra tests | Create | -| `test_agent365.py` | `tests/observability/core/test_agent365.py` | Existing config tests | Modify (add Spectra tests) | - -### Current Exporter Selection (`config.py:144-192`) - -```python -# Lines 144-149: Legacy fallback -if exporter_options is None: - exporter_options = Agent365ExporterOptions( - cluster_category=cluster_category, - token_resolver=token_resolver, - ) - -# Lines 159-171: A365 or console -if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: - exporter = _Agent365Exporter(...) -else: - exporter = ConsoleSpanExporter() - -# Lines 187-192: Optional OTLP bolt-on -if os.environ.get("ENABLE_OTLP_EXPORTER", "").lower() == "true": - otlp_exporter = OTLPSpanExporter() - tracer_provider.add_span_processor( - _EnrichingBatchSpanProcessor(otlp_exporter, **batch_processor_kwargs) - ) -``` - -### Unchanged Components - -These components are independent of the export destination and require **zero changes**: -- Scope classes: `InvokeAgentScope`, `ExecuteToolScope`, `InferenceScope` -- Span processor: `SpanProcessor` (copies baggage to span attributes) -- All framework extension packages (`*-observability-extensions-*`) -- Constants (`constants.py`) -- `Agent365ExporterOptions` class - -### Components Requiring Modification for `suppress_invoke_agent_input` - -The `suppress_invoke_agent_input` feature currently lives inside `_Agent365Exporter._map_span()` (`agent365_exporter.py:274-284`), where it strips `gen_ai.input.messages` from InvokeAgent spans during JSON serialization. This is exporter-specific — the `OTLPSpanExporter` never calls `_map_span`, so suppression would be lost in the Spectra path. - -**This must be moved to an exporter-agnostic layer** so it works with both A365 and Spectra exporters. See Section 5.4 for the approach. - ---- - -## 3. Requirements - -### Must-Have - -| ID | Requirement | -|----|------------| -| M1 | Consumer can pass `SpectraExporterOptions` to `configure()` to export traces via OTLP to a Spectra sidecar | -| M2 | Default endpoint is `http://localhost:4317` with gRPC protocol — zero-config for K8s sidecar | -| M3 | When `SpectraExporterOptions` is provided, `ENABLE_A365_OBSERVABILITY_EXPORTER` env var is ignored entirely | -| M4 | Span enrichment pipeline works identically regardless of which exporter is active | -| M5 | No new package dependencies — `opentelemetry-exporter-otlp` is already a core dep | -| M6 | `SpectraExporterOptions` is exported from `microsoft_agents_a365.observability.core` | - -### Nice-to-Have - -| ID | Requirement | -|----|------------| -| N1 | Protocol field (`"grpc"` or `"http"`) for consumers who need HTTP/protobuf instead of gRPC | -| N2 | `insecure` field for TLS configuration (defaults to `True` for localhost sidecar) | - -### Constraints - -| ID | Constraint | -|----|-----------| -| C1 | `Agent365ExporterOptions` class and its API must not change | -| C2 | Existing consumers using `Agent365ExporterOptions` must see zero behavioral change | -| C3 | `ENABLE_OTLP_EXPORTER` bolt-on path remains separate and unchanged | -| C4 | No shared base class between `Agent365ExporterOptions` and `SpectraExporterOptions` (decided in brainstorm) | - ---- - -## 4. Options Evaluation - -### Option A: Type-based dispatch with union parameter (Recommended) - -`configure()` accepts `exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None`. The `_configure_internal()` method uses `isinstance` to select the exporter. - -**Pros:** Explicit, type-safe, no env var ambiguity, consumer controls exporter in code -**Cons:** Union type is slightly more complex than a single type - -### Option B: Separate `spectra_exporter_options` parameter - -Add a new keyword arg `spectra_exporter_options: SpectraExporterOptions | None` alongside the existing `exporter_options`. - -**Pros:** No type change on existing parameter -**Cons:** Two params for the same concept, awkward if both are passed, more args on an already-long signature - -### Option C: Env-var-driven selection - -New `ENABLE_SPECTRA_EXPORTER` env var. Consumer sets env vars instead of passing options in code. - -**Pros:** Consistent with existing `ENABLE_A365_OBSERVABILITY_EXPORTER` pattern -**Cons:** Two env vars could conflict, harder to reason about, no type safety - -### Comparison Matrix - -| Criterion | Option A (Union) | Option B (Separate param) | Option C (Env var) | -|-----------|-----------------|--------------------------|-------------------| -| Type safety | High | Medium (mutual exclusion not enforced at type level) | Low | -| Backward compat | Full | Full | Full | -| Consumer clarity | High — one param, one choice | Medium — which param do I use? | Low — env var precedence unclear | -| Implementation complexity | Low | Low | Medium (precedence logic) | - -**Decision: Option A** — confirmed in brainstorm with stakeholder input. - ---- - -## 5. Recommended Approach - -### 5.1 New File: `spectra_exporter_options.py` - -**Location:** `libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py` - -**Estimated size:** ~40 lines - -```python -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from typing import Literal - - -class SpectraExporterOptions: - """ - Configuration for exporting traces to a Spectra Collector sidecar via OTLP. - - Spectra Collector is deployed as a Kubernetes sidecar that accepts - standard OTLP telemetry on localhost. Defaults are tuned for this - deployment topology — most consumers should not need to override them. - - Note: Batch processor fields (max_queue_size, scheduled_delay_ms, etc.) - are duplicated from Agent365ExporterOptions intentionally — these two - options classes have no shared base class per design decision C4. - """ - - def __init__( - self, - endpoint: str = "http://localhost:4317", - protocol: Literal["grpc", "http"] = "grpc", - insecure: bool = True, - max_queue_size: int = 2048, - scheduled_delay_ms: int = 5000, - exporter_timeout_ms: int = 30000, - max_export_batch_size: int = 512, - ): - """ - Args: - endpoint: Spectra sidecar OTLP endpoint. Default: http://localhost:4317. - protocol: OTLP protocol — "grpc" or "http". Default: grpc. - insecure: Use insecure (no TLS) connection. Default: True (localhost sidecar). - max_queue_size: Batch processor queue size. Default: 2048. - scheduled_delay_ms: Export interval in milliseconds. Default: 5000. - exporter_timeout_ms: Export timeout in milliseconds. Default: 30000. - max_export_batch_size: Max spans per export batch. Default: 512. - """ - if protocol not in ("grpc", "http"): - raise ValueError( - f"protocol must be 'grpc' or 'http', got '{protocol}'" - ) - self.endpoint = endpoint - self.protocol = protocol - self.insecure = insecure - self.max_queue_size = max_queue_size - self.scheduled_delay_ms = scheduled_delay_ms - self.exporter_timeout_ms = exporter_timeout_ms - self.max_export_batch_size = max_export_batch_size -``` - -### 5.2 Changes to `config.py` - -#### Import additions (module level in `config.py`) - -```python -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( - OTLPSpanExporter as GrpcOTLPSpanExporter, -) -from .exporters.spectra_exporter_options import SpectraExporterOptions -``` - -The gRPC exporter is imported at module level (matching the existing HTTP `OTLPSpanExporter` import at `config.py:11`). The `opentelemetry-exporter-otlp` core dependency pulls in both gRPC and HTTP sub-packages transitively, so the import is safe. This also provides a clean mock target for tests: `@patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter")`. - -#### Signature change — all three functions - -The `exporter_options` parameter type changes on **all three** function signatures that must stay in sync: - -1. Public `configure()` at `config.py:245` -2. `TelemetryManager.configure()` at `config.py:54` -3. `TelemetryManager._configure_internal()` at `config.py:96` - -From: -```python -exporter_options: Optional[Agent365ExporterOptions] = None -``` -To: -```python -exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None -``` - -#### Exporter selection in `_configure_internal()` (replaces lines 144-171) - -The existing early-resolve pattern is preserved — `None` resolves to `Agent365ExporterOptions` first, then `batch_processor_kwargs` are extracted once, then `isinstance` dispatch: - -```python -# Resolve None to default Agent365ExporterOptions (legacy fallback) -if exporter_options is None: - exporter_options = Agent365ExporterOptions( - cluster_category=cluster_category, - token_resolver=token_resolver, - ) - -# Extract batch processor kwargs — works for both options types -# (both have identical field names: max_queue_size, scheduled_delay_ms, etc.) -batch_processor_kwargs = { - "max_queue_size": exporter_options.max_queue_size, - "schedule_delay_millis": exporter_options.scheduled_delay_ms, - "export_timeout_millis": exporter_options.exporter_timeout_ms, - "max_export_batch_size": exporter_options.max_export_batch_size, -} - -# Type-based exporter dispatch -if isinstance(exporter_options, SpectraExporterOptions): - # Spectra path — OTLP exporter to sidecar - # ENABLE_A365_OBSERVABILITY_EXPORTER is intentionally ignored. - # suppress_invoke_agent_input is handled by _EnrichingBatchSpanProcessor - # (see Section 5.3), so it works with both A365 and Spectra exporters. - if exporter_options.protocol == "grpc": - exporter = GrpcOTLPSpanExporter( - endpoint=exporter_options.endpoint, - insecure=exporter_options.insecure, - ) - else: - exporter = OTLPSpanExporter( - endpoint=exporter_options.endpoint, - ) - -else: - # A365 path (existing logic, unchanged) - if is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: - exporter = _Agent365Exporter( - token_resolver=exporter_options.token_resolver, - cluster_category=exporter_options.cluster_category, - use_s2s_endpoint=exporter_options.use_s2s_endpoint, - ) - else: - exporter = ConsoleSpanExporter() -``` - -**Design notes:** -- `None` resolves to `Agent365ExporterOptions` early, preserving the existing single-path pattern and eliminating code duplication. -- `batch_processor_kwargs` are extracted once — both options classes share identical field names and defaults. -- `suppress_invoke_agent_input` is moved from `_Agent365Exporter._map_span()` to `_EnrichingBatchSpanProcessor.on_end()` so it works with any exporter (see Section 5.3). The flag is passed to the batch processor, not the exporter. - -### 5.3 Moving `suppress_invoke_agent_input` to the enrichment layer - -Currently, input message suppression lives inside `_Agent365Exporter._map_span()` (`agent365_exporter.py:274-284`). It checks if a span is an InvokeAgent span and removes `gen_ai.input.messages` from the serialized attributes. This only works for the A365 exporter because the `OTLPSpanExporter` never calls `_map_span`. - -To make suppression work with any exporter, we move it into `_EnrichingBatchSpanProcessor.on_end()`, which runs before all exporters. - -#### Step 1: Extend `EnrichedReadableSpan` to support attribute exclusion - -`EnrichedReadableSpan` (`enriched_span.py`) currently only supports *adding* attributes. Add an `excluded_attribute_keys` parameter so attributes can also be *removed*: - -```python -class EnrichedReadableSpan(ReadableSpan): - def __init__( - self, - span: ReadableSpan, - extra_attributes: dict, - excluded_attribute_keys: set[str] | None = None, - ): - self._span = span - self._extra_attributes = extra_attributes - self._excluded_attribute_keys = excluded_attribute_keys or set() - - @property - def attributes(self) -> types.Attributes: - original = dict(self._span.attributes or {}) - original.update(self._extra_attributes) - for key in self._excluded_attribute_keys: - original.pop(key, None) - return original -``` - -The new parameter is optional and defaults to empty — existing callers are unaffected. - -#### Step 2: Pass `suppress_invoke_agent_input` to `_EnrichingBatchSpanProcessor` - -```python -class _EnrichingBatchSpanProcessor(BatchSpanProcessor): - def __init__( - self, - *args: object, - suppress_invoke_agent_input: bool = False, - **kwargs: object, - ): - super().__init__(*args, **kwargs) - self._suppress_invoke_agent_input = suppress_invoke_agent_input - - def on_end(self, span: ReadableSpan) -> None: - enriched_span = span - - # Apply registered enricher (framework extensions) - enricher = get_span_enricher() - if enricher is not None: - try: - enriched_span = enricher(span) - except Exception: - logger.exception(...) - - # Apply input message suppression for InvokeAgent spans - if self._suppress_invoke_agent_input: - attrs = enriched_span.attributes or {} - operation_name = attrs.get(GEN_AI_OPERATION_NAME_KEY) - if ( - enriched_span.name.startswith(INVOKE_AGENT_OPERATION_NAME) - and operation_name == INVOKE_AGENT_OPERATION_NAME - ): - enriched_span = EnrichedReadableSpan( - enriched_span, - extra_attributes={}, - excluded_attribute_keys={GEN_AI_INPUT_MESSAGES_KEY}, - ) - - super().on_end(enriched_span) -``` - -#### Step 3: Wire it up in `config.py` - -In `_configure_internal()`, pass `suppress_invoke_agent_input` to the batch processor: - -```python -batch_processor = _EnrichingBatchSpanProcessor( - exporter, - suppress_invoke_agent_input=suppress_invoke_agent_input, - **batch_processor_kwargs, -) -``` - -This works for both the A365 and Spectra exporter paths. - -#### Step 4: Remove suppression from `_Agent365Exporter._map_span()` - -Remove lines 274-284 from `agent365_exporter.py`. The suppression is now handled by the batch processor before the exporter sees the span. Also remove the `suppress_invoke_agent_input` parameter from `_Agent365Exporter.__init__()`. - -### 5.4 Export surface changes - -#### `exporters/__init__.py` - -Add `SpectraExporterOptions` to exports: -```python -from .agent365_exporter_options import Agent365ExporterOptions -from .spectra_exporter_options import SpectraExporterOptions - -__all__ = ["Agent365ExporterOptions", "SpectraExporterOptions"] -``` - -#### `core/__init__.py` - -Add both options classes to the public API for import symmetry. Currently `Agent365ExporterOptions` is only exported from `exporters/__init__.py` — consumers must import from the deeper path. Adding it here alongside `SpectraExporterOptions` ensures consistent import ergonomics: - -```python -from .exporters.agent365_exporter_options import Agent365ExporterOptions -from .exporters.spectra_exporter_options import SpectraExporterOptions -# ... in __all__: -"Agent365ExporterOptions", -"SpectraExporterOptions", -``` - ---- - -## 6. Compliance Checklist - -| Check | Status | -|-------|--------| -| Copyright header on new files | Required | -| No `typing.Any` usage | Will follow — `SpectraExporterOptions` uses concrete types | -| No `_async` suffix on async methods | N/A — no async methods | -| Type hints on all parameters and return types | Yes | -| Explicit `None` checks (`is not None`) | Yes | -| Line length ≤ 100 characters | Yes | -| `Agent365ExporterOptions` API unchanged | Yes | -| Existing test suite passes without modification | Yes | -| No new package dependencies | Yes — `opentelemetry-exporter-otlp` already in core deps | - ---- - -## 7. Test Strategy - -### New test file: `tests/observability/core/test_spectra_exporter.py` - -Tests follow the existing pattern in `test_agent365.py` (unittest.TestCase, Mock, patch): - -**Core functionality:** - -| Test | What it verifies | -|------|-----------------| -| `test_configure_with_spectra_options_default` | `configure()` succeeds with `SpectraExporterOptions()` (all defaults) via public API | -| `test_configure_with_spectra_options_creates_grpc_exporter` | gRPC `OTLPSpanExporter` created with correct endpoint and `insecure=True` | -| `test_configure_with_spectra_options_creates_http_exporter` | HTTP `OTLPSpanExporter` created when `protocol="http"` | -| `test_configure_with_spectra_options_custom_endpoint` | Custom endpoint is passed through to exporter | -| `test_configure_with_spectra_options_ignores_a365_env_var` | `ENABLE_A365_OBSERVABILITY_EXPORTER=true` does not create `_Agent365Exporter`; `is_agent365_exporter_enabled` is not called | -| `test_configure_with_spectra_options_batch_settings` | Batch processor kwargs extracted from `SpectraExporterOptions` | -| `test_configure_with_agent365_options_unchanged` | Existing A365 path still works identically (regression) | -| `test_spectra_exporter_options_defaults` | All default values are correct (`endpoint`, `protocol`, `insecure=True`, batch settings) | - -**Edge cases and interactions:** - -| Test | What it verifies | -|------|-----------------| -| `test_spectra_options_invalid_protocol_raises` | `SpectraExporterOptions(protocol="websocket")` raises `ValueError` | -| `test_configure_spectra_with_otlp_bolt_on` | With `SpectraExporterOptions` + `ENABLE_OTLP_EXPORTER=true`, two exporters are created (documented behavior) | -| `test_configure_spectra_with_suppress_invoke_agent_input` | `suppress_invoke_agent_input=True` with Spectra options creates batch processor with suppression enabled | -| `test_suppress_invoke_agent_input_strips_attribute_in_enriching_processor` | `_EnrichingBatchSpanProcessor` strips `gen_ai.input.messages` from InvokeAgent spans when flag is set | -| `test_enriched_span_excluded_attribute_keys` | `EnrichedReadableSpan` with `excluded_attribute_keys` removes specified attributes | - -### Mocking strategy - -- Mock `microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter` for gRPC tests (module-level import) -- Mock `microsoft_agents_a365.observability.core.config.OTLPSpanExporter` for HTTP tests (module-level import) -- Mock `_EnrichingBatchSpanProcessor` to verify batch kwargs -- Reset `_telemetry_manager` singleton in setUp/tearDown (existing pattern from `test_agent365.py:22-27`) - ---- - -## 8. Consumer Usage - -### Spectra deployment (Weave/Copilot Cowork) — zero config - -```python -from microsoft_agents_a365.observability.core import configure, SpectraExporterOptions - -configure( - service_name="weave-agent", - service_namespace="copilot-cowork", - exporter_options=SpectraExporterOptions(), -) -``` - -### Spectra with custom settings - -```python -configure( - service_name="weave-agent", - service_namespace="copilot-cowork", - exporter_options=SpectraExporterOptions( - endpoint="http://spectra-sidecar:4317", - protocol="http", - max_export_batch_size=1024, - ), -) -``` - -### A365 deployment (unchanged) - -```python -from microsoft_agents_a365.observability.core import configure, Agent365ExporterOptions - -configure( - service_name="my-agent", - service_namespace="my-namespace", - exporter_options=Agent365ExporterOptions( - token_resolver=my_token_resolver, - cluster_category="prod", - ), -) -``` - ---- - -## 9. Risk Assessment - -| Risk | Severity | Likelihood | Mitigation | -|------|----------|------------|------------| -| Spectra sidecar not running → traces silently dropped | Medium | Low (deployment issue) | OTLP exporter logs connection errors with retry. Document sidecar as deployment prereq. | -| Consumer accidentally passes both A365 env var + Spectra options | Low | Medium | Type-based dispatch means Spectra path is taken regardless. No ambiguity. | -| Consumer sets `insecure=False` for remote Spectra endpoint but forgets TLS setup | Low | Low | Only relevant for non-sidecar deployments. Default `insecure=True` is correct for localhost. | -| gRPC import fails if grpc extras not installed | Low | Low | `opentelemetry-exporter-otlp` (core dep) pulls in both gRPC and HTTP sub-packages | -| Union type confuses consumers | Low | Low | Clear docstrings. Only two concrete types. | - ---- - -## 10. Interactions and Notes - -### `ENABLE_OTLP_EXPORTER` - -When `SpectraExporterOptions` is provided and `ENABLE_OTLP_EXPORTER=true` is also set, two OTLP exporters will be active simultaneously: the Spectra exporter and the generic OTLP bolt-on. This doubles memory queues and export I/O. This is documented and tested but is unlikely to be intentional in practice. Consumers should not set `ENABLE_OTLP_EXPORTER` when using `SpectraExporterOptions`. - ---- - -## 11. Version History - -| Version | Date | Changes | -|---------|------|---------| -| 1.0 | 2026-03-14 | Initial design from brainstorm | -| 1.1 | 2026-03-14 | Review feedback: fixed `insecure` default to `True`, eliminated fallback duplication, added `protocol` validation, module-level gRPC import, export surface symmetry, added edge case tests | -| 1.2 | 2026-03-14 | Moved `suppress_invoke_agent_input` from `_Agent365Exporter._map_span()` to `_EnrichingBatchSpanProcessor.on_end()` so it works with both A365 and Spectra exporters. Extended `EnrichedReadableSpan` with `excluded_attribute_keys`. |