Skip to content

Export BEAM + cgroup/OS host metrics for reconciliation#4678

Draft
alco wants to merge 13 commits into
mainfrom
alco--host-cgroup-metrics
Draft

Export BEAM + cgroup/OS host metrics for reconciliation#4678
alco wants to merge 13 commits into
mainfrom
alco--host-cgroup-metrics

Conversation

@alco

@alco alco commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

In-app telemetry poller that exports a complete, reconcilable memory/CPU/disk picture for Electric — the BEAM plane (what the VM thinks it uses) alongside the cgroup/OS plane (what the kernel charges), in one event stream with identical resource attributes.

What's included

New ElectricTelemetry.SystemMetrics poller (+ Cgroup, Proc, ProcfsParse submodules) reading /proc and /sys/fs/cgroup directly and emitting OTel gauges:

  • BEAM allocatorsvm.alloc.{allocated,used,unused,carrier_usage} (5s), plus gated per-allocator vm.alloc.fragmentation.unused{allocator} (~60s, O(carriers), gated via :counters). allocated − used is the carrier-fragmentation / OOM-risk signal.
  • cgroup plane (v1 + v2)cgroup.memory.*, cgroup.cpu.*, cgroup.io.*, and PSI *.pressure.full.avg10 (v2). Version detected once at boot from cached system_info/0; stable metric names regardless of host cgroup version.
  • host / per-processhost.mem.* and host.proc.beam.* (incl. io.{read,write}_bytes) to bridge BEAM ↔ cgroup accounting.
  • per-shape diskelectric.storage.dir.bytes{stack_id,shape} (top-N), by extending the existing DiskUsage 60s walk (single traversal, no second walk, no hot-path counters). Group depth is derived from PureFileStorage.shape_data_dir/3, so it's correct for both standalone (depth 5) and cloud (depth 4) layouts. Named electric.storage.* to sit next to electric.storage.used.bytes in StackTelemetry.

Not included (deferred follow-ups)

  • app.fs.capacity/used/avail (statvfs filesystem-capacity gauges)
  • Honeycomb board/alert migration (done by hand against the new metrics)

Why it matters

Lets us answer, from one Honeycomb query, whether memory growth is real (BEAM heap/binaries), reclaimable (page cache), or fragmentation (carriers held from the OS) — and the equivalent for CPU saturation and disk fill. Without the cgroup/OS plane next to the BEAM plane, there's no way to tell a genuine leak from reclaimable page cache, or to attribute CPU to real scheduler work vs. kernel accounting.

🤖 Generated with Claude Code

@netlify

netlify Bot commented Jul 2, 2026

Copy link
Copy Markdown

Deploy Preview for electric-next ready!

Name Link
🔨 Latest commit 9357ea7
🔍 Latest deploy log https://app.netlify.com/projects/electric-next/deploys/6a498b868758e50008a91292
😎 Deploy Preview https://deploy-preview-4678--electric-next.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@alco
alco force-pushed the alco--host-cgroup-metrics branch from 7248e1b to 9357ea7 Compare July 4, 2026 22:39
@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.09677% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.24%. Comparing base (9e3af10) to head (dbd1b85).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...telemetry/lib/electric/telemetry/system_monitor.ex 28.57% 5 Missing ⚠️
...ry/lib/electric/telemetry/system_metrics/cgroup.ex 88.57% 4 Missing ⚠️
...etry/lib/electric/telemetry/system_metrics/proc.ex 80.00% 3 Missing ⚠️
.../electric/telemetry/system_metrics/procfs_parse.ex 85.71% 2 Missing ⚠️
...ric-telemetry/lib/electric/telemetry/disk_usage.ex 94.44% 1 Missing ⚠️
...telemetry/lib/electric/telemetry/system_metrics.ex 95.65% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4678      +/-   ##
==========================================
+ Coverage   60.03%   60.24%   +0.21%     
==========================================
  Files         395      414      +19     
  Lines       43763    44502     +739     
  Branches    12587    12589       +2     
==========================================
+ Hits        26271    26809     +538     
- Misses      17411    17612     +201     
  Partials       81       81              
Flag Coverage Δ
electric-telemetry 73.74% <87.09%> (?)
elixir 73.74% <87.09%> (?)
packages/agents 72.64% <ø> (ø)
packages/agents-mcp 77.70% <ø> (ø)
packages/agents-mobile 80.67% <ø> (ø)
packages/agents-runtime 83.73% <ø> (ø)
packages/agents-server 75.45% <ø> (-0.10%) ⬇️
packages/agents-server-ui 8.32% <ø> (ø)
packages/electric-ax 51.06% <ø> (ø)
packages/experimental 87.73% <ø> (ø)
packages/react-hooks 86.48% <ø> (ø)
packages/start 82.83% <ø> (ø)
packages/typescript-client 91.86% <ø> (ø)
packages/y-electric 56.05% <ø> (ø)
typescript 60.01% <ø> (-0.02%) ⬇️
unit-tests 60.24% <87.09%> (+0.21%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@alco
alco force-pushed the alco--host-cgroup-metrics branch from 2a943f8 to 1e1c98e Compare July 5, 2026 21:57
alco and others added 13 commits July 7, 2026 12:07
Scaffold ElectricTelemetry.SystemMetrics, a module of poller-invoked
measurement functions complementing ApplicationTelemetry's VM stats.

This first piece delivers:

- Cached platform/cgroup detection (system_info/0) stored in
  :persistent_term. Detects OS via :os.type() and cgroup version (v1/v2/
  none) by stat-ing /sys/fs/cgroup. Later tasks (cgroup/host readers)
  build on this.
- recon_alloc aggregate metrics (every 5s tick): vm.alloc.allocated,
  vm.alloc.used, vm.alloc.unused (allocated - used), and
  vm.alloc.carrier_usage (used/allocated ratio).
- Per-allocator fragmentation (vm.alloc.fragmentation.unused, tagged by
  allocator), gated via an atomic :counters ref created once and cached
  in :persistent_term (one put per gate key), bumped via :counters.add/3
  on the hot path. Roughly once a minute since :recon_alloc.fragmentation/1
  is O(carriers).

Registers the measurements in ApplicationTelemetry and adds the
corresponding Telemetry.Metrics definitions. Adds recon ~> 2.5 dep.
Adds ElectricTelemetry.SystemMetrics.Cgroup, a defensive reader for cgroup
v1 and v2 accounting files, plus a thin cgroup_measurement/2 delegator in
SystemMetrics registered as a builtin periodic measurement.

Emits cgroup.* memory (current/anon/file/working_set/max), cpu
(usage_usec/nr_throttled/throttled_usec) and io (rbytes/wbytes) metrics,
plus PSI full avg10 for memory and cpu on v2. v2 reads /sys/fs/cgroup/*
directly; v1 reads per-controller subdirs. The cgroup version comes from the
cached system_info/0 detection; :none (incl. non-Linux) no-ops cleanly.

memory.max is skipped when unlimited ("max" on v2, the huge sentinel on v1);
working_set is skipped when inactive_file is missing; cpu times are converted
ns->us on v1 for a consistent microsecond unit. All reads are exhaustively
defensive: missing/malformed files skip the metric rather than crash. IO is
v2-only (v1 blkio is awkward and absent on our hosts). The cgroup root is
configurable via opts for fixture-based tests.
Add ElectricTelemetry.SystemMetrics.Proc, a defensive Linux /proc reader
that emits host.mem.* (from /proc/meminfo) and host.proc.beam.* (from
/proc/<beam_pid>/status and /proc/<beam_pid>/io). This bridges the cgroup
plane and the BEAM allocator plane: per-process anon/file/shmem RSS shows
cgroup anon tracking the BEAM footprint, and host meminfo gives the
host-RAM ceiling/cache picture (one task per host on EC2, so host ~=
container).

Parsing of the flat /proc text files lives in a shared
ElectricTelemetry.SystemMetrics.ProcfsParse helper. All reads are
exhaustively defensive: missing files, EACCES on /proc/<pid>/io, missing
keys, and malformed values skip the affected metric rather than crash.
meminfo and status Rss*/VmRSS values (kB) are converted to bytes; io
read_bytes/write_bytes are already bytes and left unscaled.

The reader no-ops cleanly on non-Linux. It is wired in as a thin
proc_measurement/2 delegator on SystemMetrics, registered in
ApplicationTelemetry.builtin_periodic_measurements/1 (read every ~5s
tick) with last_value metric defs in metrics/1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend ElectricTelemetry.DiskUsage to optionally compute per-directory
subtotals during the existing periodic disk walk (a single traversal,
no second pass and no new timer).

Disk.recursive_usage_grouped/3 returns {total, %{dir_name => bytes}}
where directories are bucketed by name at a configurable depth. The
returned total is byte-identical to recursive_usage/2, including the
legacy reset-to-0 behaviour on an unreadable directory; the bucket map
is a best-effort tally that only grows on regular files.

DiskUsage gains :group_depth (default nil = no bucketing, preserving
current behaviour) and :top_n (default 10) options, caches the top-N
largest buckets in ETS, and exposes them via current_dirs/1.

StackTelemetry wires group_depth: 4 (the electric shape storage layout
<storage_dir>/<stack_id>/<p1>/<p2>/<shape_handle>) and defines the new
electric.storage.dir.bytes metric tagged [:stack_id, :shape].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
report_disk_usage/2 now also reads DiskUsage.current_dirs/1 and emits
electric.storage.dir.bytes (tagged by shape handle) for the top-N
largest shapes, reusing the existing periodic disk-usage walk and its
emission cadence. Cardinality is bounded to the top-N buckets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shape-handle directory depth relative to the disk-usage walk root
differs by deployment: standalone walks the bare storage dir with
shapes nested under shapes/<stack_id>/<p1>/<p2>/<shape_handle> (depth
5), while cloud walks the per-tenant shapes dir directly (depth 4). The
previous hard-coded depth of 4 in the generic telemetry package was
therefore wrong for standalone, where it bucketed the per-shape
electric.storage.dir.bytes metric by the 2-char hash-shard prefix
(aggregating many shapes) instead of by shape handle.

StackTelemetry now reads :shape_dir_group_depth from its opts, supplied
by the electric-side caller, which computes it from the path that
PureFileStorage.shape_data_dir/3 actually produces for a sample handle,
measured relative to the walk root. This stays correct if the storage
sharding scheme changes, and passes nil through to disable grouping for
storage backends without an on-disk shape layout. Cross-stack
attribution is a non-issue: each stack's walk root is already its own
subtree, so a stack only ever buckets its own shapes.

Also document the transient full-map + O(n log n) sort in top_n/2 as a
known tradeoff that is fine at ~10k shapes per stack, and use a
realistic two-level-sharded fixture layout in the tests with a
regression guard asserting a too-shallow depth buckets by the
hash-shard prefix instead of the shape handle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the otel-export.lux test to assert that the new SystemMetrics
families reach the collector end-to-end: vm.alloc.* (allocator carriers),
host.mem.* / host.proc.beam.* (/proc), and cgroup.memory.current.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Electric Cloud runs on cgroup v2 and the v1 path was a parallel
read/emit implementation (per-controller subdirs, rss/cache key
mapping, ns->us conversion, unlimited-limit sentinel) that doubled the
reader's surface for hosts we no longer target. cgroup.* metrics are
now v2-only; v1 hosts cleanly no-op like non-Linux platforms.

This also lets cgroup version detection drop the `stat -fc` shell-out
in favour of checking for /sys/fs/cgroup/cgroup.controllers, the
canonical v2 marker, and memory.max no longer needs a sentinel filter
(v2 spells unlimited as the literal "max", which already parses to
nil and is skipped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract the patterns the cgroup and /proc readers had each grown a
private copy of:

- SystemMetrics.emit/2 drops nil measurements and fires a single
  :telemetry.execute per event. The readers previously emitted one
  single-key event per scalar (~19 executes per poll tick); each plane
  now emits one multi-key event (6 executes), matching the package
  convention (cf. get_system_memory_usage) with no metric-definition
  changes.

- ProcfsParse.read_kv_file/1 replaces the three byte-identical
  read_meminfo/read_status/read_proc_io wrappers and Cgroup's own
  read_stat_file: one whitespace-tolerant "key[:] value [unit]" parser
  covers meminfo, status, io, memory.stat and cpu.stat. parse_float
  moves here next to parse_int, and read_int_file is shared too.

Also:

- Register Cgroup.measurement / Proc.measurement MFAs directly with the
  poller instead of going through thin delegators on SystemMetrics
  (the poller accepts any MFA; the extra layer bought nothing).

- recon_alloc_measurement reads :allocated and :used once and derives
  unused/carrier_usage locally instead of triggering four full
  allocator sweeps per tick; the fragmentation reduce no longer
  computes unused_bytes twice per instance.

- Fold the duplicated persistent_term get-or-compute code into a
  memoized/2 helper, drop the unused default_root/0 public functions,
  sum io.stat rbytes/wbytes in a single pass, and trim comment essays
  down to the load-bearing points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Disk.recursive_usage/2 and recursive_usage_grouped/3 were two parallel
copies of the same traversal whose totals had to be kept byte-identical
by hand (with a comment and a test enforcing it). The grouped walk with
a nil group_depth never establishes a bucket key, so it already IS the
ungrouped walk: recursive_usage/2 is now a thin wrapper over it and the
duplicate walker is deleted, making the byte-identical contract true by
construction. This in turn collapses DiskUsage.read_disk_usage's
group_depth branching into a single call.

Also share the defensive ETS lookup between current/1 and
current_dirs/1, and rename the telemetry opt shape_dir_group_depth ->
disk_usage_group_depth: the generic package buckets directories at a
depth and knows nothing about shapes, so the shape vocabulary now stays
in sync-service where the depth is derived.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ApplicationTelemetry already reads host memory via
:memsup.get_system_memory_data() (meminfo-backed on Linux) and emits it
as the [:system, :memory] event, so the meminfo-derived host.mem.total/
free/cached family added on this branch was a second reader for the
same three numbers. Drop it; the Proc reader now covers only the
genuinely new data — the BEAM's own /proc/<pid>/status RSS breakdown
and /proc/<pid>/io counters (host.proc.beam.*).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-allocator fragmentation breakdown is O(carriers) and was
registered with the shared ~5s poller, self-gating to every 12th tick
through a hand-rolled :counters ref cached in :persistent_term — plus
:force/:gate_key test-only options and a public interval accessor to
make the gate testable.

SystemMonitor is already a long-lived GenServer doing periodic work (the
hourly full GC), so give it a one-minute timer that calls the now
ungated allocator_fragmentation_measurement/0 directly. This deletes the
whole gate: the counter, the persistent_term entry, both test-only
options, and the accessor. The sampling call is wrapped in a rescue so a
metrics hiccup can't take down the system monitor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two once-ever answers were being recomputed on every ~5s poll tick:

- read_kv_file/2 now takes a set of wanted keys and skips value parsing
  for every other line. /proc/<pid>/status is ~60 lines of which we
  consume 4 and cgroup memory.stat is ~40 of which we consume 3, so the
  bulk of the per-tick Integer.parse/Map.put work disappears.

- The readers' file paths are constant for the life of the VM. Cgroup
  precomputes the default-root paths at compile time (tests passing
  :cgroup_root still build them dynamically); Proc resolves the BEAM pid
  and joins its two paths once, cached via SystemMetrics.memoized/2
  (made public for this).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alco
alco force-pushed the alco--host-cgroup-metrics branch from 1e1c98e to dbd1b85 Compare July 7, 2026 10:56
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.

1 participant