visionary-sdk 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d9bc46402d28b75e7053fcfa03ac8161a77701c23ba450641dda44385a4e9c9
4
- data.tar.gz: f516ea1508f97c1ad0d7729013fcd400f8de25768338438d658d7c7d01dde94e
3
+ metadata.gz: d5db5f7ee39c2f5a3f6bb1e45851b7add7033d3eecbc6cbcf514dd800f747bc1
4
+ data.tar.gz: f9e1c6ef1391e5780d3d3a0419c375b5c014ce2a3281cdfc71ba76582a258797
5
5
  SHA512:
6
- metadata.gz: 86c78e45acd34ee3c5fd8bb0733618a7eb9957bc1b1a2cb9ad18c0160504bcbbddf0f49873acd8a16509aaa0062e144f8273b13196d9f6267696ff772256b4ac
7
- data.tar.gz: 2f750c54be572e26ec4ebcdfc670d69de77a172003bbdf5613e0935514b9f46cdcea91ca327ca7d8dbe24c80e981348e01a7940ef7610e6bb68822c3c5361dd2
6
+ metadata.gz: 1d651e697211f0989b533ab906304c339de432252ff60080bd9f626215f533611ea95f8197438bb507ffc688fe16cecb0ac8450ab79ff7c672570609e22c407b
7
+ data.tar.gz: 85a4320df09ff95498f6e8c06a6ab26bcce6adc1a9941169d0b52af5222501be5fcd3f60f6affa8b5c25b8914c8abce02e89ad38ed898310201ca32b0fdb9364
data/README.md CHANGED
@@ -169,6 +169,9 @@ from `GC.stat`, heap from `GC.stat` slot and page counts, threads from
169
169
  `/proc/self/status` (else `Thread.list`). Pool columns come from the
170
170
  ActiveRecord pool when it exists, or from `c.db_stats = -> { { total:, idle:,
171
171
  in_use:, max: } }`. On macOS and Windows most host columns are NULL.
172
+ In a container (or a cgroup with a CPU or memory limit) the `container_*`
173
+ columns come from the process's own cgroup, v2 or v1: CPU used, limit and
174
+ throttling, memory used (without inactive page cache) and limit, OOM kills.
172
175
 
173
176
  ## Configuration
174
177
 
@@ -66,11 +66,17 @@ CREATE TABLE IF NOT EXISTS system_metrics (
66
66
  ctx_switches_ps DOUBLE,
67
67
  proc_read_kbps DOUBLE,
68
68
  proc_write_kbps DOUBLE,
69
- proc_max_fds BIGINT
70
- service VARCHAR(255),
71
- version VARCHAR(255),
72
- sdk VARCHAR(255),
73
- runtime VARCHAR(255)
69
+ proc_max_fds BIGINT
70
+ service VARCHAR(255),
71
+ version VARCHAR(255),
72
+ sdk VARCHAR(255),
73
+ runtime VARCHAR(255),
74
+ container_cpu_cores DOUBLE,
75
+ container_cpu_limit DOUBLE,
76
+ container_cpu_throttled_percent DOUBLE,
77
+ container_mem_used_mb DOUBLE,
78
+ container_mem_limit_mb DOUBLE,
79
+ container_oom_kills BIGINT
74
80
  ) CHARACTER SET utf8mb4;
75
81
  CREATE INDEX idx_system_metrics_created_at ON system_metrics (created_at);
76
82
  CREATE INDEX idx_system_metrics_instance_created ON system_metrics (instance, created_at);
@@ -66,11 +66,17 @@ CREATE TABLE IF NOT EXISTS system_metrics (
66
66
  ctx_switches_ps DOUBLE PRECISION,
67
67
  proc_read_kbps DOUBLE PRECISION,
68
68
  proc_write_kbps DOUBLE PRECISION,
69
- proc_max_fds BIGINT
70
- service TEXT,
71
- version TEXT,
72
- sdk TEXT,
73
- runtime TEXT
69
+ proc_max_fds BIGINT
70
+ service TEXT,
71
+ version TEXT,
72
+ sdk TEXT,
73
+ runtime TEXT,
74
+ container_cpu_cores DOUBLE PRECISION,
75
+ container_cpu_limit DOUBLE PRECISION,
76
+ container_cpu_throttled_percent DOUBLE PRECISION,
77
+ container_mem_used_mb DOUBLE PRECISION,
78
+ container_mem_limit_mb DOUBLE PRECISION,
79
+ container_oom_kills BIGINT
74
80
  );
75
81
  CREATE INDEX IF NOT EXISTS idx_system_metrics_created_at ON system_metrics (created_at);
76
82
  CREATE INDEX IF NOT EXISTS idx_system_metrics_instance_created ON system_metrics (instance, created_at);
@@ -66,11 +66,17 @@ CREATE TABLE IF NOT EXISTS system_metrics (
66
66
  ctx_switches_ps REAL,
67
67
  proc_read_kbps REAL,
68
68
  proc_write_kbps REAL,
69
- proc_max_fds INTEGER
70
- service TEXT,
71
- version TEXT,
72
- sdk TEXT,
73
- runtime TEXT
69
+ proc_max_fds INTEGER
70
+ service TEXT,
71
+ version TEXT,
72
+ sdk TEXT,
73
+ runtime TEXT,
74
+ container_cpu_cores REAL,
75
+ container_cpu_limit REAL,
76
+ container_cpu_throttled_percent REAL,
77
+ container_mem_used_mb REAL,
78
+ container_mem_limit_mb REAL,
79
+ container_oom_kills INTEGER
74
80
  );
75
81
  CREATE INDEX IF NOT EXISTS idx_system_metrics_created_at ON system_metrics (created_at);
76
82
  CREATE INDEX IF NOT EXISTS idx_system_metrics_instance_created ON system_metrics (instance, created_at);
@@ -47,7 +47,10 @@ module Visionary
47
47
  %w[host_procs int], %w[procs_running int], %w[procs_blocked int],
48
48
  %w[ctx_switches_ps real], %w[proc_read_kbps real], %w[proc_write_kbps real],
49
49
  %w[proc_max_fds int], %w[service text], %w[version text],
50
- %w[sdk text], %w[runtime text]
50
+ %w[sdk text], %w[runtime text],
51
+ %w[container_cpu_cores real], %w[container_cpu_limit real],
52
+ %w[container_cpu_throttled_percent real], %w[container_mem_used_mb real],
53
+ %w[container_mem_limit_mb real], %w[container_oom_kills int]
51
54
  ].freeze
52
55
 
53
56
  SPAN_COLUMNS = %w[
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Visionary
4
+ module System
5
+ # Reads the CPU and memory limits and use of the process's own cgroup
6
+ # (Linux, v2 or v1): in a container these are the numbers that matter,
7
+ # since the host columns describe the whole machine underneath. The
8
+ # container_* columns are filled only in a container, or when the cgroup
9
+ # sets a CPU or memory limit (a systemd unit with MemoryMax=, say).
10
+ class Cgroup
11
+ MB = 1024.0 * 1024
12
+ MARKERS = %w[docker kubepods containerd libpod lxc].freeze
13
+ # Variables container platforms set in every container: Kubernetes,
14
+ # Railway, Render, Heroku, Cloud Run, ECS/Fargate and Azure Container Apps.
15
+ CONTAINER_ENV = %w[KUBERNETES_SERVICE_HOST RAILWAY_SERVICE_ID RENDER_SERVICE_ID DYNO K_SERVICE ECS_CONTAINER_METADATA_URI_V4 CONTAINER_APP_NAME].freeze
16
+
17
+ # The process's cgroup; nil off Linux or when there is none to read.
18
+ # root and env are for tests.
19
+ def self.detect(root: "/", env: ENV)
20
+ return nil unless Sources::LINUX || root != "/"
21
+ text = Sources.read(File.join(root, "proc/self/cgroup")) or return nil
22
+ c = new(root, text, env)
23
+ c.cpu_dir || c.mem_dir ? c : nil
24
+ end
25
+
26
+ attr_reader :cpu_dir, :mem_dir
27
+
28
+ def initialize(root, text, env)
29
+ @root = root
30
+ @in_ctr = in_container?(text, env)
31
+ @v2 = false
32
+ @cpu_dir = @cpu_acct = @mem_dir = nil
33
+ text.strip.each_line do |line|
34
+ id, ctrls, path = line.chomp.split(":", 3)
35
+ next if path.nil?
36
+ list = ctrls.split(",")
37
+ if id == "0" && ctrls.empty?
38
+ dir = dir_for("sys/fs/cgroup", path)
39
+ if File.exist?(File.join(dir, "cpu.stat"))
40
+ @v2 = true
41
+ @cpu_dir = @mem_dir = dir
42
+ break
43
+ end
44
+ elsif list.include?("memory")
45
+ @mem_dir = dir_for("sys/fs/cgroup/memory", path)
46
+ elsif list.include?("cpu") || list.include?("cpuacct")
47
+ dir = dir_for("sys/fs/cgroup/#{ctrls}", path)
48
+ @cpu_dir = dir if list.include?("cpu")
49
+ @cpu_acct = dir if list.include?("cpuacct")
50
+ end
51
+ end
52
+ @primed = false
53
+ end
54
+
55
+ # The baseline the first sample's deltas start from.
56
+ def prime
57
+ @usage, @periods, @throttled, @ooms = counters
58
+ @last = mono
59
+ @primed = true
60
+ end
61
+
62
+ # Fills the container_* columns of v.
63
+ def sample(v)
64
+ cpus, mem_limit = limits
65
+ return if !@in_ctr && cpus.zero? && mem_limit.zero?
66
+ now = mono
67
+ usage, periods, throttled, ooms = counters
68
+ secs = @primed ? now - @last : 0
69
+ if secs.positive? && usage >= @usage
70
+ v["container_cpu_cores"] = ((usage - @usage) / 1e9 / secs).round(3)
71
+ if periods > @periods && throttled >= @throttled
72
+ v["container_cpu_throttled_percent"] = (100.0 * (throttled - @throttled) / (periods - @periods)).round(1)
73
+ elsif cpus.positive?
74
+ v["container_cpu_throttled_percent"] = 0.0
75
+ end
76
+ v["container_oom_kills"] = ooms - @ooms if ooms && @ooms && ooms >= @ooms
77
+ end
78
+ @usage, @periods, @throttled, @ooms = usage, periods, throttled, ooms
79
+ @last = now
80
+ @primed = true
81
+ v["container_cpu_limit"] = cpus.round(2) if cpus.positive?
82
+ if (used = mem_used)
83
+ v["container_mem_used_mb"] = (used / MB).round(1)
84
+ end
85
+ v["container_mem_limit_mb"] = (mem_limit / MB).round(1) if mem_limit.positive?
86
+ end
87
+
88
+ private
89
+
90
+ def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+
92
+ def path(*parts) = File.join(@root, *parts)
93
+
94
+ # The cgroup's directory under a controller's mount. Inside a
95
+ # container's cgroup namespace the path is "/", and with a private mount
96
+ # the host path does not exist below it, so the mount itself is the
97
+ # cgroup.
98
+ def dir_for(mount, cgroup_path)
99
+ dir = File.join(@root, mount, cgroup_path)
100
+ File.directory?(dir) ? dir : File.join(@root, mount)
101
+ end
102
+
103
+ # Tells a container from a process on the host by the runtimes' marker
104
+ # files, the platforms' variables and the cgroup path.
105
+ def in_container?(text, env)
106
+ File.exist?(path(".dockerenv")) || File.exist?(path("run/.containerenv")) ||
107
+ CONTAINER_ENV.any? { |k| !env[k].to_s.empty? } || MARKERS.any? { |k| text.include?(k) }
108
+ end
109
+
110
+ def read_int(file)
111
+ s = Sources.read(file)&.strip
112
+ s&.match?(/\A\d+\z/) ? s.to_i : nil
113
+ end
114
+
115
+ # "key value" lines (cpu.stat, memory.stat, memory.events).
116
+ def read_kv(file)
117
+ out = {}
118
+ Sources.read(file)&.each_line do |line|
119
+ k, n, extra = line.split
120
+ out[k] = n.to_i if extra.nil? && n&.match?(/\A\d+\z/)
121
+ end
122
+ out
123
+ end
124
+
125
+ # The CPU limit in cores and the memory limit in bytes, 0 when
126
+ # unlimited.
127
+ def limits
128
+ cpus = 0.0
129
+ mem = 0
130
+ if @v2
131
+ q, p = Sources.read(File.join(@cpu_dir, "cpu.max"))&.split
132
+ cpus = q.to_f / p.to_f if q && q != "max" && p.to_f.positive?
133
+ mem = read_int(File.join(@mem_dir, "memory.max")) || 0 # "max": 0
134
+ return [cpus, mem]
135
+ end
136
+ if @cpu_dir
137
+ q = Sources.read(File.join(@cpu_dir, "cpu.cfs_quota_us")).to_s.strip.to_i
138
+ p = read_int(File.join(@cpu_dir, "cpu.cfs_period_us"))
139
+ cpus = q.to_f / p if q.positive? && p&.positive?
140
+ end
141
+ if @mem_dir
142
+ # v1 writes a page-rounded maximum int64 for no limit.
143
+ m = read_int(File.join(@mem_dir, "memory.limit_in_bytes"))
144
+ mem = m if m && m < 2**62
145
+ end
146
+ [cpus, mem]
147
+ end
148
+
149
+ # The cumulative CPU time (ns), CFS periods, throttled periods and OOM
150
+ # kills (nil where the kernel doesn't count them).
151
+ def counters
152
+ if @v2
153
+ st = read_kv(File.join(@cpu_dir, "cpu.stat"))
154
+ ev = read_kv(File.join(@mem_dir, "memory.events"))
155
+ return [st["usage_usec"].to_i * 1000, st["nr_periods"].to_i, st["nr_throttled"].to_i, ev["oom_kill"]]
156
+ end
157
+ usage = @cpu_acct ? read_int(File.join(@cpu_acct, "cpuacct.usage")).to_i : 0
158
+ st = @cpu_dir ? read_kv(File.join(@cpu_dir, "cpu.stat")) : {}
159
+ ooms = @mem_dir ? read_kv(File.join(@mem_dir, "memory.oom_control"))["oom_kill"] : nil
160
+ [usage, st["nr_periods"].to_i, st["nr_throttled"].to_i, ooms]
161
+ end
162
+
163
+ # Memory in use without the inactive page cache, as docker stats
164
+ # counts it.
165
+ def mem_used
166
+ return nil unless @mem_dir
167
+ file, key = @v2 ? %w[memory.current inactive_file] : %w[memory.usage_in_bytes total_inactive_file]
168
+ used = read_int(File.join(@mem_dir, file)) or return nil
169
+ inactive = read_kv(File.join(@mem_dir, "memory.stat"))[key]
170
+ inactive && inactive < used ? used - inactive : used
171
+ end
172
+ end
173
+ end
174
+ end
@@ -90,6 +90,7 @@ module Visionary
90
90
  v["version"] = @cfg.version
91
91
  v["sdk"] = "ruby"
92
92
  v["runtime"] = Telemetry.runtime
93
+ @cgroup&.sample(v)
93
94
  Schema::TABLES["system_metrics"].map { |c| v[c] }
94
95
  end
95
96
 
@@ -119,6 +120,8 @@ module Visionary
119
120
  @gc_ms_prev = heap[:gc_ms]
120
121
  @db_prev = db_stats || {}
121
122
  @prev = read_counters(stat, Sources::LINUX ? Sources.tcp : {})
123
+ @cgroup = Cgroup.detect # nil off Linux
124
+ @cgroup&.prime
122
125
  end
123
126
 
124
127
  def run
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Visionary
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/visionary.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "visionary/queue"
11
11
  require_relative "visionary/writer"
12
12
  require_relative "visionary/retention"
13
13
  require_relative "visionary/system/sources"
14
+ require_relative "visionary/system/cgroup"
14
15
  require_relative "visionary/system/recorder"
15
16
  require_relative "visionary/telemetry"
16
17
  require_relative "visionary/telemetry/hooks"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: visionary-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - styropyr0
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-18 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  The Ruby collector for Visionary. A Rack middleware (with a Railtie for Rails)
@@ -49,6 +49,7 @@ files:
49
49
  - lib/visionary/store/active_record.rb
50
50
  - lib/visionary/store/d1.rb
51
51
  - lib/visionary/store/sql.rb
52
+ - lib/visionary/system/cgroup.rb
52
53
  - lib/visionary/system/recorder.rb
53
54
  - lib/visionary/system/sources.rb
54
55
  - lib/visionary/telemetry.rb