OpenTelemetry for C++: Traces, Metrics & Logs with OpenObserve
Instrument C++ apps with opentelemetry-cpp — traces, metrics, and logs via OTLP HTTP to OpenObserve. CMake, vcpkg, Conan setup, code, and troubleshooting.
C++ is in an unusually strong position in OpenTelemetry: opentelemetry-cpp has all three signals — traces, metrics, and logs — marked stable, something many higher-level languages still cannot claim. The trade-off is that C++ asks more of you at build time: you compile the SDK (or pull it from vcpkg/Conan), enable the OTLP HTTP exporter explicitly, and wire every provider up in code. This guide walks through the whole path — building the SDK, instrumenting traces, metrics, and logs, and shipping everything to OpenObserve over OTLP.
If you are new to OpenTelemetry itself — the signals, the collector, OTLP — start with What is OpenTelemetry? A Complete Guide and come back here for the C++ specifics.
Why OpenTelemetry for C++
C++ services live where performance is non-negotiable — trading systems, game servers, databases, media pipelines, embedded gateways. Observability options there have historically been grim: hand-rolled logging, vendor agents that do not exist for C++, or nothing. OpenTelemetry changes that:
- Vendor-neutral instrumentation. Instrument once, export anywhere OTLP is accepted. No proprietary SDK compiled into your binary.
- An ABI-stable, header-only API. The
opentelemetry-apicomponent is header-only and designed so that a library instrumented against it keeps working across SDK upgrades — critical in C++ where ABI breaks are expensive. - Low overhead by design. When no SDK is installed, every API call resolves to a no-op. Instrumented libraries cost effectively nothing until an application actually wires up exporters.
Signal stability in C++
As of mid-2026, the status of the C++ implementation is:
| Signal | Status | Notes |
|---|---|---|
| Traces | Stable | API and SDK stable; safe for production |
| Metrics | Stable | All instrument types, views, and OTLP export are GA |
| Logs | Stable | Logs API/SDK and OTLP log exporter are stable — ahead of several other languages |
All three signals are covered by the project’s stability guarantees. What is not on par with other SDKs is configuration ergonomics: several standard OTEL_* environment variables are unimplemented in C++, and there is no autoconfiguration layer. The environment variables section below covers exactly what works.
Prerequisites
- A C++14-or-later toolchain (GCC, Clang, or MSVC) and CMake 3.16+. C++17 is a comfortable choice for new projects; check the opentelemetry-cpp README for the currently supported standards and platforms.
- The OTLP HTTP exporter’s dependencies if building from source: Protobuf and libcurl (and nlohmann-json). On Debian/Ubuntu:
apt install libcurl4-openssl-dev libprotobuf-dev protobuf-compiler nlohmann-json3-dev. - An OpenObserve instance. Either an OpenObserve Cloud account or a self-hosted instance. For local testing, a single container is enough:
docker run -d --name openobserve \
-p 5080:5080 -p 5081:5081 \
-e ZO_ROOT_USER_EMAIL="root@example.com" \
-e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
public.ecr.aws/zinclabs/openobserve:latest
- Your OpenObserve credentials. OTLP ingestion uses HTTP Basic auth. Generate the token once:
echo -n 'root@example.com:Complexpass#123' | base64
This prints cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM= — the value you will pass in the Authorization: Basic header.
In OpenObserve Cloud, the ingestion page under Data Sources shows your organization-specific endpoint and a ready-made Basic auth token, so you rarely need to construct it by hand.
Installing opentelemetry-cpp
First, understand the split you are installing:
opentelemetry-api— header-only. Libraries that want to emit telemetry depend only on this; it compiles to no-ops unless an SDK is present. The API deliberately usesnostd::types (nostd::shared_ptr,nostd::string_view) instead ofstd::in its surface to keep the ABI stable across compilers and standard-library versions.opentelemetry-sdk— the compiled implementation: providers, processors, samplers, resources. The application (the thing withmain()) links the SDK plus the exporters it needs, and wires them to the API’s global providers at startup.
This is the same API/SDK separation every OpenTelemetry language has, but in C++ it is physical: the API is headers you include, the SDK is libraries you link. The OTLP exporters are opt-in at build time — if the SDK was built without them, no amount of runtime configuration will conjure them up.
Option 1: build from source with CMake
git clone --recurse-submodules https://github.com/open-telemetry/opentelemetry-cpp.git
cd opentelemetry-cpp
cmake -B build -S . \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_STANDARD=17 \
-DWITH_OTLP_HTTP=ON \
-DWITH_OTLP_GRPC=OFF \
-DBUILD_TESTING=OFF \
-DWITH_EXAMPLES=OFF \
-DOPENTELEMETRY_INSTALL=ON
cmake --build build --parallel
sudo cmake --install build
-DWITH_OTLP_HTTP=ON is the flag that matters for this guide: it builds the OTLP/HTTP exporters for all three signals and pulls in the Protobuf and libcurl dependencies. Add -DWITH_OTLP_GRPC=ON only if you actually need gRPC transport — it drags in the full gRPC stack, which is the single biggest source of C++ OTel build pain (Abseil/Protobuf version conflicts). If you can live with HTTP, leave gRPC off.
Option 2: vcpkg
vcpkg install "opentelemetry-cpp[otlp-http]"
Add otlp-grpc to the feature list if you need gRPC. With the vcpkg CMake toolchain file in play, find_package(opentelemetry-cpp CONFIG REQUIRED) resolves automatically.
Option 3: Conan
[requires]
opentelemetry-cpp/1.26.0
[options]
opentelemetry-cpp/*:with_otlp_http=True
[generators]
CMakeDeps
CMakeToolchain
Check ConanCenter for the latest recipe version — the recipes typically trail upstream releases by a little.
Linking in your project
However you installed it, consumption looks the same:
cmake_minimum_required(VERSION 3.16)
project(checkout_service CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(opentelemetry-cpp CONFIG REQUIRED)
add_executable(checkout_service main.cc)
target_link_libraries(checkout_service PRIVATE
opentelemetry-cpp::trace
opentelemetry-cpp::metrics
opentelemetry-cpp::logs
opentelemetry-cpp::otlp_http_exporter
opentelemetry-cpp::otlp_http_metric_exporter
opentelemetry-cpp::otlp_http_log_record_exporter
)
Zero-code instrumentation: the short answer for C++
C++ has no runtime agent. There is no bytecode to rewrite and no interpreter to monkey-patch — your program is a native binary, so the SDK must be compiled in and initialized explicitly in main(). If you are arriving from Java or .NET, recalibrate: in C++, you build the pipeline.
A few things exist adjacent to zero-code, and it is worth knowing their real scope:
- Web-server modules. The opentelemetry-cpp-contrib repository ships OpenTelemetry modules for NGINX and Apache httpd that trace requests through those servers without touching your upstream application code. Useful at the edge; they do not instrument your own binaries.
- eBPF instrumentation. OpenTelemetry’s eBPF-based instrumentation can capture HTTP/gRPC spans from unmodified processes at the kernel level on Linux. It gives coarse, protocol-level visibility — not the function-level spans, custom attributes, or metrics you get from the SDK.
- Instrumented libraries. Because the API is header-only and no-op by default, some C++ libraries and frameworks (and the contrib repo’s gRPC/httpd/nginx pieces) emit telemetry that lights up automatically once your application installs an SDK. The ecosystem here is thinner than Go’s or Java’s, though — expect to write more manual spans in C++ than in any other major language.
The rest of this guide covers the production path: explicit SDK setup in your application code.
Manual instrumentation
The pattern for every signal is the same: create an OTLP exporter, wrap it in a processor (batch for traces/logs, periodic reader for metrics), attach a Resource, build a provider, and register it globally. One deliberate detail in all the code below: the exporter options are default-constructed, which makes them read OTEL_EXPORTER_OTLP_* environment variables — so the same binary runs against local, staging, and cloud backends without a rebuild.
Traces
A complete, runnable example:
#include <chrono>
#include <memory>
#include <thread>
#include "opentelemetry/context/propagation/global_propagator.h"
#include "opentelemetry/exporters/otlp/otlp_http_exporter_factory.h"
#include "opentelemetry/exporters/otlp/otlp_http_exporter_options.h"
#include "opentelemetry/sdk/resource/resource.h"
#include "opentelemetry/sdk/trace/batch_span_processor_factory.h"
#include "opentelemetry/sdk/trace/batch_span_processor_options.h"
#include "opentelemetry/sdk/trace/provider.h"
#include "opentelemetry/sdk/trace/tracer_provider.h"
#include "opentelemetry/sdk/trace/tracer_provider_factory.h"
#include "opentelemetry/trace/propagation/http_trace_context.h"
#include "opentelemetry/trace/provider.h"
namespace trace_api = opentelemetry::trace;
namespace trace_sdk = opentelemetry::sdk::trace;
namespace otlp = opentelemetry::exporter::otlp;
namespace resource = opentelemetry::sdk::resource;
// Keep a handle to the SDK provider so we can flush it at shutdown.
std::shared_ptr<trace_sdk::TracerProvider> tracer_provider;
void InitTracer()
{
// Default-constructed options read OTEL_EXPORTER_OTLP_ENDPOINT and
// OTEL_EXPORTER_OTLP_HEADERS — see the OpenObserve section below.
otlp::OtlpHttpExporterOptions opts;
auto exporter = otlp::OtlpHttpExporterFactory::Create(opts);
trace_sdk::BatchSpanProcessorOptions bsp_opts; // defaults honor OTEL_BSP_* vars
auto processor =
trace_sdk::BatchSpanProcessorFactory::Create(std::move(exporter), bsp_opts);
auto res = resource::Resource::Create({
{"service.name", "checkout-service"},
{"service.version", "1.4.2"},
});
tracer_provider = trace_sdk::TracerProviderFactory::Create(std::move(processor), res);
std::shared_ptr<trace_api::TracerProvider> api_provider = tracer_provider;
trace_sdk::Provider::SetTracerProvider(api_provider);
// W3C traceparent propagation must be set in code — the C++ SDK
// does NOT read OTEL_PROPAGATORS.
opentelemetry::context::propagation::GlobalTextMapPropagator::SetGlobalPropagator(
opentelemetry::nostd::shared_ptr<
opentelemetry::context::propagation::TextMapPropagator>(
new trace_api::propagation::HttpTraceContext()));
}
void CleanupTracer()
{
// Flush pending spans, then restore the no-op provider.
if (tracer_provider)
{
tracer_provider->ForceFlush(std::chrono::microseconds(5'000'000));
}
tracer_provider.reset();
std::shared_ptr<trace_api::TracerProvider> none;
trace_sdk::Provider::SetTracerProvider(none);
}
int main()
{
InitTracer();
auto tracer =
trace_api::Provider::GetTracerProvider()->GetTracer("checkout-service", "1.4.2");
auto span = tracer->StartSpan("process-payment");
auto scope = tracer->WithActiveSpan(span); // active for this lexical scope
span->SetAttribute("payment.provider", "stripe");
span->SetAttribute("cart.items", 3);
{
// Parented to process-payment automatically via the active span.
auto child = tracer->StartSpan("charge-card");
std::this_thread::sleep_for(std::chrono::milliseconds(40));
child->End();
}
span->End();
CleanupTracer();
return 0;
}
Three details matter most:
- Scopes drive parenting.
tracer->WithActiveSpan(span)returns an RAIIScopethat makes the span active on the current thread; spans started while it lives become children automatically. Let theScopefall out of scope and the previous context is restored — neverEnd()a span while relying on a scope you already destroyed elsewhere. - The propagator is your job. Unlike most SDKs, C++ ships with no propagator installed and ignores
OTEL_PROPAGATORS. Without theHttpTraceContextregistration above, notraceparentheader is injected or extracted and every service produces disconnected traces. CleanupTraceris not optional. The batch processor buffers spans in memory; a process that exits without flushing silently drops its final batch.
For cross-service propagation you also need to wire the propagator into your HTTP layer: implement TextMapCarrier over your client’s header map and call propagator->Inject(carrier, context) on outgoing requests (and Extract on incoming ones). The contrib repository has ready-made pieces for some frameworks, but with C++‘s fragmented HTTP ecosystem, expect to write this glue once per client/server library you use.
Metrics
Metrics swap the batch processor for a PeriodicExportingMetricReader that collects and exports on an interval:
#include <chrono>
#include <map>
#include "opentelemetry/common/key_value_iterable_view.h"
#include "opentelemetry/context/context.h"
#include "opentelemetry/exporters/otlp/otlp_http_metric_exporter_factory.h"
#include "opentelemetry/exporters/otlp/otlp_http_metric_exporter_options.h"
#include "opentelemetry/metrics/provider.h"
#include "opentelemetry/sdk/metrics/export/periodic_exporting_metric_reader_factory.h"
#include "opentelemetry/sdk/metrics/export/periodic_exporting_metric_reader_options.h"
#include "opentelemetry/sdk/metrics/meter_context_factory.h"
#include "opentelemetry/sdk/metrics/meter_provider.h"
#include "opentelemetry/sdk/metrics/meter_provider_factory.h"
#include "opentelemetry/sdk/metrics/provider.h"
namespace metrics_api = opentelemetry::metrics;
namespace metrics_sdk = opentelemetry::sdk::metrics;
namespace otlp = opentelemetry::exporter::otlp;
void InitMetrics()
{
otlp::OtlpHttpMetricExporterOptions opts; // reads OTEL_EXPORTER_OTLP_* vars
auto exporter = otlp::OtlpHttpMetricExporterFactory::Create(opts);
metrics_sdk::PeriodicExportingMetricReaderOptions reader_opts;
reader_opts.export_interval_millis = std::chrono::milliseconds(15000);
reader_opts.export_timeout_millis = std::chrono::milliseconds(5000);
auto reader = metrics_sdk::PeriodicExportingMetricReaderFactory::Create(
std::move(exporter), reader_opts);
// The default MeterContext resource merges OTEL_SERVICE_NAME and
// OTEL_RESOURCE_ATTRIBUTES from the environment.
auto context = metrics_sdk::MeterContextFactory::Create();
context->AddMetricReader(std::move(reader));
auto provider = metrics_sdk::MeterProviderFactory::Create(std::move(context));
std::shared_ptr<metrics_api::MeterProvider> api_provider(std::move(provider));
metrics_sdk::Provider::SetMeterProvider(api_provider);
}
int main()
{
InitMetrics();
auto meter =
metrics_api::Provider::GetMeterProvider()->GetMeter("checkout-service", "1.4.2");
auto orders = meter->CreateUInt64Counter(
"orders.processed", "Number of orders processed", "{order}");
auto latency = meter->CreateDoubleHistogram(
"payment.duration", "Payment processing duration", "ms");
std::map<std::string, std::string> attrs{{"payment.provider", "stripe"}};
auto attrs_view =
opentelemetry::common::KeyValueIterableView<decltype(attrs)>{attrs};
orders->Add(1, attrs_view);
latency->Record(42.0, attrs_view, opentelemetry::context::Context{});
// In a real service the process keeps running; here, wait for one
// export cycle before exiting.
std::this_thread::sleep_for(std::chrono::seconds(20));
return 0;
}
Create instruments once (at startup or as members of a long-lived object), not per request — creation involves registry lookups; recording is the cheap path. For values you sample rather than accumulate (queue depth, connection counts), use CreateInt64ObservableGauge with a callback instead of a synchronous instrument.
Logs
Logs are stable in C++ and follow the exact same provider shape. The high-value feature is automatic trace correlation: a log emitted while a span is active carries that span’s trace_id and span_id, so you can pivot from a slow trace to its exact log lines in OpenObserve.
#include <map>
#include <string>
#include "opentelemetry/exporters/otlp/otlp_http_log_record_exporter_factory.h"
#include "opentelemetry/exporters/otlp/otlp_http_log_record_exporter_options.h"
#include "opentelemetry/logs/provider.h"
#include "opentelemetry/sdk/logs/batch_log_record_processor_factory.h"
#include "opentelemetry/sdk/logs/batch_log_record_processor_options.h"
#include "opentelemetry/sdk/logs/logger_provider.h"
#include "opentelemetry/sdk/logs/logger_provider_factory.h"
#include "opentelemetry/sdk/logs/provider.h"
#include "opentelemetry/sdk/resource/resource.h"
namespace logs_api = opentelemetry::logs;
namespace logs_sdk = opentelemetry::sdk::logs;
namespace otlp = opentelemetry::exporter::otlp;
namespace resource = opentelemetry::sdk::resource;
std::shared_ptr<logs_sdk::LoggerProvider> logger_provider;
void InitLogger()
{
otlp::OtlpHttpLogRecordExporterOptions opts; // reads OTEL_EXPORTER_OTLP_* vars
auto exporter = otlp::OtlpHttpLogRecordExporterFactory::Create(opts);
logs_sdk::BatchLogRecordProcessorOptions bp_opts;
auto processor =
logs_sdk::BatchLogRecordProcessorFactory::Create(std::move(exporter), bp_opts);
auto res = resource::Resource::Create({{"service.name", "checkout-service"}});
logger_provider = logs_sdk::LoggerProviderFactory::Create(std::move(processor), res);
std::shared_ptr<logs_api::LoggerProvider> api_provider = logger_provider;
logs_sdk::Provider::SetLoggerProvider(api_provider);
}
void UseLogger()
{
auto logger =
logs_api::Provider::GetLoggerProvider()->GetLogger("checkout-service");
// Emitted inside an active span scope, this record automatically
// carries the current trace_id and span_id.
logger->Info("order processed",
std::map<std::string, std::string>{{"order_id", "ord_8412"},
{"amount_cents", "4599"}});
logger->Error("payment declined",
std::map<std::string, std::string>{{"reason", "insufficient_funds"}});
}
Flush the logger provider at shutdown just like the tracer (logger_provider->ForceFlush() then reset the global). If your codebase already logs through spdlog, glog, or a house framework, the pragmatic integration is a custom sink/appender that forwards records into logger->EmitLogRecord(...) — you keep your call sites and gain OTLP export with trace correlation. Until you build that, the interim setup that works well: traces and metrics from the SDK, plus your existing file/stdout logs collected by an OpenTelemetry Collector or FluentBit and forwarded to OpenObserve.
Environment variable support in C++: know the limits
The C++ SDK implements the standard configuration environment variables selectively. What works and what does not, as of mid-2026:
| Variable | C++ support | Where it takes effect |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT, _HEADERS, _TIMEOUT, _PROTOCOL (and per-signal variants) | Yes | Default-constructed OTLP exporter options |
OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES | Yes | The default resource detector (merged into Resource::Create) |
OTEL_BSP_* (batch span processor tuning) | Yes | Default-constructed BatchSpanProcessorOptions |
OTEL_METRIC_EXPORT_INTERVAL, _TIMEOUT | Yes | Default-constructed periodic reader options |
OTEL_SDK_DISABLED | Yes | Disables the SDK globally |
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG | No | Configure the sampler in code |
OTEL_PROPAGATORS | No | Set the global propagator in code |
OTEL_LOG_LEVEL | No | Use internal_log::GlobalLogHandler::SetLogLevel for SDK diagnostics |
Two caveats beyond the table. First, env vars only apply when you default-construct the options structs — the moment you assign opts.url in code, the environment is out of the loop for that field. Second, there is no autoconfiguration: environment variables tune components, but they never create providers. The InitTracer-style wiring above is always required.
Sending data to OpenObserve
OpenObserve ingests OTLP natively — no collector required in between, although running one is a fine architecture too. Two transports are supported:
- OTLP/HTTP at
<host>/api/<org>/v1/traces,/v1/metrics, and/v1/logs(port5080self-hosted). This is what the exporters above speak; the C++ exporter’s defaulthttp/protobufencoding is exactly what OpenObserve expects. - OTLP/gRPC on port
5081(self-hosted), which additionally requires anorganizationheader alongsideAuthorization(set both via themetadatafield onOtlpGrpcExporterOptions), and requires the SDK built with-DWITH_OTLP_GRPC=ON.
Stick with HTTP unless you have a reason not to; it needs the least configuration and avoids the gRPC dependency stack entirely.
Because the code above default-constructs the exporter options, configuration comes from standard environment variables:
OpenObserve Cloud:
export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/<your-org-name>"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <your-base64-token>"
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=production"
./checkout_service
Self-hosted OpenObserve:
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=dev"
./checkout_service
Three rules that prevent 90% of setup failures:
- No trailing slash on the endpoint. The exporter appends
/v1/traces(or/v1/metrics,/v1/logs) itself..../api/default/produces.../api/default//v1/tracesand a 404. - Use a literal space in the header value.
OTEL_EXPORTER_OTLP_HEADERSuses akey=value,key=valueformat, and the C++ OTLP exporter does not percent-decode header values — it only trims whitespace. So%20is sent verbatim andAuthorization=Basic%20<token>yields a401. Write the space literally:Authorization=Basic <token>(and quote the value in the shell). The same literal-space rule applies to the in-codehttp_headersfield — there is no asymmetry between the two. - The organization is part of the URL path.
/api/defaulttargets thedefaultorganization; replace it with your org name in OpenObserve Cloud (visible on the ingestion/Data Sources page, which also shows the exact endpoint and token to copy).
To configure in code instead — common when credentials come from a secret manager — set the fields on the options struct. Note the asymmetry: the environment variable takes the base URL, but opts.url takes the full per-signal path:
otlp::OtlpHttpExporterOptions opts;
opts.url = "http://localhost:5080/api/default/v1/traces"; // full signal path
opts.http_headers.insert(std::make_pair(
"Authorization", "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="));
// Literal space here too — the C++ SDK never percent-decodes header values,
// so the env var and http_headers behave identically.
auto exporter = otlp::OtlpHttpExporterFactory::Create(opts);
For a broader tour of exporter configuration, see A Beginner’s Guide to OTLP Exporters.
Verifying data arrival in OpenObserve
Run the binary, exercise a few code paths, then open the OpenObserve UI (http://localhost:5080 self-hosted, or your cloud URL):
- Streams — you should see a
defaulttraces stream plus streams for your metrics and logs. If a stream exists, ingestion is working end to end. - Traces — pick your service (
checkout-service) in the Traces page and open a trace. You should seeprocess-paymentwithcharge-cardnested under it, along with the attributes you set. - Logs — query the log stream and confirm records include
trace_idandspan_idfields; that confirms trace correlation is working. - Metrics — your
orders.processedandpayment.durationinstruments appear as metric streams you can chart or attach to dashboards and alerts.
If nothing appears within ~30 seconds, enable the SDK’s internal diagnostics before anything else — C++ fails silently by default:
#include "opentelemetry/sdk/common/global_log_handler.h"
using opentelemetry::sdk::common::internal_log::GlobalLogHandler;
using opentelemetry::sdk::common::internal_log::LogLevel;
GlobalLogHandler::SetLogLevel(LogLevel::Debug); // prints export errors to stderr
Production tips
-
Keep the batch processor (never
SimpleSpanProcessorFactoryin production). Simple processors export synchronously on every span end — on the calling thread. Batch processors move export off the hot path; tune viaOTEL_BSP_MAX_QUEUE_SIZE,OTEL_BSP_SCHEDULE_DELAY, andOTEL_BSP_MAX_EXPORT_BATCH_SIZEif you see queue-full drops. -
Configure sampling in code — the env vars will not save you. Since
OTEL_TRACES_SAMPLERis unimplemented, high-traffic services should build a parent-based ratio sampler explicitly:#include "opentelemetry/sdk/trace/samplers/parent.h" #include "opentelemetry/sdk/trace/samplers/trace_id_ratio.h" auto sampler = std::make_unique<trace_sdk::ParentBasedSampler>( std::make_shared<trace_sdk::TraceIdRatioBasedSampler>(0.1)); // keep 10% tracer_provider = trace_sdk::TracerProviderFactory::Create( std::move(processor), res, std::move(sampler));ParentBasedmatters: a downstream service honors the sampling decision made upstream, so you get complete traces instead of fragments. -
Invest in resource attributes.
service.name,service.version, anddeployment.environmentare the dimensions you will filter by in every OpenObserve query. Since the default resource mergesOTEL_SERVICE_NAMEandOTEL_RESOURCE_ATTRIBUTES, operations can override them per environment without a rebuild. -
Flush on every exit path. Tracer, meter, and logger providers each buffer data. Call
ForceFlushwith a timeout from your shutdown handler — and remember C++ gives you exit paths other languages do not:std::exit, uncaught exceptions, aborts. Register cleanup where it actually runs, and never rely on static destructor ordering to flush telemetry. -
Mind spans across threads. The active-span mechanism is thread-local. A worker thread starts with no active context; pass the parent span (or
Context) into the thread explicitly and re-activate it there withWithActiveSpan/Scope, or your thread-pool spans end up as orphan root traces. -
Record errors on spans.
span->SetStatus(trace_api::StatusCode::kError, what)plus anexception.messageattribute is what makes failed requests filterable in the Traces UI — C++ has no automatic exception hooks to do it for you. -
Keep the SDK and exporters from a single version, built with one toolchain. Mixing an SDK built against one Protobuf/Abseil with an application using another produces baffling link or runtime errors. Let one package manager (vcpkg, Conan, or your source build) own the whole dependency subtree, and rebuild everything together on upgrades.
Troubleshooting
Symptom: everything compiles, runs, exits — and nothing ever appears in OpenObserve, with no errors.
Fix: the C++ SDK is silent by default. Set GlobalLogHandler::SetLogLevel(LogLevel::Debug) (see the verification section) and re-run; export failures then print to stderr. Nine times out of ten the underlying cause is one of the next three items.
Symptom: debug logs show 404 Not Found on export.
Fix: a URL problem. If configuring via OTEL_EXPORTER_OTLP_ENDPOINT, remove any trailing slash and confirm the path includes your organization (/api/default, not just the host). If setting opts.url in code, remember it needs the full signal path — http://localhost:5080/api/default/v1/traces.
Symptom: 401 Unauthorized on every export.
Fix: regenerate the token with echo -n 'email:password' | base64 (the -n matters — a trailing newline corrupts the token), and check the encoding rules: use a literal space after Basic — Authorization=Basic <token> — whether in the OTEL_EXPORTER_OTLP_HEADERS environment variable or in the http_headers multimap in code. The C++ SDK does not percent-decode header values, so Basic%20<token> is sent verbatim and rejected.
Symptom: undefined reference to OtlpHttpExporterFactory (or CMake cannot find the exporter target).
Fix: the SDK was built without the OTLP HTTP exporter. Rebuild with -DWITH_OTLP_HTTP=ON, or install the vcpkg otlp-http feature / Conan with_otlp_http=True option, then link opentelemetry-cpp::otlp_http_exporter (and the metric/log variants) in target_link_libraries.
Symptom: each service shows its own disconnected trace instead of one distributed trace.
Fix: propagation is not wired. C++ ignores OTEL_PROPAGATORS, so every service must call GlobalTextMapPropagator::SetGlobalPropagator with HttpTraceContext, and your HTTP client code must actually Inject the context into outgoing headers via a carrier. Verify with a proxy or packet capture that requests carry a traceparent header.
Symptom: the process exits (or a short-lived job finishes) and the last spans/logs never arrive.
Fix: buffered batches were dropped. Call ForceFlush on the tracer, meter, and logger providers before exit, with a timeout so a dead backend cannot hang shutdown. For CLI tools and batch jobs, flush after each unit of work rather than trusting exit-time cleanup.
Symptom: Protobuf or Abseil version-conflict errors at build or link time.
Fix: two copies of the dependency tree are colliding — typically a system-installed Protobuf against vcpkg/Conan’s, or a gRPC build pinning a different Abseil. Standardize on one package manager for opentelemetry-cpp and its dependencies, and prefer WITH_OTLP_HTTP over gRPC unless you need it: the HTTP exporter’s dependency surface (libcurl + Protobuf) is dramatically easier to keep consistent.
With the SDK compiled in once, providers wired up in main(), and OTLP pointed at OpenObserve, a C++ service gets correlated traces, metrics, and logs in a single backend — all three signals on stable APIs, searchable side by side, with no vendor-specific code anywhere in your application.
Frequently asked questions
Which OpenTelemetry signals are stable in C++?
All three. Traces, metrics, and logs are marked stable in opentelemetry-cpp, which makes C++ one of the few languages where the complete signal set is production-ready. The API additionally carries an ABI-stability guarantee, so instrumented libraries built against one API version keep working as the SDK underneath is upgraded.
Do I have to build opentelemetry-cpp from source?
No. Building from source with CMake gives you the most control (you enable the OTLP HTTP exporter with -DWITH_OTLP_HTTP=ON), but both vcpkg and Conan ship prebuilt-recipe packages. With vcpkg, install opentelemetry-cpp with the otlp-http feature; with Conan, set the with_otlp_http option to True. For the upstream CMake build and vcpkg the OTLP HTTP exporter is opt-in (WITH_OTLP_HTTP / the otlp-http feature); Conan enables it by default.
Is there automatic (zero-code) instrumentation for C++?
No. C++ compiles to a native binary, so there is no runtime agent comparable to the Java or Python agents. You wire up the tracer, meter, and logger providers explicitly in code. Adjacent options exist — OpenTelemetry modules for NGINX and Apache httpd instrument those servers from the outside, and eBPF-based instrumentation can capture coarse HTTP spans on Linux — but in-process telemetry for your own C++ code always goes through the SDK.
Does the C++ SDK support the standard OTEL_* environment variables?
Partially, and this trips up people coming from other SDKs. The OTLP exporter options read OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, and related variables; the default resource picks up OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES; and the batch processor and periodic metric reader read their OTEL_BSP_ and OTEL_METRIC_EXPORT_ variables. However, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, and OTEL_PROPAGATORS are not implemented — samplers and propagators must be configured in code.
What endpoint do I use to send OTLP data from C++ to OpenObserve?
Set OTEL_EXPORTER_OTLP_ENDPOINT to your OpenObserve base URL including the organization path, with no trailing slash. For OpenObserve Cloud that is https://api.openobserve.ai/api/your-org-name, and for a self-hosted instance it is http://localhost:5080/api/default. The C++ OTLP HTTP exporter appends /v1/traces, /v1/metrics, or /v1/logs automatically. If you set the url field on the exporter options in code instead, you must provide the full per-signal path yourself.
How do I authenticate C++ OTLP exports to OpenObserve?
OpenObserve uses HTTP Basic authentication. Base64-encode email:password (or your ingestion token), then send it as an Authorization header. Via environment variables, set OTEL_EXPORTER_OTLP_HEADERS to Authorization=Basic <base64-token> using a literal space — the C++ OTLP exporter does not percent-decode header values, it only trims whitespace, so %20 would be sent verbatim and rejected. Quote the value in the shell: export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <token>". In code, insert the header into the exporter options http_headers multimap the same way, with a literal space.
Keep reading
Ship your OpenTelemetry data somewhere better
OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.