Skip to content

Repository files navigation

esp-opentelemetry-cpp

ESP-IDF component integrating OpenTelemetry C++ SDK with ESP32 firmware

Scope

This project is an integration of the upstream opentelemetry-cpp SDK with the ESP-IDF build system. It is not a fork and not a port — the vendored SDK submodule tracks a specific upstream commit and contains no local modifications. Where hardware constraints require deviations from upstream behaviour, the src/workarounds/ subtree provides replacement translation units wired in through CMake set_property(SOURCES) overrides rather than edits to the submodule. The integration wires the SDK into the ESP-IDF build system and exposes a C++ API aligned with ESP-IDF naming conventions.

Usage

Add this repository as a component under your project's components/ directory (e.g. as a git submodule), then declare it as a dependency:

idf_component_register(SRCS "main.cpp"
                        REQUIRES esp-opentelemetry-cpp)
#include "esp_opentelemetry.hpp"

// Once, after Wi-Fi is up:
esp_opentelemetry_tracing_setup(CONFIG_ESP_OPENTELEMETRY_SERVICE_NAME);  // traces
esp_opentelemetry_metrics_setup();                                       // metrics provider
esp_opentelemetry_logs_setup();                                          // logs provider
esp_opentelemetry_profiling_setup();                                     // statistical CPU profiler + span->profile link

// Create spans:
auto tracer = esp_opentelemetry_tracer();
auto span   = tracer->StartSpan("my.operation");
auto scope  = opentelemetry::trace::Scope(span);
span->End();

// Register metric instruments against the global meter provider:
auto meter = opentelemetry::metrics::Provider::GetMeterProvider()->GetMeter("my-device");

Ship a translation unit's existing ESP_LOG output by including one header — the call sites keep their text and their console output:

#include "esp_log.h"
#include "esp_log_otel.h"   // redefines ESP_LOGE/W/I; opt-in per translation unit

ESP_LOGI(TAG, "connected to %s", ssid);   // also emitted as a log record

Set the system time (SNTP) before emitting records — log records carry absolute timestamps, and an ESP32 that has not synced reads as 1970, which Loki and other backends reject.

The wrappers respect the project's existing compile-time cap (LOG_LOCAL_LEVEL / CONFIG_LOG_MAXIMUM_LEVEL) rather than adding a second filter, and evaluate their arguments exactly once, as the stock macros do. The message is formatted once into a CONFIG_ESP_OPENTELEMETRY_LOGS_MAX_BODY_LEN buffer and truncated there if it does not fit — in the console line as well as in the record.

Enable signals via idf.py menuconfigOpenTelemetry or set in sdkconfig.defaults:

CONFIG_ESP_OPENTELEMETRY_SERVICE_NAME="my-device"
CONFIG_ESP_OPENTELEMETRY_TRACING_ENABLED=y
CONFIG_ESP_OPENTELEMETRY_TRACING_OTLP_BASE_URL="http://192.168.1.10:4318"
CONFIG_ESP_OPENTELEMETRY_METRICS_ENABLED=y
CONFIG_ESP_OPENTELEMETRY_METRICS_OTLP_BASE_URL="http://192.168.1.10:4318"
CONFIG_ESP_OPENTELEMETRY_LOGS_ENABLED=y
CONFIG_ESP_OPENTELEMETRY_LOGS_OTLP_BASE_URL="http://192.168.1.10:4318"
CONFIG_ESP_OPENTELEMETRY_PROFILING_ENABLED=y
CONFIG_ESP_OPENTELEMETRY_PROFILES_OTLP_BASE_URL="http://192.168.1.10:4319"   # the symbolizer
CONFIG_FREERTOS_THREAD_LOCAL_STORAGE_POINTERS=2     # per-task span slot for span profiles

When a signal's ..._ENABLED option is off, the component still compiles and the API is available — all calls route to the SDK's built-in no-op provider (or return immediately) with zero runtime overhead.

The application constructs its exporter and passes it to the setup call, as it does with the upstream SDK:

esp_opentelemetry_tracing_setup(
    esp_opentelemetry::MakeOtlpHttpSpanExporter("http://192.168.1.10:4318"),
    {{"service.name", "my-device"}});

Kconfig decides only which exporter implementations are compiled in, since on a 4 MB part you do not want to carry the ones you will not call:

Exporter Kconfig Where signals go
OTLP/HTTP CONFIG_ESP_OPENTELEMETRY_EXPORTER_OTLP_HTTP (default y) esp_opentelemetry::MakeOtlpHttp*Exporter() — POSTed to a collector over Wi-Fi
Ostream CONFIG_ESP_OPENTELEMETRY_EXPORTER_OSTREAM The SDK's own ostream exporters; runs under QEMU
ESP log CONFIG_ESP_OPENTELEMETRY_EXPORTER_ESP_LOG esp_opentelemetry::MakeEspLog*Exporter() — the SDK's own Ostream span/log/metric exporters, piped through a custom std::streambuf so each physical output line becomes one ESP_LOGI call under otel.span/otel.log/otel.metric (profiles have no Ostream exporter to delegate to, so that one stays a hand-rolled single line under otel.profile); runs under QEMU
JTAG app-trace CONFIG_ESP_OPENTELEMETRY_EXPORTER_JTAG esp_opentelemetry::MakeJtag*Exporter() — one OTLP/JSON document per line on the app-trace channel, relayed by a host-side forwarder; no network

OTLP/HTTP and JTAG both serialise through protobuf-generated OTLP types, so libprotobuf and Abseil (~30 targets) are only built when at least one of the two is selected. A build with both off (CONFIG_ESP_OPENTELEMETRY_EXPORTER_OTLP_HTTP=n, CONFIG_ESP_OPENTELEMETRY_EXPORTER_JTAG=n) excludes both entirely — see examples/traces, which uses the ESP log exporter instead.

Each esp_opentelemetry_*_setup() also has a no-exporter overload that builds an OTLP/HTTP exporter from that signal's ..._OTLP_BASE_URL, which is what a firmware that only ever exports over Wi-Fi wants.

Profiles have no exporter interface in opentelemetry-cpp, so the component defines one (esp_profiles_exporter.hpp) and esp_opentelemetry_profiling_setup() takes it the same way.

Examples

Example Description Hardware needed
examples/traces/ One signal, simplest exporter: the ESP log exporter printing a parent/child span, a span event and an Error-status span to the console. None (QEMU)
examples/metrics/ One signal, simplest exporter: the ESP log exporter printing a counter. None (QEMU)
examples/logs/ One signal, simplest exporter: the ESP log exporter printing log records. None (QEMU)
examples/profiles/ One signal, simplest exporter: the CPU profiler dumping OTLP/JSON ProfilesData to the console via the ESP log exporter. None (QEMU)
examples/otlp/ Every signal over OTLP/HTTP to a collector, with Wi-Fi and SNTP bring-up. Wi-Fi
examples/jtag/ Every signal over one JTAG app-trace channel, forwarded to a collector by OpenOCD + Vector. JTAG (no network)
examples/propagation/ W3C TraceContext inject across an HTTP boundary; logs the traceparent header injected into an outgoing request. Wi-Fi

The per-signal examples each show one signal with the simplest exporter there is, so the signal's own API is the only thing on screen. The two transport examples show every signal at once, because what varies there is the exporter — they differ from each other only in which exporter each signal is handed.

Workarounds

The src/workarounds/ subtree contains code that exists purely to paper over upstream deficiencies in third-party libraries or the Xtensa toolchain. Each workaround should be removable once the upstream issue is resolved, and each is tracked by an issue here so the reason survives the code.

File Root cause Upstream Issue
src/workarounds/posix_shims.c nanosleep missing from newlib; pthread_atfork missing (causes libnosys collision); sysconf(_SC_PAGESIZE) returns -1 (causes Abseil LowLevelAlloc overflow); THREADPTR uninitialised before FreeRTOS scheduler (crashes thread_local during global ctors); linkat missing, referenced by the OTLP file exporter's rotation path Abseil, ESP-IDF newlib, opentelemetry-cpp #17, #18, #21, #96
src/workarounds/absl_varint_bool.h int32_t is long not int on Xtensa; bool/int/pid_t do not match any EncodeVarint overload — ambiguous call on GCC 13.2 Abseil #23
src/workarounds/sys/mman.h sys/mman.h absent from newlib; Abseil LowLevelAlloc calls mmap to grow its arena Abseil #19
src/workarounds/time.h struct tm in newlib lacks tm_gmtoff; Abseil cctz includes it unconditionally Abseil cctz #20
src/workarounds/absl_shadow/absl/base/internal/thread_identity.h Abseil's thread_identity.h has an unconditional static_assert(std::atomic<WaitState>::is_always_lock_free) (WaitState is enum class : uint8_t). The C++ standard does not require 1-byte atomics to be always-lock-free, so the assert fails on ESP toolchain configurations where it is not (reproduced in esp_otel CI). A header-shadow shim (the absl_shadow dir is prepended ahead of Abseil's -I) brackets only its #include_next of the upstream header with push_macro/pop_macro, rewriting the is_always_lock_free token so the failing assert becomes ... || true while the companion cache-line assert is left intact. Abseil #59
src/workarounds/pb_defaults.lf protobuf places dummy_weak_default in a pb_defaults section when PROTOBUF_DESCRIPTOR_WEAK_MESSAGES_ALLOWED is defined (port_def.inc guards it on __clang__) and reads the __start_/__stop_ symbols a linker synthesises for an output section of that name. ESP-IDF places no such section and IDF v6 rejects orphan sections, so the executable fails to link. The fragment merges the section into flash_rodata and CMakeLists.txt defines the symbol pair as an empty range, leaving InitWeakDefaults() nothing to walk — sound only while nothing is generated with --descriptor_implicit_weak_messages protobuf, ESP-IDF #94
src/workarounds/esp_heap_align.cpp ESP-IDF heap uses sizeof(void*)=4 as its alignment granularity; alignof(std::max_align_t)==8 on Xtensa; operator new is therefore non-conforming. google::protobuf::Arena / TaggedAllocationPolicyPtr stores flags in the low 3 bits of a pointer (kPtrMask=~7), requiring 8-byte alignment. A 4-byte-aligned block causes get() to read 4 bytes before the struct, treating max_block_size (0x00010000) as a function pointer → InstrFetchProhibited at PC=0x00010000. Replaces the six standard replaceable allocation operators with heap_caps_aligned_alloc-backed versions. ESP-IDF heap #32

ESP-specific integrations

The src/integration/ subtree contains code that is deliberately ESP32-specific and is part of the component's defined scope.

File What it provides
src/integration/esp_http_client_transport.cpp HttpClient implementation backed by esp_http_client, passed directly to OtlpHttpExporter's HTTP-client constructor overload (open-telemetry/opentelemetry-cpp#4071), replacing libcurl for the OTLP/HTTP exporter
src/integration/esp_tracing.cpp esp_opentelemetry_tracing_setup() / esp_opentelemetry_tracer() — ESP-friendly wiring of exporter, processor (64 KB PSRAM export-thread stack), resource, and W3C propagator via Kconfig
src/integration/esp_jtag_exporters.cpp esp_opentelemetry::MakeJtagSpanExporter() / MakeJtagLogRecordExporter() / MakeJtagMetricExporter() — OTLP/JSON written to the ESP-IDF app-trace (JTAG) channel instead of the network; reuses the SDK's OTLP file exporters through a custom OtlpFileAppender, so the encoding is identical to the OTLP/HTTP exporters'
src/integration/esp_jtag_channel.cpp The single app-trace writer behind every JTAG exporter: chunks a document into the buffer, terminates a truncated line so the stream resynchronises, and serialises whole documents so concurrent signals cannot interleave. Profiles, whose exporter is hand-rolled, write through it directly
src/integration/esp_metrics.cpp esp_opentelemetry_metrics_setup()PeriodicExportingMetricReader + OTLP/HTTP metric exporter; observe_double/observe_int64 helpers over the ObserverResult variant API
src/integration/esp_logs.cpp esp_opentelemetry_logs_setup() / esp_opentelemetry_logger()BatchLogRecordProcessor + OTLP/HTTP log record exporter; esp_opentelemetry_log_and_emit(), the bridge the esp_log_otel.h ESP_LOGx wrappers expand to
include/esp_jtag_exporters.hpp Public declarations of the JTAG exporter factories, one per signal, each compiled away when its signal or CONFIG_ESP_OPENTELEMETRY_EXPORTER_JTAG is off. Application code calls one and passes the result to the matching ..._setup() call, as it would upstream
include/esp_otlp_http_exporters.hpp / src/integration/esp_otlp_http_exporters.cpp MakeOtlpHttp*Exporter() — the SDK's OTLP/HTTP exporters bound to esp_http_client, since upstream's own factories build a libcurl client that does not cross-compile to Xtensa
include/esp_profiles_exporter.hpp ProfilesExporter — the exporter interface opentelemetry-cpp has for every signal except profiles. Shaped like the SDK's, so profiles are selected the same way: MakeJtagProfilesExporter() sits with the other JTAG factories, MakeOtlpHttpProfilesExporter() with the other OTLP/HTTP factories, MakeEspLogProfilesExporter() with the other ESP log factories, and src/integration/esp_profiles_exporter.cpp holds the ostream one
include/esp_log_exporters.hpp / src/integration/esp_log_exporters.cpp MakeEspLog*Exporter() — for spans, logs and metrics, a thin wrapper around the SDK's own OStreamSpanExporter/OStreamLogRecordExporter/OStreamMetricExporter, with a custom std::streambuf in place of std::cout that turns each physical output line into one ESP_LOGI call under otel.span/otel.log/otel.metric. MakeEspLogProfilesExporter() has no Ostream exporter to wrap, so it keeps a hand-rolled single ESP_LOGI line under otel.profile
include/esp_log_otel.h ESP_LOGE/ESP_LOGW/ESP_LOGI wrappers capturing the call site's file, line and function, gated on the project's own ESP_LOG_ENABLED() compile-time cap. Opt-in per translation unit; not pulled in by esp_opentelemetry.hpp
src/integration/esp_profiling.cpp esp_opentelemetry_profiling_setup() — per-core gptimer-ISR statistical sampler (esp_backtrace), lock-free rings, stack aggregation
src/integration/esp_profiles_document.cpp esp_opentelemetry::export_profiles() — builds the OTLP profiles (v1development) document with cJSON and hands it to the installed ProfilesExporter; opentelemetry-cpp has no profiles SDK to build it for us
src/integration/esp_task_span_slot.cpp Per-task active-span slot (FreeRTOS TLS + seqlock) mirroring Scope activation — the FreeRTOS analog of Go's goroutine labels; esp_opentelemetry_active_span_id() ISR-safe reader; span stamping with the configurable CONFIG_ESP_OPENTELEMETRY_PROFILES_SPAN_ATTRIBUTE
tools/symbolizer/ Host-side OTLP profiles symbolizer: xtensa-esp-elf-addr2line resolution against build ELFs (auto-discovered by sha256 = profile build_id), ISR-frame trimming, forwards to an OTLP collector

Tested OTel C++ SDK features

Features validated on ESP32 hardware or QEMU. Untested features compile but have not been exercised end-to-end on device.

Feature Status Example
OStreamSpanExporter Tested (QEMU) — via the ESP log exporter, which wraps it examples/traces/
ESP log span exporter (MakeEspLogSpanExporter) Tested (QEMU) examples/traces/
SimpleSpanProcessor Tested (hardware, ESP32-S3) — via the processor-taking esp_opentelemetry_tracing_setup() overload examples/jtag/
BatchSpanProcessor Tested (hardware, ESP32-S3) examples/otlp/
OtlpHttpExporter (JSON) Tested (hardware, ESP32-S3) examples/otlp/
W3C TraceContext propagation (inject) Tested (hardware) examples/propagation/
OtlpFileExporter / OtlpFileLogRecordExporter / OtlpFileMetricExporter with a custom OtlpFileAppender (OTLP/JSON over JTAG app-trace) Tested (hardware, ESP32-S3; OpenOCD 0.12 / Vector 0.50 / collector 0.156) — all four signals on one channel, verified through to Tempo/Loki examples/jtag/
Span attributes (SetAttribute) Tested covered by all examples
PeriodicExportingMetricReader + OtlpHttpMetricExporter (JSON) Tested (hardware, ESP32-S3) examples/otlp/
OTLP profiles (v1development, JSON) + span profiles Tested (hardware, ESP32-S3; Pyroscope 1.18 / collector 0.146) examples/profiles/ (QEMU), examples/otlp/
ESP log profiles exporter (MakeEspLogProfilesExporter) Tested (QEMU) examples/profiles/
Custom RuntimeContextStorage (per-task span slot) Tested (hardware, ESP32-S3 + QEMU) examples/profiles/
Span events (AddEvent) Tested (QEMU) examples/traces/
OtlpHttpExporter (protobuf) Untested
OStreamMetricExporter Tested (QEMU) — via the ESP log exporter, which wraps it examples/metrics/
ESP log metric exporter (MakeEspLogMetricExporter) Tested (QEMU) examples/metrics/
PeriodicExportingMetricReader Tested (QEMU) examples/metrics/
OtlpHttpMetricExporter Linked
Counter instrument (Add) Tested (QEMU) examples/metrics/
Observable gauge (AddCallback) Linked
OStreamLogRecordExporter Tested (QEMU) — via the ESP log exporter, which wraps it examples/logs/
ESP log log-record exporter (MakeEspLogLogRecordExporter) Tested (QEMU) examples/logs/
SimpleLogRecordProcessor Linked — via the processor-taking esp_opentelemetry_logs_setup() overload
BatchLogRecordProcessor Tested (hardware, ESP32-S3) examples/otlp/
OtlpHttpLogRecordExporter (JSON) Tested (hardware, ESP32-S3; Loki 3.7 / collector 0.156) examples/otlp/
Log record attributes + ESP_LOG call-site capture Tested (hardware, ESP32-S3) examples/otlp/

About

ESP-IDF component integrating OpenTelemetry C++ SDK with ESP32 firmware

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages