1
0
Fork 0
mirror of https://github.com/imjasonh/esp32 synced 2026-07-06 23:52:24 +00:00
esp32/docs/observability.md

349 lines
16 KiB
Markdown
Raw Permalink Normal View History

# Observability — current state
The firmware ships structured `tracing` events to **Google Cloud
Logging** and periodic chip-health snapshots to **Google Cloud
Monitoring**. Both are opt-in per device: without a `[gcp]` block in
`provisioning.toml`, the firmware boots normally and emits to serial
only.
One service account, one private key, one cached access token covers
both APIs (the JWT requests `logging.write` + `monitoring.write`
scopes; the cached bearer is shared between the two sender threads).
## Architecture
```
┌──────────────────── ESP32 ────────────────────┐ ┌──── GCP ─────┐
│ │ │ │
│ tracing event ──┐ │ │ │
│ (any module) ├─▶ ring buffer ──▶ cloud_log │ HTTPS │ Logging │
│ tracing event ──┘ (256 entries) thread │────────▶│ /v2/ │
│ drop-oldest │ │ entries: │
│ │ │ write │
│ │ │ │
│ metrics thread ─ snapshot heap/stack/wifi/... │ HTTPS │ Monitoring │
│ (every N s) build CreateTimeSeries │────────▶│ /v3/.../ │
│ │ │ timeSeries │
│ │ │ │
│ gcp_auth: shared TokenProvider │ HTTPS │ oauth2 │
│ - mints multi-scope JWT │────────▶│ /token │
│ - caches bearer for ~1h │ │ │
│ │ │ │
└────────────────────────────────────────────────┘ └──────────────┘
```
## Modules
| File | What it owns |
|------|--------------|
| `src/gcp_auth.rs` | `TokenProvider` (multi-scope JWT mint + bearer cache), `ShortHttpsLock`, `OtaDownloadGuard`, shared HTTPS / time / base64url helpers, `device_mac()`. |
| `src/cloud_log.rs` | `GcpConfig` NVS load, `LogEntry`, `LogQueue` (bounded ring buffer), `CloudLogLayer` (tracing subscriber), sender thread that POSTs to `logging.googleapis.com`. |
| `src/metrics.rs` | Per-task `TaskHandle_t` registry, snapshot collector (FFI calls into ESP-IDF), sender thread that POSTs to `monitoring.googleapis.com`. |
## Auth
The `TokenProvider` mints a single OAuth2 access token covering both
APIs:
```
JWT claims.scope = "https://www.googleapis.com/auth/logging.write
https://www.googleapis.com/auth/monitoring.write"
```
Standard service-account flow: build header + claims, base64url-encode,
sign with the SA's RSA private key (RS256), POST to
`oauth2.googleapis.com/token`, cache the returned `access_token` for
its `expires_in` minus a 5-minute safety margin.
The cache lives in a `Mutex<Option<CachedToken>>` inside the provider;
`get_or_refresh()` returns the bearer directly on hit, mints under the
lock on miss. cloud_log + metrics share an `Arc<TokenProvider>` so
neither pays for its own RSA sign.
## Cloud Logging
### Capture
`CloudLogLayer` is a `tracing_subscriber::Layer`. Every emitted event:
- Severity mapped: `TRACE`/`DEBUG → DEBUG`, `INFO → INFO`, `WARN →
WARNING`, `ERROR → ERROR`.
- Wall-clock timestamp from `SystemTime::now()` if NTP has synced
(anything before 2020-01-01 is treated as not-yet-synced → entry
goes without a timestamp; Cloud Logging assigns server-side time).
- All structured fields captured into `serde_json::Map`.
- Pushed onto a 256-entry bounded queue. When full, oldest is dropped
and a counter is bumped; the next entry pushed carries
`dropped_before` so readers can spot lossy windows in the cloud.
- The layer's own module path (`esp32_blinky::cloud_log`),
`esp32_blinky::metrics`, and `esp32_blinky::gcp_auth` are
**excluded** from capture — otherwise the senders' own tracing
calls would land on the queue they're draining and create a tight
feedback loop.
The layer composes with `EspLogger`, so events still print to serial
in addition to being queued.
### Sender thread
Background pthread, ~32 KB stack. Wakes every 5 s (or backoff if the
last POST failed):
1. Skip the cycle if `OTA_DOWNLOAD_IN_PROGRESS` is set (see "Heap
budget" below).
2. Drain up to 50 entries from the queue.
3. Acquire `ShortHttpsLock` (serialises against metrics + OTA short
fetches).
4. `auth.get_or_refresh()` — may transparently mint a token.
5. POST to `https://logging.googleapis.com/v2/entries:write`.
6. Drop the lock.
On 4xx/5xx the batch is dropped (rather than re-queued) — long outages
otherwise grow the queue unboundedly. Drop count is surfaced on the
next entry pushed.
### Schema
```json
{
"logName": "projects/<project-id>/logs/esp32-firmware",
"resource": {
"type": "generic_node",
"labels": {
"project_id": "<project-id>",
"location": "global",
"namespace": "esp32",
"node_id": "<chip-mac>"
}
},
"entries": [
{
"severity": "INFO",
"timestamp": "2026-05-02T19:45:00Z",
"jsonPayload": {
"message": "ota: boot summary",
"module": "esp32_blinky::ota",
"fw_version": "abc1234",
"fw": "abc1234",
"repo": "ghcr.io/imjasonh/esp32",
"tag": "latest",
"poll_secs": 60,
"last_digest":"sha256:..."
}
}
]
}
```
Single `logName` for both app + OTA; `jsonPayload.module` lets
queries split by component:
```
jsonPayload.module="esp32_blinky::ota" AND jsonPayload.fw_version="abc1234"
```
## Cloud Monitoring
### Snapshot
Every `metrics_interval_secs` (default 300, NVS-tunable, 0 disables
the thread entirely), `metrics::collect()` reads:
| Metric type (`custom.googleapis.com/esp32/…`) | Source | Unit | Kind |
|-----------------------------------------------|---------------------------------------------------------|------|-------|
| `free_heap` | `esp_get_free_heap_size()` | By | GAUGE |
| `free_heap_internal` | `heap_caps_get_free_size(MALLOC_CAP_INTERNAL)` | By | GAUGE |
| `min_free_heap` | `esp_get_minimum_free_heap_size()` (water-mark / boot) | By | GAUGE |
| `largest_free_block` | `heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)` | By | GAUGE |
| `stack_hwm` *(label `task`)* | `uxTaskGetStackHighWaterMark` per published task | By | GAUGE |
| `wifi_rssi` | `esp_wifi_sta_get_ap_info().rssi` | dBm | GAUGE |
| `wifi_channel` | …`.primary` | 1 | GAUGE |
| `cpu_freq_mhz` | `CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ` | MHz | GAUGE |
| `uptime_secs` | `esp_timer_get_time() / 1_000_000` | s | GAUGE |
| `nvs_used_entries` | `nvs_get_stats().used_entries` | 1 | GAUGE |
| `nvs_free_entries` | …`.free_entries` | 1 | GAUGE |
| `cloud_log_queue_depth` | `LogQueue::stats().0` | 1 | GAUGE |
| `cloud_log_dropped_total` | lifetime drop counter on `LogQueue` | 1 | GAUGE |
`stack_hwm` carries a `task` label (`main` / `ota` / `cloud_log` /
`metrics`) so all four series chart on one graph. Each thread
publishes its own `TaskHandle_t` via
`metrics::publish_self(&handles::<NAME>)` at the top of its run loop;
`metrics::collect()` reads the FFI handle out of an `AtomicUsize` (0 =
unpublished, field omitted from the snapshot).
Note: ESP-IDF Xtensa `StackType_t = uint8_t`, so
`uxTaskGetStackHighWaterMark` returns **bytes** (not "words" as
upstream FreeRTOS docs say). No multiplication.
### Sender thread
Background pthread, ~16 KB stack. Wakes every `metrics_interval_secs`:
1. Skip the cycle if `OTA_DOWNLOAD_IN_PROGRESS`.
2. `collect()` — pure FFI, no allocations beyond the snapshot struct.
3. Acquire `ShortHttpsLock`.
4. `auth.get_or_refresh()`.
5. POST one `CreateTimeSeries` request containing all ~16 series in a
single body to `monitoring.googleapis.com/v3/projects/<id>/timeSeries`.
Cloud Monitoring auto-creates `MetricDescriptor`s on first write;
no separate provisioning step on the GCP side.
### Schema
```json
{
"timeSeries": [
{
"metric": {
"type": "custom.googleapis.com/esp32/free_heap",
"labels": { "fw_version": "abc1234" }
},
"resource": {
"type": "generic_node",
"labels": {
"project_id": "<project-id>",
"location": "global",
"namespace": "esp32",
"node_id": "<chip-mac>"
}
},
"metricKind": "GAUGE",
"valueType": "INT64",
"points": [
{ "interval": { "endTime": "2026-05-02T22:30:00Z" },
"value": { "int64Value": "182456" } }
]
}
]
}
```
`fw_version` rides on every metric as a **metric label** (resource
labels can't carry it — `generic_node` has a fixed schema). Adding
new label keys to an existing `MetricDescriptor` is forbidden by Cloud
Monitoring; if the fleet's running fw doesn't match the descriptor
schema you'll see HTTP 500 on the POST. Migration in that case is
manual:
```bash
TOKEN=$(gcloud auth print-access-token)
URL='https://monitoring.googleapis.com/v3/projects/<id>/metricDescriptors'
for m in $(curl -sG "$URL" -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filter=metric.type=starts_with("custom.googleapis.com/esp32/")' \
| jq -r '.metricDescriptors[].type'); do
curl -s -X DELETE "$URL/$m" -H "Authorization: Bearer $TOKEN"
done
```
This deletes all historical points for those metrics. There's no
in-place schema migration in Cloud Monitoring.
## Heap budget + serialization
Each TLS handshake on this chip allocates ~2535 KB of mbedtls
context. With cloud_log + metrics + OTA each potentially holding a
TLS session, the worst case is three concurrent handshakes pinning
~90 KB — past the heap budget, producing
`MBEDTLS_ERR_SSL_ALLOC_FAILED` (-0x7F00).
Three knobs work together to keep this in budget:
1. **mbedtls config** (`sdkconfig.defaults.in`):
- `CONFIG_MBEDTLS_DYNAMIC_BUFFER` — I/O buffers grow as needed,
freed between handshakes (vs. pinned 16+16 KB).
- `CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA` — frees handshake
config struct after handshake.
- `CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT` — frees the CA bundle
after handshake; re-attached on the next handshake.
- `CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE=n` — drops the parsed
peer cert after handshake (~35 KB/session). We don't reuse
sessions or do mutual TLS.
2. **Short HTTPS serialised** (`gcp_auth::ShortHttpsLock`): cloud_log
POST + metrics POST + OTA's manifest fetch + sig-bundle fetch all
take this `Mutex<()>` at their call sites. Held *at the call site*
(not inside `http_post`) so token mints inside `get_or_refresh()`
are covered by the caller's lock — std `Mutex` isn't reentrant.
OTA's poll_once releases between phases (token+manifest, then
verify which is CPU-only, then sig bundle) so cloud_log/metrics
aren't blocked behind ~2 s of pure-Rust X.509/ECDSA work.
3. **OTA download pause** (`gcp_auth::OTA_DOWNLOAD_IN_PROGRESS`):
`ota::download_and_apply` flips this `AtomicBool` true via an RAII
guard for the duration of the multi-second blob download (which
intentionally **does not** take `ShortHttpsLock` — it'd otherwise
block per-5-s log flushes for tens of seconds). cloud_log + metrics
check the flag at the top of each cycle and skip — but don't
drain — when set. Entries accumulate in the bounded queue and
flush in one batched POST after the download finishes.
Even with all three, peak heap during a 2-way concurrent TLS
(OTA + cloud_log handshake on top of OTA's held-open download) was
historically tight — the gate in #3 is what made downloads reliably
complete.
## Provisioning (NVS)
Optional `[gcp]` block in `provisioning.toml` (see
`provisioning.toml.example`):
```toml
[gcp]
project_id = "..."
sa_email = "esp32-logger@my-project.iam.gserviceaccount.com"
sa_key_id = "abc123..." # `private_key_id` from the SA JSON key
sa_key_pem = "gcp-sa-key.pem" # path; tool reads + embeds the bytes
min_severity = "info" # debug / info / warn / error; default info
metrics_interval_secs = 300 # 0 disables metrics; cloud_log keeps running
```
NVS keys (15-char limit):
| ns | key | type | notes |
|-----|----------------|------|----------------------------------------------------|
| gcp | project_id | str | GCP project (logs + metrics land in) |
| gcp | sa_email | str | service-account email |
| gcp | sa_key_id | str | private-key id (JWT `kid` header) |
| gcp | sa_key_pem | blob | RSA private key, PKCS#8 PEM (~1.7 KB) |
| gcp | min_severity | u8 | 0=TRACE..4=ERROR; default 2=INFO |
| gcp | metric_intvl | u32 | metrics snapshot interval in seconds; default 300 |
Missing the namespace entirely or any of the four required string/blob
fields disables both pipelines. Missing optional fields fall back to
defaults.
The OTA-distributed firmware contains no GCP secrets — the SA key
lives in NVS and is written via USB by `make provision`.
## Concerns / known limitations
- **NVS unencrypted**: anyone with physical access can dump the SA
key. Mitigation is strict scoping (only `logging.logWriter` +
`monitoring.metricWriter`, on a logs-tolerant project). Real fix is
Flash Encryption + Secure Boot v2; deferred (see [`ota.md`](ota.md)
Future work).
- **Wi-Fi outages**: the cloud_log queue is bounded at 256 entries.
Long offline periods are lossy; drops are surfaced via
`dropped_before` on subsequent entries.
- **Metric descriptor sticky labels**: see "Schema" above. Adding a
new metric label key requires a one-time descriptor delete.
- **CPU freq metric is build-time**: `esp_clk_cpu_freq` isn't in the
esp-idf-svc bindings, so we report the configured default. Becomes
inaccurate if/when `CONFIG_PM_ENABLE` is turned on.
## Future work
- **Per-task CPU runtime stats** via `vTaskGetRunTimeStats` — needs
`CONFIG_FREERTOS_USE_TRACE_FACILITY=y` + `CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y`.
- **Light-sleep stats** once `esp_pm_*` is adopted — pairs with
modem-sleep work for power telemetry.
- **External power telemetry** (INA219/INA226 over I2C) — would warrant its own plan.
- **Pre-declared MetricDescriptors** with units + descriptions for nicer Cloud Monitoring UI.
- **Alerting policies** as Terraform / `gcloud alpha monitoring` resources, checked in.
- **Cumulative metrics with `startTime`** for proper rate aggregation
(drops/sec, polls/sec) instead of GAUGE snapshots of running totals.
- **OIDC instead of SA key** — would remove the key-leak concern, but
ESP32 has no source of a usable OIDC token today.