OpenTelemetry exporters let you send telemetry data to different backends, but the real magic is how they decouple data generation from data storage, enabling a unified view across diverse systems.
Let’s see it in action. Imagine you have a Go application generating traces.
package main
import (
"context"
"log"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
)
func initProvider() (func(context.Context) error, error) {
ctx := context.Background()
// Jaeger Exporter
jaegerExporter, err := jaeger.New(
jaeger.WithCollectorEndpoint(jaeger.WithEndpoint("http://localhost:14268/api/traces")),
)
if err != nil {
return nil, err
}
tp := trace.NewTracerProvider(
trace.WithBatcher(jaegerExporter),
trace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String("my-go-app"),
)),
)
return tp.Shutdown, nil
}
func main() {
ctx := context.Background()
shutdown, err := initProvider()
if err != nil {
log.Fatal(err)
}
defer shutdown(ctx)
tracer := otel.Tracer("my-tracer")
_, span := tracer.Start(ctx, "my-operation")
defer span.End()
time.Sleep(2 * time.Second) // Simulate work
log.Println("Application running...")
}
This simple Go program starts by initializing an OpenTelemetry TracerProvider. The initProvider function is key. It creates a jaeger.Exporter pointed at http://localhost:14268/api/traces, which is the default endpoint for a locally running Jaeger agent. It then configures a trace.NewTracerProvider that uses this exporter. Crucially, it also attaches a resource attribute, semconv.ServiceNameKey.String("my-go-app"), which tells Jaeger which service generated these traces. The tp.Shutdown function is returned to ensure all buffered spans are sent before the application exits.
In main, we get a tracer and start a span named "my-operation". The defer span.End() ensures the span is closed and its duration is recorded. When the application runs, this span, along with its timing and attributes, will be sent to the configured Jaeger collector.
Now, let’s add Prometheus metrics. For this, we’ll use a different exporter. You’d typically run a Prometheus metrics exporter alongside your application, often as a sidecar or a separate process that scrapes your application’s metrics endpoint.
Here’s how you might configure a Prometheus exporter in Go. This example assumes you’re using the prometheus exporter from OpenTelemetry Go, which exposes metrics via an HTTP server that Prometheus can scrape.
package main
import (
"context"
"log"
"net/http"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/metric/global"
"go.opentelemetry.io/otel/metric/instrument"
"go.opentelemetry.io/otel/sdk/metric/aggregator/histogram"
"go.opentelemetry.io/otel/sdk/metric/batcher/ungrouped"
"go.opentelemetry.io/otel/sdk/metric/controller/push"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
)
func initMetricsProvider() (func(context.Context) error, error) {
ctx := context.Background()
// Prometheus Exporter
exporter, err := prometheus.New(
prometheus.WithNamespace("my_app"), // Namespace for metrics
prometheus.WithContains([]string{"my-go-app"}), // Filter by service name
)
if err != nil {
return nil, err
}
// Create a push controller for the Prometheus exporter
// This controller manages the lifecycle of metric instruments and pushes them to the exporter.
// The interval specifies how often metrics are pushed.
pusher := push.New(
exporter,
push.WithInterval(5*time.Second),
push.WithCollector(histogram.New(ungrouped.New())), // Use histogram aggregator
)
// Start the pusher. This will begin exporting metrics periodically.
if err := pusher.Start(ctx); err != nil {
return nil, err
}
// Register the global meter provider with the pusher's collector.
// This makes the metrics generated by `global.MeterProvider()` available for export.
global.SetMeterProvider(pusher.MeterProvider())
// Return a function to stop the pusher gracefully.
return pusher.Stop, nil
}
func initTracerProvider() (func(context.Context) error, error) {
ctx := context.Background()
jaegerExporter, err := jaeger.New(
jaeger.WithCollectorEndpoint(jaeger.WithEndpoint("http://localhost:14268/api/traces")),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(jaegerExporter),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String("my-go-app"),
)),
)
return tp.Shutdown, nil
}
func main() {
ctx := context.Background()
// Initialize metrics provider
metricsShutdown, err := initMetricsProvider()
if err != nil {
log.Fatal(err)
}
defer metricsShutdown(ctx)
// Initialize tracer provider
tracerShutdown, err := initTracerProvider()
if err != nil {
log.Fatal(err)
}
defer tracerShutdown(ctx)
// --- Metrics Setup ---
meter := global.MeterProvider().Meter("my-meter")
counter, err := meter.SyncInt64().Counter("my_requests_total")
if err != nil {
log.Fatal(err)
}
// --- Tracing Setup ---
tracer := otel.Tracer("my-tracer")
// Simulate incoming requests
for i := 0; i < 5; i++ {
_, span := tracer.Start(ctx, "process_request")
span.SetAttributes(attribute.String("request.id", "req-123"))
// Record a metric
counter.Add(ctx, 1, instrument.WithAttributes(attribute.String("status", "success")))
time.Sleep(1 * time.Second) // Simulate work
span.End()
}
log.Println("Application running and collecting telemetry...")
time.Sleep(10 * time.Second) // Keep application alive to allow Prometheus scraping
}
In this augmented example, initMetricsProvider sets up the prometheus.Exporter. The prometheus.WithNamespace("my_app") prefixes all exported metrics with my_app., and prometheus.WithContains([]string{"my-go-app"}) ensures only metrics from our service are exposed. A push.New controller is used to periodically push metrics to the exporter. global.SetMeterProvider(pusher.MeterProvider()) makes sure that any metrics created using global.MeterProvider() will be picked up by our Prometheus exporter.
The main function now also gets a meter and creates a Counter called my_requests_total. Inside the loop, after starting a trace span, we increment this counter. The instrument.WithAttributes adds a status: success label to the metric.
To make this fully functional, you’d need a Prometheus server configured to scrape an endpoint like http://<your-app-ip>:9090/metrics (the default for the Prometheus exporter). Your Prometheus configuration would look something like this:
scrape_configs:
- job_name: 'my-go-app'
static_configs:
- targets: ['<your-app-ip>:9090']
The Prometheus exporter, by default, listens on port 9090 and exposes metrics in Prometheus exposition format. Prometheus polls this endpoint, collects the metrics (like my_app_my_requests_total{service_name="my-go-app",status="success"} 1), and stores them.
The core idea is that exporters abstract away the specifics of each backend. You configure the exporter for Jaeger, and you configure the exporter for Prometheus. OpenTelemetry then handles translating your generic trace and metric data into the format each exporter understands and sends it to the designated endpoint. This allows you to send the same trace data to Jaeger for debugging and the same metric data to Prometheus for monitoring, all from a single instrumentation library.
One subtle but powerful aspect of how exporters work is their buffering and batching behavior. For instance, the Jaeger exporter uses a trace.WithBatcher which means it doesn’t send each span as soon as it’s finished. Instead, it collects spans in memory and sends them in batches. This significantly reduces the overhead of network calls and improves efficiency, especially for high-throughput applications. The size of these batches and the frequency of sending are configurable, allowing you to tune performance based on your application’s load and network conditions.
The next logical step is to explore how to combine different exporters, such as sending traces to Jaeger and metrics to Prometheus simultaneously, or understanding how to configure the sampling rate for traces to manage volume.