feat(telemetry): RPC usage metrics — raw JSON-RPC request counts per node/method#1409
Conversation
…er node/method
Incident-driven (a node burned ~$200 of RPC credits in a day with nothing
measuring it): the existing dkg.chain.rpc.total counts LOGICAL chain operations,
but providers bill per RAW JSON-RPC request, and one logical op fans out into
several raw requests (populate = estimateGas + getTransactionCount + chainId +
fee reads). This adds exact billing-unit accounting:
- chain: CountingJsonRpcProvider hooks ethers' `_send()` — the actual wire
dispatch (NOT `send()`, which ethers' network detection bypasses) — so every
outgoing JSON-RPC request is counted per method. With the adapter's
batchMaxCount:1 each entry is exactly one HTTP request. RpcUsageTracker keeps
a per-window delta + lifetime total and emits the new OTel counter
dkg.chain.rpc.requests.total{rpc_method (bounded known set, else 'other'),
chain_id} for when a metrics backend is provisioned. `_drainRpcUsage()`
exposes the delta window (EVM-transport internal, feature-detected).
- cli: every 60s the daemon drains the window and logs one
`rpc_usage method=<m> count=<n> window_s=60 chain=<id>` logfmt line per
method through the normal Logger → redacted-OTLP → Alloy → Loki path — so
RPC usage is chartable in Grafana via the log pipeline ALREADY being rolled
out, no new backend needed. Lines carry DELTAS, so LogQL sum_over_time gives
exact request counts over any range. Format extracted to rpc-usage-log.ts
(unit-tested contract); idle windows log nothing.
- dashboards: fleet gains "RPC requests per node" (find the credit burner) and
"RPC requests by method (fleet)"; per-node gains "RPC requests by method —
$node" (see WHICH call burns credits). example-alerts.md gains an RPC-spike
alert that would have tripped within the first hour of the $200/day incident.
OPERATOR-GUIDE/RUNBOOK document the rpc_usage line contract.
Tests: real-path chain test (adapter over live loopback RPC → counter with
EXACTLY {rpc_method, chain_id} labels + drain-resets-window + lifetime
monotonic), bounded-label mapping, never-throws tracker; cli formatter contract
(logfmt shape, deltas only, token sanitization, idle → no lines). Full chain
suite 758 green (counting provider is transparent to all failover behavior);
full monorepo build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…public capability (#1409 review) 🔴 Retry undercount: ethers retries 429/5xx BELOW JsonRpcProvider._send (a single-RPC node retries up to 5 times inside one dispatch), and every attempt bills at the provider. boundedRetryFetchRequest gains an onRetryAttempt hook — each re-attempt reports its JSON-RPC method(s), parsed from the request body, to the usage tracker. _send counts the first attempt; the hook counts each additional one. Proven by a throttled-method test: tracker == server hits under 429 retries (≥2 attempts observed). 🔴 Exact-equality test: the real-path test now asserts usage.total === rpc.totalHits() and per-method === rpc.hits(method) against the loopback server's own request log — over/under-counting can no longer pass green. 🟡 Shutdown drain: daemon cleanup performs one final best-effort drain+emit BEFORE telemetry stops, so a partial window (publish burst just before a restart) still reaches Loki — log-derived totals stay exact across restarts. 🟡 Public capability instead of private-method wiring: `drainRpcUsage?()` is now an optional method on the ChainAdapter interface (EVM implements it; the mock returns an honest always-empty window — parity passes naturally), and the daemon consumes it via the new public DKGAgent.drainChainRpcUsage() boundary — no casts, no underscore reach-through. 🟡 Sanitization out of the tracker: window keys stay RAW (degenerate-input guard only); logfmt token-safety lives solely in the cli formatter; the metric label keeps the bounded known-set mapping. 🟡 Daemon emission tested: the whole drain→format→emit step is now emitRpcUsage() (shared by the minutely timer and the shutdown drain) with tests: per-method emission, empty-window/missing-capability/no-source → 0, and never-throws on a failing drain or sink. chain 759 green (full suite incl. parity + failover), cli 7, full build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…of retry policy (#1409 review) 🟡 Typed contract: `RpcUsageWindow` is now exported from the chain package and used by the ChainAdapter.drainRpcUsage capability, the mock, the agent's drainChainRpcUsage boundary, and the cli emission module (RpcUsageWindowLike is a deprecated alias of it) — no more copied inline `{ byMethod; total; lifetimeTotal }` shapes that could drift. 🟡 Retry policy vs accounting: boundedRetryFetchRequest's onRetryAttempt now forwards the raw request BODY only (pure retry policy); JSON-RPC method extraction lives in rpc-usage.ts `jsonRpcMethodsFromBody` (usage-accounting policy), called by the adapter's hook. Behaviour unchanged — the throttled- retry exact-equality test still passes. chain 24 (rpc-usage + parity + bounded-retry), cli 7; full build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ype only (#1409 review) jsonRpcMethodsFromBody + boundedRpcMethodLabel are transport-accounting internals — external consumers must not be able to depend on them, so the package index now exports ONLY the stable typed window contract. No behaviour change; chain 4 + cli 7 tests green, full build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y + drop alias (#1409 review) 🟡 rpc_usage lines now carry the EFFECTIVE chain id (chainBase resolves field-level inheritance from the network config) — an operator config that overrides only rpcUrl no longer drops `chain=` from the log contract. 🟡 createCountingJsonRpcProvider (rpc-usage.ts) is the ONE transport factory wiring BOTH billing-exact hooks (first attempt at _send + every ethers-internal retry attempt) to the tracker — a construction site can no longer pair them inconsistently, and the adapter constructor loses the accounting ceremony. 🔵 Dropped the deprecated RpcUsageWindowLike alias — the cli module uses chain's RpcUsageWindow directly. chain 24 (rpc-usage incl. throttled-retry exactness + parity + bounded-retry), cli 7; full build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nInner (#1409 review) 🟡 startRpcUsageTelemetry(...) in rpc-usage-log.ts now owns the WHOLE feature lifecycle: the minutely drain→format→emit tick (unref'd) AND the shutdown final-drain, returned as a handle whose stop() clears the timer and drains the partial window. runDaemonInner is wiring only (source = the agent's public drainChainRpcUsage boundary, sink = Logger→OTLP path, effective chainId) and teardown is one rpcUsageTelemetry.stop() call — the two lifecycle sites can no longer drift. +fake-timer tests: tick emits the drained lines; stop() drains the partial window and halts (no emissions after); idle windows emit nothing on tick or stop. cli rpc-usage 9 green; full build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…1409 review) 🟡 The agent's drainChainRpcUsage() — the exact source the daemon's startRpcUsageTelemetry consumes — had no direct coverage, so a delegation regression could silence every rpc_usage log while chain-side and formatter tests stayed green. - agent/test/rpc-usage-boundary.test.ts: drives the REAL method (prototype- invoked with a chain-bearing `this`, the established agent unit pattern): verbatim pass-through of the adapter window (same object), the real MockChainAdapter empty window, undefined for adapters lacking the optional capability, and inheritance on the composed DKGAgent class (what the daemon actually holds). - cli/test/rpc-usage-log.test.ts: end-to-end boundary case — adapter window → REAL DKGAgent.drainChainRpcUsage delegation → emitRpcUsage → asserted rpc_usage line. A broken delegation now fails the build instead of leaving the daemon signal silently dead. agent boundary 4, cli rpc-usage 10; full build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… node label, importable alert payloads Three review findings on #1414, all also applied to the LIVE Grafana so repo == server: - Node-silent alert lost node identity: count-vs-count masks a node dying while another joins in the same window. Replaced with a per-node `unless` rule (seen in [3h] offset 15m, absent in [15m]) that names the silent node and is churn-immune, plus a separate fleet-blackout rule (noData=Alerting) covering the total-outage case the unless form cannot express. Both shapes verified against the live Loki 3. - The dual-label node filter ({instance=~"X|", service_instance_id=~"X|"}) is an AND, not an OR — if a backend ever carries both labels with instance holding a scrape target, valid series get dropped. Panels now filter on the canonical OTLP mapping only (instance = service.instance.id, the default for both VictoriaMetrics native OTLP ingest and prometheusremotewrite); the dashboard description documents the one-line swap for resource_to_telemetry_conversion setups. - Alerting was prose-only: added alert-rules.provisioning.json — the exact secret-free provisioning-API payloads (10 rules, contact-point templates with placeholder webhooks, policy routes) so the setup is importable and diffable; example-alerts.md now explains rather than being the only source. Also noted in RUNBOOK that the raw-RPC panels read dkg_chain_rpc_requests_total, which ships with PR #1409 (the panels are deliberately forward-armed, like the rpc_usage log panels). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tle (review) The rule reads the rpc_usage accounting lines that ship with PR #1409 (packages/chain rpc-usage tracker + cli daemon emitter) — the producer exists, it just is not on deployed nodes yet. The other pre-armed rules carry the "(armed — needs ...)" marker in their titles; this one now does too, so the dependency is visible in the alert list, the Slack message, and the generated docs table, not only in the summary text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l profile pieces (review) - SIGNAL_ROUTES in lib/alerts.mjs is now the one definition of signal -> channel / contact point / matchers / grouping; the Grafana payload (contact points + policy routes) and the generated docs routing table + grouping sentence both derive from it — the rendered payload is byte-identical, proving the derivation is exact. - The metrics dashboard's profile-sensitive conventions live in three named pieces next to SEL (BY_NODE(), NODE_LEGEND) so a new per-node panel composes them instead of re-splicing PROM_NODE_LABEL by hand. - The two raw-RPC panels carry the "(post-#1409 node builds)" marker in their titles, same treatment as the armed alert rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Adversarial review summary (RPC accounting / logging / dashboards) I reviewed the counting provider, retry hook, adapter drain boundary, daemon emission, tests, dashboard queries, and example alert. I did not find a blocker in the core accounting design: first attempts are counted in Non-blocking findings:
Overall this looks production-usable from an observability standpoint once those hardening items are considered. |
…+ drop superseded docs (Jurij review) - Window keys stay RAW by design (Loki parses `method` at query time — no index cardinality, full diagnostic fidelity), now documented at the tracker, with a MAX_WINDOW_METHODS=64 spam guard: pathological method-name churn overflows into 'other' instead of emitting one rpc_usage line per fabricated name every minute. Existing keys keep counting raw after the cap. Covered by a unit test. - Multi-endpoint failover accounting is now directly tested: endpoint A 429s (billed refusal), endpoint B serves; with perEndpointRetries=0 the drained window must EQUAL A hits + B hits, total and per method — proving failed attempts and the fallback success both bill. - Dropped the polaris-era dashboard/alert-doc edits under tools/log-collection-poc/production/: PR #1414 owns that content now (moved to tools/observability with Loki-3 query shapes and the RPC burn alert in range+reduce form), so keeping the old-shape copies here would both duplicate a superseded source of truth and guarantee a merge conflict with the directory move. The OPERATOR-GUIDE bullet documenting the node's own rpc_usage line contract stays — that contract IS this PR's deliverable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rij review on #1409) A node name reused across deployment environments would merge its counts in every per-node log rule (silent/error/warn/rpc-burn group only by service_instance_id). All four now group by (service_instance_id, deployment_environment), summaries name the environment, and the logs/metrics routing group_by includes it — two same-named nodes in different environments alert and notify independently. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the adversarial pass @Jurij89 — all three P3s addressed. What went where: 1. Log-path method cardinality → addressed in 2. Multi-endpoint failover accounting → covered in 3. Example alert grouping by environment → correct catch, fixed in both places:
Verification: chain 6/6 (incl. the two new tests), cli 10/10, agent 4/4 locally; CI running on the new head. |
A local devnet run refreshed the generated localhost_contracts.json and it slipped into the previous commit; the RPC usage accounting does not depend on it. Restored to main's version. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ped (review nit) The interface doc still said "(sanitized)" from an earlier iteration — the actual public contract is RAW method names (diagnostic fidelity), bounded in COUNT via MAX_WINDOW_METHODS with overflow under 'other', and consumers sanitize for their own sink. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…w (review 🔴) The daemon drained only agent.drainChainRpcUsage(), but with publisher.enabled=true the async runtime owns one EVMChainAdapter PER PUBLISHER WALLET, each with its own tracker — so the publish transaction path (estimateGas, getTransactionCount, sendRawTransaction, receipt polls: the highest credit-burn traffic) never reached the rpc_usage log lines that power the Grafana panels and burn alert. - mergeRpcUsageWindows() (cli) sums windows across trackers; PublisherRuntime gains drainRpcUsage() merging its per-wallet adapters; the daemon telemetry source is now a composite that merges the agent window with the LIVE publisherRuntime variable at every drain (the runtime boots lazily after telemetry starts). - Tests: merge semantics (sums, undefined-tolerance) + a composite- source end-to-end proving publisher traffic appears in emitted lines both before and after the runtime boots. Also (review 🟡): RpcUsageTracker.record() commits the authoritative window/lifetime state OUTSIDE any try — best-effort now applies only to the OTel metric side effect (its own try/catch), so a throwing metrics backend can never be confused with lost accounting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er conflict) Main's #1410 refactored the publisher wallet loop into ConfiguredPublisherWallet[] (and added the runtime `wallets` member); this branch adds per-wallet chain-adapter RPC-usage draining. Resolution takes main's structure verbatim and re-weaves the two additions: chainAdapters collected next to each constructed adapter, and the runtime's drainRpcUsage() alongside main's wallets member. Verified: build green, rpc-usage suites 6+13+4 pass on the merged tree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…yer (review) The merge is a pure model operation on RpcUsageWindow — moved next to the type in packages/chain/src/rpc-usage.ts and exported from the barrel alongside it (a deliberate widening of the earlier type-only narrowing: it now has a genuine cross-package consumer and carries no transport/parsing internals). publisher-runner and lifecycle import it from dkg-chain; the daemon log module is back to formatting and scheduling only. Merge-semantics unit tests moved to the chain suite; the composite-source wiring test stays in cli. Suites: chain 8, cli 11, agent 4 — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…icy fully decoupled (review) - New publisher-runner-rpc-usage.test.ts constructs a REAL runtime via createPublisherRuntimeFromAgent (temp dataDir with a publisher wallet, oxigraph store, loopback JSON-RPC server) and asserts runtime.drainRpcUsage() EQUALS the loopback's own per-method hit log, with delta semantics on the second drain — the exact mutations the review cited (dropping chainAdapters.push or emptying the merge) now fail a test instead of silently losing publisher traffic. - boundedRetryFetchRequest is pure retry policy again (no accounting callback in its signature); createCountingJsonRpcProvider decorates the FetchRequest's retryFunc itself, keeping accounting composition with the accounting. The throttled-retry exact-equality test (tracker == server hits under 429s) proves the decoration counts identically. - Restore localhost_contracts.json to CURRENT main (the earlier revert used the pre-#1410 blob, leaving a phantom diff after the merge). Suites: chain 8, cli 11+1, agent 4 — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…keeps regenerating it) A local devnet process rewrites this generated snapshot, and broad git add sweeps kept re-committing it. Restored to main's exact blob; the PR diff must not touch packages/evm-module. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…comment consolidation (review) - RpcUsageDrainable (chain, next to the window type) is THE drain contract: the agent's method is now drainRpcUsage() (delegating to its adapter), PublisherRuntime already matched, and the cli source type is Partial<RpcUsageDrainable> — four near-identical names collapsed into one, so a future producer plugs in by implementing the contract rather than learning layer-specific vocabulary. - The real-runtime boundary test now configures TWO publisher wallets: the server-hit equality only holds if EVERY per-wallet adapter's tracker is merged, so a regression to chainAdapters[0] undercounts and fails (single-wallet coverage could not see that). - Incident narrative consolidated into the one module overview (chain/src/rpc-usage.ts); other layers keep invariant-only comments, and the cli header's stale polaris-era LogQL parse shape is gone. Suites: chain 8, cli 12, agent 4 — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l telemetry list (review) ConfiguredPublisherWallet now carries the wallet's chain adapter, and the runtime's drainRpcUsage() maps over wallets directly — the telemetry-only chainAdapters array (a second representation of the same per-wallet ownership that future filter/skip/rebuild logic would have to keep aligned) is gone. Two-wallet boundary test + emission suites re-run green (12/12). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RpcUsageDrainable.drainRpcUsage() now returns RpcUsageWindow, never undefined: the optional capability exists only at the ChainAdapter API edge and DKGAgentBase collapses it to emptyRpcUsageWindow() the moment it crosses into a drainable. mergeRpcUsageWindows still tolerates undefined INPUTS (a not-yet-started runtime) but always returns a concrete window — empty is the identity element, and the formatter already skips zero-count windows. Consumers no longer juggle "missing capability" vs "empty window"; a runtime-safety test keeps a rogue undefined tolerated at the emission edge. Suites: chain 8, cli 12, agent 4 — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RpcUsageWindow no longer carries a stored `total`: an inconsistent
{byMethod, total} pair is now unrepresentable. rpcUsageWindowTotal()
derives the sum on demand (exported next to the type); the formatter,
emitter, merge and drain all lost their duplicate bookkeeping.
lifetimeTotal stays — it is NOT derivable (the window resets, the
lifetime counter does not). All fixtures and assertions migrated to
the derived helper; suites: chain 8, cli 12, agent 4 — green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Why
A node burned ~$200 of RPC credits in a day and nothing measured it.
dkg.chain.rpc.total(PR #1317) counts logical chain operations, but RPC providers bill per raw JSON-RPC request — one logical op fans out into several raw requests (a tx populate alone issueseth_estimateGas+eth_getTransactionCount+eth_chainId+ fee reads). Milestone 1 (manager): a usable view of RPC usage for the whole team, with limited metrics we can be sure are computed correctly.What
CountingJsonRpcProviderhooks ethers'_send()(the actual wire dispatch;send()is bypassed by ethers' network detection) and counts every outgoing JSON-RPC request per method. With the adapter'sbatchMaxCount: 1, one entry == one HTTP request.rpc_usage method=<m> count=<n> window_s=60 chain=<id>logfmt line per method through the redacted-OTLP → Alloy → Loki path. Lines carry deltas, sosum_over_timein LogQL yields exact request counts over any range. Idle nodes log nothing.dkg.chain.rpc.requests.total{rpc_method (bounded, else 'other'), chain_id}.Correctness evidence
{rpc_method, chain_id}labels; drain returns the delta and resets; lifetime monotonic.other); tracker never throws.Rollout
Rides the same node rollout as the log collection (needs a build with this +
telemetry.logs.exporter: "otlp"). Panels light up as soon as nodes ship logs.🤖 Generated with Claude Code