1
0
Fork 0
mirror of https://github.com/imjasonh/esp32 synced 2026-07-08 00:15:18 +00:00
esp32/docs/observability.md
Jason Hall 7e32f6decb docs: reorganise into docs/, fold logs-plan + monitoring-plan into observability.md
- New `docs/observability.md`: descriptive (present-tense) write-up
  of the Cloud Logging + Cloud Monitoring pipelines as currently
  shipped. Replaces the historical `logs-plan.md` + `monitoring-plan.md`
  planning docs.
- New `docs/setup.md`: prerequisites, first flash, day-to-day Make
  targets, and the optional GCP setup (lifted out of the README so the
  README can stay terse). Includes the Python 3.12-shim explanation
  from the old `notes.txt`.
- Move `ota.md` → `docs/ota.md`.
- Move `eink-plan.md` → `docs/eink-plan.md`. Per-feature plans still
  use the `<feature>-plan.md` name; once shipped they get rewritten
  in present tense alongside the other docs.
- Delete `logs-plan.md`, `monitoring-plan.md`, `notes.txt`. Their
  user-facing content is now in `docs/setup.md`; their LLM-relevant
  bits (architectural rationale, partition-table CMake quirk, Python
  shim, no-LED, `make` conventions, NVS key length cap) are in
  `CLAUDE.md`.
- Trim `README.md` to a top-level overview + links into `docs/`.
- Update internal cross-references (Makefile, ota.md, eink-plan.md,
  tools/provision/src/main.rs doc-comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:30:43 -04:00

348 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.