diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 241bd63..c6794d0 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -11,31 +11,41 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install PlatformIO + run: python -m pip install -U platformio + + # Make repo Variables + Secrets available to all subsequent steps + - name: Export env for build run: | - python -m pip install -U platformio + 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_SERVICE_NAME=${{ vars.OTEL_SERVICE_NAME }}" + echo "OTEL_SERVICE_NAMESPACE=${{ vars.OTEL_SERVICE_NAMESPACE }}" + echo "OTEL_SERVICE_VERSION=${{ vars.OTEL_SERVICE_VERSION }}" + echo "OTEL_SERVICE_INSTANCE=${{ vars.OTEL_SERVICE_INSTANCE }}" + echo "OTEL_DEPLOY_ENV=${{ vars.OTEL_DEPLOY_ENV }}" + } >> "$GITHUB_ENV" - name: PlatformIO Update - run: | - pio update + run: pio update - name: Build for ESP32 (esp32dev) - run: | - platformio ci examples/basic/main.cpp --project-conf platformio.ini --lib "." -e esp32dev + run: platformio ci src/main.cpp --project-conf platformio.ini --lib "." -e esp32dev - name: Build for Pico W (rpipicow) - run: | - platformio ci examples/basic/main.cpp --project-conf platformio.ini --lib "." -e rpipicow + run: platformio ci src/main.cpp --project-conf platformio.ini --lib "." -e rpipicow - name: Build for ESP8266 (esp8266 d1_mini) - run: | - platformio ci examples/basic/main.cpp --project-conf platformio.ini --lib "." -e esp8266 - + run: platformio ci src/main.cpp --project-conf platformio.ini --lib "." -e esp8266 diff --git a/include/OtelDefaults.h b/include/OtelDefaults.h index 7047bed..7222057 100644 --- a/include/OtelDefaults.h +++ b/include/OtelDefaults.h @@ -3,40 +3,128 @@ #include #include -#include // for gettimeofday() +#include // gettimeofday() + +// This header provides: +// - Time helpers (nowUnixNano/Millis) +// - OTLP JSON KeyValue serializers (string/double/int) using ArduinoJson v7 APIs +// - OTelResourceConfig with legacy-compatible helpers used by Metrics/Tracer: +// setAttribute(), addResourceAttributes(JsonObject) +// and newer helpers used by Logger: +// set(), clear(), toJson(resource) +// - defaultResource() and defaultTraceResource() singletons namespace OTel { +// ------------------------------------------------------------------------------------------------- +// Time helpers +// ------------------------------------------------------------------------------------------------- -/** - * @brief Get the current UNIX time in nanoseconds. - * Uses the POSIX gettimeofday() clock, which you should have - * synchronized via configTime()/time(). - */ +/** UNIX timestamp in nanoseconds. Ensure clock is synced (configTime(), etc.) */ static inline uint64_t nowUnixNano() { struct timeval tv; gettimeofday(&tv, nullptr); - // seconds→ns, usec→ns - return uint64_t(tv.tv_sec) * 1000000000ULL - + uint64_t(tv.tv_usec) * 1000ULL; + return static_cast(tv.tv_sec) * 1000000000ULL + + static_cast(tv.tv_usec) * 1000ULL; +} + +/** UNIX timestamp in milliseconds (spare helper) */ +static inline uint64_t nowUnixMillis() { + struct timeval tv; + gettimeofday(&tv, nullptr); + return static_cast(tv.tv_sec) * 1000ULL + + static_cast(tv.tv_usec) / 1000ULL; +} + +// Portable uint64 -> String (no printf/ULL reliance; RP2040-safe) +inline String u64ToString(uint64_t v) { + if (v == 0) return String("0"); + char buf[21]; // max 20 digits + NUL + char* p = &buf[20]; + *p = '\0'; + while (v > 0) { + *--p = char('0' + (v % 10)); + v /= 10; + } + return String(p); +} + +// ------------------------------------------------------------------------------------------------- +// OTLP JSON KeyValue helpers (ArduinoJson v7 deprecation-safe) +// ------------------------------------------------------------------------------------------------- + +/** + * Serialise a string KeyValue into an OTLP JSON attributes array: + * {"key":"","value":{"stringValue":""}}. + */ +inline void serializeKeyValue(JsonArray &arr, const String &key, const String &value) { + JsonObject kv = arr.add(); + kv["key"] = key; + JsonObject any = kv["value"].to(); + any["stringValue"] = value; } +/** Double-valued KeyValue */ +inline void serializeKeyDouble(JsonArray &arr, const String &key, double value) { + JsonObject kv = arr.add(); + kv["key"] = key; + JsonObject any = kv["value"].to(); + any["doubleValue"] = value; +} + +/** Int64-valued KeyValue */ +inline void serializeKeyInt(JsonArray &arr, const String &key, int64_t value) { + JsonObject kv = arr.add(); + kv["key"] = key; + JsonObject any = kv["value"].to(); + any["intValue"] = value; +} + +// ------------------------------------------------------------------------------------------------- +// Resource attributes container (back-compat + new helpers) +// ------------------------------------------------------------------------------------------------- + +/** + * Holds resource attributes (service/host/instance/etc.). + * This struct supports: + * - Legacy calls used by your Metrics/Tracer code: + * setAttribute(k,v), addResourceAttributes(JsonObject target) + * (these write "attributes" directly under the passed object) + * - Newer usage for logs envelope: + * toJson(resource) -> writes into resource["attributes"] + */ struct OTelResourceConfig { - // internal map of attributes std::map attrs; - void setAttribute(const String& key, const String& value) { - attrs[key] = value; - } + // Newer API + void set(const String &k, const String &v) { attrs[k] = v; } + void set(const char *k, const String &v) { attrs[String(k)] = v; } + void clear() { attrs.clear(); } + bool empty() const { return attrs.empty(); } - void serializeKeyValue(JsonArray &arr, const String &k, const String &v) const { - JsonObject kv = arr.add(); - kv["key"] = k; - JsonObject val = kv["value"].to(); - val["stringValue"] = v; + // Backwards-compatible API expected by existing Metrics/Tracer code + 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: + * Writes attributes directly under the given target as: + * target["attributes"] = [ {key, value:{stringValue}}, ... ] + */ + void addResourceAttributes(JsonObject target) const { + if (attrs.empty()) return; + JsonArray attributes = target["attributes"].to(); + for (auto &p : attrs) { + serializeKeyValue(attributes, p.first, p.second); + } } - void addResourceAttributes(JsonObject &resource) const { + /** + * Logs envelope helper: + * Writes into "resource.attributes" of the given resource object: + * resource["attributes"] = [ {key, value:{stringValue}}, ... ] + */ + void toJson(JsonObject resource) const { if (attrs.empty()) return; JsonArray attributes = resource["attributes"].to(); for (auto &p : attrs) { @@ -45,11 +133,18 @@ struct OTelResourceConfig { } }; -inline OTelResourceConfig getDefaultResource() { - return OTelResourceConfig(); +// ------------------------------------------------------------------------------------------------- +// Singletons (headers-only, inline ok) +// ------------------------------------------------------------------------------------------------- + +/** Default resource for general use (metrics/logs/etc.) */ +static inline OTelResourceConfig& defaultResource() { + static OTelResourceConfig rc; + return rc; } + } // namespace OTel -#endif +#endif // OTEL_DEFAULTS_H diff --git a/include/OtelLogger.h b/include/OtelLogger.h index 3a0767f..78aa8de 100644 --- a/include/OtelLogger.h +++ b/include/OtelLogger.h @@ -1,65 +1,142 @@ +// OtelLogger.h #ifndef OTEL_LOGGER_H #define OTEL_LOGGER_H -#include -#include "OtelDefaults.h" -#include "OtelSender.h" +#include +#include +#include +#include +#include "OtelDefaults.h" // expects: nowUnixNano() +#include "OtelSender.h" // expects: OTelSender::sendJson(path, doc) +#include "OtelTracer.h" // provides: currentTraceContext(), u64ToStr(), defaults & addResAttr helpers namespace OTel { -// Your one-and-only resource config singleton -static inline OTelResourceConfig& defaultResource() { - static OTelResourceConfig rc; - return rc; +// ---- Severity mapping ------------------------------------------------------- +static inline int severityNumberFromText(const String& s) { + if (s == "TRACE") return 1; + if (s == "DEBUG") return 5; + if (s == "INFO") return 9; + if (s == "WARN") return 13; + if (s == "ERROR") return 17; + if (s == "FATAL") return 21; + return 0; +} + +// ---- Instrumentation scope for logs ----------------------------------------- +struct LogScopeConfig { + String scopeName{"otel-embedded-cpp"}; + String scopeVersion{""}; // optional +}; +static inline LogScopeConfig& logScopeConfig() { + static LogScopeConfig cfg; + return cfg; +} + +// ---- Default labels (merged into each log record's attributes) -------------- +static inline std::map& defaultLabels() { + static std::map labels; + return labels; } class Logger { public: - // Initialize service.* and host resource attributes - static void begin(const String &serviceName, - const String &serviceNamespace, - const String &collector, - const String &host, - const String &version) { - auto& rc = defaultResource(); - rc.setAttribute("service.name", serviceName); - rc.setAttribute("service.namespace", serviceNamespace); - rc.setAttribute("service.version", version); - rc.setAttribute("host.name", host); - // you can also add rc.setAttribute("collector.endpoint", collector); + // Set/merge defaults + static void setDefaultLabels(const std::map& labels) { + defaultLabels() = labels; + } + static void setDefaultLabel(const String& key, const String& value) { + defaultLabels()[key] = value; + } + + // Map-based API + static void log(const String& severity, const String& message, + const std::map& labels = {}) { + buildAndSend(severity, message, labels); } - // Core log emitter - static void log(const char* severity, const String& message) { - // In ArduinoJson 7+, JsonDocument grows as needed (heap‐based) + // Convenience overload: initializer_list of key/value pairs + static void log(const String& severity, const String& message, + std::initializer_list> kvs) { + std::map labels; + for (auto &kv : kvs) labels[String(kv.first)] = String(kv.second); + buildAndSend(severity, message, labels); + } + + // Helpers by severity + 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); } + static void logWarn (const String &m, const std::map &l = {}) { log("WARN", m, l); } + static void logError(const String &m, const std::map &l = {}) { log("ERROR", m, l); } + static void logFatal(const String &m, const std::map &l = {}) { log("FATAL", m, l); } + + static void logTrace(const String &m, std::initializer_list> kvs) { log("TRACE", m, kvs); } + static void logDebug(const String &m, std::initializer_list> kvs) { log("DEBUG", m, kvs); } + static void logInfo (const String &m, std::initializer_list> kvs) { log("INFO", m, kvs); } + 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: + static void buildAndSend(const String& severity, const String& message, + const std::map& labels) + { + // Build OTLP/HTTP logs payload (ArduinoJson v7) JsonDocument doc; - // Top-level resourceLogs array - JsonObject resourceLog = doc["resourceLogs"].add(); + JsonArray resourceLogs = doc["resourceLogs"].to(); + JsonObject rl = resourceLogs.add(); + + // Resource (with attributes to ensure service.name lands) + JsonObject resource = rl["resource"].to(); + JsonArray rattrs = resource["attributes"].to(); + addResAttr(rattrs, "service.name", defaultServiceName()); + addResAttr(rattrs, "service.instance.id", defaultServiceInstanceId()); + addResAttr(rattrs, "host.name", defaultHostName()); - // Populate resource attributes from the singleton you set in begin() - JsonObject resource = resourceLog["resource"].to(); - defaultResource().addResourceAttributes(resource); + // Scope + JsonObject sl = rl["scopeLogs"].to().add(); + JsonObject scope = sl["scope"].to(); + scope["name"] = logScopeConfig().scopeName; + if (logScopeConfig().scopeVersion.length()) + scope["version"] = logScopeConfig().scopeVersion; - // instrumentation scope - JsonObject scopeLog = resourceLog["scopeLogs"].add(); - JsonObject scope = scopeLog["scope"].to(); - scope["name"] = "otel-embedded"; - scope["version"] = "0.1.0"; + // Log record + JsonObject lr = sl["logRecords"].to().add(); + lr["timeUnixNano"] = u64ToStr(nowUnixNano()); + lr["severityNumber"] = severityNumberFromText(severity); + lr["severityText"] = severity; - // the log record itself - JsonObject logEntry = scopeLog["logRecords"].add(); - logEntry["timeUnixNano"] = nowUnixNano(); - JsonObject body = logEntry["body"].to(); - body["stringValue"] = message; - logEntry["severityText"] = severity; + // Body + JsonObject body = lr["body"].to(); + body["stringValue"] = message; - // send to OTLP HTTP exporter + // Correlate to active span if present + auto &ctx = currentTraceContext(); + if (ctx.valid()) { + lr["traceId"] = ctx.traceId; + lr["spanId"] = ctx.spanId; + // (optional) lr["flags"] = 1; + } + + // Attributes (merge defaults first, then per-call to allow override) + JsonArray lattrs = lr["attributes"].to(); + + for (const auto& kv : defaultLabels()) { + JsonObject a = lattrs.add(); + a["key"] = kv.first; + a["value"].to()["stringValue"] = kv.second; + } + for (const auto& kv : labels) { + JsonObject a = lattrs.add(); + a["key"] = kv.first; + a["value"].to()["stringValue"] = kv.second; + } + + // Send OTelSender::sendJson("/v1/logs", doc); } - - static void logInfo(const char* msg) { log("INFO", String(msg)); } - static void logInfo(const String& msg) { log("INFO", msg); } }; } // namespace OTel diff --git a/include/OtelMetrics.h b/include/OtelMetrics.h index 12ef111..10b3e62 100644 --- a/include/OtelMetrics.h +++ b/include/OtelMetrics.h @@ -1,222 +1,100 @@ +// OtelMetrics.h #ifndef OTEL_METRICS_H #define OTEL_METRICS_H +#include +#include +#include #include -#include "OtelDefaults.h" -#include "OtelSender.h" +#include "OtelDefaults.h" // expects: nowUnixNano() +#include "OtelSender.h" // expects: OTelSender::sendJson(path, doc) +#include "OtelTracer.h" // reuses: u64ToStr(), defaultServiceName(), defaultServiceInstanceId(), defaultHostName(), addResAttr() namespace OTel { - // — a single, shared ResourceConfig for all metrics — - static inline OTelResourceConfig& defaultMetricResource() { - static OTelResourceConfig rc; - return rc; // <-- returns a reference, not a temporary - } - - struct Metrics { - /// Call once in setup() to stamp service attributes on all metrics - static void begin(const String& serviceName, - const String& serviceNamespace, - const String& instanceId, - const String& version = "") { - auto& rc = defaultMetricResource(); - rc.setAttribute("service.name", serviceName); - rc.setAttribute("service.namespace", serviceNamespace); - rc.setAttribute("service.instance.id", instanceId); - if (!version.isEmpty()) { - rc.setAttribute("service.version", version); - } - } - }; - -class OTelMetricBase { -protected: - String name; - String unit; - OTelResourceConfig& config = OTel::defaultMetricResource(); - //OTelResourceConfig config; - -public: - // ctor must initialize config from the ref‑returning function - OTelMetricBase(const String& metricName, - const String& metricUnit) - : name(metricName), - unit(metricUnit), - config(OTel::defaultMetricResource()) // <-- now an lvalue reference - {} +// ---- Instrumentation scope for metrics -------------------------------------- +struct MetricsScopeConfig { + String scopeName{"otel-embedded"}; + String scopeVersion{"0.1.0"}; }; -#include -#include "OtelDefaults.h" // provides OTelResourceConfig -#include "OtelSender.h" // provides OTelSender::sendJson() +static inline MetricsScopeConfig& metricsScopeConfig() { + static MetricsScopeConfig cfg; + return cfg; +} -class OTelGauge : public OTelMetricBase { -public: - using OTelMetricBase::OTelMetricBase; - - void set(float value) { - // elastic, heap‑backed document—no need to pick static vs dynamic. - JsonDocument doc; - - // ── resourceMetrics → [ { … } ] - JsonArray resourceMetrics = doc["resourceMetrics"].to(); - JsonObject resourceMetric = resourceMetrics.add(); - - // └─ resource - JsonObject resource = resourceMetric["resource"].to(); - config.addResourceAttributes(resource); - - // ── scopeMetrics → [ { … } ] - JsonArray scopeMetrics = resourceMetric["scopeMetrics"].to(); - JsonObject scopeMetric = scopeMetrics.add(); - - // └─ scope - JsonObject scope = scopeMetric["scope"].to(); - scope["name"] = "otel-embedded"; - scope["version"] = "0.1.0"; - - // └─ metrics → [ { … } ] - JsonArray metrics = scopeMetric["metrics"].to(); - JsonObject metric = metrics.add(); - metric["name"] = name; - metric["unit"] = unit; - metric["type"] = "gauge"; - - // └─ gauge.dataPoints → [ { … } ] - JsonObject gauge = metric["gauge"].to(); - JsonArray dataPoints = gauge["dataPoints"].to(); - JsonObject dp = dataPoints.add(); - - // ├─ attach the same resource labels on each point - config.addResourceAttributes(dp); - - // ├─ timestamp & value - dp["timeUnixNano"] = nowUnixNano(); - dp["asDouble"] = value; - - // send it off - OTelSender::sendJson("/v1/metrics", doc); - } -}; - - -class OTelCounter : public OTelMetricBase { -private: - float count = 0; +// ---- Default metric labels (merged into each datapoint's attributes) -------- +static inline std::map& defaultMetricLabels() { + static std::map labels; + return labels; +} +class Metrics { public: - using OTelMetricBase::OTelMetricBase; - - void inc(float value = 1) { - count += value; - - JsonDocument doc; - - JsonObject resourceMetric = doc["resourceMetrics"].add(); - JsonObject resource = resourceMetric["resource"].to(); - config.addResourceAttributes(resource); - - JsonObject scopeMetric = resourceMetric["scopeMetrics"].add(); - JsonObject scope = scopeMetric["scope"].to(); - scope["name"] = "otel-embedded"; - scope["version"] = "0.1.0"; + // Configure the instrumentation scope name/version for metrics + static void begin(const String& scopeName, const String& scopeVersion) { + metricsScopeConfig().scopeName = scopeName; + metricsScopeConfig().scopeVersion = scopeVersion; + } - JsonObject metric = scopeMetric["metrics"].add(); - metric["name"] = name; - metric["unit"] = unit; - metric["type"] = "sum"; + // Set/merge defaults applied to *every* datapoint + static void setDefaultMetricLabels(const std::map& labels) { + defaultMetricLabels() = labels; + } + static void setDefaultMetricLabel(const String& key, const String& value) { + defaultMetricLabels()[key] = value; + } - JsonObject sum = metric["sum"].to(); - sum["isMonotonic"] = true; - sum["aggregationTemporality"] = 2; + // --------- GAUGE (double) ---------- + // Convenience with std::map + static void gauge(const String& name, double value, + const String& unit = "1", + const std::map& labels = {}) { + buildAndSendGauge(name, value, unit, labels); + } - JsonObject dp = sum["dataPoints"].add(); - config.addResourceAttributes(dp); - dp["timeUnixNano"] = nowUnixNano(); - dp["asDouble"] = count; + // Convenience with initializer_list + static void gauge(const String& name, double value, + const String& unit, + std::initializer_list> kvs) { + std::map labels; + for (auto &kv : kvs) labels[String(kv.first)] = String(kv.second); + buildAndSendGauge(name, value, unit, labels); + } - OTelSender::sendJson("/v1/metrics", doc); + // --------- SUM (double) ------------- + // OTLP requires temporality + monotonic flags for Sum + static void sum(const String& name, double value, + bool isMonotonic = false, + const String& temporality = "DELTA", // or "CUMULATIVE" + const String& unit = "1", + const std::map& labels = {}) { + buildAndSendSum(name, value, isMonotonic, temporality, unit, labels); } -}; -class OTelHistogram : public OTelMetricBase { -public: - using OTelMetricBase::OTelMetricBase; - - void record(double value) { - // 1) Build the top‐level JSON document - JsonDocument doc; - - // ── resourceMetrics → [ { … } ] - JsonArray resourceMetrics = doc["resourceMetrics"].to(); - JsonObject resourceMetric = resourceMetrics.add(); - - // └─ resource - JsonObject resource = resourceMetric["resource"].to(); - config.addResourceAttributes(resource); - - // ── scopeMetrics → [ { … } ] - JsonArray scopeMetrics = resourceMetric["scopeMetrics"].to(); - JsonObject scopeMetric = scopeMetrics.add(); - - // └─ scope - JsonObject scope = scopeMetric["scope"].to(); - scope["name"] = "otel-embedded"; - scope["version"] = "0.1.0"; - - // └─ metrics → [ { … } ] - JsonArray metrics = scopeMetric["metrics"].to(); - JsonObject metric = metrics.add(); - metric["name"] = name; - metric["unit"] = unit; - metric["type"] = "histogram"; - - // └─ histogram - JsonObject histogram = metric["histogram"].to(); - histogram["aggregationTemporality"] = 2; // DELTA = 2 - - // └─ dataPoints → [ { … } ] - JsonArray dataPoints = histogram["dataPoints"].to(); - JsonObject dp = dataPoints.add(); - - // ├─ shared resource labels - config.addResourceAttributes(dp); - - // ├─ timestamp, count, sum - dp["timeUnixNano"] = nowUnixNano(); - dp["count"] = 1; - dp["sum"] = value; - - // ├─ explicitBounds (define your buckets here) - static const double explicitBounds[] = {100.0, 200.0, 500.0, 1000.0}; - static const size_t B = sizeof(explicitBounds) / sizeof(*explicitBounds); - JsonArray boundsArr = dp["explicitBounds"].to(); - for (size_t i = 0; i < B; ++i) { - boundsArr.add(explicitBounds[i]); - } - - // └─ bucketCounts (one slot per boundary + underflow/overflow) - JsonArray countsArr = dp["bucketCounts"].to(); - // find which bucket this value falls into - size_t bucketIdx = B; // overflow by default - for (size_t i = 0; i < B; ++i) { - if (value <= explicitBounds[i]) { - bucketIdx = i; - break; - } - } - // emit counts: exactly one '1' in the right bucket, zero elsewhere - for (size_t i = 0; i <= B; ++i) { - countsArr.add(i == bucketIdx ? 1 : 0); - } - - // 2) Send it - OTelSender::sendJson("/v1/metrics", doc); + static void sum(const String& name, double value, + bool isMonotonic, + const String& temporality, + const String& unit, + std::initializer_list> kvs) { + std::map labels; + for (auto &kv : kvs) labels[String(kv.first)] = String(kv.second); + buildAndSendSum(name, value, isMonotonic, temporality, unit, labels); } -}; +private: + static void buildAndSendGauge(const String& name, double value, + const String& unit, + const std::map& labels); + + static void buildAndSendSum(const String& name, double value, + bool isMonotonic, + const String& temporality, + const String& unit, + const std::map& labels); +}; } // namespace OTel -#endif +#endif // OTEL_METRICS_H diff --git a/include/OtelTracer.h b/include/OtelTracer.h index adfc39a..6678e5e 100644 --- a/include/OtelTracer.h +++ b/include/OtelTracer.h @@ -1,93 +1,399 @@ +// OtelTracer.h #ifndef OTEL_TRACER_H #define OTEL_TRACER_H +#include #include -#include "OtelDefaults.h" // provides OTelResourceConfig -#include "OtelSender.h" // provides OTelSender::sendJson() +#include +#include "OtelDefaults.h" // expects: nowUnixNano() +#include "OtelSender.h" // expects: OTelSender::sendJson(const char* path, const JsonDocument&) namespace OTel { + +// ---- Active Trace Context --------------------------------------------------- +struct TraceContext { + String traceId; // 32 hex chars + String spanId; // 16 hex chars + bool valid() const { return traceId.length() == 32 && spanId.length() == 16; } +}; -//––– a single, shared ResourceConfig for the whole process ––– -static inline OTelResourceConfig& defaultTraceResource() { - static OTelResourceConfig rc; - return rc; +static inline TraceContext& currentTraceContext() { + static TraceContext ctx; + return ctx; } -/// A single span instance. -struct Span { - String name; - String traceId; - String spanId; - unsigned long startMs; +// --- New: Context Propagation (extract + scope) ------------------------------ +struct ExtractedContext { + TraceContext ctx; + String tracestate; // optional; unused for now but kept for future injection + bool sampled = true; // from flags; default true if unknown + bool valid() const { return ctx.valid(); } +}; - Span(const String& n, const String& tId, const String& sId) - : name(n), traceId(tId), spanId(sId), startMs(millis()) {} +// Simple key/value view for header-like maps (HTTP headers, MQTT user props) +struct KeyValuePairs { + // Provide a lambda to look up case-insensitive keys. Returns empty String if missing. + std::function get; +}; - void end() { +// W3C "traceparent": 00-<32 hex traceId>-<16 hex parentId>-<2 hex flags> +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 '-' + int p1 = tp.indexOf('-'); if (p1 < 0) return false; + int p2 = tp.indexOf('-', p1+1); if (p2 < 0) return false; + int p3 = tp.indexOf('-', p2+1); if (p3 < 0) return false; + + String ver = tp.substring(0, p1); + String tid = tp.substring(p1+1, p2); + String psid = tp.substring(p2+1, p3); + String flg = tp.substring(p3+1); + + // Validate lengths per spec + if (tid.length() != 32 || psid.length() != 16 || flg.length() != 2) return false; + + out.ctx.traceId = tid; + out.ctx.spanId = psid; + // Flags: bit 0 = sampled + out.sampled = (strtoul(flg.c_str(), nullptr, 16) & 0x01) == 0x01; + return out.valid(); +} + +// B3 single header: b3 = traceId-spanId-sampled +static inline bool parseB3Single(const String& b3, ExtractedContext& out) { + // Minimal split (traceId-spanId-[sampling?]) + int p1 = b3.indexOf('-'); if (p1 < 0) return false; + int p2 = b3.indexOf('-', p1+1); + + String tid = (p1 > 0) ? b3.substring(0, p1) : ""; + String sid = (p2 > 0) ? b3.substring(p1+1, p2) : b3.substring(p1+1); + String smp = (p2 > 0) ? b3.substring(p2+1) : ""; + + if (tid.length() != 32 || sid.length() != 16) return false; + out.ctx.traceId = tid; + out.ctx.spanId = sid; + out.sampled = (smp == "1" || smp == "d"); + return out.valid(); +} + +struct Propagators { + // 1) Extract from header-like key/values (HTTP headers, MQTT v5 user props) + static ExtractedContext extract(const KeyValuePairs& kv) { + ExtractedContext out; + + // Prefer W3C traceparent + if (kv.get) { + String tp = kv.get("traceparent"); + if (tp.length() == 0) tp = kv.get("Traceparent"); // some stacks capitalise + if (tp.length() && parseTraceparent(tp, out)) { + String ts = kv.get("tracestate"); if (ts.length() == 0) ts = kv.get("Tracestate"); + out.tracestate = ts; + return out; + } + + // Fallback: B3 single + String b3 = kv.get("b3"); if (b3.length() == 0) b3 = kv.get("B3"); + if (b3.length() && parseB3Single(b3, out)) return out; + } + return out; // invalid + } + + // 2) Extract directly from JSON payload + 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); + if (err) return out; - // resourceSpans → [ { … } ] - JsonArray rsArr = doc["resourceSpans"].to(); - JsonObject rs = rsArr.add(); - - // attach the shared resource attributes - JsonObject resource = rs["resource"].to(); - defaultTraceResource().addResourceAttributes(resource); - - // scopeSpans → [ { … } ] - JsonArray ssArr = rs["scopeSpans"].to(); - JsonObject ss = ssArr.add(); - - // scope metadata - JsonObject scope = ss["scope"].to(); - scope["name"] = "otel-embedded"; - scope["version"] = "0.1.0"; - - // spans → [ { … } ] - JsonArray spans = ss["spans"].to(); - JsonObject sp = spans.add(); - sp["traceId"] = traceId; - sp["spanId"] = spanId; - sp["name"] = name; - sp["startTimeUnixNano"] = nowUnixNano(); - sp["endTimeUnixNano"] = nowUnixNano(); - - // send it off - OTelSender::sendJson("/v1/traces", doc); + if (doc["traceparent"].is()) { + out = ExtractedContext{}; + String tp = doc["traceparent"].as(); + parseTraceparent(tp, out); + if (doc["tracestate"].is()) { + out.tracestate = doc["tracestate"].as(); + } + return out; +} + +if (doc["trace_id"].is() && doc["span_id"].is()) { + out = ExtractedContext{}; + String tid = doc["trace_id"].as(); + String sid = doc["span_id"].as(); + // (retain whatever validation / assignment you had here) + // Example: + out.ctx.traceId = tid; + out.ctx.spanId = sid; + if (doc["trace_flags"].is()) { + out.sampled = (String(doc["trace_flags"].as()).indexOf("01") >= 0); + } else if (doc["trace_flags"].is()) { + out.sampled = (doc["trace_flags"].as() & 0x01) != 0; } + return out; +} + +if (doc["b3"].is()) { + out = ExtractedContext{}; + String b3 = doc["b3"].as(); + parseB3Single(b3, out); + return out; +} + + 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) { + const auto& ctx = OTel::currentTraceContext(); + + // Only inject if we actually have a valid active context + if (ctx.traceId.length() != 32 || ctx.spanId.length() != 16) { + return; // no active span/context; skip injection rather than invent IDs + } + + char tpbuf[64]; + // "00-" + 32 + "-" + 16 + "-" + 2 = 55 chars + snprintf(tpbuf, sizeof(tpbuf), "00-%s-%s-%02x", + ctx.traceId.c_str(), + ctx.spanId.c_str(), + static_cast(flags)); + + set("traceparent", tpbuf); + + // NOTE: your TraceContext doesn't have tracestate; omit it to avoid compile errors. + // 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([&](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(add, flags); +} + + }; -/// Simple tracer: stamp service‐level attributes once, then start/end spans -class Tracer { +// RAII helper: temporarily install a remote parent context as the active one +class RemoteParentScope { +public: + RemoteParentScope(const TraceContext& incoming) { + // Save current + prev_ = currentTraceContext(); + // Install incoming (only if valid; otherwise leave as-is) + if (incoming.valid()) { + currentTraceContext().traceId = incoming.traceId; + currentTraceContext().spanId = incoming.spanId; + installed_ = true; + } + } + ~RemoteParentScope() { + if (installed_) { + currentTraceContext() = prev_; + } + } +private: + TraceContext prev_; + bool installed_ = false; +}; + + + +// ---- Utilities -------------------------------------------------------------- +static inline String u64ToStr(uint64_t v) { + // Avoid ambiguous String(uint64_t) on some cores + char buf[32]; + char *p = buf + sizeof(buf); + *--p = '\0'; + if (v == 0) { *--p = '0'; } + while (v > 0) { + *--p = char('0' + (v % 10)); + v /= 10; + } + return String(p); +} + +static inline String randomHex(size_t len) { + static const char* hexDigits = "0123456789abcdef"; + String out; + out.reserve(len); + for (size_t i = 0; i < len; ++i) out += hexDigits[random(0, 16)]; + return out; +} + +// Best-effort chip id (used for defaults) +static inline String chipIdHex() { +#if defined(ESP8266) + uint32_t id = ESP.getChipId(); + char b[9]; snprintf(b, sizeof(b), "%06x", id); + return String(b); +#elif defined(ESP32) + uint64_t id = ESP.getEfuseMac(); + char b[17]; snprintf(b, sizeof(b), "%012llx", static_cast(id)); + return String(b); +#else + return String("000000"); +#endif +} + +// Defaults for resource fields (compile-time overrides win) +static inline String defaultServiceName() { +#ifdef OTEL_SERVICE_NAME + return String(OTEL_SERVICE_NAME); +#else + return String("embedded-service"); +#endif +} +static inline String defaultServiceInstanceId() { +#ifdef OTEL_SERVICE_INSTANCE_ID + return String(OTEL_SERVICE_INSTANCE_ID); +#else + return chipIdHex(); +#endif +} +static inline String defaultHostName() { +#ifdef OTEL_HOST_NAME + return String(OTEL_HOST_NAME); +#else + return String("ESP-") + chipIdHex(); +#endif +} + +// Add one string attribute 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 --------------------------------------------------- +struct TracerConfig { + String scopeName{"otel-embedded"}; + String scopeVersion{"0.1.0"}; +}; + +static inline TracerConfig& tracerConfig() { + static TracerConfig cfg; + return cfg; +} + +// ---- Span ------------------------------------------------------------------- +class Span { public: - /// Call once in setup() to set service.name, host.name, version - static void begin(const String& serviceName, - const String& serviceNamespace, - const String& host, - const String& version = "") { - auto& rc = defaultTraceResource(); - rc.setAttribute("service.name", serviceName); - rc.setAttribute("service.namespace", serviceNamespace); - rc.setAttribute("host.name", host); - if (!version.isEmpty()) { - rc.setAttribute("service.version", version); + explicit Span(const String& name) + : name_(name), + traceId_(currentTraceContext().valid() ? currentTraceContext().traceId : randomHex(32)), + spanId_(randomHex(16)), + startNs_(nowUnixNano()) + { + // Save previous context and install this span's ids + prevTraceId_ = currentTraceContext().traceId; + prevSpanId_ = currentTraceContext().spanId; + currentTraceContext().traceId = traceId_; + currentTraceContext().spanId = spanId_; + } + + // RAII: if user forgets to call end(), do it at scope exit. + ~Span() { + if (!ended_) { + end(); } } - /// Start a new Span. You must .end() it. - static Span startSpan(const char* spanName) { - return Span(String(spanName), randomHex(32), randomHex(16)); + // You can still call this manually; it's safe to call more than once. + void end() { + if (ended_) return; // idempotent guard + ended_ = true; + + const uint64_t endNs = nowUnixNano(); + + // Build minimal OTLP/HTTP JSON payload for a single span + JsonDocument doc; + + // resourceSpans[0].resource.attributes[...] + JsonArray rattrs = doc["resourceSpans"][0]["resource"]["attributes"].to(); + addResAttr(rattrs, "service.name", defaultServiceName()); + addResAttr(rattrs, "service.instance.id", defaultServiceInstanceId()); + addResAttr(rattrs, "host.name", defaultHostName()); + + // instrumentation scope + JsonObject scope = doc["resourceSpans"][0]["scopeSpans"][0]["scope"].to(); + scope["name"] = tracerConfig().scopeName; + scope["version"] = tracerConfig().scopeVersion; + + // span body + JsonObject s = doc["resourceSpans"][0]["scopeSpans"][0]["spans"][0].to(); + s["traceId"] = traceId_; + s["spanId"] = spanId_; + s["name"] = name_; + s["kind"] = 2; // SERVER by default; adjust if you have a setter + s["startTimeUnixNano"] = u64ToStr(startNs_); + s["endTimeUnixNano"] = u64ToStr(endNs); + + // If we have a parent, set it correctly + if (prevSpanId_.length() == 16) { + s["parentSpanId"] = prevSpanId_; + } + + // Send + OTelSender::sendJson("/v1/traces", doc); + + // Restore previous active context + currentTraceContext().traceId = prevTraceId_; + currentTraceContext().spanId = prevSpanId_; } + // Optional helpers (if you have them already, keep yours) + const String& traceId() const { return traceId_; } + const String& spanId() const { return spanId_; } + private: - static String randomHex(size_t len) { - static const char* hexDigits = "0123456789abcdef"; - String out; - out.reserve(len); - for (size_t i = 0; i < len; ++i) { - out += hexDigits[random(0, 16)]; - } - return out; + // Utility to add a resource attribute + static inline void addResAttr(JsonArray& arr, const char* key, const String& val) { + JsonObject a = arr.add(); + a["key"] = key; + a["value"]["stringValue"] = val; + } + + static inline String u64ToStr(uint64_t v) { + // Avoid ambiguous Arduino String(uint64_t) by formatting manually + char buf[32]; + snprintf(buf, sizeof(buf), "%llu", static_cast(v)); + return String(buf); + } + +private: + String name_; + String traceId_; + String spanId_; + uint64_t startNs_; + + // Previous active context (for parent linkage and restoration) + String prevTraceId_; + String prevSpanId_; + + // RAII guard + bool ended_ = false; +}; + +// ---- Tracer facade ---------------------------------------------------------- +class Tracer { +public: + static void begin(const String& scopeName, const String& scopeVersion) { + tracerConfig().scopeName = scopeName; + tracerConfig().scopeVersion = scopeVersion; + } + + static Span startSpan(const String& name) { + return Span(name); } }; diff --git a/platformio.ini b/platformio.ini index fc2b550..30af1dd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,6 +1,5 @@ [platformio] default_envs = esp32dev, rpipicow, esp8266 -lib_extra_dirs = include [env:esp32dev] platform = espressif32 @@ -14,8 +13,8 @@ lib_deps = HTTPClient build_flags = - -DOTEL_WIFI_SSID=\"${sysenv.OTEL_WIFI_SSID}\" - -DOTEL_WIFI_PASS=\"${sysenv.OTEL_WIFI_PASS}\" + -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" + -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" -DOTEL_COLLECTOR_HOST=\"${sysenv.OTEL_COLLECTOR_HOST}\" -DOTEL_COLLECTOR_PORT=${sysenv.OTEL_COLLECTOR_PORT} -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" @@ -27,7 +26,6 @@ build_flags = [env:rpipicow] ; Community Raspberry Pi Pico W platform and board -env_description = Raspberry Pi Pico W (RP2040) platform = https://github.com/maxgerhardt/platform-raspberrypi.git board = rpipicow framework = arduino @@ -37,8 +35,8 @@ lib_deps = bblanchon/ArduinoJson@^7.0.0 build_flags = - -DOTEL_WIFI_SSID=\"${sysenv.OTEL_WIFI_SSID}\" - -DOTEL_WIFI_PASS=\"${sysenv.OTEL_WIFI_PASS}\" + -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" + -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" -DOTEL_COLLECTOR_HOST=\"${sysenv.OTEL_COLLECTOR_HOST}\" -DOTEL_COLLECTOR_PORT=${sysenv.OTEL_COLLECTOR_PORT} -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" @@ -46,6 +44,7 @@ build_flags = -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 @@ -58,8 +57,8 @@ lib_deps = bblanchon/ArduinoJson@^7.0.0 build_flags = - -DOTEL_WIFI_SSID=\"${sysenv.OTEL_WIFI_SSID}\" - -DOTEL_WIFI_PASS=\"${sysenv.OTEL_WIFI_PASS}\" + -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" + -DWIFI_PASS=\"${sysenv.WIFI_PASS}\" -DOTEL_COLLECTOR_HOST=\"${sysenv.OTEL_COLLECTOR_HOST}\" -DOTEL_COLLECTOR_PORT=${sysenv.OTEL_COLLECTOR_PORT} -DOTEL_SERVICE_NAME=\"${sysenv.OTEL_SERVICE_NAME}\" diff --git a/src/OtelMetrics.cpp b/src/OtelMetrics.cpp index 6068d12..36fcbc1 100644 --- a/src/OtelMetrics.cpp +++ b/src/OtelMetrics.cpp @@ -1,7 +1,118 @@ -#include "OtelDebug.h" - #include "OtelMetrics.h" -// Again, all functionality is inlined in header -// If needed, move `set()` or `inc()` bodies here +namespace OTel { + +// Helper: merge default + per-call labels into a datapoint attributes array +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; + a["value"].to()["stringValue"] = kv.second; + } +} + +static void addCommonResource(JsonObject& resource) { + JsonArray rattrs = resource["attributes"].to(); + addResAttr(rattrs, "service.name", defaultServiceName()); + addResAttr(rattrs, "service.instance.id", defaultServiceInstanceId()); + addResAttr(rattrs, "host.name", defaultHostName()); +} + +static void addCommonScope(JsonObject& scope) { + scope["name"] = metricsScopeConfig().scopeName; + scope["version"] = metricsScopeConfig().scopeVersion; +} + +// ----------------- GAUGE ----------------- +void Metrics::buildAndSendGauge(const String& name, double value, + const String& unit, + const std::map& labels) +{ + JsonDocument doc; + + JsonArray resourceMetrics = doc["resourceMetrics"].to(); + JsonObject rm = resourceMetrics.add(); + + // resource with attributes (service.name, etc.) + JsonObject resource = rm["resource"].to(); + addCommonResource(resource); + + // scope + JsonObject sm = rm["scopeMetrics"].to().add(); + JsonObject scope = sm["scope"].to(); + addCommonScope(scope); + + // metric + JsonArray metrics = sm["metrics"].to(); + JsonObject metric = metrics.add(); + metric["name"] = name; + metric["unit"] = unit; + metric["type"] = "gauge"; + + JsonObject gauge = metric["gauge"].to(); + JsonArray dps = gauge["dataPoints"].to(); + JsonObject dp = dps.add(); + + dp["timeUnixNano"] = u64ToStr(nowUnixNano()); + dp["asDouble"] = value; + + JsonArray attrs = dp["attributes"].to(); + addPointAttributes(attrs, labels); + + OTelSender::sendJson("/v1/metrics", doc); +} + +// ----------------- SUM ------------------- +void Metrics::buildAndSendSum(const String& name, double value, + bool isMonotonic, + const String& temporality, + const String& unit, + const std::map& labels) +{ + JsonDocument doc; + + JsonArray resourceMetrics = doc["resourceMetrics"].to(); + JsonObject rm = resourceMetrics.add(); + + // resource with attributes + JsonObject resource = rm["resource"].to(); + addCommonResource(resource); + + // scope + JsonObject sm = rm["scopeMetrics"].to().add(); + JsonObject scope = sm["scope"].to(); + addCommonScope(scope); + + // metric + JsonArray metrics = sm["metrics"].to(); + JsonObject metric = metrics.add(); + metric["name"] = name; + metric["unit"] = unit; + metric["type"] = "sum"; + + JsonObject sum = metric["sum"].to(); + sum["isMonotonic"] = isMonotonic; + sum["aggregationTemporality"] = temporality; // "DELTA" or "CUMULATIVE" + + JsonArray dps = sum["dataPoints"].to(); + JsonObject dp = dps.add(); + + dp["timeUnixNano"] = u64ToStr(nowUnixNano()); + dp["asDouble"] = value; + + JsonArray attrs = dp["attributes"].to(); + addPointAttributes(attrs, labels); + + OTelSender::sendJson("/v1/metrics", doc); +} + +} // namespace OTel diff --git a/src/OtelSender.cpp b/src/OtelSender.cpp index dd4ece7..0a9ddd5 100644 --- a/src/OtelSender.cpp +++ b/src/OtelSender.cpp @@ -16,35 +16,67 @@ namespace OTel { +static String baseUrl() { + String base = String(OTEL_COLLECTOR_HOST); // may be "http://192.168.8.10" or "192.168.8.10:4318" etc. + + // Ensure there is a scheme + if (!(base.startsWith("http://") || base.startsWith("https://"))) { + base = String("http://") + base; + } + + // Ensure there is a port (check for ":" after the scheme) + int scheme_end = base.indexOf("://") + 3; + int colon_after_scheme = base.indexOf(':', scheme_end); + int slash_after_scheme = base.indexOf('/', scheme_end); + if (colon_after_scheme == -1 || (slash_after_scheme != -1 && colon_after_scheme > slash_after_scheme)) { + // No explicit port present; append compile-time port + base += ":" + String(OTEL_COLLECTOR_PORT); + } + + // Strip any trailing slash before we append path + if (base.endsWith("/")) base.remove(base.length() - 1); + return base; +} + +static String fullUrl(const char* path) { + String p = path ? String(path) : String(); + if (!p.startsWith("/")) p = "/" + p; + return baseUrl() + p; +} + void OTelSender::sendJson(const char* path, JsonDocument& doc) { if (doc.overflowed()){ DBG_PRINTLN("Document Overflowed"); return; } + + String payload; serializeJson(doc, payload); -// DBG_PRINT("Sending to "); -// DBG_PRINT(OTEL_COLLECTOR_HOST); -// DBG_PRINT(":"); -// DBG_PRINT(OTEL_COLLECTOR_PORT); -// DBG_PRINTLN(path); -// DBG_PRINTLN(payload); + String url = fullUrl(path); +DBG_PRINT("HTTP begin URL: >"); DBG_PRINT(url); DBG_PRINTLN("<"); + +#ifdef ESP8266 + WiFiClient *clientPtr = nullptr; + WiFiClient client; // or WiFiClientSecure if using https + clientPtr = &client; + HTTPClient http; + http.begin(*clientPtr, url); +#else HTTPClient http; - // on ESP8266 the legacy begin(url) is removed, must pass a WiFiClient - #if defined(ESP8266) - WiFiClient client; - http.begin(client, String(OTEL_COLLECTOR_HOST) + path); - #else - http.begin(String(OTEL_COLLECTOR_HOST) + path); - #endif - - DBG_PRINTLN(payload); - - http.addHeader("Content-Type", "application/json"); - http.POST(payload); - http.end(); + http.begin(url); +#endif + +http.addHeader("Content-Type", "application/json"); +DBG_PRINT("Sending Payload: "); +DBG_PRINTLN(payload); +int code = http.POST(payload); +DBG_PRINT("HTTP POST returned: "); DBG_PRINTLN(code); +if (code < 0) { DBG_PRINTLN(http.errorToString(code)); } +http.end(); + } } // namespace OTel diff --git a/src/main.cpp b/src/main.cpp index d2e2b0b..1dacbcf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,93 +1,222 @@ +// main.cpp — ESP32 / ESP8266 / Raspberry Pi Pico W (Arduino-Pico) compatible #include -// —————————————————————————————————————————————————————————— -// Platform‐specific networking includes -// —————————————————————————————————————————————————————————— -#if defined(ESP8266) +#if defined(ESP32) + #include +#elif defined(ESP8266) #include - #include -#elif defined(ARDUINO_ARCH_ESP32) || defined(ARDUINO_ARCH_RP2040) +#elif defined(ARDUINO_ARCH_RP2040) + // Earle Philhower’s Arduino-Pico core exposes a WiFi.h for Pico W #include - #include #else - #error "Unsupported platform: must be ESP8266, ESP32 or RP2040" + #error "This example targets ESP32, ESP8266, or RP2040 (Pico W) with WiFi." #endif -// NTP / time -#include - -// For PRNG seeding -#if defined(ARDUINO_ARCH_ESP32) - #include -#endif +#include +#include +#include +#include -// OTLP library -#include "OtelDebug.h" -#include "OtelDefaults.h" -#include "OtelSender.h" +#include "OtelTracer.h" // Requires and TraceContext defined before ExtractedContext #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" +#include "OtelDebug.h" +#ifdef __has_include + #if __has_include("OtelMetrics.h") + #include "OtelMetrics.h" + #define HAVE_OTEL_METRICS 1 + #endif #endif -#ifndef OTEL_WIFI_PASS -#define OTEL_WIFI_PASS "default" +// ---------- WiFi setup (use build_flags -DWIFI_SSID=\"...\" -DWIFI_PASS=\"...\") ---------- +#ifndef WIFI_SSID + #define WIFI_SSID "WIFI" #endif - -#ifndef OTEL_COLLECTOR_HOST -#define OTEL_COLLECTOR_HOST "http://192.168.1.100:4318" +#ifndef WIFI_PASS + #define WIFI_PASS "WIFI_SSID" #endif #ifndef OTEL_SERVICE_NAME -#define OTEL_SERVICE_NAME "demo_service" + #define OTEL_SERVICE_NAME "embedded-device" #endif -#ifndef OTEL_SERVICE_INSTANCE -#define OTEL_SERVICE_INSTANCE "demo_instance" +#ifndef OTEL_SERVICE_NAMESPACE + #define OTEL_SERVICE_NAMESPACE "demo-service" #endif #ifndef OTEL_SERVICE_VERSION -#define OTEL_SERVICE_VERSION "v1.0.0" + #define OTEL_SERVICE_VERSION "0.1.0" #endif -#ifndef OTEL_SERVICE_NAMESPACE -#define OTEL_SERVICE_NAMESPACE "demo_namespace" -#endif +static WiFiServer server(80); +static volatile uint32_t g_request_count = 0; + +// ---------- Helpers ---------- +static inline String toLowerCopy(const String& s) { + String out = s; + out.toLowerCase(); + return out; +} + +static inline void sendJson(WiFiClient& client, int code, const String& json) { + client.printf("HTTP/1.1 %d\r\n", code); + client.println(F("Content-Type: application/json")); + client.printf("Content-Length: %d\r\n", json.length()); + client.println(F("Connection: close\r\n")); + client.print(json); +} + +static bool readHttpRequest(WiFiClient& client, + String& method, + String& uri, + std::map& headers, + String& body) { + client.setTimeout(5000); + + // 1) Request line + String line = client.readStringUntil('\n'); + if (!line.length()) return false; + line.trim(); // remove \r -#ifndef OTEL_DEPLOY_ENV -#define OTEL_DEPLOY_ENV "dev" + int sp1 = line.indexOf(' '); + int sp2 = sp1 >= 0 ? line.indexOf(' ', sp1 + 1) : -1; + if (sp1 < 0 || sp2 < 0) return false; + method = line.substring(0, sp1); + uri = line.substring(sp1 + 1, sp2); + + // 2) Headers until blank line + int contentLength = 0; + while (true) { + String h = client.readStringUntil('\n'); + if (!h.length()) break; + h.trim(); + if (h.length() == 0) break; // end of headers + + int colon = h.indexOf(':'); + if (colon > 0) { + String key = h.substring(0, colon); + String val = h.substring(colon + 1); + val.trim(); + // store as lower-case key for case-insensitive lookup + headers[toLowerCopy(key)] = val; + } + } + + // 3) Body (if Content-Length present) + auto it = headers.find(F("content-length")); + if (it != headers.end()) { + contentLength = it->second.toInt(); + } + if (contentLength > 0) { + body.reserve(contentLength); + while ((int)body.length() < contentLength && client.connected()) { + int c = client.read(); + if (c < 0) break; + body += (char)c; + } + } else { + body = ""; + } + return true; +} + +// ---------- Request handler ---------- +static void handleRequest(const String& method, + const String& uri, + const std::map& headers, + const String& body, + WiFiClient& client) { + g_request_count++; + +#ifdef HAVE_OTEL_METRICS + // simple metric for visibility + OTel::Metrics::gauge("http.requests.total", (double)g_request_count, "1", { + {"method", method}, + {"uri", uri} + }); #endif -// Delay between heartbeats (ms) -static constexpr uint32_t HEARTBEAT_INTERVAL = 5000; + // Build a KeyValuePairs adapter for header extraction + OTel::KeyValuePairs kv; + kv.get = [&](const String& key) -> String { + String k = toLowerCopy(key); + auto it = headers.find(k); + if (it == headers.end()) return String(); + return it->second; + }; + + // 1) Try to extract from headers + OTel::Logger::logInfo("Extracting content from headers"); + OTel::ExtractedContext ext = OTel::Propagators::extract(kv); + + // 2) If not found, try body (JSON) + if (!ext.valid() && body.length()) { + OTel::Logger::logInfo("Couldn't find context in headers, trying body instead"); + ext = OTel::Propagators::extractFromJson(body); + } + + // Install remote parent for the duration of this handler (no-op if invalid) + { + OTel::RemoteParentScope parent(ext.ctx); + // Server span + auto serverSpan = OTel::Tracer::startSpan("http.request"); + // It’s fine to enrich the span via attributes later when the Tracer class supports it. + // For now, we just demonstrate a nested child-span doing "work". + + { + auto childSpan = OTel::Tracer::startSpan("do_work"); + OTel::Logger::logInfo("Doing some work"); + // Simulate some work + delay(10); + // childSpan ends on scope exit + //childSpan.end(); + } + + // Prepare response + JsonDocument doc; + doc["ok"] = true; + doc["method"] = method; + doc["uri"] = uri; + if (ext.valid()) { + doc["parent"]["trace_id"] = ext.ctx.traceId; + doc["parent"]["span_id"] = ext.ctx.spanId; + doc["parent"]["sampled"] = ext.sampled; + if (ext.tracestate.length()) doc["parent"]["tracestate"] = ext.tracestate; + } else { + doc["parent"] = nullptr; + } + + String out; + serializeJson(doc, out); + sendJson(client, 200, out); + + //serverSpan.end(); + + // serverSpan ends on scope exit + } +} + +// ---------- Arduino setup/loop ---------- void setup() { Serial.begin(115200); + delay(200); - // —————————— - // 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); - while (WiFi.status() != WL_CONNECTED) { - delay(500); - DBG_PRINT('.'); + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PASS); + Serial.printf("Connecting to %s", WIFI_SSID); + for (int i = 0; i < 60 && WiFi.status() != WL_CONNECTED; ++i) { + delay(250); + Serial.print('.'); } - DBG_PRINTLN("\nWi-Fi connected!"); + Serial.println(); + if (WiFi.status() == WL_CONNECTED) { + Serial.print("WiFi connected, IP: "); + Serial.println(WiFi.localIP()); + } else { + Serial.println("WiFi failed to connect. Continuing anyway."); + } // 2) Sync NTP (configTime works on Pico W, ESP32 & ESP8266 in Arduino land) // We're polling until we get something > Jan 1 2020 (1609459200). configTime(0, 0, "pool.ntp.org", "time.nist.gov"); @@ -106,46 +235,58 @@ void setup() { Serial.printf("NTP time: %s", asctime(&tm)); } - // 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 - ); - DBG_PRINTLN("OTLP Logger ready"); + // Set resource attributes once (service/host/instance/etc.) + auto &res = OTel::defaultResource(); + res.set("service.name", "guidance-sytem"); + res.set("service.namespace", OTEL_SERVICE_NAMESPACE); + res.set("service.instance.id", "818b08"); +#if defined(ARDUINO_ARCH_ESP32) || defined(ESP8266) + res.set("host.name", WiFi.getHostname()); +#else + res.set("host.name", "rp2040"); +#endif + OTel::Metrics::begin("otel-embedded", "0.1.0"); + OTel::Metrics::setDefaultMetricLabel("device.role", "webserver"); + // + // Optional: default labels for all log lines from this process + OTel::Logger::setDefaultLabel("device.role", "webserver"); + + OTel::Logger::logInfo("Logger initialised"); + + server.begin(); + Serial.println("HTTP server started on port 80."); } 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)); +#if defined(ARDUINO_ARCH_RP2040) + WiFiClient client = server.accept(); // non-deprecated on Pico W +#else + WiFiClient client = server.available(); +#endif + if (!client) { + delay(1); + return; } - // Start a new trace span called "heartbeat" - auto span = OTel::Tracer::startSpan("heartbeat"); - - // Emit a simple INFO log - OTel::Logger::logInfo("Heartbeat event"); - - // Record a gauge - static OTel::OTelGauge heartbeatGauge("heartbeat.gauge", "1"); - heartbeatGauge.set(1.0f); + String method, uri, body; + std::map headers; + if (!readHttpRequest(client, method, uri, headers, body)) { + OTel::Logger::logError("Unable to read request."); + sendJson(client, 400, F("{\"error\":\"bad_request\"}")); + client.stop(); + return; + } - // End the trace span (this actually sends the trace) - span.end(); + // Route: only "/" for this demo + if (uri == "/") { + OTel::Logger::logInfo("Request received for root handler"); + Serial.println("Accepted Request"); + handleRequest(method, uri, headers, body, client); + } else { + OTel::Logger::logError("Route not found"); + sendJson(client, 404, F("{\"error\":\"not_found\"}")); + } - delay(HEARTBEAT_INTERVAL); + client.stop(); }