OpenTelemetry is often seen as just a way to add tracing to your application, but it’s fundamentally a standard for generating, collecting, and exporting telemetry data, which includes traces, metrics, and logs.

Let’s see how it looks in action with a Laravel application.

<?php

use Illuminate\Support\Facades\Route;
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\Span;
use OpenTelemetry\API\Trace\TracerInterface;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
use OpenTelemetry\SDK\Trace\SpanExporter\ConsoleSpanExporter;

// Initialize the TracerProvider
$tracerProvider = new TracerProvider(
    new SimpleSpanProcessor(new ConsoleSpanExporter())
);
Globals::setTracerProvider($tracerProvider);

Route::get('/', function () {
    $tracer = Globals::tracerProvider()->getTracer('my-app');
    $span = $tracer->spanBuilder('root_span')->start();

    // Simulate some work
    sleep(1);

    $childSpan = $tracer->spanBuilder('child_span')->start();
    sleep(0.5);
    $childSpan->end();

    $span->end();

    return "Hello, World!";
});

This code snippet shows the core of OpenTelemetry instrumentation. When a request hits the root route (/), it:

  1. Gets a Tracer: Globals::tracerProvider()->getTracer('my-app') retrieves a tracer instance associated with our application’s name. This tracer is our factory for creating spans.
  2. Starts a Root Span: $tracer->spanBuilder('root_span')->start() creates and starts a new trace segment, marking the beginning of our operation.
  3. Creates Child Spans: Inside the root span, $tracer->spanBuilder('child_span')->start() creates nested operations, showing the hierarchical nature of traces.
  4. Ends Spans: $span->end() and $childSpan->end() signal the completion of these operations, allowing their duration to be recorded.

This entire process is managed by the TracerProvider. In this example, we’ve configured a SimpleSpanProcessor that uses a ConsoleSpanExporter. This means every time a span finishes, it will be immediately printed to the console in a structured format, allowing us to inspect the trace data directly.

The problem OpenTelemetry solves is the fragmentation of observability data. Before standardized tools like OpenTelemetry, each application or service might have its own way of logging, its own tracing library, and its own metrics collection. This made it incredibly difficult to get a unified view of system behavior, especially in distributed environments. OpenTelemetry provides a vendor-neutral way to instrument your code, ensuring that the data generated can be understood and processed by any compliant backend system, whether that’s Jaeger, Prometheus, Datadog, or a custom solution.

Internally, a trace is a collection of spans. A span represents a single operation within a trace, such as an incoming HTTP request, a database query, or an outgoing HTTP call. Each span has a unique ID, a trace ID (shared by all spans in the same trace), a parent span ID (if it’s a child operation), a name, start time, end time, and attributes (key-value pairs providing context). When you start a span, OpenTelemetry records the current time. When you end it, it records the end time and calculates the duration.

The exact levers you control are primarily the instrumentation points within your code. You decide what to instrument. For a Laravel application, this typically means:

  • Incoming HTTP Requests: Middleware is the natural place to start a root span for each request.
  • Database Queries: Eloquent query listeners can be used to create spans for each SQL query.
  • Outgoing HTTP Requests: Laravel’s HTTP client can be decorated to create spans for external API calls.
  • Queue Jobs: Middleware for queue jobs can instrument the processing of messages.
  • Custom Business Logic: Any complex or critical part of your application can be wrapped in spans to understand its performance.

The TracerInterface is your primary tool for creating these spans. You get an instance of it via Globals::tracerProvider()->getTracer('your-service-name'). From there, spanBuilder('span-name') starts the process, and you can add attributes using setAttribute('key', 'value') before calling start() and end().

What most people miss is that the TracerProvider itself can be configured with multiple SpanProcessor instances. This allows you to, for example, send spans to a console exporter for debugging and simultaneously send them to a batching exporter for a production observability backend, all from the same instrumentation code. You don’t have to choose between local visibility and remote collection; you can have both.

The next concept to explore is how to collect and export this telemetry data to a backend system like Jaeger or Prometheus.

Want structured learning?

Take the full Opentelemetry course →