ESP-IDF component integrating OpenTelemetry C++ SDK with ESP32 firmware
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.
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 recordSet 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 menuconfig → OpenTelemetry 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.
| 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.
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 |
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 |
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/ |