diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c6794d0..d44a2e2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -5,31 +5,32 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: # allow manual runs from the GitHub Actions tab jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' - name: Install PlatformIO run: python -m pip install -U platformio - # Make repo Variables + Secrets available to all subsequent steps + # Expose repo variables and secrets as environment variables for PlatformIO + # sysenv.* references in platformio.ini build_flags pick these up at build time. - name: Export env for build run: | echo "::add-mask::${{ secrets.WIFI_PASS }}" { echo "WIFI_SSID=${{ vars.WIFI_SSID }}" echo "WIFI_PASS=${{ secrets.WIFI_PASS }}" - echo "OTEL_COLLECTOR_HOST=${{ vars.OTEL_COLLECTOR_HOST }}" - echo "OTEL_COLLECTOR_PORT=${{ vars.OTEL_COLLECTOR_PORT }}" + echo "OTEL_EXPORTER_OTLP_ENDPOINT=${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT || '' }}" echo "OTEL_SERVICE_NAME=${{ vars.OTEL_SERVICE_NAME }}" echo "OTEL_SERVICE_NAMESPACE=${{ vars.OTEL_SERVICE_NAMESPACE }}" echo "OTEL_SERVICE_VERSION=${{ vars.OTEL_SERVICE_VERSION }}" @@ -38,14 +39,63 @@ jobs: } >> "$GITHUB_ENV" - name: PlatformIO Update - run: pio update + run: pio pkg update - - name: Build for ESP32 (esp32dev) + # Remove any cached registry version so --lib "." always wins and we + # compile the local source, not the published package at the same version. + - name: Flush cached library + run: pio pkg uninstall --global --library "otel-embedded-cpp" 2>/dev/null || true + + # ── Native unit tests (host GCC, no hardware required) ────────────────── + # Compiles and runs test/native/test_otlp.cpp against a fake OTelSender + # that captures emitted JSON so tests can assert on payload structure. + # GCC is pre-installed on ubuntu-latest; no extra toolchain step needed. + + - name: Run native unit tests + run: pio test -e native + + # ── src/main.cpp (existing integration test) ─────────────────────────── + + - name: Build src for ESP32 (esp32dev) run: platformio ci src/main.cpp --project-conf platformio.ini --lib "." -e esp32dev - - name: Build for Pico W (rpipicow) + - name: Build src for Pico W (rpipicow) run: platformio ci src/main.cpp --project-conf platformio.ini --lib "." -e rpipicow - - name: Build for ESP8266 (esp8266 d1_mini) + - name: Build src for ESP8266 (d1_mini) run: platformio ci src/main.cpp --project-conf platformio.ini --lib "." -e esp8266 + # ── examples/basic (minimal heartbeat, collector-based setup) ──────────── + + - name: Build basic example for ESP32 (esp32dev) + run: platformio ci examples/basic/main.cpp --project-conf platformio.ini --lib "." -e esp32dev + + - name: Build basic example for Pico W (rpipicow) + run: platformio ci examples/basic/main.cpp --project-conf platformio.ini --lib "." -e rpipicow + + - name: Build basic example for ESP8266 (d1_mini) + run: platformio ci examples/basic/main.cpp --project-conf platformio.ini --lib "." -e esp8266 + + # ── examples/direct_otlp (validates OTLP exporter configuration) ───────── + + - name: Build direct_otlp example for ESP32 (esp32dev) + run: platformio ci examples/direct_otlp/main.cpp --project-conf platformio.ini --lib "." -e esp32dev + + - name: Build direct_otlp example for Pico W (rpipicow) + run: platformio ci examples/direct_otlp/main.cpp --project-conf platformio.ini --lib "." -e rpipicow + + - name: Build direct_otlp example for ESP8266 (d1_mini) + run: platformio ci examples/direct_otlp/main.cpp --project-conf platformio.ini --lib "." -e esp8266 + + # ── examples/proto_otlp (validates protobuf encoding path) ─────────────── + # Uses dedicated *-proto envs in platformio.ini that inherit their base + # env and add -DOTEL_EXPORTER_OTLP_PROTOCOL=...HTTP_PROTOBUF. + + - name: Build proto_otlp example for ESP32 (esp32dev-proto) + run: platformio ci examples/proto_otlp/main.cpp --project-conf platformio.ini --lib "." -e esp32dev-proto + + - name: Build proto_otlp example for Pico W (rpipicow-proto) + run: platformio ci examples/proto_otlp/main.cpp --project-conf platformio.ini --lib "." -e rpipicow-proto + + - name: Build proto_otlp example for ESP8266 (esp8266-proto) + run: platformio ci examples/proto_otlp/main.cpp --project-conf platformio.ini --lib "." -e esp8266-proto diff --git a/.gitignore b/.gitignore index 585145b..534506e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ # Environment secrets .env + +# Local dev +.claude +.vscode \ No newline at end of file diff --git a/README.md b/README.md index faa0845..09fcce1 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ This removes any blocking code and ensures that the HTTP POST call does not inte ```ini build_flags = - -DWIFI_SSID="${sysenv.OTEL_WIFI_SSID}" - -DWIFI_PASS="${sysenv.OTEL_WIFI_PASS}" - -DOTEL_COLLECTOR_HOST="${sysenv.OTEL_COLLECTOR_HOST}" - -DOTEL_COLLECTOR_PORT=${sysenv.OTEL_COLLECTOR_PORT} + -DWIFI_SSID="${sysenv.WIFI_SSID}" + -DWIFI_PASS="${sysenv.WIFI_PASS}" + -DOTEL_EXPORTER_OTLP_ENDPOINT="\"${sysenv.OTEL_EXPORTER_OTLP_ENDPOINT}\"" + -DOTEL_EXPORTER_OTLP_HEADERS="\"${sysenv.OTEL_EXPORTER_OTLP_HEADERS}\"" -DOTEL_SERVICE_NAME="${sysenv.OTEL_SERVICE_NAME}" -DOTEL_SERVICE_NAMESPACE="${sysenv.OTEL_SERVICE_NAMESPACE}" -DOTEL_SERVICE_VERSION="${sysenv.OTEL_SERVICE_VERSION}" @@ -59,10 +59,9 @@ This removes any blocking code and ensures that the HTTP POST call does not inte 3. **(Optional)** Use a `.env` file and load it into your shell: ```dotenv - OTEL_WIFI_SSID=default - OTEL_WIFI_PASS=default - OTEL_COLLECTOR_HOST=192.168.1.100 - OTEL_COLLECTOR_PORT=4318 + WIFI_SSID=default + WIFI_PASS=default + OTEL_EXPORTER_OTLP_ENDPOINT=http://192.168.1.100:4318 OTEL_SERVICE_NAME=demo_service OTEL_SERVICE_NAMESPACE=demo_namespace OTEL_SERVICE_VERSION=v1.0.0 @@ -87,23 +86,19 @@ This removes any blocking code and ensures that the HTTP POST call does not inte #if defined(ESP32) #include + #include #elif defined(ESP8266) #include #elif defined(ARDUINO_ARCH_RP2040) - // Earle Philhower’s Arduino-Pico core exposes a WiFi.h for Pico W #include #else #error "This example targets ESP32, ESP8266, or RP2040 (Pico W) with WiFi." #endif -// --------------------------------------------------------- -// Import Open Telemetry Libraries -// --------------------------------------------------------- -#include "OtelDefaults.h" +#include "OtelSender.h" #include "OtelTracer.h" #include "OtelLogger.h" #include "OtelMetrics.h" -#include "OtelDebug.h" static constexpr uint32_t HEARTBEAT_INTERVAL = 5000; @@ -111,52 +106,39 @@ void setup() { Serial.begin(115200); // Seed PRNG (fresh trace IDs each boot) - #if defined(ARDUINO_ARCH_ESP32) - randomSeed(esp_random()); - #else - randomSeed(micros()); - #endif - - // Connect to Wi‑Fi - WiFi.begin(OTEL_WIFI_SSID, OTEL_WIFI_PASS); +#if defined(ARDUINO_ARCH_ESP32) + randomSeed(esp_random()); +#else + randomSeed(micros()); +#endif + + // Connect to Wi-Fi + WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(500); } // Sync NTP configTime(0, 0, "pool.ntp.org", "time.nist.gov"); while (time(nullptr) < 1609459200UL) { delay(500); } - // Initialise Logger & Tracer - - // Set the defaults for the resources - auto &res = OTel::defaultResource(); - res.set("service", OTEL_SERVICE_NAME); - res.set("service.name", OTEL_SERVICE_NAME); - res.set("service.namespace", OTEL_SERVICE_NAMESPACE); - res.set("service.instance.id", OTEL_SERVICE_INSTANCE); - res.set("host.name", "my-embedded device"); - - // Setup our tracing engine - OTel::Tracer::begin("otel-embedded", "1.0.1"); - - // Make sure that we start with empty trace and span ID's - OTel::currentTraceContext().traceId = ""; - OTel::currentTraceContext().spanId = ""; - - // Setup the metrics engine - OTel::Metrics::begin("otel-embedded", "1.0.1"); - OTel::Metrics::setDefaultMetricLabel("device.role", "test-device"); - OTel::Metrics::setDefaultMetricLabel("device.id", "device-chip-id-or-mac"); + // Initialise tracer and metrics (scopeName, scopeVersion) + OTel::Tracer::begin("otel-embedded", "1.0.0"); + OTel::Metrics::begin("otel-embedded", "1.0.0"); + + // On RP2040, start the core-1 async send worker after Wi-Fi is ready +#if defined(ARDUINO_ARCH_RP2040) + OTelSender::beginAsyncWorker(); +#endif } void loop() { - // Heartbeat trace auto span = OTel::Tracer::startSpan("heartbeat"); OTel::Logger::logInfo("Heartbeat event"); - static OTel::OTelGauge gauge("heartbeat.gauge", "1"); - gauge.set(1.0f); - span.end(); + OTel::Metrics::gauge("heartbeat.uptime_seconds", + static_cast(millis() / 1000), "s"); + + span.end(); delay(HEARTBEAT_INTERVAL); } ``` @@ -164,8 +146,8 @@ void loop() { This will emit: * **Traces** for each `startSpan("heartbeat")` -* **Logs** with `service.*` resource attributes -* **Metrics** via `OTelGauge`, `OTelCounter` and `OTelHistogram` +* **Logs** correlated to the active span via `traceId`/`spanId` +* **Metrics** as a gauge via `Metrics::gauge()` All data is sent over OTLP/HTTP to the configured collector. @@ -173,22 +155,50 @@ All data is sent over OTLP/HTTP to the configured collector. ## 🛠 Configuration Macros -Override defaults in `OtelDefaults.h` or via `-D` flags: +Set via `-D` flags in `platformio.ini` `build_flags`. + +### Endpoint + +| Macro | Default | Description | +| ------------------------------------------ | ------------------------------ | ----------- | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | *(empty)* | Base URL for all signals; `/v1/traces`, `/v1/metrics`, `/v1/logs` are appended automatically. Follows the [OTel exporter spec](https://opentelemetry.io/docs/specs/otel/protocol/exporter/). Takes priority over `OTEL_COLLECTOR_BASE_URL`. | +| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | *(empty)* | Per-signal endpoint override, used verbatim (no path appended). Overrides `OTEL_EXPORTER_OTLP_ENDPOINT` for logs. | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | *(empty)* | Same, for traces. | +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | *(empty)* | Same, for metrics. | +| `OTEL_COLLECTOR_BASE_URL` | `http://192.168.8.50:4318` | Legacy base URL fallback. Prefer `OTEL_EXPORTER_OTLP_ENDPOINT` for new setups. | + +### Headers & Authentication + +| Macro | Default | Description | +| ------------------------------------------ | --------- | ----------- | +| `OTEL_EXPORTER_OTLP_HEADERS` | *(empty)* | Comma-separated `key=value` headers added to every request. Values containing commas must be percent-encoded. Example: `"dd-api-key=abc123"` | +| `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | *(empty)* | Per-signal header overrides, merged on top of `OTEL_EXPORTER_OTLP_HEADERS`. | +| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | *(empty)* | Same, for traces. | +| `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | *(empty)* | Same, for metrics. | + +### TLS + +| Macro | Default | Description | +| -------------------- | ------- | ----------- | +| `OTEL_TLS_INSECURE` | `1` | When `1`, HTTPS connections skip certificate validation. Set to `0` for strict validation (requires a CA cert — see `OtelSender.h`). | + +### Service identity | Macro | Default | Description | | ------------------------ | ------------------ | ----------------------------------------------- | -| `WIFI_SSID` | `"default"` | Wi‑Fi SSID | -| `WIFI_PASS` | `"default"` | Wi‑Fi password | -| `OTEL_COLLECTOR_BASE_URL`| `Null` | The base URL (http://192.168.8.10:4318) of the otel collector | -| `OTEL_SERVICE_NAME` | `"demo_service"` | Name of your service | -| `OTEL_SERVICE_NAMESPACE` | `"demo_namespace"` | Service namespace | -| `OTEL_SERVICE_VERSION` | `"v1.0.0"` | Semantic version | -| `OTEL_SERVICE_INSTANCE` | `"instance-1"` | Unique instance ID | -| `OTEL_DEPLOY_ENV` | `"dev"` | Deployment environment (e.g. `prod`, `staging`) | -| `OTEL_WORKER_BURST` | `16` | The number of telemetry messages to process at a time | -| `OTEL_WORKER_SLEEP_MS` | `0` | How long to sleep between processing messages (0 is instant) | -| `OTEL_QUEUE_CAPACITY` | `128` | The maximum number of telemetry messages we can store before we start to drop data | -| `DEBUG` | `Null` | Print verbose messages including OTEL Payload to the serial port | +| `OTEL_SERVICE_NAME` | `"embedded-service"` | Name of your service | +| `OTEL_SERVICE_INSTANCE` | chip ID | Unique instance identifier | +| `OTEL_HOST_NAME` | `"ESP-"` | Host name reported in resource attributes | + +### Send behaviour + +| Macro | Default | Description | +| --------------------- | ------- | ----------- | +| `OTEL_SEND_ENABLE` | `1` | Set to `0` to compile out all network sends (useful for latency benchmarking). | +| `OTEL_WORKER_BURST` | `8` | Items dequeued and sent per worker loop iteration (RP2040). | +| `OTEL_WORKER_SLEEP_MS`| `0` | Delay between worker iterations in ms. | +| `OTEL_QUEUE_CAPACITY` | `16` | SPSC queue depth for the RP2040 core-1 sender. | +| `DEBUG` | *(unset)* | Print verbose output including OTLP payloads to Serial. | --- diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..7553ad0 --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,42 @@ +# basic example + +Minimal working sketch that sends a heartbeat trace, a correlated log, and a +gauge metric on a fixed interval. + +## What it does + +Every 5 seconds the main loop: + +1. Opens a trace span called `heartbeat` +2. Emits an `INFO` log correlated to that span (the `traceId` and `spanId` are + attached automatically) +3. Records a gauge metric (`heartbeat.uptime_seconds`) +4. Closes the span, which triggers the OTLP send + +## Configuration + +Set these via `-D` flags in your `platformio.ini` `build_flags`: + +| Flag | Purpose | +|---|---| +| `WIFI_SSID` / `WIFI_PASS` | Wi-Fi credentials | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base URL of your collector, e.g. `http://192.168.1.100:4318` | +| `OTEL_SERVICE_NAME` | Service name reported in all telemetry | + +See the root [README](../../README.md) for the full list of configuration flags. + +## Running + +```ini +; platformio.ini +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +lib_deps = https://github.com/proffalken/otel-embedded-cpp.git#main +build_flags = + -DWIFI_SSID="\"your-ssid\"" + -DWIFI_PASS="\"your-password\"" + -DOTEL_EXPORTER_OTLP_ENDPOINT="\"http://192.168.1.100:4318\"" + -DOTEL_SERVICE_NAME="\"my-device\"" +``` diff --git a/examples/basic/main.cpp b/examples/basic/main.cpp index c16b5ec..845d25e 100644 --- a/examples/basic/main.cpp +++ b/examples/basic/main.cpp @@ -1,8 +1,6 @@ #include -// —————————————————————————————————————————————————————————— -// Platform‐specific networking includes -// —————————————————————————————————————————————————————————— +// ── Platform networking ─────────────────────────────────────────────────────── #if defined(ESP8266) #include #include @@ -10,41 +8,35 @@ #include #include #else - #error "Unsupported platform: must be ESP8266, ESP32 or RP2040" + #error "Unsupported platform: must be ESP8266, ESP32, or RP2040" #endif -// NTP / time #include -// For PRNG seeding #if defined(ARDUINO_ARCH_ESP32) #include #endif -// OTLP library -#include "OtelDefaults.h" #include "OtelSender.h" #include "OtelLogger.h" #include "OtelTracer.h" #include "OtelMetrics.h" -// ———————————————————————————————————————————————— -// Build‐time defaults (override with -D OTEL_* flags) -// ———————————————————————————————————————————————— -#ifndef OTEL_WIFI_SSID -#define OTEL_WIFI_SSID "default" -#endif +// ── Build-time defaults (override via -D flags in platformio.ini) ───────────── -#ifndef OTEL_WIFI_PASS -#define OTEL_WIFI_PASS "default" +#ifndef WIFI_SSID +#define WIFI_SSID "default" #endif -#ifndef OTEL_COLLECTOR_HOST -#define OTEL_COLLECTOR_HOST "http://192.168.1.100:4318" +#ifndef WIFI_PASS +#define WIFI_PASS "default" #endif +// Collector endpoint — set OTEL_EXPORTER_OTLP_ENDPOINT (standard) or the +// legacy OTEL_COLLECTOR_BASE_URL. See OtelSender.h for full priority chain. + #ifndef OTEL_SERVICE_NAME -#define OTEL_SERVICE_NAME "demo_service" +#define OTEL_SERVICE_NAME "demo_service" #endif #ifndef OTEL_SERVICE_INSTANCE @@ -60,91 +52,69 @@ #endif #ifndef OTEL_DEPLOY_ENV -#define OTEL_DEPLOY_ENV "dev" +#define OTEL_DEPLOY_ENV "dev" #endif -// Delay between heartbeats (ms) +// ───────────────────────────────────────────────────────────────────────────── + static constexpr uint32_t HEARTBEAT_INTERVAL = 5000; void setup() { Serial.begin(115200); - // —————————— - // 0) Seed the PRNG for truly fresh IDs each boot - // —————————— -#if defined(ARDUINO_ARCH_ESP32) - randomSeed(esp_random()); -#else - randomSeed(micros()); -#endif - - // 1) Connect to Wi-Fi - Serial.printf("Connecting to %s …\n", OTEL_WIFI_SSID); - WiFi.begin(OTEL_WIFI_SSID, OTEL_WIFI_PASS); + // Connect to Wi-Fi + Serial.printf("Connecting to %s\n", WIFI_SSID); + WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print('.'); } - Serial.println("\nWi-Fi connected!"); + Serial.println("\nWi-Fi connected"); - // 2) Sync NTP (configTime works on Pico W, ESP32 & ESP8266 in Arduino land) - // We're polling until we get something > Jan 1 2020 (1609459200). + // Sync NTP so telemetry timestamps are meaningful configTime(0, 0, "pool.ntp.org", "time.nist.gov"); - Serial.print("Waiting NTP sync"); + Serial.print("Waiting for NTP"); while (time(nullptr) < 1609459200UL) { - Serial.print('.'); delay(500); + Serial.print('.'); } Serial.println(); - // 3) (Optional) print the calendar time - { - time_t now = time(nullptr); - struct tm tm; - gmtime_r(&now, &tm); - Serial.printf("NTP time: %s", asctime(&tm)); - } + // Seed PRNG for fresh trace IDs each boot +#if defined(ARDUINO_ARCH_ESP32) + randomSeed(esp_random()); +#else + randomSeed(micros()); +#endif + + // Initialise tracer and metrics (scopeName, scopeVersion) + OTel::Tracer::begin("otel-embedded", OTEL_SERVICE_VERSION); + OTel::Metrics::begin("otel-embedded", OTEL_SERVICE_VERSION); - // 4) Init your OTLP logger & tracer - OTel::Logger::begin( - OTEL_SERVICE_NAME, - OTEL_SERVICE_NAMESPACE, - OTEL_SERVICE_INSTANCE, - OTEL_SERVICE_INSTANCE, - OTEL_SERVICE_VERSION - ); - - OTel::Tracer::begin( - OTEL_SERVICE_NAME, - OTEL_SERVICE_NAMESPACE, - OTEL_SERVICE_INSTANCE, - OTEL_SERVICE_VERSION - ); - Serial.println("OTLP Logger ready"); + // Tag every metric datapoint with deployment metadata + OTel::Metrics::setDefaultMetricLabel("deploy.environment", OTEL_DEPLOY_ENV); + OTel::Metrics::setDefaultMetricLabel("service.namespace", OTEL_SERVICE_NAMESPACE); + + // On RP2040, start the core-1 async send worker after Wi-Fi is ready +#if defined(ARDUINO_ARCH_RP2040) + OTelSender::beginAsyncWorker(); +#endif + + Serial.println("OTel ready"); } void loop() { - // Print the calendar time - { - time_t now = time(nullptr); - struct tm tm; - gmtime_r(&now, &tm); - Serial.printf("NTP time: %s", asctime(&tm)); - } - - // Start a new trace span called "heartbeat" + // Open a trace span auto span = OTel::Tracer::startSpan("heartbeat"); - // Emit a simple INFO log + // Log correlated to the active span OTel::Logger::logInfo("Heartbeat event"); - // Record a gauge - static OTel::OTelGauge heartbeatGauge("heartbeat.gauge", "1"); - heartbeatGauge.set(1.0f); + // Gauge: current value (no temporality) + OTel::Metrics::gauge("heartbeat.uptime_seconds", + static_cast(millis() / 1000), "s"); - // End the trace span (this actually sends the trace) span.end(); delay(HEARTBEAT_INTERVAL); } - diff --git a/examples/direct_otlp/README.md b/examples/direct_otlp/README.md new file mode 100644 index 0000000..ed7888c --- /dev/null +++ b/examples/direct_otlp/README.md @@ -0,0 +1,60 @@ +# direct_otlp example + +Demonstrates sending traces, metrics, and logs directly to a vendor OTLP/HTTP +endpoint (no intermediate collector required), and shows the full attribute and +tagging API across all three signals. + +## What it does + +Every 10 seconds the main loop: + +1. Opens a trace span with typed attributes (string, int, double, bool) +2. Emits a correlated log with per-call labels merged with setup-time defaults +3. Adds a timestamped span event if the simulated reading crosses a threshold +4. Records a gauge metric and a delta-temporality sum, both with per-call and + default labels +5. Closes the span + +## Attributes and tags + +| Where | API | Notes | +|---|---|---| +| Span | `span.setAttribute(key, value)` | String, int64, double, or bool | +| Span | `span.addEvent(name, attrs)` | Timestamped annotation within span | +| Metric (per-call) | last arg of `gauge()` / `sum()` | `{{"key", "val"}, ...}` | +| Metric (default) | `Metrics::setDefaultMetricLabel(k, v)` | Applied to every datapoint | +| Log (per-call) | last arg of `logInfo()` etc. | `{{"key", "val"}, ...}` | +| Log (default) | `Logger::setDefaultLabel(k, v)` | Applied to every log record | + +## Configuration + +All endpoint and authentication config lives in build flags — no credentials in +source. + +```ini +; platformio.ini +build_flags = + -DWIFI_SSID="\"your-ssid\"" + -DWIFI_PASS="\"your-password\"" + -DOTEL_EXPORTER_OTLP_ENDPOINT="\"https://your-collector\"" + -DOTEL_EXPORTER_OTLP_HEADERS="\"Authorization=Bearer your-token\"" +``` + +### Datadog (US1) + +```ini +build_flags = + -DOTEL_EXPORTER_OTLP_ENDPOINT="\"https://otlp.datadoghq.com\"" + -DOTEL_EXPORTER_OTLP_HEADERS="\"dd-api-key=${sysenv.DD_API_KEY}\"" +``` + +### Grafana Cloud + +```ini +build_flags = + -DOTEL_EXPORTER_OTLP_ENDPOINT="\"https://.grafana.net/otlp\"" + -DOTEL_EXPORTER_OTLP_HEADERS="\"Authorization=Basic \"" +``` + +HTTPS is enabled automatically when the endpoint URL starts with `https://`. +See the root [README](../../README.md) for the full list of configuration flags. diff --git a/examples/direct_otlp/main.cpp b/examples/direct_otlp/main.cpp new file mode 100644 index 0000000..472ffc7 --- /dev/null +++ b/examples/direct_otlp/main.cpp @@ -0,0 +1,180 @@ +/* + * direct_otlp — send traces, metrics, and logs directly to any OTLP/HTTP + * vendor endpoint without an intermediate collector. + * + * All endpoint and authentication configuration lives in build flags so no + * credentials are baked into source. The relevant flags are: + * + * OTEL_EXPORTER_OTLP_ENDPOINT Base URL for all signals (paths appended + * automatically). Overrides the legacy + * OTEL_COLLECTOR_BASE_URL if both are set. + * + * OTEL_EXPORTER_OTLP_HEADERS Headers applied to every signal, as a + * comma-separated "key=value" list. + * Values containing commas must be + * percent-encoded (%2C). + * + * OTEL_EXPORTER_OTLP_*_ENDPOINT Per-signal endpoint override (used + * OTEL_EXPORTER_OTLP_*_HEADERS verbatim, no path appended). Useful + * when routing signals to different + * backends, or when a vendor requires + * signal-specific authentication. + * + * HTTPS is enabled automatically when an endpoint URL starts with "https://". + * Certificate validation is skipped by default (OTEL_TLS_INSECURE=1). + * + * ── Datadog (US1) example ──────────────────────────────────────────────────── + * + * build_flags = + * -DOTEL_EXPORTER_OTLP_ENDPOINT="\"https://otlp.datadoghq.com\"" + * -DOTEL_EXPORTER_OTLP_HEADERS="\"dd-api-key=${sysenv.DD_API_KEY}\"" + * + * Or route signals to separate endpoints with per-signal headers: + * + * -DOTEL_EXPORTER_OTLP_LOGS_ENDPOINT="\"https://otlp.datadoghq.com/v1/logs\"" + * -DOTEL_EXPORTER_OTLP_TRACES_ENDPOINT="\"https://otlp.datadoghq.com/v1/traces\"" + * -DOTEL_EXPORTER_OTLP_METRICS_ENDPOINT="\"https://otlp.datadoghq.com/v1/metrics\"" + * -DOTEL_EXPORTER_OTLP_METRICS_HEADERS="\"dd-api-key=${sysenv.DD_API_KEY},dd-otel-metric-config=%7B%22resource_attributes_as_tags%22%3Atrue%7D\"" + * + * Note: Datadog requires delta temporality for Sum metrics. This library + * defaults sum() to DELTA, so no extra configuration is needed. + * + * ── Grafana Cloud example ──────────────────────────────────────────────────── + * + * build_flags = + * -DOTEL_EXPORTER_OTLP_ENDPOINT="\"https://.grafana.net/otlp\"" + * -DOTEL_EXPORTER_OTLP_HEADERS="\"Authorization=Basic \"" + * + * ───────────────────────────────────────────────────────────────────────────── + */ + +#include + +#if defined(ESP8266) + #include +#elif defined(ARDUINO_ARCH_ESP32) || defined(ARDUINO_ARCH_RP2040) + #include +#else + #error "Unsupported platform: must be ESP8266, ESP32, or RP2040" +#endif + +#include + +#include "OtelSender.h" +#include "OtelLogger.h" +#include "OtelTracer.h" +#include "OtelMetrics.h" + +// ── Build-time defaults (override via -D flags in platformio.ini) ───────────── + +#ifndef WIFI_SSID +#define WIFI_SSID "your-ssid" +#endif + +#ifndef WIFI_PASS +#define WIFI_PASS "your-password" +#endif + +// OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS are defined in +// OtelSender.h with empty defaults. Set them via build_flags — see above. + +#ifndef OTEL_DEPLOY_ENV +#define OTEL_DEPLOY_ENV "dev" +#endif + +// ───────────────────────────────────────────────────────────────────────────── + +static constexpr uint32_t SEND_INTERVAL_MS = 10000; + +void setup() { + Serial.begin(115200); + + // Connect to Wi-Fi + Serial.printf("Connecting to %s\n", WIFI_SSID); + WiFi.begin(WIFI_SSID, WIFI_PASS); + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print('.'); + } + Serial.println("\nWi-Fi connected"); + + // Sync NTP so timestamps in telemetry are meaningful + configTime(0, 0, "pool.ntp.org", "time.nist.gov"); + Serial.print("Waiting for NTP"); + while (time(nullptr) < 1609459200UL) { + delay(500); + Serial.print('.'); + } + Serial.println(); + + // Initialise tracer and metrics scope + OTel::Tracer::begin("direct-otlp-example", "1.0.0"); + OTel::Metrics::begin("direct-otlp-example", "1.0.0"); + + // Default attributes — merged into every metric datapoint and log record + // automatically, without needing to pass them on each call. + OTel::Metrics::setDefaultMetricLabel("device.id", "esp32-abc123"); + OTel::Metrics::setDefaultMetricLabel("deploy.env", OTEL_DEPLOY_ENV); + OTel::Logger::setDefaultLabel("device.id", "esp32-abc123"); + OTel::Logger::setDefaultLabel("deploy.env", OTEL_DEPLOY_ENV); + + // On RP2040, start the core-1 async send worker after Wi-Fi is ready +#if defined(ARDUINO_ARCH_RP2040) + OTelSender::beginAsyncWorker(); +#endif + + Serial.println("OTel ready — sending to: " OTEL_EXPORTER_OTLP_ENDPOINT); +} + +void loop() { + // ── Trace ────────────────────────────────────────────────────────────────── + { + auto span = OTel::Tracer::startSpan("sensor-read"); + + // Span attributes support multiple types: string, int, double, bool. + // These appear as tags on the span in your backend. + span.setAttribute("sensor.id", "1"); + span.setAttribute("sensor.type", "temperature"); + span.setAttribute("sensor.channel", (int64_t)0); + span.setAttribute("sensor.enabled", true); + + // ── Log (correlated to the active span via traceId/spanId) ─────────────── + // Per-call labels are merged with the defaults set in setup(). + OTel::Logger::logInfo("Reading sensor", {{"sensor.id", "1"}}); + + // Simulate a sensor read + float temperature = 20.0f + (random(0, 100) / 10.0f); + bool overThreshold = temperature > 28.0f; + + // Span event: a timestamped annotation within the span's duration. + if (overThreshold) { + span.addEvent("threshold-crossed", {{"threshold.celsius", "28.0"}}); + OTel::Logger::logWarn("Temperature above threshold", + {{"sensor.id", "1"}, {"value.celsius", String(temperature).c_str()}}); + } + + // ── Metrics ─────────────────────────────────────────────────────────────── + // gauge: point-in-time value, no temporality required. + // Per-call labels are merged with the defaults set in setup(). + OTel::Metrics::gauge("sensor.temperature", temperature, "Cel", + {{"sensor.id", "1"}}); + + // sum: monotonic counter. Pass 1.0 per call with DELTA temporality so + // the value represents the increment since the last report, not a running + // total. Use CUMULATIVE + an accumulator if your backend requires it. + OTel::Metrics::sum("sensor.readings.total", 1.0, + /*isMonotonic=*/true, "DELTA", "1", + {{"sensor.id", "1"}}); + + span.setAttribute("reading.celsius", (double)temperature); + span.end(); + } + + // ── Health diagnostics (optional) ───────────────────────────────────────── + uint32_t dropped = OTelSender::droppedCount(); + if (dropped > 0) { + Serial.printf("[otel] warning: %u items dropped from send queue\n", dropped); + } + + delay(SEND_INTERVAL_MS); +} diff --git a/examples/proto_otlp/main.cpp b/examples/proto_otlp/main.cpp new file mode 100644 index 0000000..9d9b8c3 --- /dev/null +++ b/examples/proto_otlp/main.cpp @@ -0,0 +1,170 @@ +/* + * proto_otlp — send traces, metrics, and logs over OTLP/HTTP using binary + * protobuf encoding instead of the default JSON. + * + * Protobuf produces smaller payloads (typically 30–60 % smaller than JSON for + * the same telemetry) and is faster to serialise on-device. It is the + * preferred encoding for bandwidth-constrained devices or high-frequency + * telemetry. + * + * To enable protobuf, add ONE build flag: + * + * build_flags = + * -DOTEL_EXPORTER_OTLP_PROTOCOL=OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + * + * All other configuration (endpoint, headers, TLS) is identical to JSON mode. + * The application code below is exactly the same as the direct_otlp example — + * only the build flag differs. + * + * Protobuf also requires the nanopb library: + * + * lib_deps = + * bblanchon/ArduinoJson@^7.0.0 + * nanopb/Nanopb@^0.4.91 + * + * ── Datadog (US1) example ──────────────────────────────────────────────────── + * + * build_flags = + * -DOTEL_EXPORTER_OTLP_PROTOCOL=OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + * -DOTEL_EXPORTER_OTLP_ENDPOINT="\"https://otlp.datadoghq.com\"" + * -DOTEL_EXPORTER_OTLP_HEADERS="\"dd-api-key=${sysenv.DD_API_KEY}\"" + * + * ── Grafana Cloud example ──────────────────────────────────────────────────── + * + * build_flags = + * -DOTEL_EXPORTER_OTLP_PROTOCOL=OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + * -DOTEL_EXPORTER_OTLP_ENDPOINT="\"https://.grafana.net/otlp\"" + * -DOTEL_EXPORTER_OTLP_HEADERS="\"Authorization=Basic \"" + * + * ── Local OpenTelemetry Collector ──────────────────────────────────────────── + * + * build_flags = + * -DOTEL_EXPORTER_OTLP_PROTOCOL=OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + * -DOTEL_EXPORTER_OTLP_ENDPOINT="\"http://192.168.1.10:4318\"" + * + * ───────────────────────────────────────────────────────────────────────────── + * + * Buffer sizing: the protobuf encoder uses a stack buffer for each signal. + * The default is 1024 bytes. Increase it if you have many attributes or long + * string values and see silent drops: + * + * -DOTEL_PROTO_BUFFER_SIZE=2048 + * + * ───────────────────────────────────────────────────────────────────────────── + */ + +#include + +#if defined(ESP8266) + #include +#elif defined(ARDUINO_ARCH_ESP32) || defined(ARDUINO_ARCH_RP2040) + #include +#else + #error "Unsupported platform: must be ESP8266, ESP32, or RP2040" +#endif + +#include + +#include "OtelSender.h" +#include "OtelLogger.h" +#include "OtelTracer.h" +#include "OtelMetrics.h" + +// ── Build-time defaults (override via -D flags in platformio.ini) ───────────── + +#ifndef WIFI_SSID +#define WIFI_SSID "your-ssid" +#endif + +#ifndef WIFI_PASS +#define WIFI_PASS "your-password" +#endif + +#ifndef OTEL_DEPLOY_ENV +#define OTEL_DEPLOY_ENV "dev" +#endif + +// ───────────────────────────────────────────────────────────────────────────── + +static constexpr uint32_t SEND_INTERVAL_MS = 10000; + +void setup() { + Serial.begin(115200); + + Serial.printf("Connecting to %s\n", WIFI_SSID); + WiFi.begin(WIFI_SSID, WIFI_PASS); + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print('.'); + } + Serial.println("\nWi-Fi connected"); + + configTime(0, 0, "pool.ntp.org", "time.nist.gov"); + Serial.print("Waiting for NTP"); + while (time(nullptr) < 1609459200UL) { + delay(500); + Serial.print('.'); + } + Serial.println(); + + OTel::Tracer::begin("proto-otlp-example", "1.0.0"); + OTel::Metrics::begin("proto-otlp-example", "1.0.0"); + + OTel::Metrics::setDefaultMetricLabel("device.id", "esp32-abc123"); + OTel::Metrics::setDefaultMetricLabel("deploy.env", OTEL_DEPLOY_ENV); + OTel::Logger::setDefaultLabel("device.id", "esp32-abc123"); + OTel::Logger::setDefaultLabel("deploy.env", OTEL_DEPLOY_ENV); + +#if defined(ARDUINO_ARCH_RP2040) + OTelSender::beginAsyncWorker(); +#endif + +#if OTEL_EXPORTER_OTLP_PROTOCOL == OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + Serial.println("OTel ready — encoding: protobuf"); +#else + Serial.println("OTel ready — encoding: JSON"); +#endif + Serial.println("Endpoint: " OTEL_EXPORTER_OTLP_ENDPOINT); +} + +void loop() { + { + auto span = OTel::Tracer::startSpan("sensor-read"); + + span.setAttribute("sensor.id", "1"); + span.setAttribute("sensor.type", "temperature"); + span.setAttribute("sensor.channel", (int64_t)0); + span.setAttribute("sensor.enabled", true); + + OTel::Logger::logInfo("Reading sensor", {{"sensor.id", "1"}}); + + float temperature = 20.0f + (random(0, 100) / 10.0f); + bool overThreshold = temperature > 28.0f; + + if (overThreshold) { + span.addEvent("threshold-crossed", {{"threshold.celsius", "28.0"}}); + OTel::Logger::logWarn("Temperature above threshold", + {{"sensor.id", "1"}, + {"value.celsius", String(temperature).c_str()}}); + } + + OTel::Metrics::gauge("sensor.temperature", temperature, "Cel", + {{"sensor.id", "1"}}); + + static double totalReadings = 0; + totalReadings += 1.0; + OTel::Metrics::sum("sensor.readings.total", totalReadings, + /*isMonotonic=*/true, "DELTA", "1", + {{"sensor.id", "1"}}); + + span.setAttribute("reading.celsius", (double)temperature); + span.end(); + } + + uint32_t dropped = OTelSender::droppedCount(); + if (dropped > 0) { + Serial.printf("[otel] warning: %u items dropped from send queue\n", dropped); + } + + delay(SEND_INTERVAL_MS); +} diff --git a/include/OtelDefaults.h b/include/OtelDefaults.h index 7cfde02..cea3476 100644 --- a/include/OtelDefaults.h +++ b/include/OtelDefaults.h @@ -1,6 +1,7 @@ #ifndef OTEL_DEFAULTS_H #define OTEL_DEFAULTS_H +#include #include #include #include // gettimeofday() @@ -36,7 +37,7 @@ static inline uint64_t nowUnixMillis() { + static_cast(tv.tv_usec) / 1000ULL; } -// Portable uint64 -> String (no printf/ULL reliance; RP2040-safe) +/** Convert a uint64 to its decimal String representation without printf/ULL (RP2040-safe). */ inline String u64ToString(uint64_t v) { if (v == 0) return String("0"); char buf[21]; // max 20 digits + NUL @@ -96,15 +97,21 @@ inline void serializeKeyInt(JsonArray &arr, const String &key, int64_t value) { struct OTelResourceConfig { std::map attrs; - // Newer API + /** @{ Set or update a resource attribute. */ void set(const String &k, const String &v) { attrs[k] = v; } void set(const char *k, const String &v) { attrs[String(k)] = v; } + /** @} */ + + /** Remove all resource attributes. */ void clear() { attrs.clear(); } + + /** @return True if no resource attributes have been set. */ bool empty() const { return attrs.empty(); } - // Backwards-compatible API expected by existing Metrics/Tracer code + /** @{ Backwards-compatible attribute setter used by Metrics/Tracer paths. */ void setAttribute(const String &k, const String &v) { attrs[k] = v; } void setAttribute(const char *k, const String &v) { attrs[String(k)] = v; } + /** @} */ /** * Legacy helper used by Metrics/Tracer paths: diff --git a/include/OtelEmbeddedCpp.h b/include/OtelEmbeddedCpp.h index d4c42ae..6d0dd1a 100644 --- a/include/OtelEmbeddedCpp.h +++ b/include/OtelEmbeddedCpp.h @@ -26,22 +26,5 @@ #define OTEL_DEPLOY_ENV "dev" #endif -namespace OTel { - using Logger = OTelLogger; - using Tracer = OTelTracer; - using Gauge = OTelGauge; - using Counter = OTelCounter; - using Histogram = OTelHistogram; - using ResourceConfig = OTelResourceConfig; - - inline OTelResourceConfig getDefaultResource() { - return OTelResourceConfig( - OTEL_SERVICE_NAME, - OTEL_SERVICE_NAMESPACE, - OTEL_SERVICE_VERSION, - OTEL_SERVICE_INSTANCE, - OTEL_DEPLOY_ENV - ); - } -} +// OTel::Logger, OTel::Tracer, OTel::Metrics are defined in their respective headers above. diff --git a/include/OtelLogger.h b/include/OtelLogger.h index 78aa8de..464ac03 100644 --- a/include/OtelLogger.h +++ b/include/OtelLogger.h @@ -12,7 +12,12 @@ namespace OTel { -// ---- Severity mapping ------------------------------------------------------- +/** + * Map a severity text string to its OTLP severityNumber enum value. + * Follows the OpenTelemetry Log Data Model specification. + * @param s Severity string: "TRACE", "DEBUG", "INFO", "WARN", "ERROR", or "FATAL". + * @return OTLP integer severity number, or 0 if unknown. + */ static inline int severityNumberFromText(const String& s) { if (s == "TRACE") return 1; if (s == "DEBUG") return 5; @@ -23,39 +28,66 @@ static inline int severityNumberFromText(const String& s) { return 0; } -// ---- Instrumentation scope for logs ----------------------------------------- +/** Instrumentation scope name and version emitted on every log payload. */ struct LogScopeConfig { String scopeName{"otel-embedded-cpp"}; String scopeVersion{""}; // optional }; -static inline LogScopeConfig& logScopeConfig() { + +/** Returns the process-wide LogScopeConfig singleton. */ +inline LogScopeConfig& logScopeConfig() { static LogScopeConfig cfg; return cfg; } -// ---- Default labels (merged into each log record's attributes) -------------- -static inline std::map& defaultLabels() { +/** Returns the process-wide default log labels map (merged into every log record). */ +inline std::map& defaultLabels() { static std::map labels; return labels; } +/** + * Static façade for emitting OTLP log records. + * + * Automatically correlates log records with the currently active span + * (traceId / spanId) when called from within a @c Span's lifetime. + */ class Logger { public: - // Set/merge defaults + /** + * Replace the full set of default labels added to every log record. + * @param labels Map of attribute key/value pairs. + */ static void setDefaultLabels(const std::map& labels) { defaultLabels() = labels; } + + /** + * Set or update a single default label added to every log record. + * @param key Attribute key. + * @param value Attribute value. + */ static void setDefaultLabel(const String& key, const String& value) { defaultLabels()[key] = value; } - // Map-based API + /** + * Emit a log record at the given severity. + * @param severity Severity string ("TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"). + * @param message Log body text. + * @param labels Per-call attributes merged on top of default labels. + */ static void log(const String& severity, const String& message, const std::map& labels = {}) { buildAndSend(severity, message, labels); } - // Convenience overload: initializer_list of key/value pairs + /** + * Emit a log record using an initializer-list of attributes. + * @param severity Severity string. + * @param message Log body text. + * @param kvs Brace-enclosed attribute pairs, e.g. {{"key","val"}}. + */ static void log(const String& severity, const String& message, std::initializer_list> kvs) { std::map labels; @@ -63,7 +95,7 @@ class Logger { buildAndSend(severity, message, labels); } - // Helpers by severity + /** @{ Convenience helpers that hard-code the severity level. */ static void logTrace(const String &m, const std::map &l = {}) { log("TRACE", m, l); } static void logDebug(const String &m, const std::map &l = {}) { log("DEBUG", m, l); } static void logInfo (const String &m, const std::map &l = {}) { log("INFO", m, l); } @@ -77,11 +109,22 @@ class Logger { static void logWarn (const String &m, std::initializer_list> kvs) { log("WARN", m, kvs); } static void logError(const String &m, std::initializer_list> kvs) { log("ERROR", m, kvs); } static void logFatal(const String &m, std::initializer_list> kvs) { log("FATAL", m, kvs); } + /** @} */ private: + /** Build the OTLP log payload and hand it to OTelSender. */ static void buildAndSend(const String& severity, const String& message, const std::map& labels) { +#if OTEL_EXPORTER_OTLP_PROTOCOL == OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + { + auto& ctx = currentTraceContext(); + OTel::Proto::sendLog(severity, severityNumberFromText(severity), message, + labels, defaultLabels(), + ctx.valid() ? ctx.traceId : String(""), + ctx.valid() ? ctx.spanId : String("")); + } +#else // Build OTLP/HTTP logs payload (ArduinoJson v7) JsonDocument doc; @@ -136,10 +179,10 @@ class Logger { // Send OTelSender::sendJson("/v1/logs", doc); +#endif // OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF } }; } // namespace OTel #endif // OTEL_LOGGER_H - diff --git a/include/OtelMetrics.h b/include/OtelMetrics.h index 10b3e62..8ee87e6 100644 --- a/include/OtelMetrics.h +++ b/include/OtelMetrics.h @@ -12,48 +12,79 @@ namespace OTel { -// ---- Instrumentation scope for metrics -------------------------------------- +/** Instrumentation scope name and version emitted on every metrics payload. */ struct MetricsScopeConfig { String scopeName{"otel-embedded"}; String scopeVersion{"0.1.0"}; }; -static inline MetricsScopeConfig& metricsScopeConfig() { +/** Returns the process-wide MetricsScopeConfig singleton. */ +inline MetricsScopeConfig& metricsScopeConfig() { static MetricsScopeConfig cfg; return cfg; } -// ---- Default metric labels (merged into each datapoint's attributes) -------- -static inline std::map& defaultMetricLabels() { +/** Returns the process-wide default metric labels map (merged into every datapoint). */ +inline std::map& defaultMetricLabels() { static std::map labels; return labels; } +/** + * Static façade for emitting OTLP metrics (gauges and sums). + * + * Call @c begin() once after connecting to Wi-Fi to set the instrumentation + * scope name/version. Then call @c gauge() or @c sum() freely from loop(). + */ class Metrics { public: - // Configure the instrumentation scope name/version for metrics + /** + * Configure the instrumentation scope name and version for all metrics. + * @param scopeName Library/component name, e.g. "my-firmware". + * @param scopeVersion Semver string, e.g. "1.0.0". + */ static void begin(const String& scopeName, const String& scopeVersion) { metricsScopeConfig().scopeName = scopeName; metricsScopeConfig().scopeVersion = scopeVersion; } - // Set/merge defaults applied to *every* datapoint + /** + * Replace the full set of default labels applied to every datapoint. + * @param labels Map of attribute key/value pairs. + */ static void setDefaultMetricLabels(const std::map& labels) { defaultMetricLabels() = labels; } + + /** + * Set or update a single default label applied to every datapoint. + * @param key Attribute key. + * @param value Attribute value. + */ static void setDefaultMetricLabel(const String& key, const String& value) { defaultMetricLabels()[key] = value; } - // --------- GAUGE (double) ---------- - // Convenience with std::map + /** + * Emit a gauge datapoint. + * @param name Metric name (e.g. "cpu.usage"). + * @param value Measured value. + * @param unit UCUM unit string, e.g. "1", "By", "ms". + * @param labels Per-call attributes merged on top of default labels. + */ static void gauge(const String& name, double value, const String& unit = "1", const std::map& labels = {}) { buildAndSendGauge(name, value, unit, labels); } - // Convenience with initializer_list + /** + * Emit a gauge datapoint using an initializer-list of attributes. + * @param name Metric name. + * @param value Measured value. + * @param unit UCUM unit string. + * @param kvs Brace-enclosed attribute pairs, e.g. {{"core","0"},{"host","esp1"}}. + */ static void gauge(const String& name, double value, const String& unit, std::initializer_list> kvs) { @@ -62,16 +93,32 @@ class Metrics { buildAndSendGauge(name, value, unit, labels); } - // --------- SUM (double) ------------- - // OTLP requires temporality + monotonic flags for Sum + /** + * Emit a sum (counter/cumulative) datapoint. + * @param name Metric name. + * @param value Measured value. + * @param isMonotonic True for counters that only increase. + * @param temporality "DELTA" or "CUMULATIVE". + * @param unit UCUM unit string. + * @param labels Per-call attributes merged on top of default labels. + */ static void sum(const String& name, double value, bool isMonotonic = false, - const String& temporality = "DELTA", // or "CUMULATIVE" + const String& temporality = "DELTA", const String& unit = "1", const std::map& labels = {}) { buildAndSendSum(name, value, isMonotonic, temporality, unit, labels); } + /** + * Emit a sum datapoint using an initializer-list of attributes. + * @param name Metric name. + * @param value Measured value. + * @param isMonotonic True for monotonic counters. + * @param temporality "DELTA" or "CUMULATIVE". + * @param unit UCUM unit string. + * @param kvs Brace-enclosed attribute pairs. + */ static void sum(const String& name, double value, bool isMonotonic, const String& temporality, @@ -83,10 +130,12 @@ class Metrics { } private: + /** Build the OTLP gauge payload and hand it to OTelSender. */ static void buildAndSendGauge(const String& name, double value, const String& unit, const std::map& labels); + /** Build the OTLP sum payload and hand it to OTelSender. */ static void buildAndSendSum(const String& name, double value, bool isMonotonic, const String& temporality, @@ -97,4 +146,3 @@ class Metrics { } // namespace OTel #endif // OTEL_METRICS_H - diff --git a/include/OtelProtoEncoder.h b/include/OtelProtoEncoder.h new file mode 100644 index 0000000..da7b858 --- /dev/null +++ b/include/OtelProtoEncoder.h @@ -0,0 +1,112 @@ +#pragma once + +// Ensure the protocol macros are defined before we evaluate the guard below. +// OTEL_EXPORTER_OTLP_PROTOCOL defaults to 0 (JSON) and +// OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF is 1, both defined in OtelSender.h. +// Without this include, an undefined macro would expand to 0 on both sides, +// making the guard evaluate to true and silently activating the protobuf path. +#include "OtelSender.h" + +// This header is only active when the protobuf protocol is selected. +// It provides encode functions called by Metrics, Logger, and Tracer +// instead of building a JsonDocument. +#if OTEL_EXPORTER_OTLP_PROTOCOL == OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + +#include +#include +#include +#include + +namespace OTel { +namespace Proto { + +/** Discriminator for typed span/log attributes. */ +enum class AttrType { Str, Int, Dbl, Bool }; + +/** + * A single typed key/value attribute used in spans and log records. + * Mirrors @c Span::Attr; forward-declared here to avoid a circular dependency + * between OtelTracer.h and OtelProtoEncoder.h. + */ +struct Attr { + String key; + AttrType type{AttrType::Str}; + String s; + int64_t i{0}; + double d{0.0}; + bool b{false}; +}; + +/** + * A span event (timestamped annotation) with an optional set of attributes. + */ +struct Event { + String name; + uint64_t t{0}; + std::vector attrs; +}; + +// ── Signal encoders ────────────────────────────────────────────────────────── + +/** + * Encode and transmit a Gauge metric datapoint via OTLP/Protobuf. + * @param name Metric name. + * @param value Gauge value. + * @param unit UCUM unit string. + * @param labels Merged default + per-call attributes. + */ +void sendGauge(const String& name, double value, const String& unit, + const std::map& labels); + +/** + * Encode and transmit a Sum metric datapoint via OTLP/Protobuf. + * @param name Metric name. + * @param value Sum value. + * @param isMonotonic True for monotonically increasing counters. + * @param temporality 1 = DELTA, 2 = CUMULATIVE. + * @param unit UCUM unit string. + * @param labels Merged default + per-call attributes. + */ +void sendSum(const String& name, double value, bool isMonotonic, + int temporality, const String& unit, + const std::map& labels); + +/** + * Encode and transmit a LogRecord via OTLP/Protobuf. + * @param severity Severity text ("INFO", "WARN", etc.). + * @param severityNum OTLP severity number (see severityNumberFromText()). + * @param message Log body. + * @param callLabels Per-call attributes. + * @param defaultLabels Process-wide default labels. + * @param traceId Active trace ID (32 hex chars), or empty string. + * @param spanId Active span ID (16 hex chars), or empty string. + */ +void sendLog(const String& severity, int severityNum, const String& message, + const std::map& callLabels, + const std::map& defaultLabels, + const String& traceId, const String& spanId); + +/** + * Encode and transmit a Span via OTLP/Protobuf. + * @param name Operation name. + * @param traceId 32 hex-char trace identifier. + * @param spanId 16 hex-char span identifier. + * @param parentSpanId 16 hex-char parent span ID, or empty string for root spans. + * @param startNs Span start time in nanoseconds since UNIX epoch. + * @param endNs Span end time in nanoseconds since UNIX epoch. + * @param attrs Span attributes. + * @param events Span events (timestamped annotations). + */ +void sendSpan(const String& name, + const String& traceId, + const String& spanId, + const String& parentSpanId, + uint64_t startNs, + uint64_t endNs, + const std::vector& attrs, + const std::vector& events); + +} // namespace Proto +} // namespace OTel + +#endif // OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF diff --git a/include/OtelSender.h b/include/OtelSender.h index 0a30a90..0ef9224 100644 --- a/include/OtelSender.h +++ b/include/OtelSender.h @@ -2,6 +2,8 @@ #include #include #include +#include +#include // Optional compile-time on/off switch for all network sends. // You can set -DOTEL_SEND_ENABLE=0 in platformio.ini for latency tests. #ifndef OTEL_SEND_ENABLE @@ -16,34 +18,175 @@ #define OTEL_WORKER_SLEEP_MS 0 #endif -// Base URL of your OTLP/HTTP collector (no trailing slash), e.g. "http://192.168.8.50:4318" -// You can override this via build_flags: -DOTEL_COLLECTOR_BASE_URL="\"http://…:4318\"" +// Base URL for all signals. Signal paths (/v1/traces, /v1/metrics, /v1/logs) +// are appended automatically, matching the OTLP exporter spec: +// https://opentelemetry.io/docs/specs/otel/protocol/exporter/ +// +// Standard name (preferred): +// -DOTEL_EXPORTER_OTLP_ENDPOINT="\"http://192.168.8.50:4318\"" +// Legacy name (still accepted, overridden by the standard name if both are set): +// -DOTEL_COLLECTOR_BASE_URL="\"http://192.168.8.50:4318\"" +#ifndef OTEL_EXPORTER_OTLP_ENDPOINT +#define OTEL_EXPORTER_OTLP_ENDPOINT "" +#endif #ifndef OTEL_COLLECTOR_BASE_URL #define OTEL_COLLECTOR_BASE_URL "http://192.168.8.50:4318" #endif +// Per-signal endpoint overrides (full URL used as-is, no path appending). +// These override OTEL_EXPORTER_OTLP_ENDPOINT for their respective signal. +// Example (Datadog US1): +// -DOTEL_EXPORTER_OTLP_LOGS_ENDPOINT="\"https://otlp.datadoghq.com/v1/logs\"" +#ifndef OTEL_EXPORTER_OTLP_LOGS_ENDPOINT +#define OTEL_EXPORTER_OTLP_LOGS_ENDPOINT "" +#endif +#ifndef OTEL_EXPORTER_OTLP_TRACES_ENDPOINT +#define OTEL_EXPORTER_OTLP_TRACES_ENDPOINT "" +#endif +#ifndef OTEL_EXPORTER_OTLP_METRICS_ENDPOINT +#define OTEL_EXPORTER_OTLP_METRICS_ENDPOINT "" +#endif + +// HTTP headers added to every outgoing OTLP request (all signals). +// Format: comma-separated "key=value" pairs. +// Example: -DOTEL_EXPORTER_OTLP_HEADERS="\"dd-api-key=abc123,dd-otlp-source=myapp\"" +#ifndef OTEL_EXPORTER_OTLP_HEADERS +#define OTEL_EXPORTER_OTLP_HEADERS "" +#endif + +// Per-signal header overrides, merged on top of OTEL_EXPORTER_OTLP_HEADERS. +// Same "key=value,…" format. +#ifndef OTEL_EXPORTER_OTLP_LOGS_HEADERS +#define OTEL_EXPORTER_OTLP_LOGS_HEADERS "" +#endif +#ifndef OTEL_EXPORTER_OTLP_TRACES_HEADERS +#define OTEL_EXPORTER_OTLP_TRACES_HEADERS "" +#endif +#ifndef OTEL_EXPORTER_OTLP_METRICS_HEADERS +#define OTEL_EXPORTER_OTLP_METRICS_HEADERS "" +#endif + +// TLS: when the endpoint URL starts with "https://", a WiFiClientSecure is used. +// Set OTEL_TLS_INSECURE=0 and supply a CA cert via OTEL_TLS_CA_CERT to enable +// certificate validation. Defaults to skipping validation (common on embedded). +#ifndef OTEL_TLS_INSECURE +#define OTEL_TLS_INSECURE 1 +#endif + +// OTLP wire protocol. +// Matches the standard OTEL_EXPORTER_OTLP_PROTOCOL environment variable values. +// Use the named constants below rather than raw integers. +// +// Default (JSON): +// no flag needed — http/json is the default +// +// Protobuf: +// -DOTEL_EXPORTER_OTLP_PROTOCOL=OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF +// +// Protobuf requires the nanopb/Nanopb library in lib_deps. +#define OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_JSON 0 +#define OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF 1 + +#ifndef OTEL_EXPORTER_OTLP_PROTOCOL +#define OTEL_EXPORTER_OTLP_PROTOCOL OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_JSON +#endif + +// Stack buffer size for protobuf encoding (bytes). Increase if you have many +// attributes or long string values and see encoding failures. +#ifndef OTEL_PROTO_BUFFER_SIZE +#define OTEL_PROTO_BUFFER_SIZE 1024 +#endif +#if OTEL_PROTO_BUFFER_SIZE <= 0 +#error "OTEL_PROTO_BUFFER_SIZE must be > 0" +#endif + // Internal queue capacity for async sender on RP2040. // Keep small to bound RAM; increase if you see drops. #ifndef OTEL_QUEUE_CAPACITY #define OTEL_QUEUE_CAPACITY 16 #endif +#if OTEL_QUEUE_CAPACITY < 2 +#error "OTEL_QUEUE_CAPACITY must be >= 2 (one slot is unusable in an SPSC ring buffer)" +#endif +/** + * Internal queue item for the RP2040 SPSC ring buffer (core-0 → core-1). + * The payload is stored as a raw byte buffer so that protobuf bodies (which + * may contain embedded null bytes) round-trip through the queue and the + * binary HTTPClient::POST(uint8_t*, size_t) overload without truncation. + */ struct OTelQueuedItem { - const char* path; // "/v1/logs", "/v1/traces", "/v1/metrics" - String payload; // serialized JSON + const char* path; // "/v1/logs", "/v1/traces", "/v1/metrics" + const char* contentType; // "application/json" or "application/x-protobuf" + std::vector payload; // serialized body (JSON text or raw protobuf bytes) }; +/** + * Low-level OTLP/HTTP sender used by Logger, Tracer, and Metrics. + * + * On RP2040 the actual HTTP calls run on core 1 via a SPSC ring buffer so + * that loop() on core 0 is never blocked. On ESP32/ESP8266 the send is + * synchronous. Call @c beginAsyncWorker() once after Wi-Fi comes up. + */ class OTelSender { public: - // Main API: called by logger/tracer/metrics to send serialized JSON to OTLP/HTTP + /** + * Serialise @p doc to JSON and send it to the collector at @p path. + * @param path OTLP signal path: "/v1/logs", "/v1/metrics", or "/v1/traces". + * @param doc ArduinoJson document representing the OTLP payload. + */ static void sendJson(const char* path, JsonDocument& doc); - // Start the RP2040 core-1 worker (no-op on non-RP2040). Call once after Wi-Fi is ready. + /** + * Send a pre-encoded protobuf buffer to the collector at @p path. + * Used by OtelProtoEncoder when @c OTEL_EXPORTER_OTLP_PROTOCOL is set to + * @c OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF. + * @param path OTLP signal path. + * @param buf Pointer to the encoded protobuf bytes. + * @param len Length of the buffer in bytes. + */ + static void sendProto(const char* path, const uint8_t* buf, size_t len); + + /** + * Start the RP2040 core-1 worker thread. + * No-op on ESP32/ESP8266. Call once after Wi-Fi is ready. + */ static void beginAsyncWorker(); - // Diagnostics (published via your health metrics if you like) - static uint32_t droppedCount(); // number of items dropped due to full queue - static bool queueIsHealthy(); // worker started? + /** + * @return Number of payloads dropped because the internal queue was full. + * Monitor this with a gauge metric to detect back-pressure. + */ + static uint32_t droppedCount(); + + /** + * @return True if the async worker has been started (RP2040) or always + * true on synchronous platforms. + */ + static bool queueIsHealthy(); + + /** + * Add a custom HTTP header that will be sent on every OTLP request for the + * given signal path. Useful for backend-specific configuration when the + * value is awkward to embed via -DOTEL_EXPORTER_OTLP_*_HEADERS build flag + * (e.g. JSON values containing quotes/commas). + * + * Headers added at runtime are layered on top of any parsed from the build + * flags. Call from setup() or after `Tracer::begin()` — must be called + * before the first send. Once the first send happens the header lists are + * frozen so the RP2040 worker on core 1 can read them without locking; + * later addHeader() calls are silently dropped. + * + * @param path OTLP signal path: "/v1/logs", "/v1/metrics", or "/v1/traces". + * @param key HTTP header name. + * @param value HTTP header value (sent verbatim — caller is responsible + * for any required encoding). + * + * Example (Datadog OTLP-to-tag promotion): + * OTelSender::addHeader("/v1/metrics", "dd-otel-metric-config", + * "{\"resource_attributes_as_tags\":true}"); + */ + static void addHeader(const char* path, const String& key, const String& value); private: // ---------- SPSC ring buffer (core0 producer -> core1 consumer) ---------- @@ -54,7 +197,7 @@ class OTelSender { static std::atomic drops_; static std::atomic worker_started_; - static bool enqueue_(const char* path, String&& payload); + static bool enqueue_(const char* path, const char* contentType, std::vector&& payload); static bool dequeue_(OTelQueuedItem& out); // ---------- Worker ---------- diff --git a/include/OtelTracer.h b/include/OtelTracer.h index 6ddca27..38fee5a 100644 --- a/include/OtelTracer.h +++ b/include/OtelTracer.h @@ -10,6 +10,7 @@ #include "OtelDebug.h" #include "OtelDefaults.h" // expects: nowUnixNano() #include "OtelSender.h" // expects: OTelSender::sendJson(const char* path, const JsonDocument&) +#include "OtelProtoEncoder.h" #if defined(ESP32) #include // esp_random, esp_fill_random @@ -23,33 +24,49 @@ namespace OTel { -// ---- Active Trace Context --------------------------------------------------- +/** + * Active W3C trace context (traceId + spanId) for the current execution scope. + * Updated automatically when a @c Span is created or destroyed. + */ struct TraceContext { String traceId; // 32 hex chars String spanId; // 16 hex chars + /** @return True when both IDs have the correct lengths per the W3C spec. */ bool valid() const { return traceId.length() == 32 && spanId.length() == 16; } }; -static inline TraceContext& currentTraceContext() { +/** Returns the process-wide active TraceContext singleton. */ +inline TraceContext& currentTraceContext() { static TraceContext ctx; return ctx; } -// --- New: Context Propagation (extract + scope) ------------------------------ +/** + * Result of extracting a remote trace context from inbound headers or a payload. + * Pass to @c RemoteParentScope to make it the active context for child spans. + */ struct ExtractedContext { TraceContext ctx; String tracestate; // optional; unused for now but kept for future injection bool sampled = true; // from flags; default true if unknown + /** @return True when the embedded TraceContext is valid. */ bool valid() const { return ctx.valid(); } }; -// Simple key/value view for header-like maps (HTTP headers, MQTT user props) +/** + * Thin abstraction over a key/value lookup used by context propagators. + * Wrap HTTP headers, MQTT user properties, or any string-keyed map. + */ struct KeyValuePairs { - // Provide a lambda to look up case-insensitive keys. Returns empty String if missing. + /** Callable that returns the value for a key, or an empty String if absent. */ std::function get; }; -// W3C "traceparent": 00-<32 hex traceId>-<16 hex parentId>-<2 hex flags> +/** + * Parse a W3C @c traceparent header value into @p out. + * Format: "00-<32 hex traceId>-<16 hex parentId>-<2 hex flags>". + * @return True on success, false if the string is malformed. + */ static inline bool parseTraceparent(const String& tp, ExtractedContext& out) { // Minimal, allocation-light parser // Expect 55 chars with version "00" or at least the 4 parts separated by '-' @@ -72,7 +89,11 @@ static inline bool parseTraceparent(const String& tp, ExtractedContext& out) { return out.valid(); } -// B3 single header: b3 = traceId-spanId-sampled +/** + * Parse a B3 single-header value into @p out. + * Format: "<32 hex traceId>-<16 hex spanId>[-]". + * @return True on success, false if the string is malformed. + */ static inline bool parseB3Single(const String& b3, ExtractedContext& out) { // Minimal split (traceId-spanId-[sampling?]) int p1 = b3.indexOf('-'); if (p1 < 0) return false; @@ -89,8 +110,18 @@ static inline bool parseB3Single(const String& b3, ExtractedContext& out) { return out.valid(); } +/** + * W3C TraceContext and B3 context propagation helpers. + * Use @c extract() / @c extractFromJson() to read a remote parent context + * and @c inject() / @c injectToJson() / @c injectToHeaders() to forward it. + */ struct Propagators { - // 1) Extract from header-like key/values (HTTP headers, MQTT v5 user props) + /** + * Extract a remote trace context from header-like key/value pairs. + * Tries W3C @c traceparent first, then B3 single-header as a fallback. + * @param kv Wrapper providing a case-insensitive key lookup. + * @return Extracted context; check @c valid() before use. + */ static ExtractedContext extract(const KeyValuePairs& kv) { ExtractedContext out; @@ -111,14 +142,19 @@ struct Propagators { return out; // invalid } - // 2) Extract directly from JSON payload + /** + * Extract a remote trace context from a JSON payload string. + * Recognises @c traceparent, @c trace_id/@c span_id, and @c b3 fields. + * @param json Serialised JSON string. + * @return Extracted context; check @c valid() before use. + */ static ExtractedContext extractFromJson(const String& json) { ExtractedContext out; if (json.length() == 0) return out; // Use a small document to avoid heap bloat; adjust if payloads are larger JsonDocument doc; - DeserializationError err = deserializeJson(doc, json); + DeserializationError err = deserializeJson(doc, json.c_str()); if (err) return out; if (doc["traceparent"].is()) { @@ -155,11 +191,14 @@ struct Propagators { return out; // invalid if none matched } -// --- ADD these inside: struct OTel::Propagators { ... } --- - -// Generic injector: pass a setter that accepts (key, value). -template -static inline void inject(Setter set, uint8_t flags = 0x01) { + /** + * Inject the active trace context into any key/value store via a setter callable. + * Writes a W3C @c traceparent value. No-op if no active span is present. + * @param set Callable accepting @c (const char* key, const char* value). + * @param flags W3C trace flags byte (bit 0 = sampled; default 0x01). + */ + template + static inline void inject(Setter set, uint8_t flags = 0x01) { const auto& ctx = OTel::currentTraceContext(); // Only inject if we actually have a valid active context @@ -180,21 +219,35 @@ static inline void inject(Setter set, uint8_t flags = 0x01) { // If you add tracestate in future, you can forward it here. } -// Convenience: inject into ArduinoJson JsonDocument payloads -static inline void injectToJson(JsonDocument& doc, uint8_t flags = 0x01) { + /** + * Inject the active trace context as a @c traceparent key into an ArduinoJson document. + * @param doc JSON document to write into. + * @param flags W3C trace flags byte. + */ + static inline void injectToJson(JsonDocument& doc, uint8_t flags = 0x01) { inject([&](const char* k, const char* v){ doc[k] = v; }, flags); } -// Convenience: inject into HTTP headers via a generic adder (e.g., http.addHeader) -template -static inline void injectToHeaders(HeaderAdder add, uint8_t flags = 0x01) { + /** + * Inject the active trace context into HTTP headers via a generic adder callable. + * @param add Callable accepting @c (const char* name, const char* value) + * — e.g. pass @c http.addHeader directly. + * @param flags W3C trace flags byte. + */ + template + static inline void injectToHeaders(HeaderAdder add, uint8_t flags = 0x01) { inject(add, flags); } }; -// RAII helper: temporarily install a remote parent context as the active one +/** + * RAII guard that installs a remote parent context as the active TraceContext + * for the duration of its lifetime, then restores the previous context on + * destruction. Use when processing inbound requests or messages that carry + * a W3C traceparent so child spans are linked to the upstream trace. + */ class RemoteParentScope { public: RemoteParentScope(const TraceContext& incoming) { @@ -220,6 +273,8 @@ class RemoteParentScope { // ---- Utilities -------------------------------------------------------------- + +/** Convert uint64 to its decimal String representation without printf (RP2040-safe). */ static inline String u64ToStr(uint64_t v) { // Avoid ambiguous String(uint64_t) on some cores char buf[32]; @@ -232,8 +287,7 @@ static inline String u64ToStr(uint64_t v) { } return String(p); } -// -// Best-effort chip id (used for defaults) +/** Best-effort chip ID as a hex string; used as a default for service.instance.id. */ static inline String chipIdHex() { #if defined(ESP8266) uint32_t id = ESP.getChipId(); @@ -248,7 +302,7 @@ static inline String chipIdHex() { #endif } -// Defaults for resource fields (compile-time overrides win) +/** @return Default service name from @c OTEL_SERVICE_NAME, or "embedded-service". */ static inline String defaultServiceName() { #ifdef OTEL_SERVICE_NAME return String(OTEL_SERVICE_NAME); @@ -256,13 +310,15 @@ static inline String defaultServiceName() { return String("embedded-service"); #endif } +/** @return Default service instance ID from @c OTEL_SERVICE_INSTANCE, or the chip ID hex. */ static inline String defaultServiceInstanceId() { -#ifdef OTEL_SERVICE_INSTANCE_ID - return String(OTEL_SERVICE_INSTANCE_ID); +#ifdef OTEL_SERVICE_INSTANCE + return String(OTEL_SERVICE_INSTANCE); #else return chipIdHex(); #endif } +/** @return Default host name from @c OTEL_HOST_NAME, or "ESP-" + chip ID hex. */ static inline String defaultHostName() { #ifdef OTEL_HOST_NAME return String(OTEL_HOST_NAME); @@ -272,7 +328,11 @@ static inline String defaultHostName() { } // ---- Entropy + ID helpers --------------------------------------------------- -// + +/** + * XOR a boot-time salt derived from the system clock and service instance ID + * into @p b to reduce cross-boot ID collisions on platforms with weak RNGs. + */ static inline void mix_boot_salt(uint8_t* b, size_t len) { uint64_t t = nowUnixNano(); uint32_t salt = (uint32_t)t ^ (uint32_t)(t >> 32); @@ -294,6 +354,7 @@ static inline void mix_boot_salt(uint8_t* b, size_t len) { } +/** Seed the PRNG with hardware entropy sources and mix in boot-time jitter. */ static inline void seedEntropy() { uint32_t seed = 0; @@ -321,6 +382,10 @@ static inline void seedEntropy() { for (int i = 0; i < 8; ++i) (void)random(); } +/** + * Fill @p out with @p len cryptographically random bytes using the best + * hardware source available for the target platform. + */ static inline void fillRandom(uint8_t* out, size_t len) { #if defined(ESP32) esp_fill_random(out, len); @@ -341,6 +406,7 @@ static inline void fillRandom(uint8_t* out, size_t len) { #endif } +/** Encode @p len bytes of @p data as a lowercase hex string. */ static inline String toHex(const uint8_t* data, size_t len) { static const char* hex = "0123456789abcdef"; String out; out.reserve(len * 2); @@ -351,6 +417,7 @@ static inline String toHex(const uint8_t* data, size_t len) { return out; } +/** Generate a random 128-bit trace ID as a 32-char lowercase hex string. */ static inline String generateTraceId() { uint8_t b[16]; fillRandom(b, sizeof b); @@ -381,6 +448,7 @@ static inline String generateTraceId() { return h; } +/** Generate a random 64-bit span ID as a 16-char lowercase hex string. */ static inline String generateSpanId() { uint8_t b[8]; fillRandom(b, sizeof b); @@ -405,25 +473,32 @@ static inline String generateSpanId() { -// Add one string attribute to a resource attributes array +/** Append a string-valued OTLP KeyValue object to a resource attributes array. */ static inline void addResAttr(JsonArray& arr, const char* key, const String& value) { JsonObject a = arr.add(); a["key"] = key; a["value"].to()["stringValue"] = value; } -// ---- Tracer configuration --------------------------------------------------- +/** Instrumentation scope name and version emitted on every trace payload. */ struct TracerConfig { String scopeName{"otel-embedded"}; String scopeVersion{"0.1.0"}; }; -static inline TracerConfig& tracerConfig() { +/** Returns the process-wide TracerConfig singleton. */ +inline TracerConfig& tracerConfig() { static TracerConfig cfg; return cfg; } -// ---- Span ------------------------------------------------------------------- +/** + * A single OTLP span. Create via @c Tracer::startSpan(); the span is + * automatically sent when @c end() is called or the object goes out of scope. + * + * Spans are not copyable. They are movable so you can store them in a + * container or return them from a factory function. + */ class Span { public: explicit Span(const String& name) @@ -486,8 +561,7 @@ class Span { return *this; } - // ---------- NEW: span attributes API --------------------------------------- - // These buffer attributes until end() and are rendered into OTLP JSON. + /** @{ Add a typed attribute to the span. Attributes are buffered until @c end(). */ Span& setAttribute(const String& key, const String& v) { //attrs_.push_back(Attr{key, Type::Str, v, 0, 0.0, false}); Attr a; @@ -512,9 +586,12 @@ class Span { Span& setAttribute(const String& key, bool v) { Attr a; a.key=key; a.type=Type::Bool; a.b=v; attrs_.push_back(a); return *this; } + /** @} */ - // ---------- NEW: span events API ------------------------------------------- - // 1) Event without attributes + /** + * Record a timestamped event on this span. + * @param name Human-readable event name, e.g. "cache.miss". + */ Span& addEvent(const String& name) { //events_.push_back(Event{name, nowUnixNano(), {}}); Event e; @@ -523,7 +600,11 @@ class Span { events_.push_back(e); return *this; } - // 2) Event with simple (string) attributes — minimal footprint + /** + * Record a timestamped event with string attributes. + * @param name Human-readable event name. + * @param attrs Key/value pairs attached to the event. + */ Span& addEvent(const String& name, const std::vector>& attrs) { //Event e{name, nowUnixNano(), {}}; // NEW @@ -546,13 +627,54 @@ class Span { return *this; } - // You can still call this manually; it's safe to call more than once. + /** + * Finalise and transmit the span to the collector. + * Safe to call more than once (idempotent). Called automatically by the + * destructor if the user does not call it explicitly. + */ void end() { if (ended_) return; // idempotent guard ended_ = true; const uint64_t endNs = nowUnixNano(); +#if OTEL_EXPORTER_OTLP_PROTOCOL == OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + { + std::vector protoAttrs; + protoAttrs.reserve(attrs_.size()); + for (const auto& a : attrs_) { + OTel::Proto::Attr pa; + pa.key = a.key; + pa.type = static_cast(static_cast(a.type)); + pa.s = a.s; + pa.i = a.i; + pa.d = a.d; + pa.b = a.b; + protoAttrs.push_back(std::move(pa)); + } + std::vector protoEvents; + protoEvents.reserve(events_.size()); + for (const auto& ev : events_) { + OTel::Proto::Event pe; + pe.name = ev.name; + pe.t = ev.t; + pe.attrs.reserve(ev.attrs.size()); + for (const auto& a : ev.attrs) { + OTel::Proto::Attr pa; + pa.key = a.key; + pa.type = static_cast(static_cast(a.type)); + pa.s = a.s; + pa.i = a.i; + pa.d = a.d; + pa.b = a.b; + pe.attrs.push_back(std::move(pa)); + } + protoEvents.push_back(std::move(pe)); + } + OTel::Proto::sendSpan(name_, traceId_, spanId_, prevSpanId_, + startNs_, endNs, protoAttrs, protoEvents); + } +#else // Build minimal OTLP/HTTP JSON payload for a single span JsonDocument doc; @@ -624,6 +746,7 @@ class Span { // Send OTelSender::sendJson("/v1/traces", doc); +#endif // OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF // Restore previous active context currentTraceContext().traceId = prevTraceId_; @@ -683,9 +806,21 @@ class Span { bool ended_ = false; }; -// ---- Tracer facade ---------------------------------------------------------- +/** + * Static façade for creating OTLP spans. + * + * Call @c begin() once after connecting to Wi-Fi to seed the PRNG and set the + * instrumentation scope name/version. Then call @c startSpan() to instrument + * any operation. + */ class Tracer { public: + /** + * Initialise the tracer: seed entropy, clear any stale context, and + * configure the instrumentation scope. + * @param scopeName Library/component name, e.g. "my-firmware". + * @param scopeVersion Semver string, e.g. "1.0.0". + */ static void begin(const String& scopeName, const String& scopeVersion) { seedEntropy(); @@ -697,6 +832,11 @@ class Tracer { tracerConfig().scopeVersion = scopeVersion; } + /** + * Start a new span. If a span is already active its IDs become the parent. + * @param name Human-readable operation name, e.g. "mqtt.publish". + * @return A @c Span object; call @c end() or let it go out of scope. + */ static Span startSpan(const String& name) { return Span(name); } diff --git a/library.json b/library.json index ae904c7..dfb32a8 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "name": "otel-embedded-cpp", - "version": "1.0.2", + "version": "1.1.0", "description": "OpenTelemetry logging, tracing, and metrics for embedded C++ devices (ESP32, RP2040 Pico W, ESP8266).", "keywords": [ "OpenTelemetry", @@ -31,10 +31,38 @@ { "name": "ArduinoJson", "version": "^7.0.0" + }, + { + "name": "nanopb/Nanopb", + "version": "^0.4.91" + }, + { + "name": "aodtorusan/opentelemetry_proto", + "version": "^1.9.0" } ], + "build": { + "srcFilter": ["+<*.cpp>", "-"] + }, "repository": { "type": "git", - "url": "https://github.com/your-username/otel-embedded-cpp.git" - } + "url": "https://github.com/proffalken/otel-embedded-cpp.git" + }, + "examples": [ + { + "name": "Basic", + "base": "examples/basic", + "files": ["main.cpp", "README.md"] + }, + { + "name": "Direct OTLP", + "base": "examples/direct_otlp", + "files": ["main.cpp", "README.md"] + }, + { + "name": "Protobuf OTLP", + "base": "examples/proto_otlp", + "files": ["main.cpp"] + } + ] } diff --git a/platformio.ini b/platformio.ini index fa98f7f..5ba7d01 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,65 +1,158 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + [platformio] default_envs = esp32dev, rpipicow, esp8266 +; ── Native host tests ───────────────────────────────────────────────────────── +; Compiles the library on the host GCC toolchain and runs Unity unit tests. +; OtelSender.cpp (WiFi/HTTP) is excluded; test/test_otlp/fake_sender.cpp is used +; instead so tests can assert on emitted JSON payloads without real hardware. +[env:native] +platform = native +build_flags = + -std=c++14 + -Itest/stubs + -DWIFI_SSID=\"ci-build\" + -DWIFI_PASS=\"ci-build\" + -DOTEL_SERVICE_NAME=\"test-service\" + -DOTEL_SERVICE_NAMESPACE=\"test\" + -DOTEL_SERVICE_VERSION=\"0.0.1\" + -DOTEL_SERVICE_INSTANCE=\"test-001\" + -DOTEL_DEPLOY_ENV=\"test\" + -DARDUINOJSON_DEFAULT_NESTING_LIMIT=15 +lib_deps = + bblanchon/ArduinoJson@^7.0.0 + nanopb/Nanopb@^0.4.91 + aodtorusan/opentelemetry_proto@^1.9.0 +build_src_filter = +<*.cpp> - - + [env:esp32dev] platform = espressif32 board = esp32dev framework = arduino monitor_speed = 115200 - -lib_deps = - ArduinoJson@^7.4.2 - WiFi - HTTPClient - -build_flags = - -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" - -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" - -DOTEL_COLLECTOR_BASE_URL="\"http://192.168.8.10:4318\"" - -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" - -DOTEL_SERVICE_NAMESPACE=\"${sysenv.OTEL_SERVICE_NAMESPACE}\" - -DOTEL_SERVICE_VERSION=\"${sysenv.OTEL_SERVICE_VERSION}\" - -DOTEL_SERVICE_INSTANCE=\"${sysenv.OTEL_SERVICE_INSTANCE}\" - -DOTEL_DEPLOY_ENV=\"${sysenv.OTEL_DEPLOY_ENV}\" - +lib_deps = + ArduinoJson@^7.4.2 + nanopb/Nanopb@^0.4.91 + WiFi + HTTPClient + aodtorusan/opentelemetry_proto@^1.9.0 +build_flags = + -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" + -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" + -DOTEL_COLLECTOR_BASE_URL="\"http://192.168.8.10:4318\"" + -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" + -DOTEL_SERVICE_NAMESPACE=\"${sysenv.OTEL_SERVICE_NAMESPACE}\" + -DOTEL_SERVICE_VERSION=\"${sysenv.OTEL_SERVICE_VERSION}\" + -DOTEL_SERVICE_INSTANCE=\"${sysenv.OTEL_SERVICE_INSTANCE}\" + -DOTEL_DEPLOY_ENV=\"${sysenv.OTEL_DEPLOY_ENV}\" [env:rpipicow] ; Community Raspberry Pi Pico W platform and board platform = https://github.com/maxgerhardt/platform-raspberrypi.git board = rpipicow framework = arduino - -; Dependencies for micro-ROS and telemetry -lib_deps = - bblanchon/ArduinoJson@^7.0.0 - -build_flags = - -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" - -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" - -DOTEL_COLLECTOR_BASE_URL="\"http://192.168.8.10:4318\"" - -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" - -DOTEL_SERVICE_NAMESPACE=\"${sysenv.OTEL_SERVICE_NAMESPACE}\" - -DOTEL_SERVICE_VERSION=\"${sysenv.OTEL_SERVICE_VERSION}\" - -DOTEL_SERVICE_INSTANCE=\"${sysenv.OTEL_SERVICE_INSTANCE}\" - -DOTEL_DEPLOY_ENV=\"${sysenv.OTEL_DEPLOY_ENV}\" - -DDEBUG +lib_deps = + bblanchon/ArduinoJson@^7.0.0 + nanopb/Nanopb@^0.4.91 + aodtorusan/opentelemetry_proto@^1.9.0 +build_flags = + -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" + -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" + -DOTEL_COLLECTOR_BASE_URL="\"http://192.168.8.10:4318\"" + -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" + -DOTEL_SERVICE_NAMESPACE=\"${sysenv.OTEL_SERVICE_NAMESPACE}\" + -DOTEL_SERVICE_VERSION=\"${sysenv.OTEL_SERVICE_VERSION}\" + -DOTEL_SERVICE_INSTANCE=\"${sysenv.OTEL_SERVICE_INSTANCE}\" + -DOTEL_DEPLOY_ENV=\"${sysenv.OTEL_DEPLOY_ENV}\" + -DDEBUG [env:esp8266] platform = espressif8266 board = d1_mini framework = arduino +lib_deps = + bblanchon/ArduinoJson@^7.0.0 + nanopb/Nanopb@^0.4.91 + aodtorusan/opentelemetry_proto@^1.9.0 +build_flags = + -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" + -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" + -DOTEL_COLLECTOR_BASE_URL="\"http://192.168.8.10:4318\"" + -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" + -DOTEL_SERVICE_NAMESPACE=\"${sysenv.OTEL_SERVICE_NAMESPACE}\" + -DOTEL_SERVICE_VERSION=\"${sysenv.OTEL_SERVICE_VERSION}\" + -DOTEL_SERVICE_INSTANCE=\"esp8266\" + -DOTEL_DEPLOY_ENV=\"esp8266\" +; ── Protobuf variants — used by CI to validate the protobuf encoding path ───── +; Fully specified (not using `extends`) because platformio ci does not reliably +; inherit platform/board/framework through the extends mechanism. +[env:esp32dev-proto] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +lib_deps = + ArduinoJson@^7.4.2 + nanopb/Nanopb@^0.4.91 + WiFi + HTTPClient + aodtorusan/opentelemetry_proto@^1.9.0 +build_flags = + -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" + -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" + -DOTEL_COLLECTOR_BASE_URL="\"http://192.168.8.10:4318\"" + -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" + -DOTEL_SERVICE_NAMESPACE=\"${sysenv.OTEL_SERVICE_NAMESPACE}\" + -DOTEL_SERVICE_VERSION=\"${sysenv.OTEL_SERVICE_VERSION}\" + -DOTEL_SERVICE_INSTANCE=\"${sysenv.OTEL_SERVICE_INSTANCE}\" + -DOTEL_DEPLOY_ENV=\"${sysenv.OTEL_DEPLOY_ENV}\" + -DOTEL_EXPORTER_OTLP_PROTOCOL=OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF -; Dependencies for micro-ROS and telemetry -lib_deps = - bblanchon/ArduinoJson@^7.0.0 +[env:rpipicow-proto] +platform = https://github.com/maxgerhardt/platform-raspberrypi.git +board = rpipicow +framework = arduino +lib_deps = + bblanchon/ArduinoJson@^7.0.0 + nanopb/Nanopb@^0.4.91 + aodtorusan/opentelemetry_proto@^1.9.0 +build_flags = + -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" + -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" + -DOTEL_COLLECTOR_BASE_URL="\"http://192.168.8.10:4318\"" + -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" + -DOTEL_SERVICE_NAMESPACE=\"${sysenv.OTEL_SERVICE_NAMESPACE}\" + -DOTEL_SERVICE_VERSION=\"${sysenv.OTEL_SERVICE_VERSION}\" + -DOTEL_SERVICE_INSTANCE=\"${sysenv.OTEL_SERVICE_INSTANCE}\" + -DOTEL_DEPLOY_ENV=\"${sysenv.OTEL_DEPLOY_ENV}\" + -DDEBUG + -DOTEL_EXPORTER_OTLP_PROTOCOL=OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF -build_flags = - -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" - -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" - -DOTEL_COLLECTOR_BASE_URL="\"http://192.168.8.10:4318\"" - -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" - -DOTEL_SERVICE_NAMESPACE=\"${sysenv.OTEL_SERVICE_NAMESPACE}\" - -DOTEL_SERVICE_VERSION=\"${sysenv.OTEL_SERVICE_VERSION}\" - -DOTEL_SERVICE_INSTANCE=\"esp8266\" - -DOTEL_DEPLOY_ENV=\"esp8266\" +[env:esp8266-proto] +platform = espressif8266 +board = d1_mini +framework = arduino +lib_deps = + bblanchon/ArduinoJson@^7.0.0 + nanopb/Nanopb@^0.4.91 + aodtorusan/opentelemetry_proto@^1.9.0 +build_flags = + -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" + -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" + -DOTEL_COLLECTOR_BASE_URL="\"http://192.168.8.10:4318\"" + -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" + -DOTEL_SERVICE_NAMESPACE=\"${sysenv.OTEL_SERVICE_NAMESPACE}\" + -DOTEL_SERVICE_VERSION=\"${sysenv.OTEL_SERVICE_VERSION}\" + -DOTEL_SERVICE_INSTANCE=\"esp8266\" + -DOTEL_DEPLOY_ENV=\"esp8266\" + -DOTEL_EXPORTER_OTLP_PROTOCOL=OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF diff --git a/src/OtelMetrics.cpp b/src/OtelMetrics.cpp index 41f8eb4..3153929 100644 --- a/src/OtelMetrics.cpp +++ b/src/OtelMetrics.cpp @@ -2,16 +2,16 @@ namespace OTel { -// Helper: merge default + per-call labels into a datapoint attributes array +#if OTEL_EXPORTER_OTLP_PROTOCOL != OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + +/** Append default metric labels then per-call labels to @p attrArray as OTLP KeyValue objects. */ static void addPointAttributes(JsonArray& attrArray, const std::map& callLabels) { - // Defaults first for (const auto& kv : defaultMetricLabels()) { JsonObject a = attrArray.add(); a["key"] = kv.first; a["value"].to()["stringValue"] = kv.second; } - // Then per-call (override by reusing key downstream in the stack) for (const auto& kv : callLabels) { JsonObject a = attrArray.add(); a["key"] = kv.first; @@ -19,29 +19,39 @@ static void addPointAttributes(JsonArray& attrArray, } } +/** Populate the OTLP resource object from defaultResource() or compile-time defaults. */ static void addCommonResource(JsonObject& resource) { auto &res = OTel::defaultResource(); if (!res.empty()) { res.addResourceAttributes(resource); return; } - JsonArray rattrs = resource["attributes"].to(); addResAttr(rattrs, "service.name", defaultServiceName()); addResAttr(rattrs, "service.instance.id", defaultServiceInstanceId()); addResAttr(rattrs, "host.name", defaultHostName()); } +/** Write the instrumentation scope name and version into @p scope. */ static void addCommonScope(JsonObject& scope) { scope["name"] = metricsScopeConfig().scopeName; scope["version"] = metricsScopeConfig().scopeVersion; } +#endif // OTEL_EXPORTER_OTLP_PROTOCOL != OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + // ----------------- GAUGE ----------------- void Metrics::buildAndSendGauge(const String& name, double value, const String& unit, const std::map& labels) { +#if OTEL_EXPORTER_OTLP_PROTOCOL == OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + { + auto merged = defaultMetricLabels(); + for (const auto& kv : labels) merged[kv.first] = kv.second; + OTel::Proto::sendGauge(name, value, unit, merged); + } +#else JsonDocument doc; JsonArray resourceMetrics = doc["resourceMetrics"].to(); @@ -74,6 +84,7 @@ void Metrics::buildAndSendGauge(const String& name, double value, addPointAttributes(attrs, labels); OTelSender::sendJson("/v1/metrics", doc); +#endif // OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF } // ----------------- SUM ------------------- @@ -83,6 +94,14 @@ void Metrics::buildAndSendSum(const String& name, double value, const String& unit, const std::map& labels) { +#if OTEL_EXPORTER_OTLP_PROTOCOL == OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + { + auto merged = defaultMetricLabels(); + for (const auto& kv : labels) merged[kv.first] = kv.second; + int temporalityInt = (temporality == "CUMULATIVE") ? 2 : 1; + OTel::Proto::sendSum(name, value, isMonotonic, temporalityInt, unit, merged); + } +#else JsonDocument doc; JsonArray resourceMetrics = doc["resourceMetrics"].to(); @@ -104,9 +123,19 @@ void Metrics::buildAndSendSum(const String& name, double value, metric["unit"] = unit; metric["type"] = "sum"; + // OTLP JSON encodes AggregationTemporality as an integer enum, not a string. + // From opentelemetry/proto/metrics/v1/metrics.proto: + // AGGREGATION_TEMPORALITY_DELTA = 1 + // AGGREGATION_TEMPORALITY_CUMULATIVE = 2 + // Previously this sent the raw string (e.g. "DELTA"), which is not spec-compliant. + // Spec-compliant collectors (including Datadog direct OTLP) require the integer. + // Collectors that were previously accepting the string may silently ignore or + // default the field — switching to the integer is the correct behaviour. + // The public API (passing "DELTA" / "CUMULATIVE" to sum()) is unchanged. + int temporalityInt = (temporality == "CUMULATIVE") ? 2 : 1; // default to DELTA JsonObject sum = metric["sum"].to(); - sum["isMonotonic"] = isMonotonic; - sum["aggregationTemporality"] = temporality; // "DELTA" or "CUMULATIVE" + sum["isMonotonic"] = isMonotonic; + sum["aggregationTemporality"] = temporalityInt; JsonArray dps = sum["dataPoints"].to(); JsonObject dp = dps.add(); @@ -118,6 +147,7 @@ void Metrics::buildAndSendSum(const String& name, double value, addPointAttributes(attrs, labels); OTelSender::sendJson("/v1/metrics", doc); +#endif // OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF } } // namespace OTel diff --git a/src/OtelProtoEncoder.cpp b/src/OtelProtoEncoder.cpp new file mode 100644 index 0000000..7002a6d --- /dev/null +++ b/src/OtelProtoEncoder.cpp @@ -0,0 +1,565 @@ +#include "OtelSender.h" + +#if OTEL_EXPORTER_OTLP_PROTOCOL == OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF + +// Nanopb-generated OTLP descriptors — provided by aodtorusan/opentelemetry_proto. +// PlatformIO compiles the library's .pb.c files automatically; no .pb.inc hack needed. +#include "opentelemetry/proto/common/v1/common.pb.h" +#include "opentelemetry/proto/resource/v1/resource.pb.h" +#include "opentelemetry/proto/metrics/v1/metrics.pb.h" +#include "opentelemetry/proto/logs/v1/logs.pb.h" + +#include "OtelProtoEncoder.h" +#include "OtelTracer.h" // defaultServiceName(), defaultServiceInstanceId(), defaultHostName() +#include "OtelMetrics.h" // metricsScopeConfig() +#include "OtelLogger.h" // logScopeConfig() + +#include + +namespace OTel { +namespace Proto { + +// ── Primitive callbacks ────────────────────────────────────────────────────── + +// Encode a single C-string as a length-delimited protobuf bytes/string field. +static bool cb_cstr(pb_ostream_t* s, const pb_field_t* f, void* const* arg) { + const char* str = *(const char* const*)arg; + if (!str || !*str) return true; // skip empty strings + return pb_encode_tag_for_field(s, f) && + pb_encode_string(s, (const pb_byte_t*)str, strlen(str)); +} + +// ── Map → repeated KeyValue ─────────────────────────────────── + +struct MapCtx { const std::map* m; }; + +static bool cb_map_attrs(pb_ostream_t* s, const pb_field_t* f, void* const* arg) { + auto* ctx = *(MapCtx**)arg; + for (const auto& kv : *ctx->m) { + opentelemetry_proto_common_v1_KeyValue entry = opentelemetry_proto_common_v1_KeyValue_init_zero; + entry.key.funcs.encode = cb_cstr; + entry.key.arg = (void*)kv.first.c_str(); + entry.has_value = true; + entry.value.which_value = opentelemetry_proto_common_v1_AnyValue_string_value_tag; + entry.value.value.string_value.funcs.encode = cb_cstr; + entry.value.value.string_value.arg = (void*)kv.second.c_str(); + + if (!pb_encode_tag_for_field(s, f)) return false; + if (!pb_encode_submessage(s, opentelemetry_proto_common_v1_KeyValue_fields, &entry)) return false; + } + return true; +} + +// Merged map: encodes defaultLabels first, then callLabels. +struct MergedMapCtx { + const std::map* defaults; + const std::map* call; +}; + +static bool cb_merged_map_attrs(pb_ostream_t* s, const pb_field_t* f, void* const* arg) { + auto* ctx = *(MergedMapCtx**)arg; + for (auto* m : {ctx->defaults, ctx->call}) { + for (const auto& kv : *m) { + opentelemetry_proto_common_v1_KeyValue entry = opentelemetry_proto_common_v1_KeyValue_init_zero; + entry.key.funcs.encode = cb_cstr; + entry.key.arg = (void*)kv.first.c_str(); + entry.has_value = true; + entry.value.which_value = opentelemetry_proto_common_v1_AnyValue_string_value_tag; + entry.value.value.string_value.funcs.encode = cb_cstr; + entry.value.value.string_value.arg = (void*)kv.second.c_str(); + + if (!pb_encode_tag_for_field(s, f)) return false; + if (!pb_encode_submessage(s, opentelemetry_proto_common_v1_KeyValue_fields, &entry)) return false; + } + } + return true; +} + +// ── Resource builder ───────────────────────────────────────────────────────── + +// Fills a Resource with service.name / service.instance.id / host.name. +// Backed by three static String values so the c_str() pointers are stable +// for the duration of the encode call. +static void fillResource(opentelemetry_proto_resource_v1_Resource& res, + String& svcName, String& svcInst, String& hostName) { + svcName = defaultServiceName(); + svcInst = defaultServiceInstanceId(); + hostName = defaultHostName(); + + // Inline KeyValue list via a callback that emits exactly three entries. + struct ResCtx { const char* sn; const char* si; const char* hn; }; + static ResCtx rctx; + rctx = {svcName.c_str(), svcInst.c_str(), hostName.c_str()}; + + res.attributes.funcs.encode = [](pb_ostream_t* s, const pb_field_t* f, void* const* arg) -> bool { + auto* c = *(ResCtx**)arg; + const char* keys[] = {"service.name", "service.instance.id", "host.name"}; + const char* values[] = {c->sn, c->si, c->hn}; + for (int i = 0; i < 3; ++i) { + opentelemetry_proto_common_v1_KeyValue kv = opentelemetry_proto_common_v1_KeyValue_init_zero; + kv.key.funcs.encode = cb_cstr; kv.key.arg = (void*)keys[i]; + kv.has_value = true; + kv.value.which_value = opentelemetry_proto_common_v1_AnyValue_string_value_tag; + kv.value.value.string_value.funcs.encode = cb_cstr; + kv.value.value.string_value.arg = (void*)values[i]; + if (!pb_encode_tag_for_field(s, f)) return false; + if (!pb_encode_submessage(s, opentelemetry_proto_common_v1_KeyValue_fields, &kv)) return false; + } + return true; + }; + res.attributes.arg = &rctx; +} + +// ── Scope builder ──────────────────────────────────────────────────────────── + +static void fillScope(opentelemetry_proto_common_v1_InstrumentationScope& scope, + const char* name, const char* version) { + scope.name.funcs.encode = cb_cstr; scope.name.arg = (void*)name; + scope.version.funcs.encode = cb_cstr; scope.version.arg = (void*)version; +} + +// ── Hex string → raw bytes ─────────────────────────────────────────────────── + +static void hexToBytes(const String& hex, uint8_t* out, size_t len) { + for (size_t i = 0; i < len; ++i) { + size_t ci = i * 2; + if (ci + 1 >= (size_t)hex.length()) { out[i] = 0; continue; } + auto n = [](char c) -> uint8_t { + return (c >= 'a') ? c - 'a' + 10 : (c >= 'A') ? c - 'A' + 10 : c - '0'; + }; + out[i] = (n(hex[ci]) << 4) | n(hex[ci + 1]); + } +} + +// ── Encode + send helpers ──────────────────────────────────────────────────── + +static void encodeAndSend(const char* path, + const pb_msgdesc_t* fields, + const void* msg) { + uint8_t buf[OTEL_PROTO_BUFFER_SIZE]; + pb_ostream_t stream = pb_ostream_from_buffer(buf, sizeof(buf)); + if (pb_encode(&stream, fields, msg)) { + OTelSender::sendProto(path, buf, stream.bytes_written); + } + // Silent drop on encode failure — same behaviour as JSON OOM. +} + +// ════════════════════════════════════════════════════════════════════════════ +// Public API +// ════════════════════════════════════════════════════════════════════════════ + +// ── Metrics ────────────────────────────────────────────────────────────────── + +static void buildAndSendNumberMetric(const String& name, double value, + const String& unit, + const std::map& labels, + bool isGauge, + bool isMonotonic, + int temporality) { + using namespace OTel; + + String svcName, svcInst, hostName; + + // DataPoint + opentelemetry_proto_metrics_v1_NumberDataPoint dp = + opentelemetry_proto_metrics_v1_NumberDataPoint_init_zero; + dp.time_unix_nano = nowUnixNano(); + dp.which_value = opentelemetry_proto_metrics_v1_NumberDataPoint_as_double_tag; + dp.value.as_double = value; + MapCtx dpCtx{&labels}; + dp.attributes.funcs.encode = cb_map_attrs; + dp.attributes.arg = &dpCtx; + + // Metric (Gauge or Sum) + opentelemetry_proto_metrics_v1_Metric metric = + opentelemetry_proto_metrics_v1_Metric_init_zero; + metric.name.funcs.encode = cb_cstr; metric.name.arg = (void*)name.c_str(); + metric.unit.funcs.encode = cb_cstr; metric.unit.arg = (void*)unit.c_str(); + + // DataPoint callback for the metric type + struct DpCtx { opentelemetry_proto_metrics_v1_NumberDataPoint* dp; }; + static DpCtx dpWrap; + dpWrap.dp = &dp; + auto dp_cb = [](pb_ostream_t* s, const pb_field_t* f, void* const* arg) -> bool { + auto* c = *(DpCtx**)arg; + return pb_encode_tag_for_field(s, f) && + pb_encode_submessage(s, opentelemetry_proto_metrics_v1_NumberDataPoint_fields, c->dp); + }; + + if (isGauge) { + metric.which_data = opentelemetry_proto_metrics_v1_Metric_gauge_tag; + metric.data.gauge.data_points.funcs.encode = dp_cb; + metric.data.gauge.data_points.arg = &dpWrap; + } else { + metric.which_data = opentelemetry_proto_metrics_v1_Metric_sum_tag; + metric.data.sum.is_monotonic = isMonotonic; + metric.data.sum.aggregation_temporality = + (opentelemetry_proto_metrics_v1_AggregationTemporality)temporality; + metric.data.sum.data_points.funcs.encode = dp_cb; + metric.data.sum.data_points.arg = &dpWrap; + } + + // ScopeMetrics + opentelemetry_proto_metrics_v1_ScopeMetrics sm = + opentelemetry_proto_metrics_v1_ScopeMetrics_init_zero; + fillScope(sm.scope, metricsScopeConfig().scopeName.c_str(), + metricsScopeConfig().scopeVersion.c_str()); + struct SmCtx { opentelemetry_proto_metrics_v1_Metric* m; }; + static SmCtx smCtx; smCtx.m = &metric; + sm.metrics.funcs.encode = [](pb_ostream_t* s, const pb_field_t* f, void* const* arg) -> bool { + auto* c = *(SmCtx**)arg; + return pb_encode_tag_for_field(s, f) && + pb_encode_submessage(s, opentelemetry_proto_metrics_v1_Metric_fields, c->m); + }; + sm.metrics.arg = &smCtx; + + // ResourceMetrics + opentelemetry_proto_metrics_v1_ResourceMetrics rm = + opentelemetry_proto_metrics_v1_ResourceMetrics_init_zero; + rm.has_resource = true; + fillResource(rm.resource, svcName, svcInst, hostName); + struct RmCtx { opentelemetry_proto_metrics_v1_ScopeMetrics* sm; }; + static RmCtx rmCtx; rmCtx.sm = &sm; + rm.scope_metrics.funcs.encode = [](pb_ostream_t* s, const pb_field_t* f, void* const* arg) -> bool { + auto* c = *(RmCtx**)arg; + return pb_encode_tag_for_field(s, f) && + pb_encode_submessage(s, opentelemetry_proto_metrics_v1_ScopeMetrics_fields, c->sm); + }; + rm.scope_metrics.arg = &rmCtx; + + // MetricsData + opentelemetry_proto_metrics_v1_MetricsData data = + opentelemetry_proto_metrics_v1_MetricsData_init_zero; + struct DataCtx { opentelemetry_proto_metrics_v1_ResourceMetrics* rm; }; + static DataCtx dataCtx; dataCtx.rm = &rm; + data.resource_metrics.funcs.encode = [](pb_ostream_t* s, const pb_field_t* f, void* const* arg) -> bool { + auto* c = *(DataCtx**)arg; + return pb_encode_tag_for_field(s, f) && + pb_encode_submessage(s, opentelemetry_proto_metrics_v1_ResourceMetrics_fields, c->rm); + }; + data.resource_metrics.arg = &dataCtx; + + encodeAndSend("/v1/metrics", + opentelemetry_proto_metrics_v1_MetricsData_fields, &data); +} + +void sendGauge(const String& name, double value, const String& unit, + const std::map& labels) { + buildAndSendNumberMetric(name, value, unit, labels, true, false, 0); +} + +void sendSum(const String& name, double value, bool isMonotonic, + int temporality, const String& unit, + const std::map& labels) { + buildAndSendNumberMetric(name, value, unit, labels, false, isMonotonic, temporality); +} + +// ── Logs ───────────────────────────────────────────────────────────────────── + +void sendLog(const String& severity, int severityNum, const String& message, + const std::map& callLabels, + const std::map& defaultLabels, + const String& traceId, const String& spanId) { + + String svcName, svcInst, hostName; + + // LogRecord + opentelemetry_proto_logs_v1_LogRecord lr = + opentelemetry_proto_logs_v1_LogRecord_init_zero; + lr.time_unix_nano = nowUnixNano(); + lr.severity_number = (opentelemetry_proto_logs_v1_SeverityNumber)severityNum; + lr.severity_text.funcs.encode = cb_cstr; + lr.severity_text.arg = (void*)severity.c_str(); + lr.has_body = true; + lr.body.which_value = opentelemetry_proto_common_v1_AnyValue_string_value_tag; + lr.body.value.string_value.funcs.encode = cb_cstr; + lr.body.value.string_value.arg = (void*)message.c_str(); + + MergedMapCtx logAttrs{&defaultLabels, &callLabels}; + lr.attributes.funcs.encode = cb_merged_map_attrs; + lr.attributes.arg = &logAttrs; + + // Trace correlation + uint8_t traceBytes[16]{}, spanBytes[8]{}; + if (traceId.length() == 32) { + hexToBytes(traceId, traceBytes, 16); + lr.trace_id.funcs.encode = [](pb_ostream_t* s, const pb_field_t* f, void* const* arg) -> bool { + return pb_encode_tag_for_field(s, f) && + pb_encode_string(s, *(const pb_byte_t* const*)arg, 16); + }; + lr.trace_id.arg = traceBytes; + } + if (spanId.length() == 16) { + hexToBytes(spanId, spanBytes, 8); + lr.span_id.funcs.encode = [](pb_ostream_t* s, const pb_field_t* f, void* const* arg) -> bool { + return pb_encode_tag_for_field(s, f) && + pb_encode_string(s, *(const pb_byte_t* const*)arg, 8); + }; + lr.span_id.arg = spanBytes; + } + + // ScopeLogs + opentelemetry_proto_logs_v1_ScopeLogs sl = + opentelemetry_proto_logs_v1_ScopeLogs_init_zero; + fillScope(sl.scope, logScopeConfig().scopeName.c_str(), + logScopeConfig().scopeVersion.c_str()); + struct SlCtx { opentelemetry_proto_logs_v1_LogRecord* lr; }; + static SlCtx slCtx; slCtx.lr = &lr; + sl.log_records.funcs.encode = [](pb_ostream_t* s, const pb_field_t* f, void* const* arg) -> bool { + auto* c = *(SlCtx**)arg; + return pb_encode_tag_for_field(s, f) && + pb_encode_submessage(s, opentelemetry_proto_logs_v1_LogRecord_fields, c->lr); + }; + sl.log_records.arg = &slCtx; + + // ResourceLogs + opentelemetry_proto_logs_v1_ResourceLogs rl = + opentelemetry_proto_logs_v1_ResourceLogs_init_zero; + rl.has_resource = true; + fillResource(rl.resource, svcName, svcInst, hostName); + struct RlCtx { opentelemetry_proto_logs_v1_ScopeLogs* sl; }; + static RlCtx rlCtx; rlCtx.sl = &sl; + rl.scope_logs.funcs.encode = [](pb_ostream_t* s, const pb_field_t* f, void* const* arg) -> bool { + auto* c = *(RlCtx**)arg; + return pb_encode_tag_for_field(s, f) && + pb_encode_submessage(s, opentelemetry_proto_logs_v1_ScopeLogs_fields, c->sl); + }; + rl.scope_logs.arg = &rlCtx; + + // LogsData + opentelemetry_proto_logs_v1_LogsData ld = + opentelemetry_proto_logs_v1_LogsData_init_zero; + struct LdCtx { opentelemetry_proto_logs_v1_ResourceLogs* rl; }; + static LdCtx ldCtx; ldCtx.rl = &rl; + ld.resource_logs.funcs.encode = [](pb_ostream_t* s, const pb_field_t* f, void* const* arg) -> bool { + auto* c = *(LdCtx**)arg; + return pb_encode_tag_for_field(s, f) && + pb_encode_submessage(s, opentelemetry_proto_logs_v1_ResourceLogs_fields, c->rl); + }; + ld.resource_logs.arg = &ldCtx; + + encodeAndSend("/v1/logs", + opentelemetry_proto_logs_v1_LogsData_fields, &ld); +} + +// ── Traces ─────────────────────────────────────────────────────────────────── +// Traces are not in the reference proto set, so we encode manually using +// nanopb's streaming API with the field numbers from the OTLP trace proto. +// Field numbers: https://github.com/open-telemetry/opentelemetry-proto +// TracesData.resource_spans = 1 +// ResourceSpans.resource = 1 +// ResourceSpans.scope_spans = 2 +// ScopeSpans.scope = 1 +// ScopeSpans.spans = 2 +// Span.trace_id = 1 (bytes, 16) +// Span.span_id = 2 (bytes, 8) +// Span.parent_span_id = 4 (bytes, 8) +// Span.name = 5 +// Span.kind = 6 (SERVER = 2) +// Span.start_time_unix_nano = 7 (fixed64) +// Span.end_time_unix_nano = 8 (fixed64) +// Span.attributes = 9 (repeated KeyValue) +// Span.events = 11 (repeated Span.Event) +// Span.Event.time_unix_nano = 1 (fixed64) +// Span.Event.name = 2 +// Span.Event.attributes = 3 (repeated KeyValue) +// +// InstrumentationScope.name = 1 +// InstrumentationScope.version = 2 +// Resource.attributes = 1 (repeated KeyValue) +// KeyValue.key = 1 +// KeyValue.value = 2 → AnyValue +// AnyValue.string_value = 1 (oneof) + +static bool writeVarInt(pb_ostream_t* s, uint64_t v) { + return pb_encode_varint(s, v); +} +static bool writeTag(pb_ostream_t* s, uint32_t field, pb_wire_type_t wt) { + return pb_encode_tag(s, wt, field); +} +static bool writeFixed64(pb_ostream_t* s, uint64_t v) { + uint8_t buf[8]; + for (int i = 0; i < 8; ++i) { buf[i] = v & 0xFF; v >>= 8; } + return pb_write(s, buf, 8); +} +static bool writeBytes(pb_ostream_t* s, uint32_t field, + const uint8_t* data, size_t len) { + return writeTag(s, field, PB_WT_STRING) && + pb_encode_varint(s, len) && + pb_write(s, data, len); +} +static bool writeString(pb_ostream_t* s, uint32_t field, const char* str) { + if (!str || !*str) return true; + size_t len = strlen(str); + return writeTag(s, field, PB_WT_STRING) && + pb_encode_varint(s, len) && + pb_write(s, (const pb_byte_t*)str, len); +} + +// Encode one KeyValue with a string value into stream. +static bool writeKVStr(pb_ostream_t* s, uint32_t field, + const char* key, const char* val) { + // KV submessage: two-pass (size then content) + auto writeKVBody = [](pb_ostream_t* s, const char* key, const char* val) -> bool { + if (!writeString(s, 1, key)) return false; // KeyValue.key + // KeyValue.value = AnyValue (field 2), AnyValue.string_value (field 1) + // AnyValue submessage + size_t avLen = 0; + pb_ostream_t sz = PB_OSTREAM_SIZING; + writeString(&sz, 1, val); // AnyValue.string_value + avLen = sz.bytes_written; + if (!writeTag(s, 2, PB_WT_STRING)) return false; + if (!pb_encode_varint(s, avLen)) return false; + return writeString(s, 1, val); + }; + + pb_ostream_t sz = PB_OSTREAM_SIZING; + writeKVBody(&sz, key, val); + if (!writeTag(s, field, PB_WT_STRING)) return false; + if (!pb_encode_varint(s, sz.bytes_written)) return false; + return writeKVBody(s, key, val); +} + +// Encode one typed Attr as a KeyValue submessage. +static bool writeAttr(pb_ostream_t* s, uint32_t field, const Attr& a) { + auto writeBody = [](pb_ostream_t* s, const Attr& a) -> bool { + if (!writeString(s, 1, a.key.c_str())) return false; + // Build AnyValue body + auto writeAVBody = [](pb_ostream_t* s, const Attr& a) -> bool { + switch (a.type) { + case AttrType::Str: + return writeString(s, 1, a.s.c_str()); + case AttrType::Bool: + return writeTag(s, 4, PB_WT_VARINT) && writeVarInt(s, a.b ? 1 : 0); + case AttrType::Int: + return writeTag(s, 3, PB_WT_VARINT) && writeVarInt(s, (uint64_t)a.i); + case AttrType::Dbl: { + if (!writeTag(s, 5, PB_WT_64BIT)) return false; + uint64_t v; memcpy(&v, &a.d, 8); + return writeFixed64(s, v); + } + } + return true; + }; + pb_ostream_t sz = PB_OSTREAM_SIZING; + writeAVBody(&sz, a); + if (!writeTag(s, 2, PB_WT_STRING)) return false; + if (!pb_encode_varint(s, sz.bytes_written)) return false; + return writeAVBody(s, a); + }; + + pb_ostream_t sz = PB_OSTREAM_SIZING; + writeBody(&sz, a); + if (!writeTag(s, field, PB_WT_STRING)) return false; + if (!pb_encode_varint(s, sz.bytes_written)) return false; + return writeBody(s, a); +} + +// Encode a Span.Event submessage. +static bool writeEvent(pb_ostream_t* s, uint32_t field, const Event& ev) { + auto writeBody = [](pb_ostream_t* s, const Event& ev) -> bool { + if (!writeTag(s, 1, PB_WT_64BIT)) return false; // time_unix_nano + if (!writeFixed64(s, ev.t)) return false; + if (!writeString(s, 2, ev.name.c_str())) return false; // name + for (const auto& a : ev.attrs) + if (!writeAttr(s, 3, a)) return false; // attributes + return true; + }; + pb_ostream_t sz = PB_OSTREAM_SIZING; + writeBody(&sz, ev); + if (!writeTag(s, field, PB_WT_STRING)) return false; + if (!pb_encode_varint(s, sz.bytes_written)) return false; + return writeBody(s, ev); +} + +void sendSpan(const String& name, + const String& traceId, + const String& spanId, + const String& parentSpanId, + uint64_t startNs, + uint64_t endNs, + const std::vector& attrs, + const std::vector& events) { + + String svcName = defaultServiceName(); + String svcInst = defaultServiceInstanceId(); + String hostName = defaultHostName(); + + uint8_t traceBytes[16]{}, spanBytes[8]{}, parentBytes[8]{}; + bool hasParent = parentSpanId.length() == 16; + if (traceId.length() == 32) hexToBytes(traceId, traceBytes, 16); + if (spanId.length() == 16) hexToBytes(spanId, spanBytes, 8); + if (hasParent) hexToBytes(parentSpanId, parentBytes, 8); + + // Two-pass encode for the entire TracesData message. + auto writeSpanBody = [&](pb_ostream_t* s) -> bool { + if (!writeBytes(s, 1, traceBytes, 16)) return false; // trace_id + if (!writeBytes(s, 2, spanBytes, 8)) return false; // span_id + if (hasParent) + if (!writeBytes(s, 4, parentBytes, 8)) return false; // parent_span_id + if (!writeString(s, 5, name.c_str())) return false; // name + if (!writeTag(s, 6, PB_WT_VARINT)) return false; // kind = SERVER + if (!writeVarInt(s, 2)) return false; + if (!writeTag(s, 7, PB_WT_64BIT)) return false; // start_time + if (!writeFixed64(s, startNs)) return false; + if (!writeTag(s, 8, PB_WT_64BIT)) return false; // end_time + if (!writeFixed64(s, endNs)) return false; + for (const auto& a : attrs) + if (!writeAttr(s, 9, a)) return false; // attributes + for (const auto& ev : events) + if (!writeEvent(s, 11, ev)) return false; // events + return true; + }; + + auto writeScopeSpansBody = [&](pb_ostream_t* s) -> bool { + // scope (field 1): InstrumentationScope.name + version + auto writeScopeBody = [](pb_ostream_t* s) -> bool { + return writeString(s, 1, tracerConfig().scopeName.c_str()) && + writeString(s, 2, tracerConfig().scopeVersion.c_str()); + }; + pb_ostream_t sz = PB_OSTREAM_SIZING; writeScopeBody(&sz); + if (!writeTag(s, 1, PB_WT_STRING)) return false; + if (!pb_encode_varint(s, sz.bytes_written)) return false; + if (!writeScopeBody(s)) return false; + // spans (field 2) + pb_ostream_t sz2 = PB_OSTREAM_SIZING; writeSpanBody(&sz2); + if (!writeTag(s, 2, PB_WT_STRING)) return false; + if (!pb_encode_varint(s, sz2.bytes_written)) return false; + return writeSpanBody(s); + }; + + auto writeResourceSpansBody = [&](pb_ostream_t* s) -> bool { + // resource (field 1): three KeyValue entries + auto writeResBody = [&](pb_ostream_t* s) -> bool { + return writeKVStr(s, 1, "service.name", svcName.c_str()) && + writeKVStr(s, 1, "service.instance.id", svcInst.c_str()) && + writeKVStr(s, 1, "host.name", hostName.c_str()); + }; + pb_ostream_t sz = PB_OSTREAM_SIZING; writeResBody(&sz); + if (!writeTag(s, 1, PB_WT_STRING)) return false; + if (!pb_encode_varint(s, sz.bytes_written)) return false; + if (!writeResBody(s)) return false; + // scope_spans (field 2) + pb_ostream_t sz2 = PB_OSTREAM_SIZING; writeScopeSpansBody(&sz2); + if (!writeTag(s, 2, PB_WT_STRING)) return false; + if (!pb_encode_varint(s, sz2.bytes_written)) return false; + return writeScopeSpansBody(s); + }; + + // TracesData { resource_spans = 1 } + pb_ostream_t sz = PB_OSTREAM_SIZING; + writeResourceSpansBody(&sz); + + uint8_t buf[OTEL_PROTO_BUFFER_SIZE]; + pb_ostream_t stream = pb_ostream_from_buffer(buf, sizeof(buf)); + if (writeTag(&stream, 1, PB_WT_STRING) && + pb_encode_varint(&stream, sz.bytes_written) && + writeResourceSpansBody(&stream)) { + OTelSender::sendProto("/v1/traces", buf, stream.bytes_written); + } +} + +} // namespace Proto +} // namespace OTel + +#endif // OTEL_EXPORTER_OTLP_PROTOCOL_HTTP_PROTOBUF diff --git a/src/OtelSender.cpp b/src/OtelSender.cpp index 1b3180e..8e37b98 100644 --- a/src/OtelSender.cpp +++ b/src/OtelSender.cpp @@ -1,23 +1,26 @@ #include "OtelSender.h" +#include +#include // --- HTTP + WiFi includes (portable) --- #if defined(ESP8266) - #include - #include +#include +#include +#include #elif defined(ESP32) - #include - #include +#include +#include +#include #elif defined(ARDUINO_ARCH_RP2040) - #include // Earle Philhower core - #include // Arduino HTTPClient +#include // Earle Philhower core +#include // Arduino HTTPClient +#include #else - #error "Unsupported platform: need WiFi + HTTPClient" +#error "Unsupported platform: need WiFi + HTTPClient" #endif - - #ifdef ARDUINO_ARCH_RP2040 - #include "pico/multicore.h" +#include "pico/multicore.h" #endif // ===== statics ===== @@ -25,35 +28,201 @@ OTelQueuedItem OTelSender::q_[QCAP]; std::atomic OTelSender::head_{0}; std::atomic OTelSender::tail_{0}; std::atomic OTelSender::drops_{0}; -std::atomic OTelSender::worker_started_{false}; -// Begin HTTP on all platforms (ESP8266 requires WiFiClient) -static bool httpBeginCompat(HTTPClient& http, const String& url) { -#if defined(ESP8266) - WiFiClient client; // or WiFiClientSecure if you later do HTTPS - return http.begin(client, url); // new API on ESP8266 -#else - return http.begin(url); // ESP32 / RP2040 -#endif +std::atomic OTelSender::worker_started_{false}; + +// ---------- Header parsing ---------- + +// Append "key=value,…" pairs from raw into out. Existing entries are preserved +// so callers can layer global headers then per-signal headers. +static void appendParsedHeaders_(const char *raw, + std::vector> &out) +{ + if (!raw || !*raw) + return; + String s(raw); + int start = 0; + while (start <= (int)s.length()) + { + int comma = s.indexOf(',', start); + if (comma < 0) + comma = (int)s.length(); + String token = s.substring(start, comma); + token.trim(); + int eq = token.indexOf('='); + if (eq > 0) + { + String k = token.substring(0, eq); + String v = token.substring(eq + 1); + k.trim(); + v.trim(); + if (k.length()) + out.push_back({k, v}); + } + start = comma + 1; + } +} + +// Per-signal header lists. Initialized lazily from build flags on first +// access, and extended at runtime via OTelSender::addHeader(). +struct SignalHeaders { + std::vector> log, trace, metric; +}; + +// Once a send happens, headers are read by the RP2040 worker on core 1. +// We freeze the SignalHeaders vectors at that point so further addHeader() +// calls from core 0 cannot reallocate them mid-iteration (UB). +static std::atomic headers_frozen_{false}; + +static SignalHeaders &mutableHeaders_() +{ + static SignalHeaders s; + static bool initialized = false; + if (!initialized) + { + initialized = true; + // Global headers first, then per-signal overrides from build flags. + appendParsedHeaders_(OTEL_EXPORTER_OTLP_HEADERS, s.log); + appendParsedHeaders_(OTEL_EXPORTER_OTLP_HEADERS, s.trace); + appendParsedHeaders_(OTEL_EXPORTER_OTLP_HEADERS, s.metric); + appendParsedHeaders_(OTEL_EXPORTER_OTLP_LOGS_HEADERS, s.log); + appendParsedHeaders_(OTEL_EXPORTER_OTLP_TRACES_HEADERS, s.trace); + appendParsedHeaders_(OTEL_EXPORTER_OTLP_METRICS_HEADERS, s.metric); + } + return s; +} + +// Touch the headers on the current core to force lazy init, then freeze. +// After this, the RP2040 worker on core 1 only ever reads the vectors so +// no synchronisation is required for the read path in doPost_(). +static void freezeHeaders_() +{ + if (!headers_frozen_.load(std::memory_order_acquire)) + { + (void)mutableHeaders_(); + headers_frozen_.store(true, std::memory_order_release); + } +} + +// Returns the merged header list for the given OTLP path. +static const std::vector> &headersForPath_(const char *path) +{ + auto &s = mutableHeaders_(); + if (path && strcmp(path, "/v1/logs") == 0) + return s.log; + if (path && strcmp(path, "/v1/traces") == 0) + return s.trace; + return s.metric; +} + +// Public API: add a header at runtime to a specific OTLP signal path. +// Must be called before the first send; once headers are frozen the call +// is rejected to avoid racing with the RP2040 worker reading the vectors. +void OTelSender::addHeader(const char *path, const String &key, const String &value) +{ + if (headers_frozen_.load(std::memory_order_acquire)) return; + auto &s = mutableHeaders_(); + if (path && strcmp(path, "/v1/logs") == 0) s.log.push_back({key, value}); + else if (path && strcmp(path, "/v1/traces") == 0) s.trace.push_back({key, value}); + else if (path && strcmp(path, "/v1/metrics") == 0) s.metric.push_back({key, value}); } +// ---------- URL resolution ---------- + +// Returns the full URL for the given OTLP signal path. +// +// Priority (highest to lowest): +// 1. Per-signal endpoint (OTEL_EXPORTER_OTLP_{LOGS,TRACES,METRICS}_ENDPOINT) +// → used as-is, no path appended (spec requirement) +// 2. Standard base URL (OTEL_EXPORTER_OTLP_ENDPOINT) +// → signal path appended automatically +// 3. Legacy base URL (OTEL_COLLECTOR_BASE_URL) +// → signal path appended automatically +String OTelSender::fullUrl_(const char *path) +{ + // 1. Per-signal overrides (used verbatim per spec) + if (path) + { + if (strcmp(path, "/v1/logs") == 0 && strlen(OTEL_EXPORTER_OTLP_LOGS_ENDPOINT) > 0) + return String(OTEL_EXPORTER_OTLP_LOGS_ENDPOINT); + if (strcmp(path, "/v1/traces") == 0 && strlen(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) > 0) + return String(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT); + if (strcmp(path, "/v1/metrics") == 0 && strlen(OTEL_EXPORTER_OTLP_METRICS_ENDPOINT) > 0) + return String(OTEL_EXPORTER_OTLP_METRICS_ENDPOINT); + } -// Build "http://host:4318" + "/v1/…" -String OTelSender::fullUrl_(const char* path) { - // Avoid double slashes if a user accidentally sets a trailing slash - String base = String(OTEL_COLLECTOR_BASE_URL); - if (base.endsWith("/")) base.remove(base.length() - 1); - if (path && *path == '/') return base + String(path); + // 2/3. Base URL with signal path appended + String base = strlen(OTEL_EXPORTER_OTLP_ENDPOINT) > 0 + ? String(OTEL_EXPORTER_OTLP_ENDPOINT) + : String(OTEL_COLLECTOR_BASE_URL); + if (base.endsWith("/")) + base.remove(base.length() - 1); + if (path && *path == '/') + return base + String(path); return base + "/" + String(path ? path : ""); } +// ---------- HTTP/HTTPS POST ---------- + +// Executes a single POST. Handles plain HTTP and HTTPS transparently based on +// the URL scheme. Custom headers are applied after Content-Type. +static void doPost_(const String &url, const std::vector &payload, + const char *path, const char *contentType) +{ + HTTPClient http; + const auto &hdrs = headersForPath_(path); + + // Lambda keeps the send logic in one place regardless of client type. + auto fire = [&](bool ok) + { + if (!ok) + return; + http.addHeader("Content-Type", contentType); + for (const auto &h : hdrs) + http.addHeader(h.first, h.second); + // Use the binary-safe (uint8_t*, size_t) overload so embedded null bytes + // in protobuf bodies are preserved. The String-based overload on some + // HTTPClient implementations would truncate at the first null byte. + (void)http.POST(const_cast(payload.data()), payload.size()); + http.end(); + }; + + if (url.startsWith("https://")) + { + // WiFiClientSecure must remain in scope until after http.end() (fire()). + WiFiClientSecure sc; +#if OTEL_TLS_INSECURE + sc.setInsecure(); +#elif defined(OTEL_TLS_CA_CERT) + // Validated TLS: caller must -DOTEL_TLS_CA_CERT="..." with a PEM-encoded + // root CA. Without this, setting OTEL_TLS_INSECURE=0 leaves the client + // with no trust anchors and the handshake will fail at runtime. + sc.setCACert(OTEL_TLS_CA_CERT); +#else +#error "OTEL_TLS_INSECURE=0 requires -DOTEL_TLS_CA_CERT=\"...PEM...\" to be defined." +#endif + fire(http.begin(sc, url)); + } + else + { +#if defined(ESP8266) + WiFiClient wc; + fire(http.begin(wc, url)); +#else + fire(http.begin(url)); +#endif + } +} + // ---------- Queue (SPSC) ---------- -// Single-producer (core0) enqueue; drop oldest on overflow -bool OTelSender::enqueue_(const char* path, String&& payload) { + +bool OTelSender::enqueue_(const char *path, const char *contentType, std::vector &&payload) +{ size_t h = head_.load(std::memory_order_relaxed); size_t t = tail_.load(std::memory_order_acquire); size_t next = (h + 1) % QCAP; - if (next == t) { + if (next == t) + { // Full: drop oldest (advance tail) size_t new_t = (t + 1) % QCAP; tail_.store(new_t, std::memory_order_release); @@ -61,116 +230,144 @@ bool OTelSender::enqueue_(const char* path, String&& payload) { } q_[h].path = path; + q_[h].contentType = contentType; q_[h].payload = std::move(payload); head_.store(next, std::memory_order_release); return true; } -bool OTelSender::dequeue_(OTelQueuedItem& out) { +bool OTelSender::dequeue_(OTelQueuedItem &out) +{ size_t t = tail_.load(std::memory_order_relaxed); size_t h = head_.load(std::memory_order_acquire); - if (t == h) return false; // empty + if (t == h) + return false; out = std::move(q_[t]); - q_[t].payload = String(); // release memory + // Free the slot's allocation now rather than waiting for the next + // overwrite, so a long idle period doesn't pin RAM proportional to + // the largest payload ever sent. + std::vector().swap(q_[t].payload); size_t next = (t + 1) % QCAP; tail_.store(next, std::memory_order_release); return true; } // ---------- Worker ---------- -void OTelSender::pumpOnce_() { -#if OTEL_SEND_ENABLE - OTelQueuedItem it; - if (!dequeue_(it)) return; - HTTPClient http; - if (httpBeginCompat(http, fullUrl_(it.path))) { - http.addHeader("Content-Type", "application/json"); - // Fire the POST; the blocking happens on core 1, not in the control path. - (void)http.POST(it.payload); - http.end(); - } -#else - // If globally disabled, just drain the queue without sending. - OTelQueuedItem sink; - (void)dequeue_(sink); +void OTelSender::pumpOnce_() +{ + OTelQueuedItem it; + if (!dequeue_(it)) + return; +#if OTEL_SEND_ENABLE + doPost_(fullUrl_(it.path), it.payload, it.path, it.contentType); #endif } -void OTelSender::workerLoop_() { - for (;;) { - for (int i = 0; i < OTEL_WORKER_BURST; ++i) { +void OTelSender::workerLoop_() +{ + for (;;) + { + for (int i = 0; i < OTEL_WORKER_BURST; ++i) + { OTelQueuedItem it; - if (!dequeue_(it)) break; - - HTTPClient http; - // Keep-alive where supported; harmless otherwise - #if defined(HTTPCLIENT_1_2_COMPATIBLE) || defined(ESP8266) || defined(ESP32) - http.setReuse(true); - #endif - if (httpBeginCompat(http, fullUrl_(it.path))) { - http.addHeader("Content-Type", "application/json"); - (void)http.POST(it.payload); - http.end(); - } + if (!dequeue_(it)) + break; +#if OTEL_SEND_ENABLE + doPost_(fullUrl_(it.path), it.payload, it.path, it.contentType); +#endif } delay(OTEL_WORKER_SLEEP_MS); } } - #ifdef ARDUINO_ARCH_RP2040 void otel_worker_entry() { OTelSender::workerLoop_(); } #endif - -void OTelSender::launchWorkerOnce_() { +void OTelSender::launchWorkerOnce_() +{ #ifdef ARDUINO_ARCH_RP2040 bool expected = false; - if (worker_started_.compare_exchange_strong(expected, true)) { + if (worker_started_.compare_exchange_strong(expected, true)) + { multicore_launch_core1(otel_worker_entry); } #endif } -void OTelSender::beginAsyncWorker() { +void OTelSender::beginAsyncWorker() +{ launchWorkerOnce_(); } -uint32_t OTelSender::droppedCount() { +uint32_t OTelSender::droppedCount() +{ return drops_.load(std::memory_order_relaxed); } -bool OTelSender::queueIsHealthy() { +bool OTelSender::queueIsHealthy() +{ +#ifdef ARDUINO_ARCH_RP2040 return worker_started_.load(std::memory_order_relaxed); +#else + // No async worker on synchronous platforms — sends happen inline, so + // there is no queue health to report. Always healthy by definition. + return true; +#endif } // ---------- Public send API ---------- -void OTelSender::sendJson(const char* path, JsonDocument& doc) { + +static constexpr const char *kContentTypeJson = "application/json"; +static constexpr const char *kContentTypeProto = "application/x-protobuf"; + +void OTelSender::sendJson(const char *path, JsonDocument &doc) +{ #if !OTEL_SEND_ENABLE - // Compile-time: completely disable sends (useful for latency tests) - (void)path; (void)doc; + (void)path; + (void)doc; return; #else - // Serialize on the caller's core (cheap), then: - // - RP2040: enqueue for core-1 worker to POST (non-blocking for control path) - // - others: POST synchronously (unchanged behaviour) - String payload; - serializeJson(doc, payload); - - #ifdef ARDUINO_ARCH_RP2040 - // Ensure worker is launched (safe to call repeatedly) - launchWorkerOnce_(); - enqueue_(path, std::move(payload)); - #else - HTTPClient http; - if (httpBeginCompat(http, fullUrl_(path))) { - http.addHeader("Content-Type", "application/json"); - (void)http.POST(payload); - http.end(); - } - #endif + freezeHeaders_(); + // Serialise JSON to a temporary String, then copy bytes into a binary + // buffer for the queue/send path. JSON is UTF-8 text without embedded + // null bytes, so the copy is safe; we use a vector here for symmetry + // with the protobuf path and to share the binary-safe POST overload. + String text; + serializeJson(doc, text); + const uint8_t *begin = reinterpret_cast(text.c_str()); + std::vector payload(begin, begin + text.length()); + +#ifdef ARDUINO_ARCH_RP2040 + launchWorkerOnce_(); + enqueue_(path, kContentTypeJson, std::move(payload)); +#else + doPost_(fullUrl_(path), payload, path, kContentTypeJson); +#endif #endif } +void OTelSender::sendProto(const char *path, const uint8_t *buf, size_t len) +{ +#if !OTEL_SEND_ENABLE + (void)path; + (void)buf; + (void)len; + return; +#else + freezeHeaders_(); + // Binary buffer round-trips embedded null bytes in protobuf payloads + // through the queue and the HTTPClient::POST(uint8_t*, size_t) overload + // without truncation. + std::vector payload(buf, buf + len); + +#ifdef ARDUINO_ARCH_RP2040 + launchWorkerOnce_(); + enqueue_(path, kContentTypeProto, std::move(payload)); +#else + doPost_(fullUrl_(path), payload, path, kContentTypeProto); +#endif +#endif +} diff --git a/test/stubs/Arduino.h b/test/stubs/Arduino.h new file mode 100644 index 0000000..efa96b7 --- /dev/null +++ b/test/stubs/Arduino.h @@ -0,0 +1,125 @@ +#pragma once +// Minimal Arduino shim for native (Linux/macOS) unit tests. +// Provides just enough of the Arduino API surface that otel-embedded-cpp +// headers (and ArduinoJson's Arduino-mode polyfills) compile on a host +// toolchain without modification. + +#ifndef ARDUINO +#define ARDUINO 100 +#endif + +#include +#include +#include +#include +#include +#include + +// ── pgmspace (AVR flash memory — identity on native) ───────────────────────── +#define pgm_read_byte(addr) (*((const uint8_t *)(addr))) +#define pgm_read_word(addr) (*((const uint16_t *)(addr))) +#define pgm_read_ptr(addr) (*((const void* const*)(addr))) +#define PROGMEM +#define PGM_P const char* +#define PSTR(s) (s) + +// ── Flash string helper ─────────────────────────────────────────────────────── +// ArduinoJson checks for __FlashStringHelper in several headers. +// On native it is just an opaque type; cast via F(). +struct __FlashStringHelper {}; +#define F(x) (reinterpret_cast(x)) + +// ── Print / Printable / Stream ──────────────────────────────────────────────── +// ArduinoJson uses these as base-class constraints in its adapters. +class Print { +public: + virtual size_t write(uint8_t) { return 0; } + virtual size_t write(const uint8_t*, size_t n) { return n; } + size_t print(const char* s) { return write((const uint8_t*)s, strlen(s)); } + size_t println(const char* s = "") { return print(s) + write((uint8_t)'\n'); } + virtual ~Print() = default; +}; + +class Printable { +public: + virtual size_t printTo(Print&) const = 0; + virtual ~Printable() = default; +}; + +class Stream : public Print { +public: + virtual int available() { return 0; } + virtual int read() { return -1; } + virtual int peek() { return -1; } + virtual size_t readBytes(char*, size_t) { return 0; } + virtual size_t readBytes(uint8_t* b, size_t n) { return readBytes((char*)b, n); } +}; + +// ── String ──────────────────────────────────────────────────────────────────── +// std::string subclass that adds the Arduino-compatible constructors and +// helpers used throughout the library (c_str, length, indexOf, etc.). +class String : public std::string { +public: + String() = default; + String(const char* s) : std::string(s ? s : "") {} + String(const std::string& s) : std::string(s) {} + String(char c) : std::string(1, c) {} + explicit String(int v) : std::string(std::to_string(v)) {} + explicit String(long v) : std::string(std::to_string(v)) {} + explicit String(unsigned int v) : std::string(std::to_string(v)) {} + explicit String(unsigned long v) : std::string(std::to_string(v)) {} + explicit String(float v) : std::string(std::to_string(v)) {} + explicit String(double v) : std::string(std::to_string(v)) {} + + unsigned int length() const { return (unsigned int)size(); } + + int indexOf(char c, unsigned int from = 0) const { + auto p = find(c, from); + return p == npos ? -1 : (int)p; + } + int indexOf(const String& s, unsigned int from = 0) const { + auto p = find(s, from); + return p == npos ? -1 : (int)p; + } + String substring(unsigned int from, unsigned int to = 0) const { + return to ? String(substr(from, to - from)) : String(substr(from)); + } + int toInt() const { return empty() ? 0 : std::stoi(*this); } + float toFloat() const { return empty() ? 0.f : std::stof(*this); } + + String& operator+=(const char* s) { std::string::operator+=(s); return *this; } + String& operator+=(const String& s) { std::string::operator+=(s); return *this; } + String operator+(const String& s) const { return String(std::string(*this) + std::string(s)); } + String operator+(const char* s) const { return String(std::string(*this) + s); } + bool operator==(const char* s) const { return compare(s) == 0; } + bool operator!=(const char* s) const { return compare(s) != 0; } + + bool concat(const char* s) { if (s) append(s); return true; } + bool concat(const String& s) { append(s); return true; } +}; + +inline String operator+(const char* a, const String& b) { + return String(std::string(a) + std::string(b)); +} + +// ── Serial stub ─────────────────────────────────────────────────────────────── +struct HardwareSerial { + void begin(unsigned long) {} + template size_t print(T) { return 0; } + template size_t println(T) { return 0; } + template size_t print(T, int) { return 0; } + template size_t println(T, int) { return 0; } + size_t println() { return 0; } + void printf(const char*, ...) {} +}; +static HardwareSerial Serial; + +// ── Timing stubs ────────────────────────────────────────────────────────────── +static inline unsigned long millis() { return 0; } +static inline unsigned long micros() { return 0; } +static inline void delay(unsigned long) {} + +// ── PRNG ────────────────────────────────────────────────────────────────────── +static inline void randomSeed(unsigned long seed) { srand((unsigned int)seed); } +static inline long random(long hi) { return (long)(rand() % hi); } +static inline long random(long lo, long hi) { return lo + (long)(rand() % (hi - lo)); } diff --git a/test/test_otlp/fake_sender.cpp b/test/test_otlp/fake_sender.cpp new file mode 100644 index 0000000..82fd344 --- /dev/null +++ b/test/test_otlp/fake_sender.cpp @@ -0,0 +1,50 @@ +#include "fake_sender.h" +#include "OtelSender.h" +#include +#include + +// ── Captured state ──────────────────────────────────────────────────────────── +namespace FakeSender { + std::string lastPath; + std::string lastJson; + std::vector lastProto; + + void reset() { + lastPath.clear(); + lastJson.clear(); + lastProto.clear(); + } +} + +// ── OTelSender static member definitions ───────────────────────────────────── +// These must live in exactly one translation unit; the real OtelSender.cpp is +// excluded from the native build, so we define them here instead. +OTelQueuedItem OTelSender::q_[OTelSender::QCAP]; +std::atomic OTelSender::head_{0}; +std::atomic OTelSender::tail_{0}; +std::atomic OTelSender::drops_{0}; +std::atomic OTelSender::worker_started_{false}; + +// ── Public API stubs ────────────────────────────────────────────────────────── + +void OTelSender::sendJson(const char* path, JsonDocument& doc) { + FakeSender::lastPath = path; + serializeJson(doc, FakeSender::lastJson); +} + +void OTelSender::sendProto(const char* path, const uint8_t* buf, size_t len) { + FakeSender::lastPath = path; + FakeSender::lastProto.assign(buf, buf + len); +} + +void OTelSender::beginAsyncWorker() {} +uint32_t OTelSender::droppedCount() { return 0; } +bool OTelSender::queueIsHealthy() { return true; } + +// ── Private helper stubs ────────────────────────────────────────────────────── +bool OTelSender::enqueue_(const char*, const char*, std::vector&&) { return true; } +bool OTelSender::dequeue_(OTelQueuedItem&) { return false; } +void OTelSender::pumpOnce_() {} +void OTelSender::workerLoop_() {} +void OTelSender::launchWorkerOnce_() {} +String OTelSender::fullUrl_(const char* path) { return String(path); } diff --git a/test/test_otlp/fake_sender.h b/test/test_otlp/fake_sender.h new file mode 100644 index 0000000..115fab7 --- /dev/null +++ b/test/test_otlp/fake_sender.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include +#include + +// Test double for OTelSender. +// Call reset() in setUp(); then inspect lastPath / lastJson / lastProto +// after exercising library code to assert on the emitted payload. +namespace FakeSender { + extern std::string lastPath; + extern std::string lastJson; + extern std::vector lastProto; + + void reset(); +} diff --git a/test/test_otlp/project_src.cpp b/test/test_otlp/project_src.cpp new file mode 100644 index 0000000..9c3f98e --- /dev/null +++ b/test/test_otlp/project_src.cpp @@ -0,0 +1,2 @@ +// OtelMetrics.cpp is included directly in test_otlp.cpp to keep all +// static-inline singletons in the same translation unit as the test fixtures. diff --git a/test/test_otlp/test_otlp.cpp b/test/test_otlp/test_otlp.cpp new file mode 100644 index 0000000..a0e49ce --- /dev/null +++ b/test/test_otlp/test_otlp.cpp @@ -0,0 +1,178 @@ +#include // must come before ArduinoJson.h to get matching inline namespace +#include +#include + +#include "OtelDefaults.h" +#include "OtelMetrics.h" +#include "OtelLogger.h" +#include "OtelTracer.h" +#include "fake_sender.h" + +// Pull in non-inline implementations in the same TU so static-inline singletons +// (defaultResource, metricsScopeConfig, etc.) are shared with the test fixtures. +#include "../../src/OtelMetrics.cpp" + +// ── Fixture ─────────────────────────────────────────────────────────────────── + +void setUp() { + FakeSender::reset(); + + auto& res = OTel::defaultResource(); + res.set("service.name", "test-service"); + res.set("service.namespace", "test-ns"); + res.set("service.instance.id", "test-001"); + res.set("host.name", "test-host"); + + OTel::Metrics::begin("otel-embedded-cpp", "0.0.1"); + OTel::Tracer::begin("otel-embedded-cpp", "0.0.1"); +} + +void tearDown() {} + +// ── Metrics: gauge ──────────────────────────────────────────────────────────── + +void test_gauge_sends_to_metrics_endpoint() { + OTel::Metrics::gauge("cpu.usage", 0.42, "1"); + TEST_ASSERT_EQUAL_STRING("/v1/metrics", FakeSender::lastPath.c_str()); +} + +void test_gauge_payload_has_metric_name() { + OTel::Metrics::gauge("cpu.usage", 0.42, "1"); + JsonDocument doc; + deserializeJson(doc, FakeSender::lastJson); + const char* name = + doc["resourceMetrics"][0]["scopeMetrics"][0]["metrics"][0]["name"]; + TEST_ASSERT_EQUAL_STRING("cpu.usage", name); +} + +void test_gauge_payload_has_value() { + OTel::Metrics::gauge("cpu.usage", 0.42, "1"); + JsonDocument doc; + deserializeJson(doc, FakeSender::lastJson); + double val = + doc["resourceMetrics"][0]["scopeMetrics"][0]["metrics"][0] + ["gauge"]["dataPoints"][0]["asDouble"]; + TEST_ASSERT_FLOAT_WITHIN(0.001, 0.42, val); +} + +void test_gauge_payload_has_unit() { + OTel::Metrics::gauge("disk.reads", 100.0, "By"); + JsonDocument doc; + deserializeJson(doc, FakeSender::lastJson); + const char* unit = + doc["resourceMetrics"][0]["scopeMetrics"][0]["metrics"][0]["unit"]; + TEST_ASSERT_EQUAL_STRING("By", unit); +} + +void test_gauge_payload_includes_call_labels() { + OTel::Metrics::gauge("cpu.usage", 0.5, "1", {{"core", "0"}}); + JsonDocument doc; + deserializeJson(doc, FakeSender::lastJson); + JsonArray attrs = + doc["resourceMetrics"][0]["scopeMetrics"][0]["metrics"][0] + ["gauge"]["dataPoints"][0]["attributes"]; + bool found = false; + for (JsonObject a : attrs) { + if (strcmp(a["key"], "core") == 0) { found = true; break; } + } + TEST_ASSERT_TRUE(found); +} + +// ── Logs ────────────────────────────────────────────────────────────────────── + +void test_log_sends_to_logs_endpoint() { + OTel::Logger::logInfo("hello"); + TEST_ASSERT_EQUAL_STRING("/v1/logs", FakeSender::lastPath.c_str()); +} + +void test_log_info_severity_text() { + OTel::Logger::logInfo("hello"); + JsonDocument doc; + deserializeJson(doc, FakeSender::lastJson); + const char* sev = + doc["resourceLogs"][0]["scopeLogs"][0]["logRecords"][0]["severityText"]; + TEST_ASSERT_EQUAL_STRING("INFO", sev); +} + +void test_log_warn_severity_number() { + OTel::Logger::logWarn("watch out"); + JsonDocument doc; + deserializeJson(doc, FakeSender::lastJson); + int num = + doc["resourceLogs"][0]["scopeLogs"][0]["logRecords"][0]["severityNumber"]; + TEST_ASSERT_EQUAL_INT(13, num); // WARN = 13 per OTLP spec +} + +void test_log_body_string_value() { + OTel::Logger::logInfo("hello world"); + JsonDocument doc; + deserializeJson(doc, FakeSender::lastJson); + const char* body = + doc["resourceLogs"][0]["scopeLogs"][0]["logRecords"][0]["body"]["stringValue"]; + TEST_ASSERT_EQUAL_STRING("hello world", body); +} + +void test_log_error_severity_text() { + OTel::Logger::logError("boom"); + JsonDocument doc; + deserializeJson(doc, FakeSender::lastJson); + const char* sev = + doc["resourceLogs"][0]["scopeLogs"][0]["logRecords"][0]["severityText"]; + TEST_ASSERT_EQUAL_STRING("ERROR", sev); +} + +// ── Traces ──────────────────────────────────────────────────────────────────── + +void test_span_sends_to_traces_endpoint() { + auto span = OTel::Tracer::startSpan("my-op"); + span.end(); + TEST_ASSERT_EQUAL_STRING("/v1/traces", FakeSender::lastPath.c_str()); +} + +void test_span_name_in_payload() { + auto span = OTel::Tracer::startSpan("my-op"); + span.end(); + JsonDocument doc; + deserializeJson(doc, FakeSender::lastJson); + const char* name = + doc["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["name"]; + TEST_ASSERT_EQUAL_STRING("my-op", name); +} + +void test_span_has_non_empty_trace_id() { + auto span = OTel::Tracer::startSpan("my-op"); + span.end(); + JsonDocument doc; + deserializeJson(doc, FakeSender::lastJson); + const char* tid = + doc["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["traceId"]; + TEST_ASSERT_NOT_NULL(tid); + TEST_ASSERT_TRUE(strlen(tid) > 0); +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +int main() { + UNITY_BEGIN(); + + // Metrics + RUN_TEST(test_gauge_sends_to_metrics_endpoint); + RUN_TEST(test_gauge_payload_has_metric_name); + RUN_TEST(test_gauge_payload_has_value); + RUN_TEST(test_gauge_payload_has_unit); + RUN_TEST(test_gauge_payload_includes_call_labels); + + // Logs + RUN_TEST(test_log_sends_to_logs_endpoint); + RUN_TEST(test_log_info_severity_text); + RUN_TEST(test_log_warn_severity_number); + RUN_TEST(test_log_body_string_value); + RUN_TEST(test_log_error_severity_text); + + // Traces + RUN_TEST(test_span_sends_to_traces_endpoint); + RUN_TEST(test_span_name_in_payload); + RUN_TEST(test_span_has_non_empty_trace_id); + + return UNITY_END(); +}