Tramai Logo
Tramai

Observability

What it is: TramAI provides OpenTelemetry instrumentation through OpenTelemetryOperationObserver. It records one span per provider attempt, emits metrics for latency, tokens, and parse failures, and integrates with your existing OpenTelemetry pipeline.

When to use it: Production deployments where you need visibility into AI call latency, token consumption, error rates, retry behavior, and provider attribution. The module is optional — without it, TramAI runs without any OpenTelemetry dependencies.

Minimum Setup

Add the dependency:

dependencies {
   implementation("dev.tramai:tramai-observability:0.3.1")
}

Create the observer with your OpenTelemetry instance and attach it to the builder:

val observer = OpenTelemetryOperationObserver(openTelemetry)

val tramai = Tramai {
   provider(OpenAiProvider(System.getenv("OPENAI_API_KEY")), name = "openai", default = true)
   model("gpt-4o", "openai")
   observer(observer)
}

That's it. Every provider attempt now produces spans and metrics.

What Is Recorded

Spans (Tracing)

One span per provider attempt with the name ai.<methodName>. Attributes recorded on every span:

AttributeExampleDescription
gen_ai.systemopenaiProvider identifier
gen_ai.request.modelgpt-4oModel requested by the operation
gen_ai.response.modelgpt-4o-2024-08-06Model actually used (when reported)
gen_ai.usage.input_tokens142Input tokens consumed
gen_ai.usage.output_tokens37Output tokens generated
tramai.operation.interfacecom.example.InvoiceAnalyzerService interface name
tramai.operation.methodanalyzeMethod name
tramai.retry.attempt0Retry attempt index (0-based)
tramai.structured.parse_successtrueWhether structured parsing succeeded
tramai.outcomesuccessOverall outcome (success/failure/parse_failure)
tramai.error.typeProviderTransportFailureError type on failure

Span events (additional detail):

  • tramai.parse.failure: Emitted when structured parsing fails (includes raw response length and validation error summary)
  • Engine events (via onEngineEvent): Retry scheduling, fallback routing, circuit-open transitions, token budget warnings

Metrics

Recorded when a meter provider is configured in your OpenTelemetry SDK:

Instrument NameTypeDescription
tramai.operation.attemptsCounterCompleted provider attempts
tramai.operation.durationHistogramDuration of provider attempts (ms)
tramai.operation.input_tokensCounterTotal input tokens across all attempts
tramai.operation.output_tokensCounterTotal output tokens across all attempts
tramai.operation.input_tokens.per_attemptHistogramDistribution of input tokens per attempt
tramai.operation.output_tokens.per_attemptHistogramDistribution of output tokens per attempt
tramai.operation.parse_failuresCounterStructured parse failures
tramai.engine.eventsCounterEngine-owned resilience and routing events

The tramai.engine.events counter carries a tramai.event.name attribute identifying the specific event type (e.g., retry_scheduled, fallback_activated, circuit_open, token_budget_warning).

Token Usage Tracking

Token counts arrive on span attributes and counter/histogram metrics when the provider reports them. Supported providers:

  • OpenAI: Input and output tokens reported per response
  • Anthropic: Input and output tokens, plus thinking tokens
  • Gemini: Input and output tokens
  • Ollama: Varies by model
  • Others: When the provider reports usage, it's captured
// Token data appears automatically in:
// - Span attributes: gen_ai.usage.input_tokens, gen_ai.usage.output_tokens
// - Counter metrics: tramai.operation.input_tokens, tramai.operation.output_tokens
// - Histogram metrics: tramai.operation.input_tokens.per_attempt, tramai.operation.output_tokens.per_attempt
// - StreamChunk.Complete: usage field for streaming operations

Error Rate Monitoring

Errors are captured through three mechanisms:

  1. Span status: Set to StatusCode.ERROR on provider failures with exception recording
  2. Metrics: tramai.operation.parse_failures counter for structured output failures
  3. Span attributes: tramai.outcome and tramai.error.type enable filtering and aggregation

Common error scenarios you can monitor:

Error PatternHow to Detect
Provider unavailabletramai.outcome = "failure", tramai.error.type = "ProviderTransportFailure"
Rate limitingtramai.engine.events with tramai.event.name = "retry_scheduled"
Circuit breaker opentramai.engine.events with tramai.event.name = "circuit_open"
Parse failurestramai.operation.parse_failures counter
Token budget exceededtramai.engine.events with tramai.event.name = "token_budget_warning"

Kotlin + Java Examples

Kotlin

import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.sdk.OpenTelemetrySdk

val openTelemetry: OpenTelemetry = OpenTelemetrySdk.builder().build()
// Configure your exporter pipeline (OTLP, Jaeger, Prometheus, etc.)

val observer = OpenTelemetryOperationObserver(openTelemetry)

val tramai = Tramai {
   provider(OpenAiProvider(System.getenv("OPENAI_API_KEY")), name = "openai", default = true)
   model("gpt-4o", "openai")
   observer(observer)
}

Java

import io.opentelemetry.api.OpenTelemetry;
import dev.tramai.observability.OpenTelemetryOperationObserver;

OpenTelemetry openTelemetry = OpenTelemetrySdk.builder().build();
// Configure your exporter pipeline

OpenTelemetryOperationObserver observer =
   new OpenTelemetryOperationObserver(openTelemetry);

Tramai tramai = Tramai.builder()
   .provider(new OpenAiProvider(System.getenv("OPENAI_API_KEY")), "openai", true)
   .model("gpt-4o", "openai")
   .observer(observer)
   .build();

Why The Observer Sits Outside Providers

TramAI keeps providers small. Providers focus on:

  • Request creation (provider-specific format)
  • HTTP transport
  • Response mapping

The engine wraps those calls with observation hooks so providers do not need tracing-specific logic. This means:

  • Adding a new provider automatically gets observability without provider changes
  • Observability is optional — remove the observer, and no tracing code runs
  • The observer API is stable across provider implementations

Integration with Spring Boot

In Spring Boot, configure OpenTelemetryOperationObserver as a bean and it will be picked up by auto-configuration:

@Configuration
class ObservabilityConfig {
   @Bean
   fun tramaiObserver(openTelemetry: OpenTelemetry): OpenTelemetryOperationObserver =
       OpenTelemetryOperationObserver(openTelemetry)
}

Spring Boot's OpenTelemetry auto-configuration (via io.opentelemetry:opentelemetry-spring-boot-starter) will provide the OpenTelemetry instance. TramAI's auto-configuration picks up any OperationObserver bean.

Verifying Traces in Tests

To assert that TramAI emitted the right spans and metrics during a test, use InMemorySpanExporter and InMemoryMetricReader from the OpenTelemetry SDK testing artifacts:

dependencies {
   testImplementation("io.opentelemetry:opentelemetry-sdk-testing:1.48.0")
   testImplementation("dev.tramai:tramai-testing:0.3.1")
}

Setup

import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter
import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader
import io.opentelemetry.sdk.trace.SdkTracerProvider
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor
import io.opentelemetry.sdk.metrics.SdkMeterProvider
import io.opentelemetry.sdk.OpenTelemetrySdk

class MyObserverTest {
   private val spanExporter = InMemorySpanExporter.create()
   private val metricReader = InMemoryMetricReader.create()

   private val tracerProvider = SdkTracerProvider.builder()
       .addSpanProcessor(SimpleSpanProcessor.create(spanExporter))
       .build()

   private val meterProvider = SdkMeterProvider.builder()
       .registerMetricReader(metricReader)
       .build()

   private val openTelemetry = OpenTelemetrySdk.builder()
       .setTracerProvider(tracerProvider)
       .setMeterProvider(meterProvider)
       .build()

   @AfterEach
   fun tearDown() {
       spanExporter.reset()
   }
}

Asserting Spans

@Test
fun `records span attributes for successful provider call`() = runBlocking {
   val provider = MockAiProvider {
       onMethod("classify") respondWith """{"priority":"high"}"""
   }

   val tramai = Tramai {
       provider(provider, default = true)
       model("gpt-4o", "mock")
       observer(OpenTelemetryOperationObserver(openTelemetry))
   }

   val service = tramai.create<Classifier>()
   val result = service.classify("Urgent issue")

   // Assert exported span
   val span = spanExporter.finishedSpanItems.single()
   assertThat(span.name).isEqualTo("ai.classify")
   assertThat(span.attributes.asMap())
       .containsEntry(AttributeKey.stringKey("gen_ai.system"), "mock")
       .containsEntry(AttributeKey.stringKey("gen_ai.request.model"), "gpt-4o")
       .containsEntry(AttributeKey.stringKey("tramai.operation.method"), "classify")
}

Asserting Metrics

@Test
fun `records attempt and token metrics`() = runBlocking {
   val provider = MockAiProvider {
       onMethod("classify") respondWith """{"priority":"high"}"""
   }

   val tramai = Tramai {
       provider(provider, default = true)
       model("gpt-4o", "mock")
       observer(OpenTelemetryOperationObserver(openTelemetry))
   }

   val service = tramai.create<Classifier>()
   service.classify("Urgent issue")

   val metrics = metricReader.collectAllMetrics()

   // Assert counter values
   val attemptsMetric = metrics.single { it.name == "tramai.operation.attempts" }
   assertThat(attemptsMetric.longSumData.points.single().value).isEqualTo(1L)

   // Assert histogram exists
   val durationMetric = metrics.single { it.name == "tramai.operation.duration" }
   assertThat(durationMetric.histogramData.points.single().count).isEqualTo(1L)
}

Asserting Parse Failures and Retries

@Test
fun `records parse failure span event on retry`() = runBlocking {
   val provider = MockAiProvider {
       onMethod("classify") respondWith "not json"
       onMethod("classify") respondWith """{"priority":"high"}"""
   }

   val tramai = Tramai {
       provider(provider, default = true)
       model("gpt-4o", "mock")
       observer(OpenTelemetryOperationObserver(openTelemetry))
   }

   val service = tramai.create<Classifier>()
   val result = service.classify("Urgent issue")

   // Two spans — first attempt (parse failure) + retry (success)
   assertThat(spanExporter.finishedSpanItems).hasSize(2)

   val firstAttempt = spanExporter.finishedSpanItems.first()
   assertThat(firstAttempt.events).anySatisfy { event ->
       assertThat(event.name).isEqualTo("tramai.parse.failure")
   }

   // Parse failure counter recorded
   val parseFailures = metricReader.collectAllMetrics()
       .single { it.name == "tramai.operation.parse_failures" }
   assertThat(parseFailures.longSumData.points.single().value).isEqualTo(1L)
}

Asserting Engine Events

@Test
fun `records engine events as span events`() = runBlocking {
   val provider = SimulatedFailureProvider {
       onMethod("classify").retryableFailure("rate limited", statusCode = 429)
       onMethod("classify") respondWith """{"priority":"high"}"""
   }

   val tramai = Tramai {
       provider(provider, default = true)
       model("gpt-4o", "mock")
       observer(OpenTelemetryOperationObserver(openTelemetry))
   }

   val service = tramai.create<Classifier>()
   val result = service.classify("Urgent issue")

   // Engine events like retry_scheduled appear as span events
   val span = spanExporter.finishedSpanItems.first()
   assertThat(span.events).anySatisfy { event ->
       assertThat(event.name).isEqualTo("tramai.retry.scheduled")
   }
}

This pattern lets you write deterministic, in-process assertions against the exact OpenTelemetry data your application will emit in production, without needing an exporter or collector running.

Limitations

  • No automatic exporter setup: The observer does not configure exporters for you. You must set up your own OpenTelemetry SDK, meter provider, and exporter pipeline (OTLP, Jaeger, Prometheus, etc.).
  • No dashboards: The module provides instrumentation, not pre-built dashboards. You configure those in your observability platform.
  • No trace correlation helpers: Outside standard OpenTelemetry usage, no additional trace ID propagation helpers are provided.
  • Streaming-specific metrics: Streaming operations produce completion metrics but do not emit per-chunk metrics.
  • Provider-specific attributes: Provider-specific details (e.g., Anthropic thinking tokens, OpenAI system fingerprint) are captured when available but not all attributes are available from all providers.

Next Steps