Observability

We will start this chapter with OpenTelemetry integration. OpenTelemetry can be used to collect all kinds of telemetry data: metrics, logs, and traces. We will focus on tracing in this example, and show how to generate metrics from those tracing events later.

OpenTelemetry integration

In this section we will implement the basics of OpenTelemetry integration.

What is OpenTelemetry? According to their site, opentelemetry.io:

"OpenTelemetry is a collection of tools, APIs, and SDKs. Use it to instrument, generate, collect, and export telemetry data (metrics, logs, and traces) to help you analyze your software’s performance and behavior."

We already use tracing in this application, but the events are simply written to the standard output. Using OpenTelemetry we can forward this data into various data collectors.

During this example I will use hyperdx.io as the data collector. They offer a free tier that is more than enough for our needs. Of course, you can use any other data collector that supports OpenTelemetry, like Jaeger, Grafana Tempo, etc.

To create a hyperdx.io account, go to their site and sign up. After you sign up, you will get an ingest API key. You will need this key to send the data to the collector.

You can find the sample codes on GitHub.

We already have a minimal tracing configuration in src/commands/serve.rs:

    let subscriber = tracing_subscriber::registry()
        .with(LevelFilter::from_level(Level::TRACE))
        .with(fmt::Layer::default());

    subscriber.init();

This one writes the events to the standard output. We will replace it with a more complex configuration that will forward the events to the HyperDX collector.

First, we need to add the OpenTelemetry dependencies to the Cargo.toml:

[dependencies]
opentelemetry = { version = "0.27", features = ["metrics", "logs"] }
opentelemetry_sdk = { version = "0.27", features = ["rt-tokio", "logs"] }
opentelemetry-otlp = { version = "0.27", features = ["tonic", "http-json", "metrics", "logs", "reqwest-client", "reqwest-rustls"]  }
opentelemetry-semantic-conventions = { version = "0.13.0" }
tracing-opentelemetry = "0.28.0"

The opentelemetry crate is the main crate that provides basics of the OpenTelemetry implementation. The opentelemetry_sdk crate is the official SDK for OpenTelemetry. The opentelemetry-otlp crate is the OpenTelemetry protocol implementation, this one is used to actually send the data to the collectors over the network.

The opentelemetry-semantic-conventions crate provides the semantic conventions for the OpenTelemetry events. The tracing-opentelemetry crate is the bridge between the tracing and opentelemetry crates.

Now we have to add an option to our application settings to enable the users to turn on the OpenTelemetry integration.

Add the following to the src/settings.rs:

#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OtlpTarget {
    pub address: String,
    pub authorization: Option<String>,
}

and extend the Logging struct with the otlp_target field:


#[derive(Debug, Deserialize, Default, Clone)]
#[allow(unused)]
pub struct Logging {
    pub log_level: Option<String>,
    pub otlp_target: Option<OtlpTarget>,
}

This way we can configure the address of the collector and the authorization key in the application settings (either via a configuration file or via environment variables).

Now, to initate the OpenTelemetry integration, we have to modify the src/commands/serve.rs file. First, we have to add the OpenTelemetry initialization code. We have to import a log of things:

use crate::settings::OtlpTarget;
use opentelemetry::trace::{TraceError, TracerProvider};
use opentelemetry::{global, KeyValue};
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
use opentelemetry_sdk::trace::{RandomIdGenerator, Sampler, Tracer};
use opentelemetry_sdk::{runtime, trace, Resource};
use std::collections::HashMap;
use opentelemetry_sdk::propagation::TraceContextPropagator;

Then we have to add the init_tracer function that will initialize the OpenTelemetry integration based on the OtltTarget settings:

pub fn init_tracer(otlp_target: &OtlpTarget) -> Result<Tracer, TraceError> {
    // ...
}

This function will return a Tracer instance that we can use to create spans in our application.

If we want to enable distributed tracing, we have to setup OpenTelemetry context propagation:

    global::set_text_map_propagator(TraceContextPropagator::new());

Now first, we have to create a SpanExporter instance that will send the data to the collector. We will use the opentelemetry_otlp crate for this:

    let otlp_endpoint = otlp_target.address.as_str();

    let mut builder = opentelemetry_otlp::SpanExporter::builder()
        .with_http()
        .with_endpoint(otlp_endpoint);

    if let Some(authorization) = &otlp_target.authorization {
        let mut headers = HashMap::new();
        headers.insert(String::from("Authorization"), authorization.clone());
        builder = builder.with_headers(headers);
    };

    let exporter = builder.build()?;

We create the SpanExporter instance with the HTTP transport and the address of the collector. If the authorization key is set, we add it to the headers of the HTTP request. Finally, we build the exporter.

Next, we have to create a TracerProvider instance that will provide the Tracer instances to our application. The provider uses our already created exporter to send the data to the collector. We also have to specify that we are using the tokio async runtime.

    let tracer_provider = trace::TracerProvider::builder()
        .with_batch_exporter(exporter, runtime::Tokio)
        .with_config(
            trace::Config::default()
                .with_sampler(Sampler::AlwaysOn)
                .with_id_generator(RandomIdGenerator::default())
                .with_max_events_per_span(64)
                .with_max_attributes_per_span(16)
                .with_max_events_per_span(16)
                .with_resource(Resource::new(vec![KeyValue::new(
                    "service.name",
                    "sample_application",
                )])),
        )
        .build();

    Ok(tracer_provider.tracer("sample_application"))

We configured the provider with same sane defaults. Most of these are optional, we just added them to demonstrate the possibilities. We also set an service name, we can use this to identify the service in the collector.

Finally, we have to modify the serve function in src/commands/serve.rs to initialize the OpenTelemetry integration when an OtlpTarget is defined in the settings.

First, we create the telemetry_layer that will be used to forward the tracing events to the collector. Notice, that this is an Option, because we only want to use it if the OtlpTarget is defined in the settings.

    let telemetry_layer = if let Some(otlp_target) = settings.logging.otlp_target.clone() {
        let tracer = init_tracer(&otlp_target)?;
        Some(tracing_opentelemetry::layer().with_tracer(tracer))
    } else {
        None
    };

Then we create the stdout_log layer that will write the events to the standard output:

    let stdout_log = tracing_subscriber::fmt::layer().with_filter(
        tracing_subscriber::EnvFilter::try_from_default_env()
            .unwrap_or(tracing_subscriber::EnvFilter::new("info")),
    );

This time we use the EnvFilter to set the log level based on the RUST_LOG environment variable (possible values for global configuration: trace, debug, info, warn, error). We will use the info level as the default. For more information about the EnvFilter see the documentation.

Finally, we create the subscriber with the telemetry_layer and the stdout_log layer:

    let subscriber = tracing_subscriber::registry()
        .with(telemetry_layer)
        .with(stdout_log);

    subscriber.init();

Luckily, the tracing crate accepts optionals as the layers, so we can simply pass the telemetry_layer and the stdout_log to the with method of the registry and it will work as expected.

Now we are ready to send tracing events to the collector, but we have to add instrumentation to our application to actually send some events.

For example, we can add a layer to our axum configuration to trace all the requests in src/api/mod.rs. We used the example from axum's repository:

pub fn configure(state: Arc<ApplicationState>) -> Router {
    Router::new()
        .merge(SwaggerUi::new("/swagger-ui").url(
            "/v1/api-docs/openapi.json",
            crate::api::v1::ApiDoc::openapi(),
        ))
        .nest("/v1", v1::configure(state))
        .layer(
            TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
                // Log the matched route's path (with placeholders not filled in).
                // Use request.uri() or OriginalUri if you want the real path.
                let matched_path = request
                    .extensions()
                    .get::<MatchedPath>()
                    .map(MatchedPath::as_str);

                tracing::info_span!(
                    "http_request",
                    method = ?request.method(),
                    matched_path,
                )
            }),
        )
}

We can also manually instrument our code with the tracing crate. For example on a specific endpoint:

#[utoipa::path(
    get,
    path = "/hello",
    tag = "hello",
    responses(
        (status = 200, description = "Hello World", body = String),
    ),
)]
#[instrument(skip(state))]
pub async fn hello(State(state): State<Arc<ApplicationState>>) -> Result<String, StatusCode> {
    tracing::info!("Hello world!");
    Ok(format!(
        "\nHello world! Using configuration from {}\n\n",
        state
            .settings
            .load()
            .config
            .location
            .clone()
            .unwrap_or("[nowhere]".to_string())
    ))
}

We had to skip the state parameter, because the instrument attribute does not support parameters that are not debuggable, and it does not carry useful information for the tracing anyway.

For more detailed information about the instrument attribute see the documentation.

We can also add further events within the instrumented function, see the tracing::info!("Hello world!"); line in the example above.

Now we can start the application with the OpenTelemetry integration enabled.

To enable logging to the console set the RUST_LOG environment variable:

$ export RUST_LOG="trace"

To enable the OpenTelemetry integration, set the APP__LOGGING__OTLP_TARGET__ADDRESS environment variable:

$ export APP__LOGGING__OTLP_TARGET__ADDRESS="https://in-otel.hyperdx.
io/v1/traces"

and the APP__LOGGING__OTLP_TARGET__AUTHORIZATION environment variable:

$ export APP__LOGGING__OTLP_TARGET__AUTHORIZATION="<YOUR_API_KEY>"

Now we can compile and run the application. If you checked out the source code from github, do not forget to start and initialize the database first, see the Persistence section for more information.

$ cargo build
$ ./target/debug/cli_app serve

To test the integration, we can use the curl command to send a request to the API:

$ curl -v http://127.0.0.1:8080/v1/hello

In the console output you can already see the tracing events and the connection to the collector:

DEBUG reqwest::connect: starting new connection: https://in-otel.hyperdx.io/    

On the HyperDX site you can see the traces in the Search section. For example, the list of traces:

.

After clicking on the http_request span, you can see the details of the trace:

.

This is a very basic example of OpenTelemetry integration. You can add more instrumentation to your application to get more detailed traces. But keep in mind that the more events you send, the more data you have to store and process. You should always consider the costs and benefits of the data you collect.