Observability Internal API Contract¶
Feature: 018-otel-observability Date: 2026-01-04
Overview¶
This document defines the internal Python API contract for the observability module. These are internal APIs used by HoloDeck components, not exposed to end users.
Module: holodeck.lib.observability¶
Public Functions¶
initialize_observability¶
Initialize all telemetry providers based on configuration.
def initialize_observability(
config: ObservabilityConfig,
agent_name: str
) -> ObservabilityContext:
"""Initialize OpenTelemetry providers for traces, metrics, and logs.
Args:
config: Observability configuration from agent.yaml
agent_name: Agent name from agent.yaml (used for default service name)
Returns:
ObservabilityContext with initialized providers
Raises:
ObservabilityConfigError: If configuration is invalid
ExporterConnectionError: If exporter fails to connect
Note:
Service name resolution order:
1. config.service_name (if provided)
2. f"holodeck-{agent_name}" (default)
Example:
>>> from holodeck.lib.observability import initialize_observability
>>> from holodeck.models.observability import ObservabilityConfig
>>>
>>> config = ObservabilityConfig(enabled=True)
>>> context = initialize_observability(config, agent_name="my-agent")
>>> # Service name is "holodeck-my-agent"
"""
shutdown_observability¶
Gracefully shutdown all telemetry providers.
def shutdown_observability(context: ObservabilityContext) -> None:
"""Flush pending telemetry and shutdown providers.
Args:
context: ObservabilityContext from initialize_observability
Note:
Should be called during application shutdown.
Blocks until all pending spans/metrics are exported.
"""
get_tracer¶
Get a tracer for creating spans.
def get_tracer(name: str) -> Tracer:
"""Get an OpenTelemetry tracer instance.
Args:
name: Tracer name (typically __name__)
Returns:
OpenTelemetry Tracer instance
Example:
>>> tracer = get_tracer(__name__)
>>> with tracer.start_as_current_span("my-operation"):
... # do work
"""
get_meter¶
Get a meter for creating metrics.
def get_meter(name: str) -> Meter:
"""Get an OpenTelemetry meter instance.
Args:
name: Meter name (typically __name__)
Returns:
OpenTelemetry Meter instance
Example:
>>> meter = get_meter(__name__)
>>> counter = meter.create_counter("requests")
>>> counter.add(1)
"""
ObservabilityContext¶
Context object returned by initialize_observability.
@dataclass
class ObservabilityContext:
"""Container for initialized observability components."""
tracer_provider: TracerProvider
meter_provider: MeterProvider
logger_provider: LoggerProvider
exporters: list[str] # Names of enabled exporters
def is_enabled(self) -> bool:
"""Check if observability is active."""
...
def get_resource(self) -> Resource:
"""Get the shared OpenTelemetry resource."""
...
Module: holodeck.lib.observability.exporters¶
Exporter Factory Functions¶
create_otlp_exporter¶
def create_otlp_exporter(
config: OTLPExporterConfig,
signal: Literal["traces", "metrics", "logs"]
) -> SpanExporter | MetricExporter | LogExporter:
"""Create OTLP exporter for the specified signal type.
Args:
config: OTLP exporter configuration
signal: Type of telemetry signal
Returns:
Configured exporter instance
Raises:
ExporterConfigError: If configuration is invalid
"""
create_prometheus_exporter¶
def create_prometheus_exporter(
config: PrometheusExporterConfig
) -> PrometheusMetricReader:
"""Create Prometheus metric reader with HTTP server.
Args:
config: Prometheus exporter configuration
Returns:
PrometheusMetricReader instance
Raises:
PortInUseError: If configured port is already bound
Note:
Starts HTTP server on configured host:port
"""
create_azure_monitor_exporter¶
def create_azure_monitor_exporter(
config: AzureMonitorExporterConfig,
signal: Literal["traces", "metrics", "logs"]
) -> AzureMonitorTraceExporter | AzureMonitorMetricExporter | AzureMonitorLogExporter:
"""Create Azure Monitor exporter for the specified signal type.
Args:
config: Azure Monitor exporter configuration
signal: Type of telemetry signal
Returns:
Configured Azure Monitor exporter
Raises:
ConnectionStringError: If connection string is invalid
"""
Module: holodeck.lib.observability.semantic_conventions¶
GenAI Attribute Constants¶
# Operation names
GENAI_OPERATION_CHAT = "chat"
GENAI_OPERATION_EMBEDDINGS = "embeddings"
GENAI_OPERATION_TEXT_COMPLETION = "text_completion"
# Attribute keys
GENAI_SYSTEM = "gen_ai.system"
GENAI_OPERATION_NAME = "gen_ai.operation.name"
GENAI_REQUEST_MODEL = "gen_ai.request.model"
GENAI_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"
GENAI_REQUEST_TEMPERATURE = "gen_ai.request.temperature"
GENAI_REQUEST_TOP_P = "gen_ai.request.top_p"
GENAI_RESPONSE_ID = "gen_ai.response.id"
GENAI_RESPONSE_MODEL = "gen_ai.response.model"
GENAI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
GENAI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
GENAI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
# Tool execution
GENAI_TOOL_NAME = "gen_ai.tool.name"
GENAI_TOOL_CALL_ID = "gen_ai.tool.call.id"
# Sensitive (opt-in)
GENAI_PROMPT_ROLE = "gen_ai.prompt.{index}.role"
GENAI_PROMPT_CONTENT = "gen_ai.prompt.{index}.content"
GENAI_COMPLETION_ROLE = "gen_ai.completion.{index}.role"
GENAI_COMPLETION_CONTENT = "gen_ai.completion.{index}.content"
Error Types¶
class ObservabilityError(HoloDeckError):
"""Base exception for observability errors."""
pass
class ObservabilityConfigError(ObservabilityError):
"""Invalid observability configuration."""
pass
class ExporterConnectionError(ObservabilityError):
"""Exporter failed to connect to backend."""
pass
class PortInUseError(ObservabilityError):
"""Port already in use (Prometheus exporter)."""
pass
class ConnectionStringError(ObservabilityError):
"""Invalid Azure Monitor connection string."""
pass
Usage in CLI Commands¶
Integration with holodeck chat¶
# In holodeck/cli/commands/chat.py
async def chat_command(agent_path: str) -> None:
config = load_agent_config(agent_path)
context = None
if config.observability and config.observability.enabled:
context = initialize_observability(config.observability, agent_name=config.name)
try:
await run_chat_session(config)
finally:
if context:
shutdown_observability(context)
Integration with holodeck test¶
# In holodeck/cli/commands/test.py
async def test_command(agent_path: str) -> None:
config = load_agent_config(agent_path)
context = None
if config.observability and config.observability.enabled:
context = initialize_observability(config.observability, agent_name=config.name)
try:
results = await run_tests(config)
report_results(results)
finally:
if context:
shutdown_observability(context)
Integration with holodeck serve¶
# In holodeck/serve/app.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
"""FastAPI lifespan handler for observability setup/teardown."""
config = app.state.agent_config
context = None
if config.observability and config.observability.enabled:
context = initialize_observability(config.observability, agent_name=config.name)
app.state.observability_context = context
yield # Server runs here
if context:
shutdown_observability(context)
def create_app(agent_path: str) -> FastAPI:
config = load_agent_config(agent_path)
app = FastAPI(lifespan=lifespan)
app.state.agent_config = config
return app
Note: The serve command uses FastAPI's lifespan context manager to ensure observability is initialized before the server starts accepting requests and properly shutdown when the server stops. Each HTTP request automatically inherits the trace context.
Thread Safety¶
All public functions are thread-safe:
initialize_observability: Uses thread-safe global provider registrationshutdown_observability: Synchronizes flush operationsget_tracer/get_meter: Return thread-safe instances
The observability module follows OpenTelemetry SDK threading guarantees.
Logging Coordination¶
Double Logging Prevention¶
When the console exporter is active (either explicitly or as default), the default HoloDeck logger must be coordinated to prevent duplicate log output.
configure_logging¶
def configure_logging(config: ObservabilityConfig) -> None:
"""Configure logging to prevent duplicates with console exporter.
This function coordinates between HoloDeck's default logging and
OpenTelemetry's console exporter to prevent double logging to stdout.
Args:
config: Observability configuration
Note:
Called automatically by initialize_observability().
When console exporter is active, removes default StreamHandlers
from the holodeck logger to prevent duplicate output.
"""
is_console_exporter_active¶
def is_console_exporter_active(config: ObservabilityConfig) -> bool:
"""Check if console exporter will be used (explicitly or as default).
Returns:
True if console exporter is active, False otherwise.
Note:
Console exporter is active when:
- Explicitly enabled in config, OR
- No other exporters are configured (default fallback)
"""
Implementation Pattern¶
import logging
import sys
def configure_logging(config: ObservabilityConfig) -> None:
"""Configure logging, preventing duplicates with console exporter."""
holodeck_logger = logging.getLogger("holodeck")
if is_console_exporter_active(config):
# Remove default console handlers to prevent double logging
for handler in holodeck_logger.handlers[:]:
if isinstance(handler, logging.StreamHandler):
if handler.stream in (sys.stdout, sys.stderr):
holodeck_logger.removeHandler(handler)
# Set up OTel logging handler if logs are enabled
if config.logs.enabled:
from opentelemetry.sdk._logs import LoggingHandler
otel_handler = LoggingHandler()
holodeck_logger.addHandler(otel_handler)
def is_console_exporter_active(config: ObservabilityConfig) -> bool:
"""Check if console exporter will be used."""
has_explicit_exporter = any([
config.exporters.otlp and config.exporters.otlp.enabled,
config.exporters.prometheus and config.exporters.prometheus.enabled,
config.exporters.azure_monitor and config.exporters.azure_monitor.enabled,
])
return not has_explicit_exporter
Behavior Matrix¶
| Scenario | Default Logger | OTel Console | Result |
|---|---|---|---|
| No observability | Active | N/A | Normal HoloDeck logging |
| Observability + OTLP | Active | Inactive | Normal logging + OTel to OTLP |
| Observability + no exporter | Suppressed | Active | OTel console only |
| Observability + console explicit | Suppressed | Active | OTel console only |
Integration¶
The configure_logging() function is called during initialize_observability():
def initialize_observability(
config: ObservabilityConfig,
agent_name: str
) -> ObservabilityContext:
resource = create_resource(config, agent_name)
# 1. Configure logging first (prevents double logging)
configure_logging(config)
# 2. Set up logging provider
set_up_logging(config, resource)
# 3. Set up tracing
set_up_tracing(config, resource)
# 4. Set up metrics
set_up_metrics(config, resource)
return ObservabilityContext(...)
def create_resource(config: ObservabilityConfig, agent_name: str) -> Resource:
"""Create OpenTelemetry resource with service name.
Service name resolution order:
1. config.service_name (if provided)
2. f"holodeck-{agent_name}" (default)
"""
service_name = config.service_name or f"holodeck-{agent_name}"
return Resource.create({
ResourceAttributes.SERVICE_NAME: service_name,
**config.resource_attributes
})