#!/usr/bin/env lua
-- Prometheus node metrics for BusyBox httpd, compatible with Lua 5.1+.
-- Linux data comes directly from /proc and /sys. Filesystem statistics use
-- statvfs when a Lua binding (nixio or luaposix) is installed, otherwise a
-- single shell invocation running df twice (bytes and inodes).
--
-- Performance notes (this file is optimised for CPU time and heap size):
-- * Metric lines are written as several io.write arguments straight into
-- the stdio buffer. Nothing is string.format'ed or concatenated per line,
-- so the output path allocates no Lua strings at all.
-- * /proc lines are parsed with a single string.match whose captures land
-- in locals; no per-line token tables are built.
-- * Escaping and normalisation helpers test with string.find first and only
-- call gsub when the value actually contains a special character.
-- * /proc/stat is read once; btime is remembered for process_start_time.
pcall(os.setlocale, "C")
local format, match, find, gmatch, gsub, byte, sub =
string.format, string.match, string.find, string.gmatch, string.gsub, string.byte, string.sub
local concat = table.concat
local tonumber, tostring, type, getenv, pcall = tonumber, tostring, type, os.getenv, pcall
local floor = math.floor
local write = io.write
---------------------------------------------------------------------------
-- Output buffer
---------------------------------------------------------------------------
-- Fully buffered stdout. (glibc/musl size the buffer themselves; the hint is
-- harmless where it is ignored.)
io.stdout:setvbuf("full", 65536)
local stdout = io.stdout
local function flush() stdout:flush() end
local gateway = getenv("GATEWAY_INTERFACE") or ""
if find(gateway, "^CGI/") then
write("Content-Type: text/plain; version=0.0.4; charset=utf-8\r\n",
"Cache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\n\r\n")
if (getenv("REQUEST_METHOD") or "GET") == "HEAD" then
flush()
os.exit(0)
end
end
---------------------------------------------------------------------------
-- Helpers
---------------------------------------------------------------------------
local function read_line(path)
local f = io.open(path, "r")
if not f then return nil end
local value = f:read("*l")
f:close()
return value
end
local function read_all(path)
local f = io.open(path, "r")
if not f then return nil end
local value = f:read("*a")
f:close()
return value
end
local function each_line(path, callback)
local f = io.open(path, "r")
if not f then return false end
for line in f:lines() do callback(line) end
f:close()
return true
end
local function label(value)
if value == nil then return "" end
if type(value) ~= "string" then value = tostring(value) end
if not find(value, '[\\"\n]') then return value end
value = gsub(value, "\\", "\\\\")
value = gsub(value, "\n", "\\n")
value = gsub(value, '"', '\\"')
return value
end
-- name{labels} value
local function metric(name, value, labels)
if value == nil then return end
if type(value) == "number" then
-- Render numbers here instead of letting io.write pick LUAI_NUMFMT,
-- which turns a large counter into "1.3721671085e+10". "%.0f" also
-- stays exact on the Lua 5.1 builds that refuse "%d" for a double
-- (OpenWrt's, which adds an int32 integer type).
value = value == floor(value) and format("%.0f", value) or format("%.14g", value)
end
write(name, labels or "", " ", value, "\n")
end
local function metric_integer(name, value, labels)
value = tonumber(value)
if not value then return end
write(name, labels or "", " ", format("%.0f", value), "\n")
end
local function metric_fraction(name, value, divisor, labels)
value = tonumber(value)
if not value then return end
write(name, labels or "", " ", format("%.6f", value / divisor), "\n")
end
local function help(name, text, kind)
write("# HELP ", name, " ", text, "\n# TYPE ", name, " ", kind, "\n")
end
local function uptime()
local line = read_line("/proc/uptime")
return line and tonumber(match(line, "^(%S+)")) or nil
end
local scrape_started = uptime() or os.clock()
local clk_tck = tonumber(getenv("NODE_EXPORTER_CLK_TCK") or "100") or 100
if clk_tck <= 0 then clk_tck = 100 end
local function architecture()
local arch = getenv("HOSTTYPE") or getenv("MACHTYPE")
if arch and arch ~= "" then return match(arch, "^[^-]+") or arch end
arch = match(package and package.cpath or "", "/lib/([^/]+)/lua")
if arch then return match(arch, "^[^-]+") or arch end
-- Only the first processor block of /proc/cpuinfo is needed; stop early
-- instead of reading the whole file (which is large on many-core hosts).
local f = io.open("/proc/cpuinfo", "r")
if not f then return "unknown" end
arch = nil
for line in f:lines() do
if line == "" then break end
if find(line, "^vendor_id") then arch = "x86_64" break end
local version = match(line, "^CPU architecture%s*:%s*(%d+)")
if version then arch = version == "8" and "aarch64" or ("armv" .. version .. "l") break end
if find(line, "^cpu model%s*:%s*MIPS") then arch = "mips" break end
if find(line, "^isa%s*:%s*rv64") then arch = "riscv64" break end
end
f:close()
return arch or "unknown"
end
---------------------------------------------------------------------------
-- Collectors
---------------------------------------------------------------------------
local function collect_uname_os()
help("node_uname_info", "Labeled system information as provided by the kernel.", "gauge")
write(format('node_uname_info{domainname="%s",machine="%s",nodename="%s",release="%s",sysname="%s",version="%s"} 1\n',
label(read_line("/proc/sys/kernel/domainname")), label(architecture()),
label(read_line("/proc/sys/kernel/hostname")), label(read_line("/proc/sys/kernel/osrelease")),
label(read_line("/proc/sys/kernel/ostype")), label(read_line("/proc/sys/kernel/version"))))
local osinfo = {}
each_line("/etc/os-release", function(line)
local key, value = match(line, "^([A-Z][A-Z0-9_]*)=(.*)$")
if key then
if find(value, '^".*"$') then
value = sub(value, 2, -2)
if find(value, "\\", 1, true) then
value = gsub(value, '\\"', '"')
value = gsub(value, "\\\\", "\\")
end
end
osinfo[key] = value
end
end)
help("node_os_info", "Labeled operating system information from /etc/os-release.", "gauge")
write(format('node_os_info{build_id="%s",id="%s",image_id="%s",image_version="%s",name="%s",pretty_name="%s",variant="%s",variant_id="%s",version_codename="%s",version_id="%s"} 1\n',
label(osinfo.BUILD_ID), label(osinfo.ID), label(osinfo.IMAGE_ID), label(osinfo.IMAGE_VERSION),
label(osinfo.NAME), label(osinfo.PRETTY_NAME), label(osinfo.VARIANT), label(osinfo.VARIANT_ID),
label(osinfo.VERSION_CODENAME), label(osinfo.VERSION_ID)))
end
local cpu_ids = {}
local boot_time -- btime from /proc/stat, reused by collect_self
local stat_metrics = {
btime = "node_boot_time_seconds", ctxt = "node_context_switches_total",
processes = "node_forks_total", intr = "node_intr_total",
procs_running = "node_procs_running", procs_blocked = "node_procs_blocked",
}
-- node_cpu_seconds_total{cpu="N",mode="M"} value, written as pieces
local function cpu_seconds(head, cpu, tail, jiffies)
write(head, cpu, tail, format("%.6f", jiffies / clk_tck), "\n")
end
local function collect_stat()
help("node_boot_time_seconds", "Node boot time, in unixtime.", "gauge")
help("node_time_seconds", "System time in seconds since epoch.", "gauge")
metric("node_time_seconds", os.time())
help("node_cpu_seconds_total", "Seconds the CPUs spent in each mode.", "counter")
help("node_cpu_guest_seconds_total", "Seconds the CPUs spent in guest mode.", "counter")
help("node_context_switches_total", "Total number of context switches.", "counter")
help("node_forks_total", "Total number of forks.", "counter")
help("node_intr_total", "Total number of interrupts serviced.", "counter")
help("node_procs_running", "Number of processes in runnable state.", "gauge")
help("node_procs_blocked", "Number of processes blocked waiting for I/O.", "gauge")
local f = io.open("/proc/stat", "r")
if not f then return end
for line in f:lines() do
-- Optional trailing fields (steal/guest/guest_nice) are absent on old kernels.
local cpu, user, nice, system, idle, iowait, irq, softirq, steal, guest, guest_nice = match(line,
"^cpu(%d+)%s+(%d+)%s*(%d*)%s*(%d*)%s*(%d*)%s*(%d*)%s*(%d*)%s*(%d*)%s*(%d*)%s*(%d*)%s*(%d*)")
if cpu then
cpu_ids[#cpu_ids + 1] = cpu
guest, guest_nice = tonumber(guest) or 0, tonumber(guest_nice) or 0
cpu_seconds('node_cpu_seconds_total{cpu="', cpu, '",mode="user"} ', (tonumber(user) or 0) - guest)
cpu_seconds('node_cpu_seconds_total{cpu="', cpu, '",mode="nice"} ', (tonumber(nice) or 0) - guest_nice)
cpu_seconds('node_cpu_seconds_total{cpu="', cpu, '",mode="system"} ', tonumber(system) or 0)
cpu_seconds('node_cpu_seconds_total{cpu="', cpu, '",mode="idle"} ', tonumber(idle) or 0)
cpu_seconds('node_cpu_seconds_total{cpu="', cpu, '",mode="iowait"} ', tonumber(iowait) or 0)
cpu_seconds('node_cpu_seconds_total{cpu="', cpu, '",mode="irq"} ', tonumber(irq) or 0)
cpu_seconds('node_cpu_seconds_total{cpu="', cpu, '",mode="softirq"} ', tonumber(softirq) or 0)
cpu_seconds('node_cpu_seconds_total{cpu="', cpu, '",mode="steal"} ', tonumber(steal) or 0)
cpu_seconds('node_cpu_guest_seconds_total{cpu="', cpu, '",mode="user"} ', guest)
cpu_seconds('node_cpu_guest_seconds_total{cpu="', cpu, '",mode="nice"} ', guest_nice)
else
local key, value = match(line, "^(%S+)%s+(%S+)")
local name = key and stat_metrics[key]
if name then
if key == "btime" then boot_time = value end
metric(name, value)
end
end
end
f:close()
end
local function collect_loadavg()
local line = read_line("/proc/loadavg")
if not line then return end
local load1, load5, load15, running, total = match(line, "^(%S+)%s+(%S+)%s+(%S+)%s+(%d+)/(%d+)")
if not load1 then return end
help("node_load1", "1m load average.", "gauge"); metric("node_load1", load1)
help("node_load5", "5m load average.", "gauge"); metric("node_load5", load5)
help("node_load15", "15m load average.", "gauge"); metric("node_load15", load15)
help("node_loadavg_processes_running", "Number of processes in runnable state.", "gauge")
metric("node_loadavg_processes_running", running)
help("node_loadavg_processes_total", "Total number of processes.", "gauge")
metric("node_loadavg_processes_total", total)
end
local function collect_meminfo()
local f = io.open("/proc/meminfo", "r")
if not f then return end
for line in f:lines() do
local key, value, unit = match(line, "^([^:]+):%s+(%-?%d+)%s*(%S*)")
if key then
if find(key, "[%(%)%-]") then
key = gsub(gsub(gsub(key, "%(", "_"), "%)", ""), "%-", "_")
end
local suffix = ""
if unit == "kB" then
suffix, value = "_bytes", format("%.0f", (tonumber(value) or 0) * 1024)
end
write("# HELP node_memory_", key, suffix, " Memory information field ", key,
".\n# TYPE node_memory_", key, suffix, " gauge\nnode_memory_", key, suffix, " ", value, "\n")
end
end
f:close()
end
local function collect_vmstat()
local f = io.open("/proc/vmstat", "r")
if not f then return end
-- Lines are "key value" with one space, so the validated line is written
-- as-is instead of capturing (and allocating) the two fields.
for line in f:lines() do
if find(line, "^[%w_]+ %-?%d+$") then
write("node_vmstat_", line, "\n")
end
end
f:close()
end
local function disk_excluded(dev)
local last = byte(dev, -1)
if not last or last < 48 or last > 57 then return false end -- every excluded pattern ends in a digit
return find(dev, "^ram%d+$") or find(dev, "^loop%d+$") or find(dev, "^fd%d+$")
or find(dev, "^x?[shv]d%a+%d+$") -- sdXN, hdXN, vdXN, xvdXN partitions
or find(dev, "^nvme%d+n%d+p%d+$") or find(dev, "^mmcblk%d+p%d+$")
end
-- major minor device + 11 mandatory fields (kernel >= 2.6) + optional discard (4.18) and flush (5.5) fields
local diskstats_pattern = "^%s*%d+%s+%d+%s+%S+%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)"
.. "%s*(%d*)%s*(%d*)%s*(%d*)%s*(%d*)%s*(%d*)%s*(%d*)"
local function disk_metrics(labels, line)
local reads, reads_merged, read_sectors, read_ms, writes, writes_merged, written_sectors, write_ms,
io_now, io_ms, io_weighted_ms, discards, discards_merged, discarded_sectors, discard_ms,
flushes, flush_ms = match(line, diskstats_pattern)
if not reads then return end
metric("node_disk_reads_completed_total", reads, labels)
metric("node_disk_reads_merged_total", reads_merged, labels)
metric_integer("node_disk_read_bytes_total", (tonumber(read_sectors) or 0) * 512, labels)
metric_fraction("node_disk_read_time_seconds_total", read_ms, 1000, labels)
metric("node_disk_writes_completed_total", writes, labels)
metric("node_disk_writes_merged_total", writes_merged, labels)
metric_integer("node_disk_written_bytes_total", (tonumber(written_sectors) or 0) * 512, labels)
metric_fraction("node_disk_write_time_seconds_total", write_ms, 1000, labels)
metric("node_disk_io_now", io_now, labels)
metric_fraction("node_disk_io_time_seconds_total", io_ms, 1000, labels)
metric_fraction("node_disk_io_time_weighted_seconds_total", io_weighted_ms, 1000, labels)
if discard_ms ~= "" then
metric("node_disk_discards_completed_total", discards, labels)
metric("node_disk_discards_merged_total", discards_merged, labels)
metric("node_disk_discarded_sectors_total", discarded_sectors, labels)
metric_fraction("node_disk_discard_time_seconds_total", discard_ms, 1000, labels)
end
if flush_ms ~= "" then
metric("node_disk_flush_requests_total", flushes, labels)
metric_fraction("node_disk_flush_requests_time_seconds_total", flush_ms, 1000, labels)
end
end
local function collect_diskstats()
help("node_disk_reads_completed_total", "The total number of reads completed successfully.", "counter")
help("node_disk_read_bytes_total", "The total number of bytes read successfully.", "counter")
help("node_disk_writes_completed_total", "The total number of writes completed successfully.", "counter")
help("node_disk_written_bytes_total", "The total number of bytes written successfully.", "counter")
local f = io.open("/proc/diskstats", "r")
if not f then return end
for line in f:lines() do
-- Cheap device-name match first; most lines (partitions, loop/ram devices) are excluded.
local dev = match(line, "^%s*%d+%s+%d+%s+(%S+)")
if dev and not disk_excluded(dev) then
disk_metrics('{device="' .. label(dev) .. '"}', line)
end
end
f:close()
end
local netdev_names = {
"node_network_receive_bytes_total", "node_network_receive_packets_total",
"node_network_receive_errs_total", "node_network_receive_drop_total",
"node_network_receive_fifo_total", "node_network_receive_frame_total",
"node_network_receive_compressed_total", "node_network_receive_multicast_total",
"node_network_transmit_bytes_total", "node_network_transmit_packets_total",
"node_network_transmit_errs_total", "node_network_transmit_drop_total",
"node_network_transmit_fifo_total", "node_network_transmit_colls_total",
"node_network_transmit_carrier_total", "node_network_transmit_compressed_total",
}
local netdev_pattern = "^%s*([^:]+):%s*(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)"
.. "%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+)"
local function collect_netdev()
local devices, seen = {}, {}
help("node_network_receive_bytes_total", "Network device statistic receive_bytes.", "counter")
help("node_network_transmit_bytes_total", "Network device statistic transmit_bytes.", "counter")
local f = io.open("/proc/net/dev", "r")
if not f then return devices end
for line in f:lines() do
local v = {match(line, netdev_pattern)}
local iface = v[1]
if iface then
if not seen[iface] then devices[#devices + 1], seen[iface] = iface, true end
local labels = '{device="' .. label(iface) .. '"}'
for i = 1, 16 do write(netdev_names[i], labels, " ", v[i + 1], "\n") end
end
end
f:close()
return devices
end
local function collect_netclass(devices)
help("node_network_info", "Non-numeric data from /sys/class/net.", "gauge")
for i = 1, #devices do
local iface = devices[i]
local base = "/sys/class/net/" .. iface .. "/"
local operstate = read_line(base .. "operstate") or "unknown"
write(format('node_network_info{address="%s",broadcast="%s",device="%s",duplex="%s",ifalias="%s",operstate="%s"} 1\n',
label(read_line(base .. "address") or ""), label(read_line(base .. "broadcast") or ""), label(iface),
label(read_line(base .. "duplex") or ""), label(read_line(base .. "ifalias") or ""), label(operstate)))
local labels = '{device="' .. label(iface) .. '"}'
metric("node_network_up", operstate == "up" and 1 or 0, labels)
metric("node_network_carrier", read_line(base .. "carrier"), labels)
metric("node_network_carrier_changes_total", read_line(base .. "carrier_changes"), labels)
metric("node_network_mtu_bytes", read_line(base .. "mtu"), labels)
local speed = tonumber(read_line(base .. "speed"))
if speed and speed >= 0 then metric_integer("node_network_speed_bytes", speed * 1000000 / 8, labels) end
end
end
-- /proc/net/snmp and /proc/net/netstat: pairs of header and value lines.
local function collect_netstat(path)
local f = io.open(path, "r")
if not f then return end
while true do
local header, values = f:read("*l"), f:read("*l")
if not header or not values then break end
local hi, vi = gmatch(header, "%S+"), gmatch(values, "%S+")
local hp, vp = hi(), vi()
if hp and hp == vp then
if byte(hp, -1) == 58 then hp = sub(hp, 1, -2) end -- strip trailing ':'
for key in hi do
local value = vi()
if not value then break end
if find(value, "^%-?%d+$") then
if find(key, "[^%w_]") then key = gsub(key, "[^%w_]", "_") end
write("node_netstat_", hp, "_", key, " ", value, "\n")
end
end
end
end
f:close()
end
local function collect_sockstat()
local function one(path)
local f = io.open(path, "r")
if not f then return end
for line in f:lines() do
local proto, rest = match(line, "^([^:%s]+):%s*(.*)$")
if proto then
for key, value in gmatch(rest, "(%S+)%s+(%S+)") do
if find(value, "^%d+$") then
if find(key, "[^%w_]") then key = gsub(key, "[^%w_]", "_") end
write("node_sockstat_", proto, "_", key, " ", value, "\n")
end
end
end
end
f:close()
end
one("/proc/net/sockstat"); one("/proc/net/sockstat6")
end
local excluded_fstype = {
autofs=true, binfmt_misc=true, bpf=true, cgroup=true, cgroup2=true, configfs=true,
debugfs=true, devpts=true, devtmpfs=true, fusectl=true, hugetlbfs=true, mqueue=true,
nsfs=true, proc=true, procfs=true, pstore=true, rpc_pipefs=true, securityfs=true,
sysfs=true, tracefs=true
}
local mount_escapes = { ["\\040"] = " ", ["\\011"] = "\t", ["\\012"] = "\n", ["\\134"] = "\\" }
local function decode_mount(value)
if not find(value, "\\", 1, true) then return value end
return (gsub(value, "\\%d%d%d", mount_escapes))
end
local function mount_excluded(mp)
return mp == "/dev" or find(mp, "^/dev/") or mp == "/proc" or find(mp, "^/proc/")
or mp == "/sys" or find(mp, "^/sys/") or find(mp, "^/run/credentials/")
or find(mp, "^/var/lib/docker/") or find(mp, "^/var/lib/containers/storage/")
end
-- statvfs binding, if one is installed (LuCI's nixio or luaposix); nil otherwise.
local statvfs
do
local ok, mod = pcall(require, "nixio.fs")
if ok and type(mod) == "table" and mod.statvfs then
statvfs = mod.statvfs
else
ok, mod = pcall(require, "posix.sys.statvfs")
if ok and type(mod) == "table" and mod.statvfs then statvfs = mod.statvfs end
end
end
local function collect_filesystem()
if getenv("NODE_EXPORTER_NO_DF") == "1" then return end
local mounts, order = {}, {}
each_line("/proc/self/mounts", function(line)
local dev, mp, fstype, options = match(line, "^(%S+)%s+(%S+)%s+(%S+)%s+(%S+)")
if not dev or excluded_fstype[fstype] then return end
dev, mp = decode_mount(dev), decode_mount(mp)
if mount_excluded(mp) then return end
if not mounts[mp] then order[#order + 1] = mp end
mounts[mp] = {
labels = format('{device="%s",fstype="%s",mountpoint="%s"}', label(dev), label(fstype), label(mp)),
readonly = find("," .. options .. ",", ",ro,", 1, true) and 1 or 0,
}
end)
help("node_filesystem_avail_bytes", "Filesystem space available to non-root users in bytes.", "gauge")
help("node_filesystem_free_bytes", "Filesystem free space in bytes.", "gauge")
help("node_filesystem_size_bytes", "Filesystem size in bytes.", "gauge")
if statvfs then
for i = 1, #order do
local mp = order[i]
local m = mounts[mp]
local ok, st = pcall(statvfs, mp)
if ok and type(st) == "table" then
-- nixio uses bare field names, luaposix prefixes them with f_.
local bs = st.frsize or st.f_frsize or st.bsize or st.f_bsize
local blocks, bfree, bavail = st.blocks or st.f_blocks, st.bfree or st.f_bfree, st.bavail or st.f_bavail
if bs and blocks then
m.files, m.ffree = st.files or st.f_files, st.ffree or st.f_ffree
metric_integer("node_filesystem_avail_bytes", (bavail or 0) * bs, m.labels)
metric_integer("node_filesystem_free_bytes", (bfree or 0) * bs, m.labels)
metric_integer("node_filesystem_size_bytes", blocks * bs, m.labels)
metric("node_filesystem_readonly", m.readonly, m.labels)
metric("node_filesystem_device_error", 0, m.labels)
else
metric("node_filesystem_device_error", 1, m.labels)
end
else
metric("node_filesystem_device_error", 1, m.labels)
end
end
for i = 1, #order do
local m = mounts[order[i]]
if m.files then
metric_integer("node_filesystem_files", m.files, m.labels)
metric_integer("node_filesystem_files_free", m.ffree, m.labels)
end
end
return
end
-- Fallback: one shell, two df runs. POSIX -k is also supported by older
-- BusyBox df versions which lack GNU -B1.
local df = "/bin/df"
local probe = io.open("/usr/bin/df", "r")
if probe then probe:close(); df = "/usr/bin/df" end
local pipe = io.popen("LC_ALL=C " .. df .. " -P -k 2>&1; echo @@INODES; LC_ALL=C " .. df .. " -P -i 2>&1", "r")
if not pipe then return end
local inode, header, failed = false, true, false
for line in pipe:lines() do
if line == "@@INODES" then
inode, header, failed = true, true, false
elseif find(line, "^df: invalid option") then
failed = true
elseif header then
header = false
elseif not failed then
local size, used, avail, mp = match(line, "^%S+%s+(%d+)%s+(%d+)%s+(%d+)%s+%S+%s+(.+)$")
local m = mp and mounts[mp]
if m then
if inode then
m.files, m.ffree = size, avail
else
m.size, m.used, m.avail = size, used, avail
end
end
end
end
pipe:close()
for i = 1, #order do
local m = mounts[order[i]]
if m.size then
local size, used, avail = tonumber(m.size), tonumber(m.used), tonumber(m.avail)
metric_integer("node_filesystem_avail_bytes", avail * 1024, m.labels)
metric_integer("node_filesystem_free_bytes", (size - used) * 1024, m.labels)
metric_integer("node_filesystem_size_bytes", size * 1024, m.labels)
metric("node_filesystem_readonly", m.readonly, m.labels)
metric("node_filesystem_device_error", 0, m.labels)
end
end
for i = 1, #order do
local m = mounts[order[i]]
if m.files then
metric("node_filesystem_files", m.files, m.labels)
metric("node_filesystem_files_free", m.ffree, m.labels)
end
end
end
local function collect_filefd_entropy_conntrack()
local allocated, maximum = match(read_line("/proc/sys/fs/file-nr") or "", "^(%d+)%s+%d+%s+(%d+)")
if allocated then
help("node_filefd_allocated", "File descriptor statistics: allocated.", "gauge")
metric("node_filefd_allocated", allocated)
help("node_filefd_maximum", "File descriptor statistics: maximum.", "gauge")
metric("node_filefd_maximum", maximum)
end
local entropy = read_line("/proc/sys/kernel/random/entropy_avail")
if entropy then
help("node_entropy_available_bits", "Bits of available entropy.", "gauge")
metric("node_entropy_available_bits", entropy)
end
metric("node_nf_conntrack_entries", read_line("/proc/sys/net/netfilter/nf_conntrack_count"))
metric("node_nf_conntrack_entries_limit", read_line("/proc/sys/net/netfilter/nf_conntrack_max"))
end
local pressure_resources = {"cpu", "io", "memory", "irq"}
local function collect_pressure()
for i = 1, #pressure_resources do
local resource = pressure_resources[i]
each_line("/proc/pressure/" .. resource, function(line)
local kind, total = match(line, "^(%S+).+total=(%d+)")
if kind then
local state = kind == "some" and "waiting" or (kind == "full" and "stalled" or nil)
if state then
metric_fraction("node_pressure_" .. resource .. "_" .. state .. "_seconds_total", total, 1000000)
end
end
end)
end
end
-- Probe 1_input, 2_input, ... Stops after 4 consecutive
-- misses once a sensor was found, or after 8 misses if none was.
local function indexed_inputs(base, stem, maximum, callback)
local misses, found = 0, false
for i = 1, maximum do
local prefix = base .. stem .. i
local value = read_line(prefix .. "_input")
if value then
found, misses = true, 0
callback(prefix, stem .. i, value)
else
misses = misses + 1
if misses >= (found and 4 or 8) then break end
end
end
end
local function collect_hwmon_thermal()
local misses = 0
for h = 0, 63 do
local base = "/sys/class/hwmon/hwmon" .. h .. "/"
local chip = read_line(base .. "name")
if chip then
misses = 0
chip = label(chip)
indexed_inputs(base, "temp", 32, function(prefix, sensor, value)
sensor = read_line(prefix .. "_label") or sensor
metric_fraction("node_hwmon_temp_celsius", value, 1000,
'{chip="' .. chip .. '",sensor="' .. label(sensor) .. '"}')
end)
indexed_inputs(base, "fan", 32, function(prefix, sensor, value)
sensor = read_line(prefix .. "_label") or sensor
metric("node_hwmon_fan_rpm", value, '{chip="' .. chip .. '",sensor="' .. label(sensor) .. '"}')
end)
else
misses = misses + 1
if misses >= 4 then break end
end
end
misses = 0
for z = 0, 255 do
local base = "/sys/class/thermal/thermal_zone" .. z .. "/"
local value = read_line(base .. "temp")
if value then
misses = 0
metric_fraction("node_thermal_zone_temp", value, 1000,
'{type="' .. label(read_line(base .. "type") or "unknown") .. '",zone="' .. z .. '"}')
else
misses = misses + 1
if misses >= 4 then break end
end
end
end
local cpufreq_files = {
{"scaling_cur_freq", "node_cpu_scaling_frequency_hertz"},
{"cpuinfo_cur_freq", "node_cpu_frequency_hertz"},
{"cpuinfo_max_freq", "node_cpu_frequency_max_hertz"},
{"cpuinfo_min_freq", "node_cpu_frequency_min_hertz"},
}
local function collect_cpufreq()
for i = 1, #cpu_ids do
local cpu = cpu_ids[i]
local base = "/sys/devices/system/cpu/cpu" .. cpu .. "/cpufreq/"
local labels = '{cpu="' .. cpu .. '"}'
for j = 1, #cpufreq_files do
local value = tonumber(read_line(base .. cpufreq_files[j][1]))
if value then metric_integer(cpufreq_files[j][2], value * 1000, labels) end
end
end
end
local dmi_keys = {
"bios_date", "bios_release", "bios_vendor", "bios_version", "board_asset_tag", "board_name",
"board_vendor", "board_version", "chassis_asset_tag", "chassis_type", "chassis_vendor",
"chassis_version", "product_family", "product_name", "product_sku", "product_uuid",
"product_version", "system_vendor"
}
local function collect_dmi()
if not read_line("/sys/class/dmi/id/product_name") then return end
local labels = {}
for i = 1, #dmi_keys do
labels[i] = dmi_keys[i] .. '="' .. label(read_line("/sys/class/dmi/id/" .. dmi_keys[i])) .. '"'
end
help("node_dmi_info", "A metric with a constant 1 value labeled by DMI information.", "gauge")
write("node_dmi_info{", concat(labels, ","), "} 1\n")
end
local function collect_self()
local stat = read_line("/proc/self/stat")
if stat then
-- Fields after the command name: state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime cutime cstime priority nice threads itrealvalue starttime
local utime, stime, starttime = match(stat, "^%d+ %(.+%)%s+%S+%s+%S+%s+%S+%s+%S+%s+%S+%s+%S+%s+%S+%s+%S+%s+%S+%s+%S+%s+%S+%s+(%d+)%s+(%d+)%s+%S+%s+%S+%s+%S+%s+%S+%s+%S+%s+%S+%s+(%d+)")
if utime then
metric_fraction("process_cpu_seconds_total", (tonumber(utime) or 0) + (tonumber(stime) or 0), clk_tck)
local status = read_all("/proc/self/status") or ""
local rss = match(status, "VmRSS:%s+(%d+)")
local size = match(status, "VmSize:%s+(%d+)")
if rss then metric_integer("process_resident_memory_bytes", tonumber(rss) * 1024) end
if size then metric_integer("process_virtual_memory_bytes", tonumber(size) * 1024) end
local boot = boot_time
if not boot then
each_line("/proc/stat", function(line) if not boot then boot = match(line, "^btime%s+(%d+)") end end)
end
if boot then
metric_fraction("process_start_time_seconds", tonumber(boot) * clk_tck + (tonumber(starttime) or 0), clk_tck)
end
end
end
local limits = read_all("/proc/self/limits") or ""
metric("process_max_fds", match(limits, "Max open files%s+(%d+)"))
end
---------------------------------------------------------------------------
-- Main
---------------------------------------------------------------------------
help("node_exporter_build_info", "A metric with a constant 1 value labeled by version information.", "gauge")
write(format('node_exporter_build_info{branch="lua-cgi",goversion="",revision="",version="%s"} 1\n', label(_VERSION)))
-- Each collector runs under pcall so one unexpected /proc format cannot take
-- the whole scrape down; failures are reported via node_scrape_collector_success.
local success = 1
local function run(collector, ...)
local ok, result = pcall(collector, ...)
if not ok then
success = 0
io.stderr:write("node-exporter: ", tostring(result), "\n")
return nil
end
return result
end
run(collect_uname_os)
run(collect_stat)
run(collect_loadavg)
run(collect_meminfo)
run(collect_vmstat)
run(collect_diskstats)
local network_devices = run(collect_netdev) or {}
run(collect_netclass, network_devices)
run(collect_netstat, "/proc/net/snmp")
run(collect_netstat, "/proc/net/netstat")
run(collect_sockstat)
run(collect_filesystem)
run(collect_filefd_entropy_conntrack)
run(collect_pressure)
run(collect_hwmon_thermal)
run(collect_cpufreq)
run(collect_dmi)
run(collect_self)
local scrape_finished = uptime() or os.clock()
help("node_scrape_collector_success", "Whether the Lua collector succeeded.", "gauge")
metric("node_scrape_collector_success", success, '{collector="lua"}')
help("node_scrape_duration_seconds", "node-exporter-lua.cgi scrape duration.", "gauge")
write(format("node_scrape_duration_seconds %.6f\n", scrape_finished - scrape_started))
flush()