Skip to content

feat(otel): export node metrics via the OpenTelemetry SDK - #5178

Open
skandragon wants to merge 17 commits into
freenet:mainfrom
skandragon:feat/otel-metrics-exporter
Open

feat(otel): export node metrics via the OpenTelemetry SDK#5178
skandragon wants to merge 17 commits into
freenet:mainfrom
skandragon:feat/otel-metrics-exporter

Conversation

@skandragon

@skandragon skandragon commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Problem

Node operators have no way to monitor a node over time — memory, ring health, transport throughput, queue depths — beyond point-in-time snapshots. The existing telemetry pipeline (telemetry-enabled / telemetry-endpoint) is a hand-rolled OTLP-JSON log POST that feeds the project's central dashboard; it isn't a general-purpose metrics exporter and operators shouldn't have to piggyback on it.

Approach

Adds a second, fully independent pipeline built on the OpenTelemetry SDK (tracing/otel.rs), gated by otel-telemetry-enabled / otel-endpoint. The two pipelines share no config, no endpoint, and no fallback — enabling one has no effect on the other, and otel-endpoint never defaults to the dashboard collector.

Key decisions:

  • Standard OTEL env vars win over config (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, etc.); without any, exports go to http://localhost:4318.
  • Observable instruments for existing state (ring, transport, queue gauges read at collection time — nothing added to the hot path); synchronous instruments (Histogram/Counter) held in a OnceLock so they bind to the real provider, recorded via record_* helpers that cost one atomic load when the exporter is off.
  • All histograms are base-2 exponential via a single with_view — no per-instrument bucket boundaries.
  • Collector auth (otel-auth-mode, default disabled): in freenet mode, a per-request Authorization: Bearer freenet/<pubkey>/<audience>/<timestamp>/<signature> — an XEdDSA signature over the token prefix, signed with the node's x25519 transport secret. The collector verifies with stock Ed25519 after Montgomery→Edwards conversion; no shared secret, no second key. <audience> is the target collector's host:port, so a token cannot be replayed at a different collector. Default is disabled (no header) so that pointing the exporter at your own collector never ships a signed identity assertion unasked; an Authorization supplied via OTEL_EXPORTER_OTLP_HEADERS is never overwritten in either mode.
  • No new native build dependency: the exporter always installs its own HttpClient (workspace reqwest 0.12), so none of opentelemetry-otlp's reqwest-*/TLS features are enabled. Net new packages in Cargo.lock: xeddsa + convert_case.
  • No per-connection identifying attributes (series-cost multiplication); the node identifies itself via two resource attributes, freenet.node.pubkey (verifiable against the bearer signature) and freenet.node.fingerprint (dashboard cross-reference). Never a PeerId, which would leak the socket address.

Design doc: docs/design/otel-metrics-exporter.md. Operator docs: docs/otel-metrics.md.

Testing

  • Unit tests covering config parsing/precedence (flat keys, env-var priority, endpoint isolation), provider construction inside a tokio runtime, bearer-token format and XEdDSA sign/verify round-trip, suppression when disabled, and resource-attribute derivation.
  • cargo test --release -p freenet --lib otel (18 passed), config:: (139 passed), transport:: (694 passed) all green locally.
  • Verified end-to-end against a local OTLP collector on :4318.

Note: freenet.process.memory.rss is Linux-only by design; on macOS/Windows the gauge registers but emits no datapoints.

Fixes

Refs #5046 — that issue also asks for log export, which is an explicit non-goal here, so it stays open.

[AI-assisted - Claude]

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Rule review against .claude/rules/. WARNING findings block merge.

@skandragon
skandragon force-pushed the feat/otel-metrics-exporter branch 3 times, most recently from 998d316 to 5e4e06f Compare August 5, 2026 19:53
@sanity

sanity commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Review

Thanks for this. The craft here is high, and a few things in it are genuinely careful in ways that are easy to get wrong: the OnceLock reasoning about instruments binding to the no-op provider, the std::thread::scope hop to build the exporter off the async runtime, dodging the clap SetTrue trap on --otel-telemetry-enabled, and adding new cumulative packet counters rather than observing the period accumulators that take_snapshot zeroes. That last one is exactly the trap the AGENTS.md note warns about, and you avoided it deliberately.

The problems below are mostly about packaging, not correctness. The biggest one has a small fix.

Review ran five independent lenses (supply chain / security, cryptography, code-first correctness, test quality, and architecture), plus direct verification of the load-bearing claims against crate sources.


Blocking

1. The OTel stack is now unconditional, and it pulls a C build dependency into every build.

crates/core/Cargo.toml moves opentelemetry-otlp and opentelemetry_sdk out of the optional trace-ot feature, so every freenet binary compiles them whether or not the operator ever sets otel-telemetry-enabled. Via reqwest-rustls that pulls rustls-platform-verifier, which pulls aws-lc-rs and aws-lc-sys. aws-lc-sys builds AWS-LC from C and assembly through cc + cmake.

Eleven new lockfile packages, ten of them downstream of that chain, plus a second reqwest major (0.13 alongside the workspace's 0.12) and a second TLS root store.

Suggested fix: put the pipeline behind a default-off cargo feature, or switch reqwest-rustls to reqwest-rustls-webpki-roots, which skips the platform verifier and therefore aws-lc entirely. Worth noting the default auth_mode = "freenet" path uses the workspace reqwest 0.12 (which already has TLS) through FreenetAuthClient, so the 0.13 TLS stack is dead weight in the default configuration.

2. The release build path was never exercised, and it is where this is most likely to break.

.github/workflows/cross-compile.yml explicitly skips PRs ("Skip PRs - only build on main and releases to reduce CI costs"). It builds x86_64-unknown-linux-musl and aarch64-unknown-linux-musl (the arm64 job installs only musl-tools musl-dev), a macOS cross-compile, and Windows MSVC. No workflow in the repo installs cmake or nasm.

So aws-lc-sys can pass every PR check and fail on main, on the release path, after merge. docs/design/otel-metrics-exporter.md lines 246-251 identifies this risk and says to verify with a workflow_dispatch run on the branch before merging. Please do that and post the result.

3. Dual rustls crypto provider.

On main, rustls 0.23.40 resolves with ring only. With this PR it carries both ring and aws-lc-rs. In rustls-0.23.40/src/crypto/mod.rs:265-286, from_crate_features() has one cfg block per provider and each requires not(feature = <the other>), so with both enabled it returns None, and get_default_or_install_from_crate_features() panics with "Could not automatically determine the process-level CryptoProvider". There is no CryptoProvider::install_default() anywhere in crates/.

To be fair about severity: this does not panic today. Every reachable caller passes a provider explicitly (reqwest-0.12.28/src/async_impl/client.rs:770 hardcodes ring, reqwest 0.13 hardcodes aws_lc_rs, sqlx uses tls-rustls-ring). What the PR removes is the property that made it impossible. The bare ClientConfig::builder() path does exist in tokio-tungstenite-0.27.0/src/tls.rs:126 for wss:// connects, and that crate also backs freenet-stdlib's WebApi, so the exposure reaches downstream consumers. This codebase has hit the dual-provider panic before, and CI did not catch it that time either. Fixing (1) removes this.

4. rule-review/warnings is red on the untested OtelAuthMode::Disabled path. That gate blocks merge. (The macOS Service Unit failure was an unrelated rustc SIGSEGV compiling wasmprinter; it passed on re-run.)


Functional bugs worth fixing before merge

5. OTEL_SERVICE_NAME is silently ignored, which contradicts the PR description's "Standard OTEL env vars win over config".

Resource::builder() seeds from EnvResourceDetector into self (opentelemetry_sdk-0.32.1/src/resource/mod.rs:62-68). ResourceBuilder::with_attributes then does self.resource.merge(&Resource::new(kvs)), and merge inserts other over self (mod.rs:179-182). So .with_service_name("freenet-node") overwrites the env value. Same shape for service.version, os.type, and host.arch against OTEL_RESOURCE_ATTRIBUTES.

Effect: two nodes on one host with distinct OTEL_SERVICE_NAME values both export service.name=freenet-node, and any dashboard keyed on it merges them. Fix: apply the literal only when the env var is absent.

6. The bearer token clobbers an operator's own Authorization header.

tracing/otel.rs:142 does an unconditional headers_mut().insert(AUTHORIZATION, ...), and the exporter applies OTEL_EXPORTER_OTLP_HEADERS before calling send_bytes. An operator pointing at a hosted collector that requires Authorization: Basic ... gets a 401 on every export, and the only workaround (otel-auth-mode = disabled) is not documented anywhere operator-facing.

This one bites the use case in #5046 directly, since the ask was to export to the operator's own collector. Fix: if !request.headers().contains_key(AUTHORIZATION).

Related: FreenetAuthClient uses reqwest::blocking::Client::new(), where the client it replaces is built with .timeout(resolve_timeout(...)), so OTEL_EXPORTER_OTLP_TIMEOUT is ignored.

7. The bearer token has no audience binding.

Commit 7035ae4a4 removed the nonce, so the signed payload is freenet/<pubkey>/<timestamp> and does not name the collector it is for. A collector you export to can replay your token to any other collector accepting this scheme and impersonate your node, bounded only by clock skew. The default endpoint is plain http, so a passive observer on that path can do the same.

Two thoughts. First, if the auth scheme stays, sign the target endpoint into the payload. Second, and worth considering more seriously: auth_mode defaults to freenet, so an operator pointing at their own collector ships a signed assertion of their node identity there every 60 seconds without asking for it, and there is no collector today that verifies these tokens. Defaulting to disabled and letting OTEL_EXPORTER_OTLP_HEADERS carry auth would cover #5046's actual request. The XEdDSA scheme could then land separately, alongside the collector that consumes it.

To be clear, the cryptography itself checks out. I read xeddsa 1.1.0's source: it clamps the scalar, forces the Edwards sign bit to zero by negating when needed, and derives the nonce as H(padding || a || M || Z), so even a failing RNG cannot leak the key. auth_token_signer() passes the x25519 secret correctly, and the interop test verifies with genuinely independent curve25519-dalek + ed25519-dalek rather than the code that signed. The concern is protocol design and defaults, not the primitive.

8. AGENTS.md describes the wrong key type. It says freenet.node.pubkey is "the base58 ed25519 verifying key derived from the transport keypair". The code emits the x25519 Montgomery public key (transport/crypto.rs:117-119). Those are different byte strings, and a collector implementer following that line would call VerifyingKey::from_bytes on it and fail. The module rustdoc in tracing/otel.rs gets it right. Same paragraph calls freenet.node.fingerprint "unverifiable by the collector", while otel.rs and its test say the collector recomputes it.

9. The startup log hides the destination in the one case it matters. When the endpoint comes from OTEL_EXPORTER_OTLP_*, the log prints the literal <resolved by OTEL_* env or SDK default> instead of the URL. Env-over-config is the documented and intended precedence, so that part is fine, but an operator whose config endpoint was overridden by an inherited env var cannot tell from the logs where their node's signed identity is being sent. Logging the resolved value, and warning when env overrides a config endpoint, is cheap.


Tests

Several of the new tests cannot fail for the reason they name:

  • Deleting the entire suppression block in init() leaves every test passing. otel_suppression_reason is well covered but init() is its only caller and is unreachable under cfg(test), so a --id test network would ship to a collector with no test going red. A source-scrape pin that init calls it and returns before build_provider would close this.
  • The identity-attribute guards build their expected strings in the test body and never call init or build_provider. Changing init to use peer_id.to_string(), the exact regression the comment names, leaves them green. Extracting an identity_attributes(keypair) helper and asserting on its output would fix it.
  • OtelConfig is not destructured in the config round-trip guard, unlike TelemetryConfig and its other siblings. Per .claude/rules/code-style.md the guard's value is that a new field fails to compile until classified; a fourth OtelConfig field will compile clean and silently never merge. This is the fix(config): merge allowed-source-cidrs and allowed-host from config file #3890 / fix: service start silently drops ExecStart CLI flags #4275 class.
  • DEFAULT_OTEL_ENDPOINT has no production reader; its only consumers are the two assertions in the test that pins it.
  • The eight new record_* mirrors in transport/metrics.rs have no pin. Delete any one and the counter reports zero forever with nothing failing. This is verbatim the "manually-mirrored telemetry counters" row in .claude/rules/bug-prevention-patterns.md (refactor(dashboard): read subscribed contracts from canonical ring state #4009 / Dashboard op_stats counters for SUBSCRIBE and UPDATE silently stuck #4010 precedents).
  • --otel-telemetry-enabled=false cannot override a config-file true (config.rs:922 is one-directional, and clap's default_value = "false" makes "unset" and "explicitly false" indistinguishable). The test that looks like it covers this asserts at parse level, before the merge. Making the field Option<bool> with get_or_insert, matching endpoint and auth_mode, would fix both. I realize this matches the existing reference-ping / iface-tx pattern, so it is not a regression, but "the off switch does not work" reads differently for a network export than for a ping toggle.

Smaller

  • docs/superpowers/ is a new directory whose only content is an 869-line agent work plan, already stale against what shipped (it describes a three-field OtelConfig; the shipped struct has four). docs/design/otel-metrics-exporter.md links to it as the canonical plan, so a reader gets pointed at the stale copy. Suggest deleting it.
  • docs/design/otel-metrics-exporter.md still says "Status: proposed" and disagrees with the code in several places: it documents otel::init(&self.config.otel, self.local_peer_id_string()) where the code passes &self.key_pair, documents service.instance.id and peer.id attributes that are never set, and its configuration table omits otel-auth-mode entirely.
  • The ponytail: comment convention is defined only in this PR's own docs. Future readers of crates/core/ will not know what it means. A plain NOTE: with the same text, or a tracked issue reference, would carry better.
  • "Closes feat(telemetry): actual Otel metrics export #5046" is a little strong given log export is an explicit non-goal; the issue asks for "logs for the Freenet server as well as metrics". Maybe leave it open.
  • No operator-facing docs for otel-telemetry-enabled / otel-endpoint / otel-auth-mode. The requester is a node operator, and these currently appear only in AGENTS.md and docs/design/. Compare docs/secrets-at-rest.md.
  • A poisoned NETWORK_STATUS lock takes down ten of the nineteen instruments permanently, including the fair-queue stats, which are pure atomics and do not need that lock at all.
  • freenet.contract.queue.depth emits queue="total" alongside the three tiers, so sum by (queue) double-counts.
  • error_for_status() discards the collector's response body, which is where OTLP partial-success and error detail live.
  • AGENTS.md gains 72 lines for an off-by-default exporter. That file is loaded into every agent session in this repo. The genuinely rule-shaped parts (do not add per-instrument bucket boundaries, never export a PeerId, read cumulative rather than period counters) are about eight lines plus a link to the design doc.

Checked and clean

Recording these so nobody re-runs them: no CI, workflow, build script, or .claude/rules changes. The transport secret is never logged or serialized (the manual Debug impl on FreenetAuthClient emits nothing), and no attacker-influenced bytes reach sign(). Every exported attribute is either a node-derived constant or a low-cardinality literal; no PeerId, socket address, IP, or contract key appears anywhere, and the Resource builder attaches no hostname or process detector. No deadlock or hot-path stall from the observable callbacks: fair_queue_stats() and the transport counters are lock-free atomic loads, and otel_metrics_snapshot() takes only short read locks that already have the homepage handler as a caller. No task or thread leak from the missing shutdown hook, and it cannot block process exit. Endpoint precedence matches resolve_http_endpoint exactly, including the /v1/metrics asymmetry. Isolation from the existing dashboard pipeline is structurally enforced rather than asserted.


Suggested path

Items 1 through 4 are what I would want resolved before merge. Feature-gating (1) default-off takes care of (3) as a side effect and shrinks (2) to a formality. Items 5, 6, and 8 are small and worth doing in the same pass. Item 7 is the one real design question, and splitting the auth scheme into its own change would let the metrics exporter land sooner.

Happy to re-review once those are in.

[AI-assisted - Claude]

@skandragon

Copy link
Copy Markdown
Contributor Author

Thanks — most of this landed. Pushed as faa6a1e8.

1–3 (deps, release build, dual crypto provider): fixed, but not the suggested way

The reqwest-rustlsreqwest-rustls-webpki-roots swap does not drop aws-lc:

opentelemetry-http/reqwest-rustls-webpki-roots = ["reqwest/default-tls", "reqwest/webpki-roots"]
reqwest 0.13: default-tls = ["rustls"]; rustls = ["__rustls-aws-lc-rs", "dep:rustls-platform-verifier", …]

It adds webpki roots on top of the same stack.

What actually removes it: FreenetAuthClient (now OtlpHttpClient) is installed in every auth mode, not just when a signer exists. opentelemetry-otlp builds a client only when none was supplied (exporter/http/mod.rs:223-267), so its reqwest-blocking-client / reqwest-rustls features are now off entirely. Our client rides the workspace reqwest 0.12, which already has rustls-tls, so https:// endpoints still work.

Cargo.lock delta versus main went from 11 new packages to 2xeddsa and its convert_case. No reqwest 0.13, no rustls-platform-verifier, no aws-lc-rs/aws-lc-sys, no cmake, and rustls resolves with ring alone again. That also means (2) no longer has a native-toolchain risk to verify, and (3) is structurally back to impossible.

I preferred this to a default-off cargo feature: feature-gating would mean an operator can't enable metrics without building their own binary, which defeats the point of the issue.

4 (rule-review warning): fixed — provider_builds_with_auth_disabled.

5, 6, 8, 9: fixed

  • Resource literals are applied only for keys OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES did not declare.
  • Authorization is set only when absent, so OTEL_EXPORTER_OTLP_HEADERS wins. Also honors OTEL_EXPORTER_OTLP_{METRICS_,}TIMEOUT (the SDK only resolves that for a client it builds itself), and no longer calls error_for_status(), so the body survives for the SDK's HttpClient.StatusError log.
  • AGENTS.md corrected (x25519, not ed25519) and cut from +72 lines to +29: the rule-shaped parts plus links.
  • Startup log names the resolved endpoint and warns when an OTEL_* variable overrode a configured one.

7 (audience binding + default): both done

Default otel-auth-mode is now disabled. Tokens are freenet/<pubkey>/<audience>/<timestamp>/<signature> where <audience> is the target collector's host:port, taken from the request URI — a token sent to one collector no longer verifies at another (a_token_for_one_collector_does_not_verify_at_another).

One correction on the premise, though: a collector that verifies these tokens does exist and works. It's an OTel collector extension — https://github.com/cardinalhq/cardinalhq-otel-collector/tree/main/extension/fbnauthextension — which is what the scheme was built against, and it verified end-to-end before this PR opened. I'll update it to match whatever format we settle on here, so treat the wire format as ours to choose. That's also why I'd rather keep the scheme in this PR than split it out.

Tests

Fixed the vacuous ones you named:

  • init now returns its suppression reason, so init_refuses_to_start_from_a_test_process asserts on init's own behavior rather than on otel_suppression_reason in isolation. Mutation-tested: deleting the suppression block makes it fail.
  • Identity attributes come from an identity_attributes(keypair) helper that production calls; the guards assert on its output.
  • OtelConfig is destructured in the config round-trip guard.
  • Cross-file scrape pins all nine hot-path record_* mirrors (cross-file, so it can't be satisfied by its own literal). Mutation-tested: deleting one fails.
  • DEFAULT_OTEL_ENDPOINT deleted — you're right that it had no production reader.
  • otel-telemetry-enabled is Option<bool> now, and otel_cli_false_overrides_a_config_file_that_says_true asserts through build(), not at parse level.

Smaller

docs/superpowers/ deleted. Design doc updated to match what shipped (status, auth-mode row, wire-up signature, dependency section). ponytail:NOTE:. queue="total" dropped. Closes #5046Refs, since log export is a non-goal. New operator page at docs/otel-metrics.md.

Not changed: the poisoned-NETWORK_STATUS-lock point is fair but it's a pre-existing property of otel_metrics_snapshot's callers, and splitting the fair-queue reads out of it is a separate change.

New OtelArgs/OtelConfig sit beside TelemetryArgs/TelemetryConfig rather
than inside them: the SDK metrics pipeline and the dashboard reporter are
independent features that are not expected to share a backend, so neither
enable-flag nor endpoint may fall back to the other.
Both decisions are pure functions so the production direction is testable
from inside a test process. Endpoint resolution deliberately returns None
when a standard OTEL_* var is set: opentelemetry-otlp gives a programmatic
endpoint priority over the env vars, which is the opposite of the
precedence operators expect.
Installs a global meter provider backed by an OTLP/HTTP exporter, plus one
RSS gauge so the pipeline carries a real datapoint end to end. Future
instrumentation is a global::meter call at the site, with no registry to
keep in sync. The OTel crates become non-optional because the pipeline
ships in every build, not just trace-ot ones.
The isolation between telemetry-enabled and otel-telemetry-enabled is a
design constraint, not an accident, so it belongs where the next person
reads before touching either.
Config::otel used serde(default) instead of serde(flatten), so the
documented flat config.toml keys (otel-telemetry-enabled, otel-endpoint)
were silently ignored. opentelemetry-otlp also had no TLS backend, so an
https:// collector would never export, and its default features pulled
in the trace/logs exporters despite metrics being the only goal.
The trim drops the logs exporter only. http-proto mandates trace, prost and
opentelemetry-proto, so the comment and the design doc were both wrong about
what stays out of the default build. Also record the aws-lc-rs/CMake cost
that reqwest-rustls adds to the release cross-compile.
Adds the instruments behind the dashboard's connection-status tiles plus
transport wire counters and RTT/cwnd distributions. Observable callbacks
read state that already existed for the local dashboard, so nothing new
lands on the hot path except two packet atomics.

Identity moves from a PeerId to the transport public key fingerprint:
PeerId renders as {pub_key}@{addr}, so the previous peer.id resource
attribute exported this node's socket address and re-identified the node
on every address change.

No instrument carries an attribute identifying the remote end of a
connection.

Claude-Session: https://claude.ai/code/session_015Entifnvj528KjPErWsRyJ
New otel-auth-mode config (default "freenet", or "disabled"). Each export
request carries Authorization: Bearer freenet/<pubkey>/<ts>/<nonce>/<sig>,
signed with the x25519 transport key itself (XEdDSA) so the collector
verifies node identity against the same pubkey peers and UIs see.
Resource attrs freenet.node.pubkey / freenet.node.fingerprint replace
service.instance.id; the fingerprint is derivable from the pubkey, so the
collector can validate both.

Claude-Session: https://claude.ai/code/session_01QNvMzjXWyYsMeiJF5DrB2s
The collector no longer accepts a nonce field; freshness is the
timestamp alone.
reqwest's blocking client owns a private tokio runtime; creating or
dropping it inside an async context panics. build_provider now hops to
a plain thread, and the test shuts down via spawn_blocking.
- correct stale nonce segment in OtelAuthMode's token-format doc
- surface build-thread panics as ExporterBuildError instead of
  propagating into node startup
- add wire-level test for FreenetAuthClient::send_bytes
- consolidate module imports per code-style layout
- always install our own HttpClient, so opentelemetry-otlp needs no
  reqwest/TLS feature: 11 new lockfile packages down to 2, no reqwest
  0.13, no aws-lc-sys/cmake, no dual rustls provider
- never overwrite an operator's Authorization header, honor the export
  timeout, keep the response body for OTLP error detail
- otel-auth-mode defaults to disabled, and tokens bind the target
  collector's authority so they cannot be replayed elsewhere
- --otel-telemetry-enabled=false now overrides config.toml
- resource attributes no longer clobber OTEL_SERVICE_NAME
- pin init's suppression check and the hot-path record_* mirrors
- trim AGENTS.md, drop the stale plan, add operator docs
The audience was the URI authority, which carries userinfo: an endpoint
of https://user:secret@collector/ would have signed the operator's
password into a token sent over the wire and logged by the collector.

Now base58(SHA-256(canonical URL)[..16]) — credentials stripped, and
hashed because a URL contains the token's '/' separator. Binding the
path too distinguishes two collectors behind one hostname.
Hash host:port/path, not scheme://host:port/path. The scheme names a
transport, not a party, so binding it never narrows which collector may
use a token — it only forces a collector reachable over both http and
https to be configured twice. Userinfo was already stripped.
Findings from a full review pass on this branch.

Export outcomes were invisible: opentelemetry-otlp logs network errors
and non-2xx at DEBUG, justified by a comment claiming PeriodicReader
re-logs them via otel_error!. That holds for the batch log/span
processors and NOT for metrics, where the reader logs its export result
with otel_debug! and the only otel_error! is thread creation. A dead
collector produced no output at all while startup still said "started".
send_bytes now warns once per failing streak and logs the recovery edge.

OTEL_RESOURCE_ATTRIBUTES could shadow freenet.node.pubkey, exporting an
identity that does not match the key the bearer token was signed with,
silently breaking the collector's self-validation. The two identity
attributes are now always emitted; descriptive attributes still defer.

Also: reject endpoints reqwest cannot send (http::Uri accepts host:port
as a schemeless authority, so the exporter built and every export then
failed); propagate the HTTP client build error instead of falling back
to a client with no timeout, which could stall the reader thread
forever; warn when the exporter is enabled but suppressed, and on
unparseable or millisecond-confused export timeouts; return None rather
than defaults when the ring provider is unregistered, so a gauge is
absent rather than reporting a real zero; publish the fair-queue
counters with fetch_max, since they are now exported as observable
counters where a decrease reads as a reset.

Docs corrected against the pinned crate sources: init's return value,
the two endpoint env vars not being interchangeable, timeout ownership,
OTEL_EXPORTER_OTLP_COMPRESSION being unsupported rather than free,
slowdowns_triggered not being dead code, the auth-mode default, and the
instrument counts. Fixed a guard test that rebuilt the identity strings
locally instead of calling identity_attributes, so it would have passed
had production switched back to PeerId.
@skandragon
skandragon force-pushed the feat/otel-metrics-exporter branch from 6f72232 to f891539 Compare August 8, 2026 20:05
The hosting cache already records why each contract is there (access
type, local-client flag, abandonment, subscriber maps) but nothing
outside hosting.rs could see it, so a node's hosted count was a single
opaque number.

Adds HostingReason as a partition over the hosted set and exports count
plus state bytes per reason. Partition, not flags: the signals overlap,
and an overlapping breakdown makes sum-by-reason lie.

Its own provider rather than RingStatsSnapshot — that one runs on every
dashboard request and this is an O(hosted) walk under the cache lock.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants