Getting Started with OpenTelemetry (OTel 101)

OpenTelemetry (OTel) has quickly become a cornerstone of modern observability. If you’re a developer or engineer looking to instrument your applications for better insight, this beginner’s guide is for you. I’ll explain what OpenTelemetry is, why it matters, and walk through a step-by-step tutorial to instrument a simple Java app with basic traces and metrics – all using console output (no external systems required). By the end, you’ll have a solid foundation in OTel and know how to get started in your own projects.
What is OpenTelemetry and Why Does It Matter?
OpenTelemetry (OTel) is an open-source observability framework – essentially a collection of APIs, SDKs, and tools for generating and exporting telemetry data (traces, metrics, and logs) from your software. In practice, OTel allows you to instrument code in a vendor-neutral way, meaning you aren’t locked into any specific monitoring vendor or product. You can send your app’s telemetry to any backend that supports OpenTelemetry, or even multiple backends simultaneously. This flexibility makes OTel a popular choice for cloud-native and microservices architectures.
OTel covers the three key types of telemetry data (aka Observability pillars).
Why use OpenTelemetry? In a word: standardization. Before OTel, engineers had to rely on disparate libraries or vendor-specific agents for tracing and metrics. OTel combines the efforts of prior projects (OpenTracing and OpenCensus) into one standard, simplifying instrumentation. With OTel, you instrument your code once and gain the ability to export data to many analysis tools (open source or commercial) without changing your instrumentation. It’s also cloud-native and CNCF-endorsed – OpenTelemetry is an incubating project under CNCF and is generally available (GA) in many languages, meaning it’s production-ready and supported by a broad community. In short, OTel makes it easier to build reliable, observable systems and focus on writing code rather than wiring up monitoring.
OpenTelemetry Architecture: API, SDK, and Collector
OpenTelemetry’s design is modular. To understand how it works, let’s break down its three main components: the API, the SDK, and the Collector.
- OpenTelemetry API: The API is the interface for instrumentation. It defines the data types and operations for creating spans, recording metrics, and propagating context. When you add OTel to your app, you use the API to mark up your code (for example, starting a span or updating a metric). The API is deliberately kept separate from any specific backend or vendor. It’s lightweight and no-op by default – if no SDK is present, API calls do essentially nothing, incurring minimal overhead. This design means libraries or applications can include OpenTelemetry API calls without forcing a particular telemetry backend on users. Using the API gives you vendor-neutral instrumentation: your telemetry can be sent anywhere, and you can swap out how data is handled without changing the instrumentation code.
- OpenTelemetry SDK: The SDK is the engine under the hood that actually collects and exports telemetry. It’s the implementation of the API for a given language. The SDK provides the functionality to batch and process spans, aggregate metrics, and send data out via exporters. You’ll configure the SDK in your application to specify things like which exporter to use (e.g., send data to Jaeger, Prometheus, etc.), what sampling rate to apply for traces, and other behaviors. In Java, the SDK includes components like the SdkTracerProvider (a factory for tracers), SdkMeterProvider (for metrics), processors, and exporters. Think of the SDK as the piece that handles the heavy lifting of telemetry data – buffering, processing, and transmitting it according to your configuration. Without the SDK, the API calls alone wouldn’t output anything; with the SDK, your spans and metrics get captured and forwarded to your chosen destination.
- OpenTelemetry Collector: The Collector is a separate process (or set of processes) that receives, processes, and exports telemetry data on behalf of your application. It is vendor-agnostic and can accept data in various formats (OTLP, Jaeger, Zipkin, Prometheus, etc.), optionally transform or filter it, and then export it to one or multiple backends. The Collector is often run as an agent (next to your app) or as a centralized service. Why use a Collector? It decouples your application code from specific exporters. Your app can send data in a standard format (OTLP) to the Collector, and the Collector worries about forwarding it to the right place(s). This provides flexibility: you can change or add monitoring backends by updating Collector config, without redeploying your app. In our beginner example we won’t set up a Collector (to keep things simple), but it’s good to know this component exists. In production, a Collector is highly recommended for its buffering, load shedding, and routing capabilities.
How these pieces fit together: In a typical setup, your application code uses the OTel API (e.g., to start spans). You also include and configure the OTel SDK in the app, which handles those API calls (for example, it takes a span and sends it via an exporter). Optionally, you might configure the SDK’s exporter to send data to the Collector instead of directly to a backend. The Collector then forwards data to your observability backend (like Jaeger, Zipkin, Prometheus, Honeycomb, etc.). This layering provides a lot of flexibility and has become an industry standard approach to observability.
Tutorial: Instrumenting a Java Application with OpenTelemetry
Enough theory – let’s get hands-on! In this tutorial, we’ll instrument a simple Java application to emit a basic trace and a metric using OpenTelemetry. We’ll use console logging as our “exporter” so that you can see the telemetry output right in the console (no external systems like Jaeger or Prometheus needed). The goal is to illustrate the end-to-end process: from setting up OTel, to generating spans and metrics, to seeing the results.
Scenario: Imagine we have a simple Java program that processes a batch of items. We want to trace the processing and count how many items were processed. We’ll create spans for the overall batch and each item, and use a counter metric to record the count. All telemetry data will be printed to the console via OpenTelemetry’s logging exporter.
I. Setting Up the Project and Dependencies
First, we need to include OpenTelemetry in our project. If you’re using Maven or Gradle, add the following dependencies (using the latest version of OpenTelemetry Java, which at the time of writing is 1.x):
- OpenTelemetry API (
io.opentelemetry:opentelemetry-api
) – for the instrumentation API (tracers, meters, etc.). - OpenTelemetry SDK (
io.opentelemetry:opentelemetry-sdk
) – for the core implementation (needed to actually emit telemetry). - OpenTelemetry Logging Exporter (
io.opentelemetry:opentelemetry-exporter-logging
) – an exporter that logs spans and metrics to the console using Java’s logging framework. This is what allows us to see trace and metric output in stdout.
With Maven, you might also include the BOM (opentelemetry-bom) to manage versions. For brevity, we’ll skip showing the full pom.xml or Gradle file, but ensure these artifacts are present. Once dependencies are set, we can proceed to writing code.
II. Initializing OpenTelemetry (API + SDK Configuration)
Before we create any spans or metrics, we have to initialize OpenTelemetry in our application. This means configuring the OTel SDK and specifying how it should export data. In our case, we’ll configure the SDK to use the Logging exporter for both traces and metrics (so data goes to console). We’ll do this in a setup method for clarity:
Let’s break down what this does:
- We create an
SdkTracerProvider
and attach aSimpleSpanProcessor
with aLoggingSpanExporter
. The span processor is what takes finished spans and passes them to an exporter; here we use a simple processor that exports spans immediately as they end. The LoggingSpanExporter, provided by the OTel SDK, will log span data using Java’s java.util.logging (JUL) in a human-readable format. In short, each span will result in a log line in our console output. - We create an
SdkMeterProvider
and register aPeriodicMetricReader
with aLoggingMetricExporter
. This means the SDK will collect metrics and, every 1000ms (1 second), it will export the metrics via the LoggingMetricExporter. TheLoggingMetricExporter
similarly uses JUL to log metric data. The periodic reader is necessary because metrics are aggregated – we don’t send a metric every single update, but rather periodically report the current value or delta. By setting the interval to 1 second, we’ll see metrics printed to console every second (which is fine for demonstration). We choose a short interval here just so we don’t have to wait long to see output. - Finally, we build the
OpenTelemetrySdk
with the tracer and meter providers, and register it as the global instance. Marking it global (buildAndRegisterGlobal()) means the staticGlobalOpenTelemetry
accessor or anyone else in the app can get this configured instance. We then return the OpenTelemetry instance as well, for direct use.
At this point, we have an OpenTelemetry SDK initialized to log all telemetry to the console. In a real application, you might configure a different exporter (e.g., an OTLP exporter to send data to a collector or backend), and you might use a BatchSpanProcessor for efficiency, but the above is the simplest setup for illustrative purposes.
III. Instrumenting Metrics and Traces (Spans)
With the SDK ready, we can now instrument our application code. We’ll obtain a Tracer from the OpenTelemetry API to create spans. We’ll also get a Meter to record metrics. Let’s write a simple main method for our demo application:
Let’s walk through this code step by step:
- We retrieve a Tracer and Meter from the OpenTelemetry instance. We pass an instrumentation name("OTelDemoApp") to identify our tracer and meter. This name can be anything describing your instrumentation or service (often it’s a package name or component name, optionally with version). It helps group and identify telemetry – for example, the logging exporter will include this name in span logs (as the “tracer” identity). Using a consistent name is useful when you have multiple instrumented components.
- We create a
LongCounter
named "processed_items". This is a type of metric instrument suited for counting things. We give it a description and unit (just “1” as it counts items). We’ll use this counter to tally how many items we processed. In OpenTelemetry Metrics, a counter is a monotonic increasing value (it only goes up, and often reported as a rate by monitoring systems). - We start a span named "processBatch". This represents the overall work of processing a batch of items. We use
tracer.spanBuilder("processBatch").startSpan()
to create and start the span. The span is now recording, and we want any work done inside this span to be attributed to it. We therefore use a try-with-resources andbatchSpan.makeCurrent()
to make this span the current context. This ensures that any new spans created inside will automatically be children of batchSpan (the OpenTelemetry context is propagated within the thread). - Inside the batch span’s scope, we loop over 5 items. For each item, we create a new span "processItem" and make it current inside the loop. This span represents processing of an individual item. Within that span, our simulated work is just a System.out.println (imagine this could be some processing logic or an I/O operation in a real app). We also call itemsProcessedCounter.add(1) to increment our counter metric each time an item is processed.
- We end each item span when done (in the finally block). After the loop, we exit the batch span’s try-with-resources, which automatically closes the scope, and then we end the batch span. It’s important to end spans; ending a span signals to the SDK that the span is complete and ready to be exported.
- Finally, we sleep for 1.5 seconds. Why? Recall that our metric exporter sends data every 1 second. The sleep ensures that the loop’s last metric update gets picked up by the periodic reader and logged out. Without this, the program might exit before the last metric export occurs. (In a long-running application this isn’t needed; metrics would continuously export on the interval. We do it here because this is a short-lived demo program.)
A quick note on the use of Scope and makeCurrent(): This is one way to manage span context. By making a span current, you tie it to the thread context so that any child spans or telemetry in that context automatically knows the parent span. We used try-with-resources for neatness to ensure scopes are closed. You could also manually manage context, but the pattern above is common and ensures parent-child relationships in traces are set up correctly.
IV. Running the Application and Viewing the Output
Now it’s time to run our DemoApp and see OpenTelemetry in action! Compile and run the Java program (e.g., java DemoApp
). As the program executes, you’ll see the System.out.println messages for “Processing item X”, and more importantly, you’ll see log output from OpenTelemetry’s exporters in the console.
The LoggingSpanExporter will log each span as it ends. You should see log lines for the "processItem" spans and the "processBatch" span. They will look something like:
INFO io.opentelemetry.exporter.logging.LoggingSpanExporter - 'processItem' : 6f9a2f3d2eae4ac9be405ea3d1e2f847 d2e9f12bb6f1d3c9 INTERNAL [tracer: OTelDemoApp] AttributesMap{data={}}
Each span log includes the span name (in quotes), the trace ID and span ID, the span kind (INTERNAL, since these are internal spans), the tracer name (which includes our instrumentation name “OTelDemoApp”), and any attributes (none in our simple example). You’ll have five lines similar to the above for each "processItem" (they will share the same trace ID, since they are part of the same trace, and have different span IDs). Finally, you’ll see the "processBatch" span logged with the same trace ID (as parent) once it ends.
The LoggingMetricExporter will log metrics on the set interval. Since we kept the program alive for a bit, it should have exported at least one metric update. The output might be a bit verbose, but essentially it will indicate the metric name (processed_items) and the value. For example, it could log something like:
INFO io.opentelemetry.exporter.logging.LoggingMetricExporter - Received a collection of 1 metrics for export
INFO io.opentelemetry.exporter.logging.LoggingMetricExporter - Instrument: processed_items, Total: 5
(The exact format may vary, but the key is that our counter’s value (5) gets reported.) This indicates that 5 items were processed, as expected.
Check that the trace logs and metric log make sense: All 5 item spans should have the same trace ID (meaning they are all part of one trace corresponding to the batch), and the batch span is the parent. The metric count should equal 5 at the end. If you see those, congratulations – you have successfully instrumented a Java app with OpenTelemetry!
V. Next Steps and Best Practices
We’ve kept this example simple, but you’ve learned the fundamentals of using OpenTelemetry in Java:
- Initializing the OpenTelemetry SDK with an exporter (console logging in our case).
- Creating a tracer and spans to generate trace data (with parent-child relationships).
- Creating a meter and counter to collect metric data.
- Verifying that telemetry is exported (seeing it in console logs).
From here, there are many directions to go:
- Use a real backend: Instead of logging to console, you can configure other exporters. For example, you could use the OTLP exporter to send data to an OpenTelemetry Collector, or use Jaeger/Zipkin exporters to send traces to those systems. With a backend like Jaeger, you could visualize the trace we created in a web UI, seeing the hierarchy of spans and their durations. Using Prometheus or another metrics backend, you could scrape or receive the metric we created and observe it over time. OTel makes it easy to switch exporters – you’d just swap out the Logging exporter for another in the initialization step (and perhaps adjust configuration like endpoints).
- Automatic instrumentation: In our tutorial, we did manual instrumentation (we wrote code to create spans and metrics). OpenTelemetry also supports auto-instrumentation via the Java Agent, which can instrument many libraries/frameworks without code changes. For instance, if our app were a Spring Boot service, the OTel Java agent could automatically trace incoming HTTP requests, database calls, etc. Auto-instrumentation can jump-start your observability with minimal effort, though manual instrumentation (as we did) is still useful for business-specific code and custom metrics.
- Add more telemetry: We only scratched the surface by recording one metric and very simple spans. In a real app, you might instrument multiple operations with spans, add attributes to spans (e.g., to record context like order ID, user ID, etc.), record additional metrics (gauges, histograms for latencies, etc.), and attach logs to traces. OpenTelemetry allows you to correlate all three signal types (traces, metrics, logs) for a holistic view.
- Learn the API in depth: The OpenTelemetry API has more to offer – for example, context propagation across threads or services (so traces can continue across network calls), configuring Samplers to control tracing volume, and Propagators for context (e.g., W3C Trace Context). As a beginner, you don’t need to master these upfront, but be aware they exist for advanced use cases.
OpenTelemetry provides a powerful, standardized way to make your applications observable. In this guide, we introduced OTel and demonstrated a basic Java instrumentation that prints traces and metrics to the console. You saw how the OpenTelemetry API and SDK work together: the API lets us define spans and metrics in code, while the SDK handles exporting that data. We also discussed OTel’s architecture, including the optional Collector that can sit between your app and telemetry backends for added flexibility.
By implementing OTel in even a simple program, you’ve taken the first steps toward robust observability. From here, you can confidently instrument more complex applications, knowing that the same concepts apply. As you grow, you might integrate with production-grade backends, leverage auto-instrumentation, and fine-tune what you collect – all while relying on OpenTelemetry as a consistent framework. Observability is a journey, but with OpenTelemetry, getting started is no longer the hardest part. Happy tracing!
Source code: https://github.com/ObservabilityHow/OTel-101