From 71e56d95289526f2225ba29eeba02661165143ac Mon Sep 17 00:00:00 2001 From: bilkoua Date: Mon, 16 Mar 2026 11:47:02 +0200 Subject: [PATCH 01/12] init inputs.node_info plugin --- plugins/inputs/all/node_info.go | 5 + plugins/inputs/node_info/README.md | 140 +++ plugins/inputs/node_info/node_info.go | 329 +++++++ .../inputs/node_info/node_info_notlinux.go | 34 + plugins/inputs/node_info/node_info_test.go | 803 ++++++++++++++++++ plugins/inputs/node_info/sample.conf | 17 + 6 files changed, 1328 insertions(+) create mode 100644 plugins/inputs/all/node_info.go create mode 100644 plugins/inputs/node_info/README.md create mode 100644 plugins/inputs/node_info/node_info.go create mode 100644 plugins/inputs/node_info/node_info_notlinux.go create mode 100644 plugins/inputs/node_info/node_info_test.go create mode 100644 plugins/inputs/node_info/sample.conf diff --git a/plugins/inputs/all/node_info.go b/plugins/inputs/all/node_info.go new file mode 100644 index 0000000000000..2a2898d3f1972 --- /dev/null +++ b/plugins/inputs/all/node_info.go @@ -0,0 +1,5 @@ +//go:build !custom || inputs || inputs.node_info + +package all + +import _ "github.com/influxdata/telegraf/plugins/inputs/node_info" // register plugin diff --git a/plugins/inputs/node_info/README.md b/plugins/inputs/node_info/README.md new file mode 100644 index 0000000000000..d8c19893f8218 --- /dev/null +++ b/plugins/inputs/node_info/README.md @@ -0,0 +1,140 @@ +# Node Info Input Plugin + +Collects static node information and exposes it as labeled gauge metrics with a +constant value of `1`, mirroring the behavior of +[prometheus-node-exporter][node-exporter] for the `node_os_info`, +`node_dmi_info`, and `node_uname_info` metrics. + +⭐ Telegraf v1.34.0 +🏷️ system +💻 linux + +[node-exporter]: https://github.com/prometheus/node_exporter + +## Global configuration options + +Plugins support additional global and plugin configuration settings for tasks +such as modifying metrics, tags, and fields, creating aliases, and configuring +plugin ordering. See [CONFIGURATION.md][CONFIGURATION.md] for more details. + +[CONFIGURATION.md]: ../../../docs/CONFIGURATION.md#plugins + +## Configuration + +```toml @sample.conf +# Collect node OS, DMI, and uname information (analogous to prometheus-node-exporter) +# This plugin ONLY supports Linux +[[inputs.node_info]] + ## Path to the host /etc directory. + ## Useful when running inside a container with the host filesystem mounted. + ## Defaults: + # host_etc = "/etc" + + ## Path to the host /sys directory. + ## Useful when running inside a container with the host filesystem mounted. + ## Defaults: + # host_sys = "/sys" + + ## Metric groups to collect. + ## Available options: "os", "dmi", "uname" + ## Defaults: + # collect = ["os", "dmi", "uname"] +``` + +### Container / privileged usage + +When Telegraf runs inside a container but needs to inspect the **host** +filesystem, mount the host paths and point the plugin at them: + +```toml +[[inputs.node_info]] + host_etc = "/host/etc" + host_sys = "/host/sys" +``` + +Some DMI files under `/sys/class/dmi/id/` (e.g. `product_serial`, +`board_serial`, `product_uuid`) are readable only by `root`. When running as +an unprivileged user those fields will appear as empty strings in the metric +tags; no error is returned. + +> [!NOTE] +> On platforms where `/sys/class/dmi/id/` does not exist (ARM SBCs, +> unprivileged containers, etc.) the `node_dmi` metric is silently skipped. +> To avoid the directory lookup entirely, set `collect = ["os", "uname"]`. + +## Metrics + +Each measurement has a single field `info` (integer, gauge, always `1`) +with labels encoded as tags. + +### `node_os_info` + +Sourced from `/etc/os-release` ([os-release(5)][os-release]). Not all +distributions provide every key; missing keys appear as empty-string tags. + +| Tag | Description | +|--------------------|--------------------------------------------------| +| `id` | Distribution identifier | +| `id_like` | Space-separated list of related distribution IDs | +| `name` | Human-readable distribution name | +| `pretty_name` | Human-readable name including version | +| `variant` | Variant of the distribution (if any) | +| `variant_id` | Machine-readable variant identifier | +| `version` | Version string | +| `version_codename` | Release codename | +| `version_id` | Machine-readable version identifier | + +[os-release]: https://www.freedesktop.org/software/systemd/man/os-release.html + +### `node_dmi_info` + +Sourced from individual files under `/sys/class/dmi/id/` +([DMI/SMBIOS][smbios]). Tag names match the source file names, except +`system_vendor` which reads from `sys_vendor`. Absent or unreadable fields +are reported as empty strings. + +| Tag | Description | +|--------------------|---------------------------------------| +| `bios_date` | BIOS release date | +| `bios_release` | BIOS major.minor release number | +| `bios_vendor` | BIOS vendor name | +| `bios_version` | BIOS version string | +| `board_asset_tag` | Baseboard asset tag | +| `board_name` | Baseboard product name | +| `board_serial` | Baseboard serial number *(root only)* | +| `board_vendor` | Baseboard manufacturer | +| `board_version` | Baseboard version | +| `chassis_asset_tag`| Chassis asset tag | +| `chassis_serial` | Chassis serial number *(root only)* | +| `chassis_vendor` | Chassis manufacturer | +| `chassis_version` | Chassis version | +| `product_family` | Product family | +| `product_name` | Product name | +| `product_serial` | Product serial number *(root only)* | +| `product_sku` | Product SKU number | +| `product_uuid` | Product UUID *(root only)* | +| `product_version` | Product version | +| `system_vendor` | System manufacturer | + +[smbios]: https://www.dmtf.org/standards/smbios + +### `node_uname_info` + +Sourced from the `uname(2)` system call. + +| Tag | Description | Example | +|--------------|------------------------------------------------|--------------------------------------------| +| `sysname` | Operating system name | `Linux` | +| `nodename` | Node hostname | `worker-01.example.com` | +| `release` | Kernel release string | `6.12.57+deb13-amd64` | +| `version` | Kernel version / build info | `#1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1` | +| `machine` | Hardware architecture | `x86_64` | +| `domainname` | NIS domain name (`(none)` when not configured) | `(none)` | + +## Example Output + +```text +node_os,host=worker-01,id=debian,id_like=,name=Debian\ GNU/Linux,pretty_name=Debian\ GNU/Linux\ 13\ (trixie),variant=,variant_id=,version=13\ (trixie),version_codename=trixie,version_id=13 info=1i 1748000000000000000 +node_dmi,bios_date=04/01/2014,bios_release=0.0,bios_vendor=SeaBIOS,bios_version=1.16.3-debian-1.16.3-2,board_asset_tag=,board_name=,board_serial=,board_vendor=,board_version=,chassis_asset_tag=,chassis_serial=,chassis_vendor=QEMU,chassis_version=pc-q35-10.0,host=worker-01,product_family=,product_name=Standard\ PC\ (Q35\ +\ ICH9\,\ 2009),product_serial=,product_sku=,product_uuid=,product_version=pc-q35-10.0,system_vendor=QEMU info=1i 1748000000000000000 +node_uname,domainname=(none),host=worker-01,machine=x86_64,nodename=worker-01.example.com,release=6.12.57+deb13-amd64,sysname=Linux,version=#1\ SMP\ PREEMPT_DYNAMIC\ Debian\ 6.12.57-1\ (2025-11-05) info=1i 1748000000000000000 +``` diff --git a/plugins/inputs/node_info/node_info.go b/plugins/inputs/node_info/node_info.go new file mode 100644 index 0000000000000..d122af94142b7 --- /dev/null +++ b/plugins/inputs/node_info/node_info.go @@ -0,0 +1,329 @@ +//go:generate ../../../tools/readme_config_includer/generator +//go:build linux + +package node_info + +import ( + "bufio" + _ "embed" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/internal/choice" + "github.com/influxdata/telegraf/plugins/inputs" +) + +//go:embed sample.conf +var sampleConfig string + +const ( + defaultHostEtc = "/etc" + defaultHostSys = "/sys" +) + +// Available metric groups that can be selected via the "collect" option. +var availableCollectors = []string{"os", "dmi", "uname"} + +// dmiFiles maps the Telegraf tag name to the corresponding file name under +// /sys/class/dmi/id/. The order is deterministic so that log messages are +// reproducible across runs. +var dmiFiles = []struct { + tag string + filename string +}{ + {"bios_date", "bios_date"}, + {"bios_release", "bios_release"}, + {"bios_vendor", "bios_vendor"}, + {"bios_version", "bios_version"}, + {"board_asset_tag", "board_asset_tag"}, + {"board_name", "board_name"}, + {"board_serial", "board_serial"}, + {"board_vendor", "board_vendor"}, + {"board_version", "board_version"}, + {"chassis_asset_tag", "chassis_asset_tag"}, + {"chassis_serial", "chassis_serial"}, + {"chassis_vendor", "chassis_vendor"}, + {"chassis_version", "chassis_version"}, + {"product_family", "product_family"}, + {"product_name", "product_name"}, + {"product_serial", "product_serial"}, + {"product_sku", "product_sku"}, + {"product_uuid", "product_uuid"}, + {"product_version", "product_version"}, + // The kernel exposes this as "sys_vendor"; we surface it as + // "system_vendor" to match the prometheus-node-exporter label name. + {"system_vendor", "sys_vendor"}, +} + +// osReleaseKeys are the keys extracted from os-release(5) in the order they +// appear as Telegraf tags. +var osReleaseKeys = []string{ + "ID", + "ID_LIKE", + "NAME", + "PRETTY_NAME", + "VARIANT", + "VARIANT_ID", + "VERSION", + "VERSION_CODENAME", + "VERSION_ID", +} + +// NodeInfo collects static node information: OS release, DMI/SMBIOS, and +// uname data. Each metric is a gauge with a constant value of 1 and all +// informational fields encoded as tags, mirroring the prometheus-node-exporter +// node_os_info / node_dmi_info / node_uname_info metrics. +// +// Because the underlying data is static (it only changes on OS upgrade, +// hardware swap, or reboot), all information is read once during Init() and +// cached. Gather() emits the pre-built tags with zero I/O and minimal +// allocations. +type NodeInfo struct { + PathEtc string `toml:"host_etc"` + PathSys string `toml:"host_sys"` + Collect []string `toml:"collect"` + + Log telegraf.Logger `toml:"-"` + + // Pre-resolved collect flags — O(1) lookup in Gather(). + collectOS bool + collectDMI bool + collectUname bool + + // Cached tag maps, populated once during Init(). A nil map means the + // data source was unavailable and the corresponding metric should be + // skipped. + osTags map[string]string + dmiTags map[string]string + unameTags map[string]string +} + +func (*NodeInfo) SampleConfig() string { return sampleConfig } + +// Init validates configuration, resolves defaults, reads all static data +// sources, and caches the results for the lifetime of the process. +func (n *NodeInfo) Init() error { + if n.PathEtc == "" { + n.PathEtc = defaultHostEtc + } + if n.PathSys == "" { + n.PathSys = defaultHostSys + } + + // Default: collect everything. + if len(n.Collect) == 0 { + n.Collect = availableCollectors + } + if err := choice.CheckSlice(n.Collect, availableCollectors); err != nil { + return fmt.Errorf("invalid collect option: %w", err) + } + + n.collectOS = choice.Contains("os", n.Collect) + n.collectDMI = choice.Contains("dmi", n.Collect) + n.collectUname = choice.Contains("uname", n.Collect) + + // --- cache os-release ------------------------------------------------ + if n.collectOS { + tags, err := n.initOSTags() + if err != nil { + n.Log.Warnf("Could not read os-release: %v; node_os_info will not be emitted", err) + } else { + n.osTags = tags + } + } + + // --- cache DMI ------------------------------------------------------- + if n.collectDMI { + tags, err := n.initDMITags() + if err != nil { + n.Log.Warnf("Could not read DMI info: %v; node_dmi_info will not be emitted", err) + } else { + n.dmiTags = tags + } + } + + // --- cache uname ----------------------------------------------------- + if n.collectUname { + tags, err := initUnameTags() + if err != nil { + n.Log.Warnf("Could not read uname info: %v; node_uname_info will not be emitted", err) + } else { + n.unameTags = tags + } + } + + return nil +} + +// Gather emits the cached info metrics. No file I/O or syscalls are +// performed; the only work is copying the pre-built tag maps into the +// accumulator. +func (n *NodeInfo) Gather(acc telegraf.Accumulator) error { + if n.osTags != nil { + acc.AddGauge("node_os", map[string]interface{}{"info": int64(1)}, n.osTags) + } + if n.dmiTags != nil { + acc.AddGauge("node_dmi", map[string]interface{}{"info": int64(1)}, n.dmiTags) + } + if n.unameTags != nil { + acc.AddGauge("node_uname", map[string]interface{}{"info": int64(1)}, n.unameTags) + } + return nil +} + +// --------------------------------------------------------------------------- +// Init-time data readers +// --------------------------------------------------------------------------- + +// initOSTags reads the os-release file (with fallback) and returns the +// tag map for node_os_info. +func (n *NodeInfo) initOSTags() (map[string]string, error) { + info, err := n.readOSRelease() + if err != nil { + return nil, err + } + + tags := make(map[string]string, len(osReleaseKeys)) + for _, key := range osReleaseKeys { + tags[strings.ToLower(key)] = info[key] // missing key → "" + } + return tags, nil +} + +// readOSRelease tries the primary and fallback locations for the os-release +// file, as specified by os-release(5). +func (n *NodeInfo) readOSRelease() (map[string]string, error) { + primary := filepath.Join(n.PathEtc, "os-release") + fallback := filepath.Join(n.PathEtc, "..", "usr", "lib", "os-release") + + f, err := os.Open(primary) + if err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("opening %q: %w", primary, err) + } + n.Log.Debugf("Primary os-release not found at %q, trying fallback", primary) + f, err = os.Open(fallback) + if err != nil { + return nil, fmt.Errorf("opening os-release (tried %q and %q): %w", primary, fallback, err) + } + } + defer f.Close() + + return parseOSRelease(f) +} + +// parseOSRelease parses a KEY="value" formatted file (as defined by the +// os-release(5) man page) and returns a map of the key/value pairs. +func parseOSRelease(r io.Reader) (map[string]string, error) { + result := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + result[strings.TrimSpace(key)] = unquoteOSReleaseValue(strings.TrimSpace(value)) + } + if err := scanner.Err(); err != nil { + return result, fmt.Errorf("reading os-release: %w", err) + } + return result, nil +} + +// unquoteOSReleaseValue strips optional surrounding double- or single-quotes +// from a value as produced by os-release(5). +func unquoteOSReleaseValue(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +// initDMITags reads every DMI file under /sys/class/dmi/id/ and returns +// the tag map for node_dmi_info. If the DMI directory does not exist at +// all (ARM boards, containers) it returns nil, nil. +func (n *NodeInfo) initDMITags() (map[string]string, error) { + dmiDir := filepath.Join(n.PathSys, "class", "dmi", "id") + + if _, err := os.Stat(dmiDir); os.IsNotExist(err) { + n.Log.Debugf("DMI directory %q does not exist, skipping node_dmi_info", dmiDir) + return nil, nil + } + + tags := make(map[string]string, len(dmiFiles)) + for _, entry := range dmiFiles { + value, err := readFileTrimmed(filepath.Join(dmiDir, entry.filename)) + if err != nil { + n.Log.Debugf("Reading DMI file %q: %v", entry.filename, err) + value = "" + } + tags[entry.tag] = value + } + return tags, nil +} + +// initUnameTags calls the uname(2) syscall and returns the tag map for +// node_uname_info. +func initUnameTags() (map[string]string, error) { + var utsname unix.Utsname + if err := unix.Uname(&utsname); err != nil { + return nil, fmt.Errorf("calling uname: %w", err) + } + + tags := map[string]string{ + "domainname": utsFieldToString(utsname.Domainname[:]), + "machine": utsFieldToString(utsname.Machine[:]), + "nodename": utsFieldToString(utsname.Nodename[:]), + "release": utsFieldToString(utsname.Release[:]), + "sysname": utsFieldToString(utsname.Sysname[:]), + "version": utsFieldToString(utsname.Version[:]), + } + return tags, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// utsFieldToString converts a NUL-terminated character array from a +// unix.Utsname field to a Go string. The type parameter T covers both +// int8 (e.g. Linux/amd64) and byte/uint8 (e.g. Linux/arm64) variants of +// the utsname struct. +func utsFieldToString[T byte | int8](field []T) string { + b := make([]byte, 0, len(field)) + for _, c := range field { + if c == 0 { + break + } + b = append(b, byte(c)) + } + return string(b) +} + +// readFileTrimmed reads the entire content of path and returns it with +// surrounding whitespace (including the trailing newline) stripped. +func readFileTrimmed(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +func init() { + inputs.Add("node_info", func() telegraf.Input { + return &NodeInfo{} + }) +} diff --git a/plugins/inputs/node_info/node_info_notlinux.go b/plugins/inputs/node_info/node_info_notlinux.go new file mode 100644 index 0000000000000..916c55a5fc28a --- /dev/null +++ b/plugins/inputs/node_info/node_info_notlinux.go @@ -0,0 +1,34 @@ +//go:generate ../../../tools/readme_config_includer/generator +//go:build !linux + +package node_info + +import ( + _ "embed" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/plugins/inputs" +) + +//go:embed sample.conf +var sampleConfig string + +// NodeInfo is a stub for non-Linux platforms. +type NodeInfo struct { + Log telegraf.Logger `toml:"-"` +} + +func (*NodeInfo) SampleConfig() string { return sampleConfig } + +func (n *NodeInfo) Init() error { + n.Log.Warn("Current platform is not supported") + return nil +} + +func (*NodeInfo) Gather(_ telegraf.Accumulator) error { return nil } + +func init() { + inputs.Add("node_info", func() telegraf.Input { + return &NodeInfo{} + }) +} diff --git a/plugins/inputs/node_info/node_info_test.go b/plugins/inputs/node_info/node_info_test.go new file mode 100644 index 0000000000000..54cf57e370763 --- /dev/null +++ b/plugins/inputs/node_info/node_info_test.go @@ -0,0 +1,803 @@ +//go:build linux + +package node_info + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/metric" + "github.com/influxdata/telegraf/testutil" +) + +const sampleOSReleaseDebian = `# os-release(5) file for Debian +PRETTY_NAME="Debian GNU/Linux 13 (trixie)" +NAME="Debian GNU/Linux" +VERSION_ID="13" +VERSION="13 (trixie)" +VERSION_CODENAME=trixie +ID=debian +HOME_URL="https://www.debian.org/" +SUPPORT_URL="https://www.debian.org/support" +BUG_REPORT_URL="https://bugs.debian.org/" +` + +const sampleOSReleaseUbuntu = `NAME="Ubuntu" +VERSION="22.04.3 LTS (Jammy Jellyfish)" +ID=ubuntu +ID_LIKE=debian +PRETTY_NAME="Ubuntu 22.04.3 LTS" +VERSION_ID="22.04" +VERSION_CODENAME=jammy +` + +const sampleOSReleaseRocky = `NAME="Rocky Linux" +VERSION="9.3 (Blue Onyx)" +ID="rocky" +ID_LIKE="rhel centos fedora" +VERSION_ID="9.3" +PLATFORM_ID="platform:el9" +PRETTY_NAME="Rocky Linux 9.3 (Blue Onyx)" +` + +const sampleOSReleaseArch = `NAME="Arch Linux" +PRETTY_NAME="Arch Linux" +ID=arch +BUILD_ID=rolling +ANSI_COLOR="38;2;23;147;209" +HOME_URL="https://archlinux.org/" +` + +const sampleOSReleaseAlpine = `NAME="Alpine Linux" +ID=alpine +VERSION_ID=3.19.0 +PRETTY_NAME="Alpine Linux v3.19" +HOME_URL="https://alpinelinux.org/" +BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues" +` + +const sampleOSReleaseFedoraServer = `NAME="Fedora Linux" +VERSION="39 (Server Edition)" +ID=fedora +VERSION_ID=39 +VERSION_CODENAME="" +PRETTY_NAME="Fedora Linux 39 (Server Edition)" +VARIANT="Server Edition" +VARIANT_ID=server +` + +func TestParseOSRelease(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "debian", + input: sampleOSReleaseDebian, + expected: map[string]string{ + "PRETTY_NAME": "Debian GNU/Linux 13 (trixie)", + "NAME": "Debian GNU/Linux", + "VERSION_ID": "13", + "VERSION": "13 (trixie)", + "VERSION_CODENAME": "trixie", + "ID": "debian", + "HOME_URL": "https://www.debian.org/", + "SUPPORT_URL": "https://www.debian.org/support", + "BUG_REPORT_URL": "https://bugs.debian.org/", + }, + }, + { + name: "ubuntu with ID_LIKE", + input: sampleOSReleaseUbuntu, + expected: map[string]string{ + "NAME": "Ubuntu", + "VERSION": "22.04.3 LTS (Jammy Jellyfish)", + "ID": "ubuntu", + "ID_LIKE": "debian", + "PRETTY_NAME": "Ubuntu 22.04.3 LTS", + "VERSION_ID": "22.04", + "VERSION_CODENAME": "jammy", + }, + }, + { + name: "rocky linux / RHEL-like with double-quoted ID", + input: sampleOSReleaseRocky, + expected: map[string]string{ + "NAME": "Rocky Linux", + "VERSION": "9.3 (Blue Onyx)", + "ID": "rocky", + "ID_LIKE": "rhel centos fedora", + "VERSION_ID": "9.3", + "PLATFORM_ID": "platform:el9", + "PRETTY_NAME": "Rocky Linux 9.3 (Blue Onyx)", + }, + }, + { + name: "arch linux rolling (no VERSION keys)", + input: sampleOSReleaseArch, + expected: map[string]string{ + "NAME": "Arch Linux", + "PRETTY_NAME": "Arch Linux", + "ID": "arch", + "BUILD_ID": "rolling", + "ANSI_COLOR": "38;2;23;147;209", + "HOME_URL": "https://archlinux.org/", + }, + }, + { + name: "alpine linux minimal", + input: sampleOSReleaseAlpine, + expected: map[string]string{ + "NAME": "Alpine Linux", + "ID": "alpine", + "VERSION_ID": "3.19.0", + "PRETTY_NAME": "Alpine Linux v3.19", + "HOME_URL": "https://alpinelinux.org/", + "BUG_REPORT_URL": "https://gitlab.alpinelinux.org/alpine/aports/-/issues", + }, + }, + { + name: "fedora server with VARIANT", + input: sampleOSReleaseFedoraServer, + expected: map[string]string{ + "NAME": "Fedora Linux", + "VERSION": "39 (Server Edition)", + "ID": "fedora", + "VERSION_ID": "39", + "VERSION_CODENAME": "", + "PRETTY_NAME": "Fedora Linux 39 (Server Edition)", + "VARIANT": "Server Edition", + "VARIANT_ID": "server", + }, + }, + { + name: "unquoted values", + input: `ID=ubuntu +VERSION_ID=22.04 +`, + expected: map[string]string{ + "ID": "ubuntu", + "VERSION_ID": "22.04", + }, + }, + { + name: "single-quoted values", + input: `NAME='My Linux' +ID=mylinux +`, + expected: map[string]string{ + "NAME": "My Linux", + "ID": "mylinux", + }, + }, + { + name: "empty input", + input: "", + expected: map[string]string{}, + }, + { + name: "comments and blank lines are skipped", + input: `# comment + +ID=test +`, + expected: map[string]string{ + "ID": "test", + }, + }, + { + name: "value containing equals sign", + input: `SOME_KEY=val=ue=extra +`, + expected: map[string]string{ + "SOME_KEY": "val=ue=extra", + }, + }, + { + name: "value with empty double quotes", + input: `VERSION_CODENAME="" +`, + expected: map[string]string{ + "VERSION_CODENAME": "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseOSRelease(strings.NewReader(tt.input)) + require.NoError(t, err) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestUnquoteOSReleaseValue(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {`"hello world"`, "hello world"}, + {`'hello world'`, "hello world"}, + {`noquotes`, "noquotes"}, + {`""`, ""}, + {`''`, ""}, + {`"`, `"`}, + {`'`, `'`}, + {`"mismatched'`, `"mismatched'`}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + require.Equal(t, tt.expected, unquoteOSReleaseValue(tt.input)) + }) + } +} + +func TestUtsFieldToString(t *testing.T) { + t.Run("int8 array", func(t *testing.T) { + field := [8]int8{'L', 'i', 'n', 'u', 'x', 0, 0, 0} + require.Equal(t, "Linux", utsFieldToString(field[:])) + }) + t.Run("byte array", func(t *testing.T) { + field := [8]byte{'L', 'i', 'n', 'u', 'x', 0, 0, 0} + require.Equal(t, "Linux", utsFieldToString(field[:])) + }) + t.Run("nul at start produces empty string", func(t *testing.T) { + field := [4]int8{0, 'a', 'b', 'c'} + require.Equal(t, "", utsFieldToString(field[:])) + }) + t.Run("no nul terminator fills whole array", func(t *testing.T) { + field := [4]int8{'a', 'b', 'c', 'd'} + require.Equal(t, "abcd", utsFieldToString(field[:])) + }) + t.Run("empty array", func(t *testing.T) { + var field [0]int8 + require.Equal(t, "", utsFieldToString(field[:])) + }) +} + +func TestInitDefaults(t *testing.T) { + plugin := &NodeInfo{ + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + require.Equal(t, defaultHostEtc, plugin.PathEtc) + require.Equal(t, defaultHostSys, plugin.PathSys) + require.Equal(t, []string{"os", "dmi", "uname"}, plugin.Collect) +} + +func TestInitCustomPaths(t *testing.T) { + plugin := &NodeInfo{ + PathEtc: "/custom/etc", + PathSys: "/custom/sys", + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + require.Equal(t, "/custom/etc", plugin.PathEtc) + require.Equal(t, "/custom/sys", plugin.PathSys) +} + +func TestInitInvalidCollectOption(t *testing.T) { + plugin := &NodeInfo{ + Collect: []string{"os", "bogus"}, + Log: testutil.Logger{}, + } + err := plugin.Init() + require.Error(t, err) + require.ErrorContains(t, err, "invalid collect option") +} + +func TestInitValidCollectSubset(t *testing.T) { + plugin := &NodeInfo{ + Collect: []string{"uname"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + require.Equal(t, []string{"uname"}, plugin.Collect) +} + +// setupEtcDir creates a temporary etc directory with the given os-release +// content. Returns the path to the tmp root that serves as host_etc. +func setupEtcDir(t *testing.T, content string) string { + t.Helper() + td := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(td, "os-release"), []byte(content), 0o644)) + return td +} + +// setupDMIDir creates a fake /sys/class/dmi/id/ tree and returns the "sys" +// root path. +func setupDMIDir(t *testing.T, files map[string]string) string { + t.Helper() + dmiDir := filepath.Join(t.TempDir(), "class", "dmi", "id") + require.NoError(t, os.MkdirAll(dmiDir, 0o755)) + for name, content := range files { + require.NoError(t, os.WriteFile(filepath.Join(dmiDir, name), []byte(content+"\n"), 0o644)) + } + // Return three levels up: id → dmi → class → + return filepath.Dir(filepath.Dir(filepath.Dir(dmiDir))) +} + +func TestGatherOSInfoDebian(t *testing.T) { + etcDir := setupEtcDir(t, sampleOSReleaseDebian) + + plugin := &NodeInfo{ + PathEtc: etcDir, + PathSys: etcDir, // unused for this test but must be set + Collect: []string{"os"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + expected := []telegraf.Metric{ + metric.New( + "node_os", + map[string]string{ + "id": "debian", + "id_like": "", + "name": "Debian GNU/Linux", + "pretty_name": "Debian GNU/Linux 13 (trixie)", + "variant": "", + "variant_id": "", + "version": "13 (trixie)", + "version_codename": "trixie", + "version_id": "13", + }, + map[string]interface{}{"info": int64(1)}, + time.Unix(0, 0), + telegraf.Gauge, + ), + } + + testutil.RequireMetricsEqual(t, expected, acc.GetTelegrafMetrics(), testutil.IgnoreTime()) +} + +func TestGatherOSInfoArch(t *testing.T) { + // Arch Linux has no VERSION, VERSION_ID, VERSION_CODENAME, VARIANT keys. + etcDir := setupEtcDir(t, sampleOSReleaseArch) + + plugin := &NodeInfo{ + PathEtc: etcDir, + PathSys: etcDir, + Collect: []string{"os"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + metrics := acc.GetTelegrafMetrics() + require.Len(t, metrics, 1) + + tags := metrics[0].Tags() + require.Equal(t, "arch", tags["id"]) + require.Equal(t, "Arch Linux", tags["name"]) + // Missing keys must appear as empty-string tags. + require.Equal(t, "", tags["version"]) + require.Equal(t, "", tags["version_id"]) + require.Equal(t, "", tags["version_codename"]) + require.Equal(t, "", tags["variant"]) + require.Equal(t, "", tags["variant_id"]) + require.Equal(t, "", tags["id_like"]) + + _, ok := metrics[0].GetField("info") + require.True(t, ok) +} + +func TestGatherOSInfoAlpine(t *testing.T) { + etcDir := setupEtcDir(t, sampleOSReleaseAlpine) + + plugin := &NodeInfo{ + PathEtc: etcDir, + PathSys: etcDir, + Collect: []string{"os"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + metrics := acc.GetTelegrafMetrics() + require.Len(t, metrics, 1) + + tags := metrics[0].Tags() + require.Equal(t, "alpine", tags["id"]) + require.Equal(t, "3.19.0", tags["version_id"]) + require.Equal(t, "", tags["version"]) + require.Equal(t, "", tags["version_codename"]) + + _, ok := metrics[0].GetField("info") + require.True(t, ok) +} + +func TestGatherOSInfoFedoraVariant(t *testing.T) { + etcDir := setupEtcDir(t, sampleOSReleaseFedoraServer) + + plugin := &NodeInfo{ + PathEtc: etcDir, + PathSys: etcDir, + Collect: []string{"os"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + metrics := acc.GetTelegrafMetrics() + require.Len(t, metrics, 1) + + tags := metrics[0].Tags() + require.Equal(t, "fedora", tags["id"]) + require.Equal(t, "Server Edition", tags["variant"]) + require.Equal(t, "server", tags["variant_id"]) + require.Equal(t, "", tags["version_codename"]) + + _, ok := metrics[0].GetField("info") + require.True(t, ok) +} + +func TestGatherOSInfoFallbackToUsrLib(t *testing.T) { + // Simulate a system where /etc/os-release is absent but + // /usr/lib/os-release exists (common in some containers). + td := t.TempDir() + // Do NOT create td/os-release. + usrLib := filepath.Join(td, "..", "usr", "lib") + require.NoError(t, os.MkdirAll(usrLib, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(usrLib, "os-release"), []byte(sampleOSReleaseAlpine), 0o644)) + + plugin := &NodeInfo{ + PathEtc: td, + PathSys: td, + Collect: []string{"os"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + metrics := acc.GetTelegrafMetrics() + require.Len(t, metrics, 1) + require.Equal(t, "alpine", metrics[0].Tags()["id"]) +} + +func TestGatherOSInfoMissingBothFiles(t *testing.T) { + td := t.TempDir() + + plugin := &NodeInfo{ + PathEtc: td, + PathSys: td, + Collect: []string{"os"}, + Log: testutil.Logger{}, + } + // Init succeeds (warns but does not fail) even when os-release is missing. + require.NoError(t, plugin.Init()) + // The osTags cache must be nil so that Gather skips the metric. + require.Nil(t, plugin.osTags) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + + // No metrics and no errors — the warning was already logged during Init. + require.Empty(t, acc.GetTelegrafMetrics()) + require.Empty(t, acc.Errors) +} + +func TestGatherDMIInfo(t *testing.T) { + sysRoot := setupDMIDir(t, map[string]string{ + "bios_date": "04/01/2014", + "bios_release": "0.0", + "bios_vendor": "SeaBIOS", + "bios_version": "1.16.3-debian-1.16.3-2", + "board_asset_tag": "", + "board_name": "Standard PC", + "board_serial": "board-serial-001", + "board_vendor": "QEMU", + "board_version": "1.0", + "chassis_asset_tag": "", + "chassis_serial": "", + "chassis_vendor": "QEMU", + "chassis_version": "pc-q35-10.0", + "product_family": "", + "product_name": "Standard PC (Q35 + ICH9, 2009)", + "product_serial": "", + "product_sku": "", + "product_uuid": "11111111-2222-3333-4444-555555555555", + "product_version": "pc-q35-10.0", + "sys_vendor": "QEMU", + }) + + etcDir := setupEtcDir(t, sampleOSReleaseDebian) + + plugin := &NodeInfo{ + PathEtc: etcDir, + PathSys: sysRoot, + Collect: []string{"dmi"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + metrics := acc.GetTelegrafMetrics() + require.Len(t, metrics, 1) + require.Equal(t, "node_dmi", metrics[0].Name()) + + tags := metrics[0].Tags() + require.Equal(t, "04/01/2014", tags["bios_date"]) + require.Equal(t, "0.0", tags["bios_release"]) + require.Equal(t, "SeaBIOS", tags["bios_vendor"]) + require.Equal(t, "1.16.3-debian-1.16.3-2", tags["bios_version"]) + require.Equal(t, "Standard PC", tags["board_name"]) + require.Equal(t, "QEMU", tags["system_vendor"]) + require.Equal(t, "Standard PC (Q35 + ICH9, 2009)", tags["product_name"]) + require.Equal(t, "11111111-2222-3333-4444-555555555555", tags["product_uuid"]) + + value, ok := metrics[0].GetField("info") + require.True(t, ok) + require.Equal(t, int64(1), value) +} + +func TestGatherDMIInfoMissingFiles(t *testing.T) { + // Only provide a subset of DMI files; the rest should default to empty strings. + sysRoot := setupDMIDir(t, map[string]string{ + "bios_vendor": "TestVendor", + "product_name": "TestProduct", + "sys_vendor": "TestSystemVendor", + }) + + plugin := &NodeInfo{ + PathSys: sysRoot, + PathEtc: sysRoot, + Collect: []string{"dmi"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + metrics := acc.GetTelegrafMetrics() + require.Len(t, metrics, 1) + + tags := metrics[0].Tags() + require.Equal(t, "TestVendor", tags["bios_vendor"]) + require.Equal(t, "TestProduct", tags["product_name"]) + require.Equal(t, "TestSystemVendor", tags["system_vendor"]) + // Missing files must produce empty tag values, not absent tags. + require.Equal(t, "", tags["bios_date"]) + require.Equal(t, "", tags["chassis_vendor"]) + require.Equal(t, "", tags["product_uuid"]) + + _, ok := metrics[0].GetField("info") + require.True(t, ok) +} + +func TestGatherDMIInfoDirectoryMissing(t *testing.T) { + // Simulate ARM board or container where /sys/class/dmi/id/ is absent. + td := t.TempDir() + + plugin := &NodeInfo{ + PathSys: td, + PathEtc: td, + Collect: []string{"dmi"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + + // No metric should be emitted and no error should be accumulated. + require.Empty(t, acc.Errors) + require.Empty(t, acc.GetTelegrafMetrics()) +} + +func TestGatherUnameInfo(t *testing.T) { + plugin := &NodeInfo{ + PathEtc: t.TempDir(), + PathSys: t.TempDir(), + Collect: []string{"uname"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + metrics := acc.GetTelegrafMetrics() + require.Len(t, metrics, 1) + require.Equal(t, "node_uname", metrics[0].Name()) + + tags := metrics[0].Tags() + + // We cannot predict exact kernel values on the CI machine, but we can + // assert that the mandatory tags are present and non-empty. + for _, key := range []string{"sysname", "release", "machine", "nodename", "version"} { + v, ok := tags[key] + require.Truef(t, ok, "tag %q is missing", key) + require.NotEmptyf(t, v, "tag %q should not be empty", key) + } + // domainname may legitimately be "(none)" but the tag must exist. + _, ok := tags["domainname"] + require.True(t, ok, "tag \"domainname\" is missing") + + value, ok := metrics[0].GetField("info") + require.True(t, ok) + require.Equal(t, int64(1), value) +} + +func TestCollectOnlyOS(t *testing.T) { + etcDir := setupEtcDir(t, sampleOSReleaseDebian) + + plugin := &NodeInfo{ + PathEtc: etcDir, + PathSys: t.TempDir(), + Collect: []string{"os"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + names := metricNames(acc.GetTelegrafMetrics()) + require.Contains(t, names, "node_os") + require.NotContains(t, names, "node_dmi") + require.NotContains(t, names, "node_uname") +} + +func TestCollectOnlyUname(t *testing.T) { + plugin := &NodeInfo{ + PathEtc: t.TempDir(), + PathSys: t.TempDir(), + Collect: []string{"uname"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + names := metricNames(acc.GetTelegrafMetrics()) + require.Contains(t, names, "node_uname") + require.NotContains(t, names, "node_os") + require.NotContains(t, names, "node_dmi") +} + +func TestCollectOnlyDMI(t *testing.T) { + sysRoot := setupDMIDir(t, map[string]string{ + "sys_vendor": "TestVendor", + }) + + plugin := &NodeInfo{ + PathEtc: t.TempDir(), + PathSys: sysRoot, + Collect: []string{"dmi"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + names := metricNames(acc.GetTelegrafMetrics()) + require.Contains(t, names, "node_dmi") + require.NotContains(t, names, "node_os") + require.NotContains(t, names, "node_uname") +} + +func TestGatherAllMetrics(t *testing.T) { + etcDir := setupEtcDir(t, sampleOSReleaseDebian) + + sysRoot := setupDMIDir(t, map[string]string{ + "sys_vendor": "QEMU", + "product_name": "Standard PC", + }) + + plugin := &NodeInfo{ + PathEtc: etcDir, + PathSys: sysRoot, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + names := metricNames(acc.GetTelegrafMetrics()) + require.Contains(t, names, "node_os") + require.Contains(t, names, "node_dmi") + require.Contains(t, names, "node_uname") +} + +func TestGatherMultipleCollectRuns(t *testing.T) { + // Verify that repeated Gather() calls produce consistent results. + etcDir := setupEtcDir(t, sampleOSReleaseDebian) + + plugin := &NodeInfo{ + PathEtc: etcDir, + PathSys: t.TempDir(), + Collect: []string{"os", "uname"}, + Log: testutil.Logger{}, + } + require.NoError(t, plugin.Init()) + + for i := 0; i < 3; i++ { + var acc testutil.Accumulator + require.NoError(t, plugin.Gather(&acc)) + require.Empty(t, acc.Errors) + + names := metricNames(acc.GetTelegrafMetrics()) + require.Contains(t, names, "node_os") + require.Contains(t, names, "node_uname") + require.NotContains(t, names, "node_dmi") + } +} + +func TestReadFileTrimmed(t *testing.T) { + td := t.TempDir() + + t.Run("normal value with newline", func(t *testing.T) { + p := filepath.Join(td, "normal") + require.NoError(t, os.WriteFile(p, []byte("SeaBIOS\n"), 0o644)) + v, err := readFileTrimmed(p) + require.NoError(t, err) + require.Equal(t, "SeaBIOS", v) + }) + + t.Run("value with extra whitespace", func(t *testing.T) { + p := filepath.Join(td, "whitespace") + require.NoError(t, os.WriteFile(p, []byte(" QEMU \n"), 0o644)) + v, err := readFileTrimmed(p) + require.NoError(t, err) + require.Equal(t, "QEMU", v) + }) + + t.Run("empty file", func(t *testing.T) { + p := filepath.Join(td, "empty") + require.NoError(t, os.WriteFile(p, []byte(""), 0o644)) + v, err := readFileTrimmed(p) + require.NoError(t, err) + require.Equal(t, "", v) + }) + + t.Run("missing file returns error", func(t *testing.T) { + _, err := readFileTrimmed(filepath.Join(td, "nonexistent")) + require.Error(t, err) + }) +} + +func metricNames(metrics []telegraf.Metric) []string { + names := make([]string, 0, len(metrics)) + for _, m := range metrics { + names = append(names, m.Name()) + } + return names +} diff --git a/plugins/inputs/node_info/sample.conf b/plugins/inputs/node_info/sample.conf new file mode 100644 index 0000000000000..55418ea235f08 --- /dev/null +++ b/plugins/inputs/node_info/sample.conf @@ -0,0 +1,17 @@ +# Collect node OS, DMI, and uname information (analogous to prometheus-node-exporter) +# This plugin ONLY supports Linux +[[inputs.node_info]] + ## Path to the host /etc directory. + ## Useful when running inside a container with the host filesystem mounted. + ## Defaults: + # host_etc = "/etc" + + ## Path to the host /sys directory. + ## Useful when running inside a container with the host filesystem mounted. + ## Defaults: + # host_sys = "/sys" + + ## Metric groups to collect. + ## Available options: "os", "dmi", "uname" + ## Defaults: + # collect = ["os", "dmi", "uname"] From 8ebaa097cddb51d7f544f6fa9427d15724152b0b Mon Sep 17 00:00:00 2001 From: bilkoua Date: Mon, 16 Mar 2026 11:47:15 +0200 Subject: [PATCH 02/12] readme --- plugins/inputs/node_info/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/plugins/inputs/node_info/README.md b/plugins/inputs/node_info/README.md index d8c19893f8218..e2efd2a85455a 100644 --- a/plugins/inputs/node_info/README.md +++ b/plugins/inputs/node_info/README.md @@ -138,3 +138,28 @@ node_os,host=worker-01,id=debian,id_like=,name=Debian\ GNU/Linux,pretty_name=Deb node_dmi,bios_date=04/01/2014,bios_release=0.0,bios_vendor=SeaBIOS,bios_version=1.16.3-debian-1.16.3-2,board_asset_tag=,board_name=,board_serial=,board_vendor=,board_version=,chassis_asset_tag=,chassis_serial=,chassis_vendor=QEMU,chassis_version=pc-q35-10.0,host=worker-01,product_family=,product_name=Standard\ PC\ (Q35\ +\ ICH9\,\ 2009),product_serial=,product_sku=,product_uuid=,product_version=pc-q35-10.0,system_vendor=QEMU info=1i 1748000000000000000 node_uname,domainname=(none),host=worker-01,machine=x86_64,nodename=worker-01.example.com,release=6.12.57+deb13-amd64,sysname=Linux,version=#1\ SMP\ PREEMPT_DYNAMIC\ Debian\ 6.12.57-1\ (2025-11-05) info=1i 1748000000000000000 ``` + +## Example Output (Prometheus) + +When using the [Prometheus output plugin][prom-output] or +[Prometheus client plugin][prom-client], Telegraf converts each metric to +Prometheus exposition format: the field name (`info`) is appended to the +measurement name, producing `node_os_info`, `node_dmi_info`, and +`node_uname_info` — identical to what `prometheus-node-exporter` exposes. + +[prom-output]: ../../../plugins/outputs/prometheus_client/README.md +[prom-client]: ../../../plugins/outputs/prometheus_client/README.md + +```text +# HELP node_os_info Telegraf collected metric +# TYPE node_os_info gauge +node_os_info{host="worker-01",id="debian",id_like="",name="Debian GNU/Linux",pretty_name="Debian GNU/Linux 13 (trixie)",variant="",variant_id="",version="13 (trixie)",version_codename="trixie",version_id="13"} 1 1748000000000 + +# HELP node_dmi_info Telegraf collected metric +# TYPE node_dmi_info gauge +node_dmi_info{bios_date="04/01/2014",bios_release="0.0",bios_vendor="SeaBIOS",bios_version="1.16.3-debian-1.16.3-2",board_asset_tag="",board_name="",board_serial="",board_vendor="",board_version="",chassis_asset_tag="",chassis_serial="",chassis_vendor="QEMU",chassis_version="pc-q35-10.0",host="worker-01",product_family="",product_name="Standard PC (Q35 + ICH9, 2009)",product_serial="",product_sku="",product_uuid="",product_version="pc-q35-10.0",system_vendor="QEMU"} 1 1748000000000 + +# HELP node_uname_info Telegraf collected metric +# TYPE node_uname_info gauge +node_uname_info{domainname="(none)",host="worker-01",machine="x86_64",nodename="worker-01.example.com",release="6.12.57+deb13-amd64",sysname="Linux",version="#1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1 (2025-11-05)"} 1 1748000000000 +``` From a3e67c972eb715c21dd383483511aa02e49fd0e2 Mon Sep 17 00:00:00 2001 From: bilkoua Date: Mon, 16 Mar 2026 13:10:49 +0200 Subject: [PATCH 03/12] fix --- plugins/inputs/node_info/node_info.go | 15 +++---- plugins/inputs/node_info/node_info_test.go | 46 +++++++++++----------- 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/plugins/inputs/node_info/node_info.go b/plugins/inputs/node_info/node_info.go index d122af94142b7..124a98621e8ca 100644 --- a/plugins/inputs/node_info/node_info.go +++ b/plugins/inputs/node_info/node_info.go @@ -140,12 +140,7 @@ func (n *NodeInfo) Init() error { // --- cache DMI ------------------------------------------------------- if n.collectDMI { - tags, err := n.initDMITags() - if err != nil { - n.Log.Warnf("Could not read DMI info: %v; node_dmi_info will not be emitted", err) - } else { - n.dmiTags = tags - } + n.dmiTags = n.initDMITags() } // --- cache uname ----------------------------------------------------- @@ -253,13 +248,13 @@ func unquoteOSReleaseValue(s string) string { // initDMITags reads every DMI file under /sys/class/dmi/id/ and returns // the tag map for node_dmi_info. If the DMI directory does not exist at -// all (ARM boards, containers) it returns nil, nil. -func (n *NodeInfo) initDMITags() (map[string]string, error) { +// all (ARM boards, containers) it returns nil. +func (n *NodeInfo) initDMITags() map[string]string { dmiDir := filepath.Join(n.PathSys, "class", "dmi", "id") if _, err := os.Stat(dmiDir); os.IsNotExist(err) { n.Log.Debugf("DMI directory %q does not exist, skipping node_dmi_info", dmiDir) - return nil, nil + return nil } tags := make(map[string]string, len(dmiFiles)) @@ -271,7 +266,7 @@ func (n *NodeInfo) initDMITags() (map[string]string, error) { } tags[entry.tag] = value } - return tags, nil + return tags } // initUnameTags calls the uname(2) syscall and returns the tag map for diff --git a/plugins/inputs/node_info/node_info_test.go b/plugins/inputs/node_info/node_info_test.go index 54cf57e370763..bb520c6cbd5cf 100644 --- a/plugins/inputs/node_info/node_info_test.go +++ b/plugins/inputs/node_info/node_info_test.go @@ -251,7 +251,7 @@ func TestUtsFieldToString(t *testing.T) { }) t.Run("nul at start produces empty string", func(t *testing.T) { field := [4]int8{0, 'a', 'b', 'c'} - require.Equal(t, "", utsFieldToString(field[:])) + require.Empty(t, utsFieldToString(field[:])) }) t.Run("no nul terminator fills whole array", func(t *testing.T) { field := [4]int8{'a', 'b', 'c', 'd'} @@ -259,7 +259,7 @@ func TestUtsFieldToString(t *testing.T) { }) t.Run("empty array", func(t *testing.T) { var field [0]int8 - require.Equal(t, "", utsFieldToString(field[:])) + require.Empty(t, utsFieldToString(field[:])) }) } @@ -308,7 +308,7 @@ func TestInitValidCollectSubset(t *testing.T) { func setupEtcDir(t *testing.T, content string) string { t.Helper() td := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(td, "os-release"), []byte(content), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(td, "os-release"), []byte(content), 0o600)) return td } @@ -317,9 +317,9 @@ func setupEtcDir(t *testing.T, content string) string { func setupDMIDir(t *testing.T, files map[string]string) string { t.Helper() dmiDir := filepath.Join(t.TempDir(), "class", "dmi", "id") - require.NoError(t, os.MkdirAll(dmiDir, 0o755)) + require.NoError(t, os.MkdirAll(dmiDir, 0o750)) for name, content := range files { - require.NoError(t, os.WriteFile(filepath.Join(dmiDir, name), []byte(content+"\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dmiDir, name), []byte(content+"\n"), 0o600)) } // Return three levels up: id → dmi → class → return filepath.Dir(filepath.Dir(filepath.Dir(dmiDir))) @@ -386,12 +386,12 @@ func TestGatherOSInfoArch(t *testing.T) { require.Equal(t, "arch", tags["id"]) require.Equal(t, "Arch Linux", tags["name"]) // Missing keys must appear as empty-string tags. - require.Equal(t, "", tags["version"]) - require.Equal(t, "", tags["version_id"]) - require.Equal(t, "", tags["version_codename"]) - require.Equal(t, "", tags["variant"]) - require.Equal(t, "", tags["variant_id"]) - require.Equal(t, "", tags["id_like"]) + require.Empty(t, tags["version"]) + require.Empty(t, tags["version_id"]) + require.Empty(t, tags["version_codename"]) + require.Empty(t, tags["variant"]) + require.Empty(t, tags["variant_id"]) + require.Empty(t, tags["id_like"]) _, ok := metrics[0].GetField("info") require.True(t, ok) @@ -418,8 +418,8 @@ func TestGatherOSInfoAlpine(t *testing.T) { tags := metrics[0].Tags() require.Equal(t, "alpine", tags["id"]) require.Equal(t, "3.19.0", tags["version_id"]) - require.Equal(t, "", tags["version"]) - require.Equal(t, "", tags["version_codename"]) + require.Empty(t, tags["version"]) + require.Empty(t, tags["version_codename"]) _, ok := metrics[0].GetField("info") require.True(t, ok) @@ -447,7 +447,7 @@ func TestGatherOSInfoFedoraVariant(t *testing.T) { require.Equal(t, "fedora", tags["id"]) require.Equal(t, "Server Edition", tags["variant"]) require.Equal(t, "server", tags["variant_id"]) - require.Equal(t, "", tags["version_codename"]) + require.Empty(t, tags["version_codename"]) _, ok := metrics[0].GetField("info") require.True(t, ok) @@ -459,8 +459,8 @@ func TestGatherOSInfoFallbackToUsrLib(t *testing.T) { td := t.TempDir() // Do NOT create td/os-release. usrLib := filepath.Join(td, "..", "usr", "lib") - require.NoError(t, os.MkdirAll(usrLib, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(usrLib, "os-release"), []byte(sampleOSReleaseAlpine), 0o644)) + require.NoError(t, os.MkdirAll(usrLib, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(usrLib, "os-release"), []byte(sampleOSReleaseAlpine), 0o600)) plugin := &NodeInfo{ PathEtc: td, @@ -586,9 +586,9 @@ func TestGatherDMIInfoMissingFiles(t *testing.T) { require.Equal(t, "TestProduct", tags["product_name"]) require.Equal(t, "TestSystemVendor", tags["system_vendor"]) // Missing files must produce empty tag values, not absent tags. - require.Equal(t, "", tags["bios_date"]) - require.Equal(t, "", tags["chassis_vendor"]) - require.Equal(t, "", tags["product_uuid"]) + require.Empty(t, tags["bios_date"]) + require.Empty(t, tags["chassis_vendor"]) + require.Empty(t, tags["product_uuid"]) _, ok := metrics[0].GetField("info") require.True(t, ok) @@ -766,7 +766,7 @@ func TestReadFileTrimmed(t *testing.T) { t.Run("normal value with newline", func(t *testing.T) { p := filepath.Join(td, "normal") - require.NoError(t, os.WriteFile(p, []byte("SeaBIOS\n"), 0o644)) + require.NoError(t, os.WriteFile(p, []byte("SeaBIOS\n"), 0o600)) v, err := readFileTrimmed(p) require.NoError(t, err) require.Equal(t, "SeaBIOS", v) @@ -774,7 +774,7 @@ func TestReadFileTrimmed(t *testing.T) { t.Run("value with extra whitespace", func(t *testing.T) { p := filepath.Join(td, "whitespace") - require.NoError(t, os.WriteFile(p, []byte(" QEMU \n"), 0o644)) + require.NoError(t, os.WriteFile(p, []byte(" QEMU \n"), 0o600)) v, err := readFileTrimmed(p) require.NoError(t, err) require.Equal(t, "QEMU", v) @@ -782,10 +782,10 @@ func TestReadFileTrimmed(t *testing.T) { t.Run("empty file", func(t *testing.T) { p := filepath.Join(td, "empty") - require.NoError(t, os.WriteFile(p, []byte(""), 0o644)) + require.NoError(t, os.WriteFile(p, []byte(""), 0o600)) v, err := readFileTrimmed(p) require.NoError(t, err) - require.Equal(t, "", v) + require.Empty(t, v) }) t.Run("missing file returns error", func(t *testing.T) { From ba9427a9fe2d848d44e53615678720ec150a817c Mon Sep 17 00:00:00 2001 From: bilkoua Date: Mon, 16 Mar 2026 13:49:26 +0200 Subject: [PATCH 04/12] fix broken configHandlingFlags --- cmd/telegraf/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/telegraf/main.go b/cmd/telegraf/main.go index 042b2f536e280..b98639cff80cc 100644 --- a/cmd/telegraf/main.go +++ b/cmd/telegraf/main.go @@ -102,7 +102,7 @@ func deleteEmpty(s []string) []string { // this abstraction is used for testing, so outputBuffer and args can be changed func runApp(args []string, outputBuffer io.Writer, pprof Server, c TelegrafConfig, m App) error { cliFlags := cliFlags() - configHandlingFlags := make([]cli.Flag, 0, len(cliFlags)+10) + configHandlingFlags := make([]cli.Flag, 0, 10) configHandlingFlags = append(configHandlingFlags, &cli.StringSliceFlag{ Name: "config", From 09275f096509370a26adc9c2e34aa77a5f77df2e Mon Sep 17 00:00:00 2001 From: beliys Date: Sat, 21 Mar 2026 12:39:14 +0200 Subject: [PATCH 05/12] move to system --- plugins/inputs/all/node_info.go | 5 - plugins/inputs/node_info/README.md | 165 --------- plugins/inputs/node_info/node_info.go | 324 ------------------ .../inputs/node_info/node_info_notlinux.go | 34 -- plugins/inputs/node_info/sample.conf | 17 - plugins/inputs/system/README.md | 214 +++++++++++- plugins/inputs/system/sample.conf | 22 +- plugins/inputs/system/system.go | 122 +++++-- plugins/inputs/system/system_linux.go | 241 +++++++++++++ .../system_linux_test.go} | 153 +++++---- plugins/inputs/system/system_notlinux.go | 11 + plugins/inputs/system/testdata/influx.conf | 10 + .../inputs/system/testdata/prometheus.conf | 10 + 13 files changed, 660 insertions(+), 668 deletions(-) delete mode 100644 plugins/inputs/all/node_info.go delete mode 100644 plugins/inputs/node_info/README.md delete mode 100644 plugins/inputs/node_info/node_info.go delete mode 100644 plugins/inputs/node_info/node_info_notlinux.go delete mode 100644 plugins/inputs/node_info/sample.conf create mode 100644 plugins/inputs/system/system_linux.go rename plugins/inputs/{node_info/node_info_test.go => system/system_linux_test.go} (84%) create mode 100644 plugins/inputs/system/system_notlinux.go create mode 100644 plugins/inputs/system/testdata/influx.conf create mode 100644 plugins/inputs/system/testdata/prometheus.conf diff --git a/plugins/inputs/all/node_info.go b/plugins/inputs/all/node_info.go deleted file mode 100644 index 2a2898d3f1972..0000000000000 --- a/plugins/inputs/all/node_info.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !custom || inputs || inputs.node_info - -package all - -import _ "github.com/influxdata/telegraf/plugins/inputs/node_info" // register plugin diff --git a/plugins/inputs/node_info/README.md b/plugins/inputs/node_info/README.md deleted file mode 100644 index e2efd2a85455a..0000000000000 --- a/plugins/inputs/node_info/README.md +++ /dev/null @@ -1,165 +0,0 @@ -# Node Info Input Plugin - -Collects static node information and exposes it as labeled gauge metrics with a -constant value of `1`, mirroring the behavior of -[prometheus-node-exporter][node-exporter] for the `node_os_info`, -`node_dmi_info`, and `node_uname_info` metrics. - -⭐ Telegraf v1.34.0 -🏷️ system -💻 linux - -[node-exporter]: https://github.com/prometheus/node_exporter - -## Global configuration options - -Plugins support additional global and plugin configuration settings for tasks -such as modifying metrics, tags, and fields, creating aliases, and configuring -plugin ordering. See [CONFIGURATION.md][CONFIGURATION.md] for more details. - -[CONFIGURATION.md]: ../../../docs/CONFIGURATION.md#plugins - -## Configuration - -```toml @sample.conf -# Collect node OS, DMI, and uname information (analogous to prometheus-node-exporter) -# This plugin ONLY supports Linux -[[inputs.node_info]] - ## Path to the host /etc directory. - ## Useful when running inside a container with the host filesystem mounted. - ## Defaults: - # host_etc = "/etc" - - ## Path to the host /sys directory. - ## Useful when running inside a container with the host filesystem mounted. - ## Defaults: - # host_sys = "/sys" - - ## Metric groups to collect. - ## Available options: "os", "dmi", "uname" - ## Defaults: - # collect = ["os", "dmi", "uname"] -``` - -### Container / privileged usage - -When Telegraf runs inside a container but needs to inspect the **host** -filesystem, mount the host paths and point the plugin at them: - -```toml -[[inputs.node_info]] - host_etc = "/host/etc" - host_sys = "/host/sys" -``` - -Some DMI files under `/sys/class/dmi/id/` (e.g. `product_serial`, -`board_serial`, `product_uuid`) are readable only by `root`. When running as -an unprivileged user those fields will appear as empty strings in the metric -tags; no error is returned. - -> [!NOTE] -> On platforms where `/sys/class/dmi/id/` does not exist (ARM SBCs, -> unprivileged containers, etc.) the `node_dmi` metric is silently skipped. -> To avoid the directory lookup entirely, set `collect = ["os", "uname"]`. - -## Metrics - -Each measurement has a single field `info` (integer, gauge, always `1`) -with labels encoded as tags. - -### `node_os_info` - -Sourced from `/etc/os-release` ([os-release(5)][os-release]). Not all -distributions provide every key; missing keys appear as empty-string tags. - -| Tag | Description | -|--------------------|--------------------------------------------------| -| `id` | Distribution identifier | -| `id_like` | Space-separated list of related distribution IDs | -| `name` | Human-readable distribution name | -| `pretty_name` | Human-readable name including version | -| `variant` | Variant of the distribution (if any) | -| `variant_id` | Machine-readable variant identifier | -| `version` | Version string | -| `version_codename` | Release codename | -| `version_id` | Machine-readable version identifier | - -[os-release]: https://www.freedesktop.org/software/systemd/man/os-release.html - -### `node_dmi_info` - -Sourced from individual files under `/sys/class/dmi/id/` -([DMI/SMBIOS][smbios]). Tag names match the source file names, except -`system_vendor` which reads from `sys_vendor`. Absent or unreadable fields -are reported as empty strings. - -| Tag | Description | -|--------------------|---------------------------------------| -| `bios_date` | BIOS release date | -| `bios_release` | BIOS major.minor release number | -| `bios_vendor` | BIOS vendor name | -| `bios_version` | BIOS version string | -| `board_asset_tag` | Baseboard asset tag | -| `board_name` | Baseboard product name | -| `board_serial` | Baseboard serial number *(root only)* | -| `board_vendor` | Baseboard manufacturer | -| `board_version` | Baseboard version | -| `chassis_asset_tag`| Chassis asset tag | -| `chassis_serial` | Chassis serial number *(root only)* | -| `chassis_vendor` | Chassis manufacturer | -| `chassis_version` | Chassis version | -| `product_family` | Product family | -| `product_name` | Product name | -| `product_serial` | Product serial number *(root only)* | -| `product_sku` | Product SKU number | -| `product_uuid` | Product UUID *(root only)* | -| `product_version` | Product version | -| `system_vendor` | System manufacturer | - -[smbios]: https://www.dmtf.org/standards/smbios - -### `node_uname_info` - -Sourced from the `uname(2)` system call. - -| Tag | Description | Example | -|--------------|------------------------------------------------|--------------------------------------------| -| `sysname` | Operating system name | `Linux` | -| `nodename` | Node hostname | `worker-01.example.com` | -| `release` | Kernel release string | `6.12.57+deb13-amd64` | -| `version` | Kernel version / build info | `#1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1` | -| `machine` | Hardware architecture | `x86_64` | -| `domainname` | NIS domain name (`(none)` when not configured) | `(none)` | - -## Example Output - -```text -node_os,host=worker-01,id=debian,id_like=,name=Debian\ GNU/Linux,pretty_name=Debian\ GNU/Linux\ 13\ (trixie),variant=,variant_id=,version=13\ (trixie),version_codename=trixie,version_id=13 info=1i 1748000000000000000 -node_dmi,bios_date=04/01/2014,bios_release=0.0,bios_vendor=SeaBIOS,bios_version=1.16.3-debian-1.16.3-2,board_asset_tag=,board_name=,board_serial=,board_vendor=,board_version=,chassis_asset_tag=,chassis_serial=,chassis_vendor=QEMU,chassis_version=pc-q35-10.0,host=worker-01,product_family=,product_name=Standard\ PC\ (Q35\ +\ ICH9\,\ 2009),product_serial=,product_sku=,product_uuid=,product_version=pc-q35-10.0,system_vendor=QEMU info=1i 1748000000000000000 -node_uname,domainname=(none),host=worker-01,machine=x86_64,nodename=worker-01.example.com,release=6.12.57+deb13-amd64,sysname=Linux,version=#1\ SMP\ PREEMPT_DYNAMIC\ Debian\ 6.12.57-1\ (2025-11-05) info=1i 1748000000000000000 -``` - -## Example Output (Prometheus) - -When using the [Prometheus output plugin][prom-output] or -[Prometheus client plugin][prom-client], Telegraf converts each metric to -Prometheus exposition format: the field name (`info`) is appended to the -measurement name, producing `node_os_info`, `node_dmi_info`, and -`node_uname_info` — identical to what `prometheus-node-exporter` exposes. - -[prom-output]: ../../../plugins/outputs/prometheus_client/README.md -[prom-client]: ../../../plugins/outputs/prometheus_client/README.md - -```text -# HELP node_os_info Telegraf collected metric -# TYPE node_os_info gauge -node_os_info{host="worker-01",id="debian",id_like="",name="Debian GNU/Linux",pretty_name="Debian GNU/Linux 13 (trixie)",variant="",variant_id="",version="13 (trixie)",version_codename="trixie",version_id="13"} 1 1748000000000 - -# HELP node_dmi_info Telegraf collected metric -# TYPE node_dmi_info gauge -node_dmi_info{bios_date="04/01/2014",bios_release="0.0",bios_vendor="SeaBIOS",bios_version="1.16.3-debian-1.16.3-2",board_asset_tag="",board_name="",board_serial="",board_vendor="",board_version="",chassis_asset_tag="",chassis_serial="",chassis_vendor="QEMU",chassis_version="pc-q35-10.0",host="worker-01",product_family="",product_name="Standard PC (Q35 + ICH9, 2009)",product_serial="",product_sku="",product_uuid="",product_version="pc-q35-10.0",system_vendor="QEMU"} 1 1748000000000 - -# HELP node_uname_info Telegraf collected metric -# TYPE node_uname_info gauge -node_uname_info{domainname="(none)",host="worker-01",machine="x86_64",nodename="worker-01.example.com",release="6.12.57+deb13-amd64",sysname="Linux",version="#1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1 (2025-11-05)"} 1 1748000000000 -``` diff --git a/plugins/inputs/node_info/node_info.go b/plugins/inputs/node_info/node_info.go deleted file mode 100644 index 124a98621e8ca..0000000000000 --- a/plugins/inputs/node_info/node_info.go +++ /dev/null @@ -1,324 +0,0 @@ -//go:generate ../../../tools/readme_config_includer/generator -//go:build linux - -package node_info - -import ( - "bufio" - _ "embed" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "golang.org/x/sys/unix" - - "github.com/influxdata/telegraf" - "github.com/influxdata/telegraf/internal/choice" - "github.com/influxdata/telegraf/plugins/inputs" -) - -//go:embed sample.conf -var sampleConfig string - -const ( - defaultHostEtc = "/etc" - defaultHostSys = "/sys" -) - -// Available metric groups that can be selected via the "collect" option. -var availableCollectors = []string{"os", "dmi", "uname"} - -// dmiFiles maps the Telegraf tag name to the corresponding file name under -// /sys/class/dmi/id/. The order is deterministic so that log messages are -// reproducible across runs. -var dmiFiles = []struct { - tag string - filename string -}{ - {"bios_date", "bios_date"}, - {"bios_release", "bios_release"}, - {"bios_vendor", "bios_vendor"}, - {"bios_version", "bios_version"}, - {"board_asset_tag", "board_asset_tag"}, - {"board_name", "board_name"}, - {"board_serial", "board_serial"}, - {"board_vendor", "board_vendor"}, - {"board_version", "board_version"}, - {"chassis_asset_tag", "chassis_asset_tag"}, - {"chassis_serial", "chassis_serial"}, - {"chassis_vendor", "chassis_vendor"}, - {"chassis_version", "chassis_version"}, - {"product_family", "product_family"}, - {"product_name", "product_name"}, - {"product_serial", "product_serial"}, - {"product_sku", "product_sku"}, - {"product_uuid", "product_uuid"}, - {"product_version", "product_version"}, - // The kernel exposes this as "sys_vendor"; we surface it as - // "system_vendor" to match the prometheus-node-exporter label name. - {"system_vendor", "sys_vendor"}, -} - -// osReleaseKeys are the keys extracted from os-release(5) in the order they -// appear as Telegraf tags. -var osReleaseKeys = []string{ - "ID", - "ID_LIKE", - "NAME", - "PRETTY_NAME", - "VARIANT", - "VARIANT_ID", - "VERSION", - "VERSION_CODENAME", - "VERSION_ID", -} - -// NodeInfo collects static node information: OS release, DMI/SMBIOS, and -// uname data. Each metric is a gauge with a constant value of 1 and all -// informational fields encoded as tags, mirroring the prometheus-node-exporter -// node_os_info / node_dmi_info / node_uname_info metrics. -// -// Because the underlying data is static (it only changes on OS upgrade, -// hardware swap, or reboot), all information is read once during Init() and -// cached. Gather() emits the pre-built tags with zero I/O and minimal -// allocations. -type NodeInfo struct { - PathEtc string `toml:"host_etc"` - PathSys string `toml:"host_sys"` - Collect []string `toml:"collect"` - - Log telegraf.Logger `toml:"-"` - - // Pre-resolved collect flags — O(1) lookup in Gather(). - collectOS bool - collectDMI bool - collectUname bool - - // Cached tag maps, populated once during Init(). A nil map means the - // data source was unavailable and the corresponding metric should be - // skipped. - osTags map[string]string - dmiTags map[string]string - unameTags map[string]string -} - -func (*NodeInfo) SampleConfig() string { return sampleConfig } - -// Init validates configuration, resolves defaults, reads all static data -// sources, and caches the results for the lifetime of the process. -func (n *NodeInfo) Init() error { - if n.PathEtc == "" { - n.PathEtc = defaultHostEtc - } - if n.PathSys == "" { - n.PathSys = defaultHostSys - } - - // Default: collect everything. - if len(n.Collect) == 0 { - n.Collect = availableCollectors - } - if err := choice.CheckSlice(n.Collect, availableCollectors); err != nil { - return fmt.Errorf("invalid collect option: %w", err) - } - - n.collectOS = choice.Contains("os", n.Collect) - n.collectDMI = choice.Contains("dmi", n.Collect) - n.collectUname = choice.Contains("uname", n.Collect) - - // --- cache os-release ------------------------------------------------ - if n.collectOS { - tags, err := n.initOSTags() - if err != nil { - n.Log.Warnf("Could not read os-release: %v; node_os_info will not be emitted", err) - } else { - n.osTags = tags - } - } - - // --- cache DMI ------------------------------------------------------- - if n.collectDMI { - n.dmiTags = n.initDMITags() - } - - // --- cache uname ----------------------------------------------------- - if n.collectUname { - tags, err := initUnameTags() - if err != nil { - n.Log.Warnf("Could not read uname info: %v; node_uname_info will not be emitted", err) - } else { - n.unameTags = tags - } - } - - return nil -} - -// Gather emits the cached info metrics. No file I/O or syscalls are -// performed; the only work is copying the pre-built tag maps into the -// accumulator. -func (n *NodeInfo) Gather(acc telegraf.Accumulator) error { - if n.osTags != nil { - acc.AddGauge("node_os", map[string]interface{}{"info": int64(1)}, n.osTags) - } - if n.dmiTags != nil { - acc.AddGauge("node_dmi", map[string]interface{}{"info": int64(1)}, n.dmiTags) - } - if n.unameTags != nil { - acc.AddGauge("node_uname", map[string]interface{}{"info": int64(1)}, n.unameTags) - } - return nil -} - -// --------------------------------------------------------------------------- -// Init-time data readers -// --------------------------------------------------------------------------- - -// initOSTags reads the os-release file (with fallback) and returns the -// tag map for node_os_info. -func (n *NodeInfo) initOSTags() (map[string]string, error) { - info, err := n.readOSRelease() - if err != nil { - return nil, err - } - - tags := make(map[string]string, len(osReleaseKeys)) - for _, key := range osReleaseKeys { - tags[strings.ToLower(key)] = info[key] // missing key → "" - } - return tags, nil -} - -// readOSRelease tries the primary and fallback locations for the os-release -// file, as specified by os-release(5). -func (n *NodeInfo) readOSRelease() (map[string]string, error) { - primary := filepath.Join(n.PathEtc, "os-release") - fallback := filepath.Join(n.PathEtc, "..", "usr", "lib", "os-release") - - f, err := os.Open(primary) - if err != nil { - if !os.IsNotExist(err) { - return nil, fmt.Errorf("opening %q: %w", primary, err) - } - n.Log.Debugf("Primary os-release not found at %q, trying fallback", primary) - f, err = os.Open(fallback) - if err != nil { - return nil, fmt.Errorf("opening os-release (tried %q and %q): %w", primary, fallback, err) - } - } - defer f.Close() - - return parseOSRelease(f) -} - -// parseOSRelease parses a KEY="value" formatted file (as defined by the -// os-release(5) man page) and returns a map of the key/value pairs. -func parseOSRelease(r io.Reader) (map[string]string, error) { - result := make(map[string]string) - scanner := bufio.NewScanner(r) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - key, value, found := strings.Cut(line, "=") - if !found { - continue - } - result[strings.TrimSpace(key)] = unquoteOSReleaseValue(strings.TrimSpace(value)) - } - if err := scanner.Err(); err != nil { - return result, fmt.Errorf("reading os-release: %w", err) - } - return result, nil -} - -// unquoteOSReleaseValue strips optional surrounding double- or single-quotes -// from a value as produced by os-release(5). -func unquoteOSReleaseValue(s string) string { - if len(s) >= 2 { - if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { - return s[1 : len(s)-1] - } - } - return s -} - -// initDMITags reads every DMI file under /sys/class/dmi/id/ and returns -// the tag map for node_dmi_info. If the DMI directory does not exist at -// all (ARM boards, containers) it returns nil. -func (n *NodeInfo) initDMITags() map[string]string { - dmiDir := filepath.Join(n.PathSys, "class", "dmi", "id") - - if _, err := os.Stat(dmiDir); os.IsNotExist(err) { - n.Log.Debugf("DMI directory %q does not exist, skipping node_dmi_info", dmiDir) - return nil - } - - tags := make(map[string]string, len(dmiFiles)) - for _, entry := range dmiFiles { - value, err := readFileTrimmed(filepath.Join(dmiDir, entry.filename)) - if err != nil { - n.Log.Debugf("Reading DMI file %q: %v", entry.filename, err) - value = "" - } - tags[entry.tag] = value - } - return tags -} - -// initUnameTags calls the uname(2) syscall and returns the tag map for -// node_uname_info. -func initUnameTags() (map[string]string, error) { - var utsname unix.Utsname - if err := unix.Uname(&utsname); err != nil { - return nil, fmt.Errorf("calling uname: %w", err) - } - - tags := map[string]string{ - "domainname": utsFieldToString(utsname.Domainname[:]), - "machine": utsFieldToString(utsname.Machine[:]), - "nodename": utsFieldToString(utsname.Nodename[:]), - "release": utsFieldToString(utsname.Release[:]), - "sysname": utsFieldToString(utsname.Sysname[:]), - "version": utsFieldToString(utsname.Version[:]), - } - return tags, nil -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -// utsFieldToString converts a NUL-terminated character array from a -// unix.Utsname field to a Go string. The type parameter T covers both -// int8 (e.g. Linux/amd64) and byte/uint8 (e.g. Linux/arm64) variants of -// the utsname struct. -func utsFieldToString[T byte | int8](field []T) string { - b := make([]byte, 0, len(field)) - for _, c := range field { - if c == 0 { - break - } - b = append(b, byte(c)) - } - return string(b) -} - -// readFileTrimmed reads the entire content of path and returns it with -// surrounding whitespace (including the trailing newline) stripped. -func readFileTrimmed(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - return strings.TrimSpace(string(data)), nil -} - -func init() { - inputs.Add("node_info", func() telegraf.Input { - return &NodeInfo{} - }) -} diff --git a/plugins/inputs/node_info/node_info_notlinux.go b/plugins/inputs/node_info/node_info_notlinux.go deleted file mode 100644 index 916c55a5fc28a..0000000000000 --- a/plugins/inputs/node_info/node_info_notlinux.go +++ /dev/null @@ -1,34 +0,0 @@ -//go:generate ../../../tools/readme_config_includer/generator -//go:build !linux - -package node_info - -import ( - _ "embed" - - "github.com/influxdata/telegraf" - "github.com/influxdata/telegraf/plugins/inputs" -) - -//go:embed sample.conf -var sampleConfig string - -// NodeInfo is a stub for non-Linux platforms. -type NodeInfo struct { - Log telegraf.Logger `toml:"-"` -} - -func (*NodeInfo) SampleConfig() string { return sampleConfig } - -func (n *NodeInfo) Init() error { - n.Log.Warn("Current platform is not supported") - return nil -} - -func (*NodeInfo) Gather(_ telegraf.Accumulator) error { return nil } - -func init() { - inputs.Add("node_info", func() telegraf.Input { - return &NodeInfo{} - }) -} diff --git a/plugins/inputs/node_info/sample.conf b/plugins/inputs/node_info/sample.conf deleted file mode 100644 index 55418ea235f08..0000000000000 --- a/plugins/inputs/node_info/sample.conf +++ /dev/null @@ -1,17 +0,0 @@ -# Collect node OS, DMI, and uname information (analogous to prometheus-node-exporter) -# This plugin ONLY supports Linux -[[inputs.node_info]] - ## Path to the host /etc directory. - ## Useful when running inside a container with the host filesystem mounted. - ## Defaults: - # host_etc = "/etc" - - ## Path to the host /sys directory. - ## Useful when running inside a container with the host filesystem mounted. - ## Defaults: - # host_sys = "/sys" - - ## Metric groups to collect. - ## Available options: "os", "dmi", "uname" - ## Defaults: - # collect = ["os", "dmi", "uname"] diff --git a/plugins/inputs/system/README.md b/plugins/inputs/system/README.md index c81e0e975eabd..d88e1b9252844 100644 --- a/plugins/inputs/system/README.md +++ b/plugins/inputs/system/README.md @@ -3,10 +3,15 @@ This plugin gathers general system statistics like system load, uptime or the number of users logged in. It is similar to the unix `uptime` command. +On Linux it also collects static node-identity metrics (OS release, DMI/SMBIOS, +and uname), similar to [prometheus-node-exporter][node-exporter]. + ⭐ Telegraf v0.1.6 🏷️ system 💻 all +[node-exporter]: https://github.com/prometheus/node_exporter + ## Global configuration options Plugins support additional global and plugin configuration settings for tasks @@ -18,9 +23,27 @@ plugin ordering. See [CONFIGURATION.md][CONFIGURATION.md] for more details. ## Configuration ```toml @sample.conf -# Read metrics about system load & uptime +# Read metrics about system load, uptime, users, and node identity [[inputs.system]] - # no configuration + ## Metric groups to collect. + ## Available options: + ## load - load averages (load1, load5, load15) + ## users - logged-in user counts (n_users, n_unique_users) + ## n_cpus - CPU counts (n_cpus, n_physical_cpus) + ## uptime - system uptime (uptime, uptime_format) + ## os - OS release info from /etc/os-release (Linux only) + ## dmi - DMI/SMBIOS hardware info from /sys/class/dmi (Linux only) + ## uname - kernel info from uname(2) (Linux only) + ## By default all groups available on the current platform are collected. + # collect = ["load", "users", "n_cpus", "uptime", "os", "dmi", "uname"] + + ## Path to the host /etc directory (used by the "os" collector). + ## Useful when running inside a container with the host filesystem mounted. + # host_etc = "/etc" + + ## Path to the host /sys directory (used by the "dmi" collector). + ## Useful when running inside a container with the host filesystem mounted. + # host_sys = "/sys" ``` ### Permissions @@ -33,24 +56,183 @@ The `n_unique_users` shows the count of unique usernames logged in. This way if a user has multiple sessions open/started they would only get counted once. The same requirements for `n_users` apply. +### Container / privileged usage (Linux) + +When Telegraf runs inside a container but needs to inspect the **host** +filesystem, mount the host paths and point the plugin at them: + +```toml +[[inputs.system]] + host_etc = "/host/etc" + host_sys = "/host/sys" +``` + +Some DMI files under `/sys/class/dmi/id/` (e.g. `product_serial`, +`board_serial`, `product_uuid`) are readable only by `root`. When running as +an unprivileged user those fields will appear as empty strings in the metric +tags; no error is returned. + +On platforms where `/sys/class/dmi/id/` does not exist (ARM SBCs, +unprivileged containers, etc.) the `system_dmi` metric is silently skipped. + +> [!TIP] +> Any collector group can be disabled by removing it from the `collect` list. +> For example, to collect only load averages and uptime: +> +> ```toml +> collect = ["load", "uptime"] +> ``` + ## Metrics -- system - - fields: - - load1 (float) - - load15 (float) - - load5 (float) - - n_users (integer) - - n_unique_users (integer) - - n_cpus (integer) - - n_physical_cpus (integer) - - uptime (integer, seconds) - - uptime_format (string, deprecated in 1.10, use `uptime` field) +### `system` + +All fields are emitted in the `system` measurement. Each field is only present +when its collector group is enabled in `collect`. + +| Field | Group | Type | Description | +|-------------------|----------|---------|------------------------------------------------| +| `load1` | `load` | float | 1-minute load average | +| `load5` | `load` | float | 5-minute load average | +| `load15` | `load` | float | 15-minute load average | +| `n_users` | `users` | integer | Number of logged-in user sessions | +| `n_unique_users` | `users` | integer | Number of unique logged-in usernames | +| `n_cpus` | `n_cpus` | integer | Number of logical CPUs | +| `n_physical_cpus` | `n_cpus` | integer | Number of physical CPUs | +| `uptime` | `uptime` | integer | System uptime in seconds | +| `uptime_format` | `uptime` | string | Human-readable uptime (deprecated, use uptime) | + +### `system_os` (Linux only) + +Sourced from `/etc/os-release` ([os-release(5)][os-release]). Not all +distributions provide every key; missing keys are set to empty strings +internally (visible in Prometheus output, omitted in InfluxDB line protocol). +Each measurement has a single field `info` (integer gauge, always `1`). + +| Tag | Description | Example | +|--------------------|--------------------------------------------------|----------------------| +| `id` | Distribution identifier | `debian` | +| `id_like` | Space-separated list of related distribution IDs | `rhel centos fedora` | +| `name` | Human-readable distribution name | `Debian GNU/Linux` | +| `pretty_name` | Human-readable name including version | `Debian GNU/Linux 13 (trixie)` | +| `variant` | Variant of the distribution (if any) | `Server Edition` | +| `variant_id` | Machine-readable variant identifier | `server` | +| `version` | Version string | `13 (trixie)` | +| `version_codename` | Release codename | `trixie` | +| `version_id` | Machine-readable version identifier | `13` | + +[os-release]: https://www.freedesktop.org/software/systemd/man/os-release.html + +### `system_dmi` (Linux only) + +Sourced from individual files under `/sys/class/dmi/id/` +([DMI/SMBIOS][smbios]). Tag names match the source file names, except +`system_vendor` which reads from `sys_vendor`. Absent or unreadable fields +are set to empty strings internally (visible in Prometheus output, omitted in +InfluxDB line protocol). +Each measurement has a single field `info` (integer gauge, always `1`). + +| Tag | Description | Example | +|---------------------|---------------------------------------|----------------------------------| +| `bios_date` | BIOS release date | `04/01/2014` | +| `bios_release` | BIOS major.minor release number | `0.0` | +| `bios_vendor` | BIOS vendor name | `SeaBIOS` | +| `bios_version` | BIOS version string | `1.16.3-debian-1.16.3-2` | +| `board_asset_tag` | Baseboard asset tag | `board-asset-tag` | +| `board_name` | Baseboard product name | `Standard PC (Q35 + ICH9, 2009)` | +| `board_serial` | Baseboard serial number *(root only)* | `board-serial-001` | +| `board_vendor` | Baseboard manufacturer | `QEMU` | +| `board_version` | Baseboard version | `pc-q35-10.0` | +| `chassis_asset_tag` | Chassis asset tag | `chassis-asset-tag` | +| `chassis_serial` | Chassis serial number *(root only)* | `chassis-serial-001` | +| `chassis_vendor` | Chassis manufacturer | `QEMU` | +| `chassis_version` | Chassis version | `pc-q35-10.0` | +| `product_family` | Product family | `QEMU Virtual Machine` | +| `product_name` | Product name | `Standard PC (Q35 + ICH9, 2009)` | +| `product_serial` | Product serial number *(root only)* | `product-serial-001` | +| `product_sku` | Product SKU number | `pc-q35-10.0` | +| `product_uuid` | Product UUID *(root only)* | `11111111-2222-3333-4444-555555555555` | +| `product_version` | Product version | `pc-q35-10.0` | +| `system_vendor` | System manufacturer | `QEMU` | + +[smbios]: https://www.dmtf.org/standards/smbios + +### `system_uname` (Linux only) + +Sourced from the `uname(2)` system call. +Each measurement has a single field `info` (integer gauge, always `1`). + +| Tag | Description | Example | +|--------------|------------------------------------------------|-------------------------------------------| +| `sysname` | Operating system name | `Linux` | +| `nodename` | Node hostname | `worker-01.example.com` | +| `release` | Kernel release string | `6.12.57+deb13-amd64` | +| `version` | Kernel version / build info | `#1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1` | +| `machine` | Hardware architecture | `x86_64` | +| `domainname` | NIS domain name (`(none)` when not configured) | `(none)` | ## Example Output ```text -system,host=tyrion load1=3.72,load5=2.4,load15=2.1,n_users=3i,n_cpus=4i,n_physical_cpus=2i 1483964144000000000 -system,host=tyrion uptime=1249632i 1483964144000000000 -system,host=tyrion uptime_format="14 days, 11:07" 1483964144000000000 +system,host=worker-01 load1=3.72,load5=2.4,load15=2.1,n_users=3i,n_unique_users=2i,n_cpus=4i,n_physical_cpus=2i 1748000000000000000 +system,host=worker-01 uptime=1249632i 1748000000000000000 +system,host=worker-01 uptime_format="14 days, 11:07" 1748000000000000000 +system_os,host=worker-01,id=debian,name=Debian\ GNU/Linux,pretty_name=Debian\ GNU/Linux\ 13\ (trixie),version=13\ (trixie),version_codename=trixie,version_id=13 info=1i 1748000000000000000 +system_dmi,bios_date=04/01/2014,bios_release=0.0,bios_vendor=SeaBIOS,bios_version=1.16.3-debian-1.16.3-2,chassis_vendor=QEMU,chassis_version=pc-q35-10.0,host=worker-01,product_name=Standard\ PC\ (Q35\ +\ ICH9\,\ 2009),product_version=pc-q35-10.0,system_vendor=QEMU info=1i 1748000000000000000 +system_uname,domainname=(none),host=worker-01,machine=x86_64,nodename=worker-01.example.com,release=6.12.57+deb13-amd64,sysname=Linux,version=#1\ SMP\ PREEMPT_DYNAMIC\ Debian\ 6.12.57-1\ (2025-11-05) info=1i 1748000000000000000 +``` + +## Example Output (Prometheus) + +When using the [Prometheus output plugin][prom-output] or +[Prometheus client plugin][prom-client], Telegraf converts each field into +its own Prometheus metric by appending the field name to the measurement name. + +[prom-output]: ../../../plugins/outputs/prometheus_client/README.md +[prom-client]: ../../../plugins/outputs/prometheus_client/README.md + +```text +# HELP system_load15 Telegraf collected metric +# TYPE system_load15 gauge +system_load15{host="worker-01"} 2.1 + +# HELP system_load1 Telegraf collected metric +# TYPE system_load1 gauge +system_load1{host="worker-01"} 3.72 + +# HELP system_load5 Telegraf collected metric +# TYPE system_load5 gauge +system_load5{host="worker-01"} 2.4 + +# HELP system_n_cpus Telegraf collected metric +# TYPE system_n_cpus gauge +system_n_cpus{host="worker-01"} 4 + +# HELP system_n_physical_cpus Telegraf collected metric +# TYPE system_n_physical_cpus gauge +system_n_physical_cpus{host="worker-01"} 2 + +# HELP system_n_unique_users Telegraf collected metric +# TYPE system_n_unique_users gauge +system_n_unique_users{host="worker-01"} 2 + +# HELP system_n_users Telegraf collected metric +# TYPE system_n_users gauge +system_n_users{host="worker-01"} 3 + +# HELP system_uptime Telegraf collected metric +# TYPE system_uptime counter +system_uptime{host="worker-01"} 1249632 + +# HELP system_os_info Telegraf collected metric +# TYPE system_os_info gauge +system_os_info{host="worker-01",id="debian",id_like="",name="Debian GNU/Linux",pretty_name="Debian GNU/Linux 13 (trixie)",variant="",variant_id="",version="13 (trixie)",version_codename="trixie",version_id="13"} 1 + +# HELP system_dmi_info Telegraf collected metric +# TYPE system_dmi_info gauge +system_dmi_info{bios_date="04/01/2014",bios_release="0.0",bios_vendor="SeaBIOS",bios_version="1.16.3-debian-1.16.3-2",board_asset_tag="",board_name="",board_serial="",board_vendor="",board_version="",chassis_asset_tag="",chassis_serial="",chassis_vendor="QEMU",chassis_version="pc-q35-10.0",host="worker-01",product_family="",product_name="Standard PC (Q35 + ICH9, 2009)",product_serial="",product_sku="",product_uuid="",product_version="pc-q35-10.0",system_vendor="QEMU"} 1 + +# HELP system_uname_info Telegraf collected metric +# TYPE system_uname_info gauge +system_uname_info{domainname="(none)",host="worker-01",machine="x86_64",nodename="worker-01.example.com",release="6.12.57+deb13-amd64",sysname="Linux",version="#1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1 (2025-11-05)"} 1 ``` diff --git a/plugins/inputs/system/sample.conf b/plugins/inputs/system/sample.conf index 03f911c5b0890..1ad4845bdbe8b 100644 --- a/plugins/inputs/system/sample.conf +++ b/plugins/inputs/system/sample.conf @@ -1,3 +1,21 @@ -# Read metrics about system load & uptime +# Read metrics about system load, uptime, users, and node identity [[inputs.system]] - # no configuration + ## Metric groups to collect. + ## Available options: + ## load - load averages (load1, load5, load15) + ## users - logged-in user counts (n_users, n_unique_users) + ## n_cpus - CPU counts (n_cpus, n_physical_cpus) + ## uptime - system uptime (uptime, uptime_format) + ## os - OS release info from /etc/os-release (Linux only) + ## dmi - DMI/SMBIOS hardware info from /sys/class/dmi (Linux only) + ## uname - kernel info from uname(2) (Linux only) + ## By default all groups available on the current platform are collected. + # collect = ["load", "users", "n_cpus", "uptime", "os", "dmi", "uname"] + + ## Path to the host /etc directory (used by the "os" collector). + ## Useful when running inside a container with the host filesystem mounted. + # host_etc = "/etc" + + ## Path to the host /sys directory (used by the "dmi" collector). + ## Useful when running inside a container with the host filesystem mounted. + # host_sys = "/sys" diff --git a/plugins/inputs/system/system.go b/plugins/inputs/system/system.go index c4e8e8079cf07..b0d936b62247d 100644 --- a/plugins/inputs/system/system.go +++ b/plugins/inputs/system/system.go @@ -15,68 +15,116 @@ import ( "github.com/shirou/gopsutil/v4/load" "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/internal/choice" "github.com/influxdata/telegraf/plugins/inputs" ) //go:embed sample.conf var sampleConfig string +var crossPlatformCollectors = []string{"load", "users", "n_cpus", "uptime"} + type System struct { + Collect []string `toml:"collect"` + PathEtc string `toml:"host_etc"` + PathSys string `toml:"host_sys"` + Log telegraf.Logger `toml:"-"` + + collectLoad bool + collectUsers bool + collectNCPUs bool + collectUptime bool + + collectOS bool + collectDMI bool + collectUname bool + + osTags map[string]string + dmiTags map[string]string + unameTags map[string]string } func (*System) SampleConfig() string { return sampleConfig } -func (s *System) Gather(acc telegraf.Accumulator) error { - loadavg, err := load.Avg() - if err != nil && !strings.Contains(err.Error(), "not implemented") { - return err +func (s *System) initCommon(available []string) error { + if len(s.Collect) == 0 { + s.Collect = available } - - numLogicalCPUs, err := cpu.Counts(true) - if err != nil { - return err + if err := choice.CheckSlice(s.Collect, available); err != nil { + return fmt.Errorf("config option 'collect': %w", err) } - numPhysicalCPUs, err := cpu.Counts(false) - if err != nil { - return err + s.collectLoad = choice.Contains("load", s.Collect) + s.collectUsers = choice.Contains("users", s.Collect) + s.collectNCPUs = choice.Contains("n_cpus", s.Collect) + s.collectUptime = choice.Contains("uptime", s.Collect) + + return nil +} + +func (s *System) Gather(acc telegraf.Accumulator) error { + now := time.Now() + fields := make(map[string]interface{}) + + if s.collectLoad { + loadavg, err := load.Avg() + if err != nil { + if !strings.Contains(err.Error(), "not implemented") { + return err + } + } else { + fields["load1"] = loadavg.Load1 + fields["load5"] = loadavg.Load5 + fields["load15"] = loadavg.Load15 + } } - fields := map[string]interface{}{ - "load1": loadavg.Load1, - "load5": loadavg.Load5, - "load15": loadavg.Load15, - "n_cpus": numLogicalCPUs, - "n_physical_cpus": numPhysicalCPUs, + if s.collectNCPUs { + numLogicalCPUs, err := cpu.Counts(true) + if err != nil { + return err + } + numPhysicalCPUs, err := cpu.Counts(false) + if err != nil { + return err + } + fields["n_cpus"] = numLogicalCPUs + fields["n_physical_cpus"] = numPhysicalCPUs } - users, err := host.Users() - if err == nil { - fields["n_users"] = len(users) - fields["n_unique_users"] = findUniqueUsers(users) - } else if os.IsNotExist(err) { - s.Log.Debugf("Reading users: %s", err.Error()) - } else if os.IsPermission(err) { - s.Log.Debug(err.Error()) + if s.collectUsers { + users, err := host.Users() + if err == nil { + fields["n_users"] = len(users) + fields["n_unique_users"] = findUniqueUsers(users) + } else if os.IsNotExist(err) { + s.Log.Debugf("Reading users: %s", err.Error()) + } else if os.IsPermission(err) { + s.Log.Debug(err.Error()) + } } - now := time.Now() - acc.AddGauge("system", fields, nil, now) + if len(fields) > 0 { + acc.AddGauge("system", fields, nil, now) + } - uptime, err := host.Uptime() - if err != nil { - return err + if s.collectUptime { + uptime, err := host.Uptime() + if err != nil { + return err + } + acc.AddCounter("system", map[string]interface{}{ + "uptime": uptime, + }, nil, now) + acc.AddFields("system", map[string]interface{}{ + "uptime_format": formatUptime(uptime), + }, nil, now) } - acc.AddCounter("system", map[string]interface{}{ - "uptime": uptime, - }, nil, now) - acc.AddFields("system", map[string]interface{}{ - "uptime_format": formatUptime(uptime), - }, nil, now) + s.gatherPlatformInfo(acc) return nil } @@ -88,7 +136,6 @@ func findUniqueUsers(userStats []host.UserStat) int { uniqueUsers[userstat.User] = true } } - return len(uniqueUsers) } @@ -97,7 +144,6 @@ func formatUptime(uptime uint64) string { w := bufio.NewWriter(buf) days := uptime / (60 * 60 * 24) - if days != 0 { s := "" if days > 1 { diff --git a/plugins/inputs/system/system_linux.go b/plugins/inputs/system/system_linux.go new file mode 100644 index 0000000000000..c7f95a9cf5adb --- /dev/null +++ b/plugins/inputs/system/system_linux.go @@ -0,0 +1,241 @@ +//go:build linux + +package system + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/internal/choice" +) + +const ( + defaultHostEtc = "/etc" + defaultHostSys = "/sys" +) + +var linuxCollectors = []string{"os", "dmi", "uname"} + +var dmiFiles = []struct { + tag string + filename string +}{ + {"bios_date", "bios_date"}, + {"bios_release", "bios_release"}, + {"bios_vendor", "bios_vendor"}, + {"bios_version", "bios_version"}, + {"board_asset_tag", "board_asset_tag"}, + {"board_name", "board_name"}, + {"board_serial", "board_serial"}, + {"board_vendor", "board_vendor"}, + {"board_version", "board_version"}, + {"chassis_asset_tag", "chassis_asset_tag"}, + {"chassis_serial", "chassis_serial"}, + {"chassis_vendor", "chassis_vendor"}, + {"chassis_version", "chassis_version"}, + {"product_family", "product_family"}, + {"product_name", "product_name"}, + {"product_serial", "product_serial"}, + {"product_sku", "product_sku"}, + {"product_uuid", "product_uuid"}, + {"product_version", "product_version"}, + // The kernel exposes this as "sys_vendor"; we surface it as + // "system_vendor" to match the prometheus-node-exporter label name. + {"system_vendor", "sys_vendor"}, +} + +var osReleaseKeys = []string{ + "ID", + "ID_LIKE", + "NAME", + "PRETTY_NAME", + "VARIANT", + "VARIANT_ID", + "VERSION", + "VERSION_CODENAME", + "VERSION_ID", +} + +func (s *System) Init() error { + if s.PathEtc == "" { + s.PathEtc = defaultHostEtc + } + if s.PathSys == "" { + s.PathSys = defaultHostSys + } + + available := make([]string, 0, len(crossPlatformCollectors)+len(linuxCollectors)) + available = append(available, crossPlatformCollectors...) + available = append(available, linuxCollectors...) + if err := s.initCommon(available); err != nil { + return err + } + + s.collectOS = choice.Contains("os", s.Collect) + s.collectDMI = choice.Contains("dmi", s.Collect) + s.collectUname = choice.Contains("uname", s.Collect) + + // --- cache os-release ------------------------------------------------ + if s.collectOS { + tags, err := s.initOSTags() + if err != nil { + s.Log.Warnf("Could not read os-release: %v; system_os will not be emitted", err) + } else { + s.osTags = tags + } + } + + // --- cache DMI ------------------------------------------------------- + if s.collectDMI { + s.dmiTags = s.initDMITags() + } + + // --- cache uname ----------------------------------------------------- + if s.collectUname { + tags, err := initUnameTags() + if err != nil { + s.Log.Warnf("Could not read uname info: %v; system_uname will not be emitted", err) + } else { + s.unameTags = tags + } + } + + return nil +} + +func (s *System) gatherPlatformInfo(acc telegraf.Accumulator) { + if s.osTags != nil { + acc.AddGauge("system_os", map[string]interface{}{"info": int64(1)}, s.osTags) + } + if s.dmiTags != nil { + acc.AddGauge("system_dmi", map[string]interface{}{"info": int64(1)}, s.dmiTags) + } + if s.unameTags != nil { + acc.AddGauge("system_uname", map[string]interface{}{"info": int64(1)}, s.unameTags) + } +} + +func (s *System) initOSTags() (map[string]string, error) { + info, err := s.readOSRelease() + if err != nil { + return nil, err + } + + tags := make(map[string]string, len(osReleaseKeys)) + for _, key := range osReleaseKeys { + tags[strings.ToLower(key)] = info[key] // missing key → "" + } + return tags, nil +} + +func (s *System) readOSRelease() (map[string]string, error) { + primary := filepath.Join(s.PathEtc, "os-release") + fallback := filepath.Join(s.PathEtc, "..", "usr", "lib", "os-release") + + f, err := os.Open(primary) + if err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("opening %q: %w", primary, err) + } + s.Log.Debugf("Primary os-release not found at %q, trying fallback", primary) + f, err = os.Open(fallback) + if err != nil { + return nil, fmt.Errorf("opening os-release (tried %q and %q): %w", primary, fallback, err) + } + } + defer f.Close() + + return parseOSRelease(f) +} + +func parseOSRelease(r io.Reader) (map[string]string, error) { + result := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + result[strings.TrimSpace(key)] = unquoteOSReleaseValue(strings.TrimSpace(value)) + } + if err := scanner.Err(); err != nil { + return result, fmt.Errorf("reading os-release: %w", err) + } + return result, nil +} + +func unquoteOSReleaseValue(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +func (s *System) initDMITags() map[string]string { + dmiDir := filepath.Join(s.PathSys, "class", "dmi", "id") + + if _, err := os.Stat(dmiDir); os.IsNotExist(err) { + s.Log.Debugf("DMI directory %q does not exist, skipping system_dmi", dmiDir) + return nil + } + + tags := make(map[string]string, len(dmiFiles)) + for _, entry := range dmiFiles { + value, err := readFileTrimmed(filepath.Join(dmiDir, entry.filename)) + if err != nil { + s.Log.Debugf("Reading DMI file %q: %v", entry.filename, err) + value = "" + } + tags[entry.tag] = value + } + return tags +} + +func initUnameTags() (map[string]string, error) { + var utsname unix.Utsname + if err := unix.Uname(&utsname); err != nil { + return nil, fmt.Errorf("calling uname: %w", err) + } + + tags := map[string]string{ + "domainname": utsFieldToString(utsname.Domainname[:]), + "machine": utsFieldToString(utsname.Machine[:]), + "nodename": utsFieldToString(utsname.Nodename[:]), + "release": utsFieldToString(utsname.Release[:]), + "sysname": utsFieldToString(utsname.Sysname[:]), + "version": utsFieldToString(utsname.Version[:]), + } + return tags, nil +} + +func utsFieldToString[T byte | int8](field []T) string { + b := make([]byte, 0, len(field)) + for _, c := range field { + if c == 0 { + break + } + b = append(b, byte(c)) + } + return string(b) +} + +func readFileTrimmed(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} diff --git a/plugins/inputs/node_info/node_info_test.go b/plugins/inputs/system/system_linux_test.go similarity index 84% rename from plugins/inputs/node_info/node_info_test.go rename to plugins/inputs/system/system_linux_test.go index bb520c6cbd5cf..69146ce280643 100644 --- a/plugins/inputs/node_info/node_info_test.go +++ b/plugins/inputs/system/system_linux_test.go @@ -1,6 +1,6 @@ //go:build linux -package node_info +package system import ( "os" @@ -263,18 +263,18 @@ func TestUtsFieldToString(t *testing.T) { }) } -func TestInitDefaults(t *testing.T) { - plugin := &NodeInfo{ +func TestSystemInitDefaults(t *testing.T) { + plugin := &System{ Log: testutil.Logger{}, } require.NoError(t, plugin.Init()) require.Equal(t, defaultHostEtc, plugin.PathEtc) require.Equal(t, defaultHostSys, plugin.PathSys) - require.Equal(t, []string{"os", "dmi", "uname"}, plugin.Collect) + require.Equal(t, []string{"load", "users", "n_cpus", "uptime", "os", "dmi", "uname"}, plugin.Collect) } -func TestInitCustomPaths(t *testing.T) { - plugin := &NodeInfo{ +func TestSystemInitCustomPaths(t *testing.T) { + plugin := &System{ PathEtc: "/custom/etc", PathSys: "/custom/sys", Log: testutil.Logger{}, @@ -284,18 +284,18 @@ func TestInitCustomPaths(t *testing.T) { require.Equal(t, "/custom/sys", plugin.PathSys) } -func TestInitInvalidCollectOption(t *testing.T) { - plugin := &NodeInfo{ - Collect: []string{"os", "bogus"}, +func TestSystemInitInvalidCollectOption(t *testing.T) { + plugin := &System{ + Collect: []string{"load", "bogus"}, Log: testutil.Logger{}, } err := plugin.Init() require.Error(t, err) - require.ErrorContains(t, err, "invalid collect option") + require.ErrorContains(t, err, "config option 'collect'") } -func TestInitValidCollectSubset(t *testing.T) { - plugin := &NodeInfo{ +func TestSystemInitValidCollectSubset(t *testing.T) { + plugin := &System{ Collect: []string{"uname"}, Log: testutil.Logger{}, } @@ -304,7 +304,7 @@ func TestInitValidCollectSubset(t *testing.T) { } // setupEtcDir creates a temporary etc directory with the given os-release -// content. Returns the path to the tmp root that serves as host_etc. +// content. Returns the path to the tmp root that serves as host_etc. func setupEtcDir(t *testing.T, content string) string { t.Helper() td := t.TempDir() @@ -328,7 +328,7 @@ func setupDMIDir(t *testing.T, files map[string]string) string { func TestGatherOSInfoDebian(t *testing.T) { etcDir := setupEtcDir(t, sampleOSReleaseDebian) - plugin := &NodeInfo{ + plugin := &System{ PathEtc: etcDir, PathSys: etcDir, // unused for this test but must be set Collect: []string{"os"}, @@ -342,7 +342,7 @@ func TestGatherOSInfoDebian(t *testing.T) { expected := []telegraf.Metric{ metric.New( - "node_os", + "system_os", map[string]string{ "id": "debian", "id_like": "", @@ -360,14 +360,21 @@ func TestGatherOSInfoDebian(t *testing.T) { ), } - testutil.RequireMetricsEqual(t, expected, acc.GetTelegrafMetrics(), testutil.IgnoreTime()) + // Filter to only system_os metrics to avoid interference from system metrics. + var nodeOSMetrics []telegraf.Metric + for _, m := range acc.GetTelegrafMetrics() { + if m.Name() == "system_os" { + nodeOSMetrics = append(nodeOSMetrics, m) + } + } + testutil.RequireMetricsEqual(t, expected, nodeOSMetrics, testutil.IgnoreTime()) } func TestGatherOSInfoArch(t *testing.T) { // Arch Linux has no VERSION, VERSION_ID, VERSION_CODENAME, VARIANT keys. etcDir := setupEtcDir(t, sampleOSReleaseArch) - plugin := &NodeInfo{ + plugin := &System{ PathEtc: etcDir, PathSys: etcDir, Collect: []string{"os"}, @@ -379,7 +386,7 @@ func TestGatherOSInfoArch(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - metrics := acc.GetTelegrafMetrics() + metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_os") require.Len(t, metrics, 1) tags := metrics[0].Tags() @@ -400,7 +407,7 @@ func TestGatherOSInfoArch(t *testing.T) { func TestGatherOSInfoAlpine(t *testing.T) { etcDir := setupEtcDir(t, sampleOSReleaseAlpine) - plugin := &NodeInfo{ + plugin := &System{ PathEtc: etcDir, PathSys: etcDir, Collect: []string{"os"}, @@ -412,7 +419,7 @@ func TestGatherOSInfoAlpine(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - metrics := acc.GetTelegrafMetrics() + metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_os") require.Len(t, metrics, 1) tags := metrics[0].Tags() @@ -428,7 +435,7 @@ func TestGatherOSInfoAlpine(t *testing.T) { func TestGatherOSInfoFedoraVariant(t *testing.T) { etcDir := setupEtcDir(t, sampleOSReleaseFedoraServer) - plugin := &NodeInfo{ + plugin := &System{ PathEtc: etcDir, PathSys: etcDir, Collect: []string{"os"}, @@ -440,7 +447,7 @@ func TestGatherOSInfoFedoraVariant(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - metrics := acc.GetTelegrafMetrics() + metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_os") require.Len(t, metrics, 1) tags := metrics[0].Tags() @@ -462,7 +469,7 @@ func TestGatherOSInfoFallbackToUsrLib(t *testing.T) { require.NoError(t, os.MkdirAll(usrLib, 0o750)) require.NoError(t, os.WriteFile(filepath.Join(usrLib, "os-release"), []byte(sampleOSReleaseAlpine), 0o600)) - plugin := &NodeInfo{ + plugin := &System{ PathEtc: td, PathSys: td, Collect: []string{"os"}, @@ -474,7 +481,7 @@ func TestGatherOSInfoFallbackToUsrLib(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - metrics := acc.GetTelegrafMetrics() + metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_os") require.Len(t, metrics, 1) require.Equal(t, "alpine", metrics[0].Tags()["id"]) } @@ -482,7 +489,7 @@ func TestGatherOSInfoFallbackToUsrLib(t *testing.T) { func TestGatherOSInfoMissingBothFiles(t *testing.T) { td := t.TempDir() - plugin := &NodeInfo{ + plugin := &System{ PathEtc: td, PathSys: td, Collect: []string{"os"}, @@ -496,8 +503,8 @@ func TestGatherOSInfoMissingBothFiles(t *testing.T) { var acc testutil.Accumulator require.NoError(t, plugin.Gather(&acc)) - // No metrics and no errors — the warning was already logged during Init. - require.Empty(t, acc.GetTelegrafMetrics()) + // No system_os metric and no errors — the warning was already logged during Init. + require.Empty(t, filterMetrics(acc.GetTelegrafMetrics(), "system_os")) require.Empty(t, acc.Errors) } @@ -527,7 +534,7 @@ func TestGatherDMIInfo(t *testing.T) { etcDir := setupEtcDir(t, sampleOSReleaseDebian) - plugin := &NodeInfo{ + plugin := &System{ PathEtc: etcDir, PathSys: sysRoot, Collect: []string{"dmi"}, @@ -539,9 +546,9 @@ func TestGatherDMIInfo(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - metrics := acc.GetTelegrafMetrics() + metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_dmi") require.Len(t, metrics, 1) - require.Equal(t, "node_dmi", metrics[0].Name()) + require.Equal(t, "system_dmi", metrics[0].Name()) tags := metrics[0].Tags() require.Equal(t, "04/01/2014", tags["bios_date"]) @@ -566,7 +573,7 @@ func TestGatherDMIInfoMissingFiles(t *testing.T) { "sys_vendor": "TestSystemVendor", }) - plugin := &NodeInfo{ + plugin := &System{ PathSys: sysRoot, PathEtc: sysRoot, Collect: []string{"dmi"}, @@ -578,7 +585,7 @@ func TestGatherDMIInfoMissingFiles(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - metrics := acc.GetTelegrafMetrics() + metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_dmi") require.Len(t, metrics, 1) tags := metrics[0].Tags() @@ -598,7 +605,7 @@ func TestGatherDMIInfoDirectoryMissing(t *testing.T) { // Simulate ARM board or container where /sys/class/dmi/id/ is absent. td := t.TempDir() - plugin := &NodeInfo{ + plugin := &System{ PathSys: td, PathEtc: td, Collect: []string{"dmi"}, @@ -609,13 +616,13 @@ func TestGatherDMIInfoDirectoryMissing(t *testing.T) { var acc testutil.Accumulator require.NoError(t, plugin.Gather(&acc)) - // No metric should be emitted and no error should be accumulated. + // No system_dmi metric should be emitted and no error should be accumulated. require.Empty(t, acc.Errors) - require.Empty(t, acc.GetTelegrafMetrics()) + require.Empty(t, filterMetrics(acc.GetTelegrafMetrics(), "system_dmi")) } func TestGatherUnameInfo(t *testing.T) { - plugin := &NodeInfo{ + plugin := &System{ PathEtc: t.TempDir(), PathSys: t.TempDir(), Collect: []string{"uname"}, @@ -627,9 +634,9 @@ func TestGatherUnameInfo(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - metrics := acc.GetTelegrafMetrics() + metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_uname") require.Len(t, metrics, 1) - require.Equal(t, "node_uname", metrics[0].Name()) + require.Equal(t, "system_uname", metrics[0].Name()) tags := metrics[0].Tags() @@ -652,7 +659,7 @@ func TestGatherUnameInfo(t *testing.T) { func TestCollectOnlyOS(t *testing.T) { etcDir := setupEtcDir(t, sampleOSReleaseDebian) - plugin := &NodeInfo{ + plugin := &System{ PathEtc: etcDir, PathSys: t.TempDir(), Collect: []string{"os"}, @@ -664,14 +671,14 @@ func TestCollectOnlyOS(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - names := metricNames(acc.GetTelegrafMetrics()) - require.Contains(t, names, "node_os") - require.NotContains(t, names, "node_dmi") - require.NotContains(t, names, "node_uname") + names := metricNameSet(acc.GetTelegrafMetrics()) + require.Contains(t, names, "system_os") + require.NotContains(t, names, "system_dmi") + require.NotContains(t, names, "system_uname") } func TestCollectOnlyUname(t *testing.T) { - plugin := &NodeInfo{ + plugin := &System{ PathEtc: t.TempDir(), PathSys: t.TempDir(), Collect: []string{"uname"}, @@ -683,10 +690,10 @@ func TestCollectOnlyUname(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - names := metricNames(acc.GetTelegrafMetrics()) - require.Contains(t, names, "node_uname") - require.NotContains(t, names, "node_os") - require.NotContains(t, names, "node_dmi") + names := metricNameSet(acc.GetTelegrafMetrics()) + require.Contains(t, names, "system_uname") + require.NotContains(t, names, "system_os") + require.NotContains(t, names, "system_dmi") } func TestCollectOnlyDMI(t *testing.T) { @@ -694,7 +701,7 @@ func TestCollectOnlyDMI(t *testing.T) { "sys_vendor": "TestVendor", }) - plugin := &NodeInfo{ + plugin := &System{ PathEtc: t.TempDir(), PathSys: sysRoot, Collect: []string{"dmi"}, @@ -706,13 +713,13 @@ func TestCollectOnlyDMI(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - names := metricNames(acc.GetTelegrafMetrics()) - require.Contains(t, names, "node_dmi") - require.NotContains(t, names, "node_os") - require.NotContains(t, names, "node_uname") + names := metricNameSet(acc.GetTelegrafMetrics()) + require.Contains(t, names, "system_dmi") + require.NotContains(t, names, "system_os") + require.NotContains(t, names, "system_uname") } -func TestGatherAllMetrics(t *testing.T) { +func TestGatherAllMetricGroups(t *testing.T) { etcDir := setupEtcDir(t, sampleOSReleaseDebian) sysRoot := setupDMIDir(t, map[string]string{ @@ -720,7 +727,7 @@ func TestGatherAllMetrics(t *testing.T) { "product_name": "Standard PC", }) - plugin := &NodeInfo{ + plugin := &System{ PathEtc: etcDir, PathSys: sysRoot, Log: testutil.Logger{}, @@ -731,17 +738,17 @@ func TestGatherAllMetrics(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - names := metricNames(acc.GetTelegrafMetrics()) - require.Contains(t, names, "node_os") - require.Contains(t, names, "node_dmi") - require.Contains(t, names, "node_uname") + names := metricNameSet(acc.GetTelegrafMetrics()) + require.Contains(t, names, "system_os") + require.Contains(t, names, "system_dmi") + require.Contains(t, names, "system_uname") } func TestGatherMultipleCollectRuns(t *testing.T) { // Verify that repeated Gather() calls produce consistent results. etcDir := setupEtcDir(t, sampleOSReleaseDebian) - plugin := &NodeInfo{ + plugin := &System{ PathEtc: etcDir, PathSys: t.TempDir(), Collect: []string{"os", "uname"}, @@ -754,10 +761,10 @@ func TestGatherMultipleCollectRuns(t *testing.T) { require.NoError(t, plugin.Gather(&acc)) require.Empty(t, acc.Errors) - names := metricNames(acc.GetTelegrafMetrics()) - require.Contains(t, names, "node_os") - require.Contains(t, names, "node_uname") - require.NotContains(t, names, "node_dmi") + names := metricNameSet(acc.GetTelegrafMetrics()) + require.Contains(t, names, "system_os") + require.Contains(t, names, "system_uname") + require.NotContains(t, names, "system_dmi") } } @@ -794,10 +801,22 @@ func TestReadFileTrimmed(t *testing.T) { }) } -func metricNames(metrics []telegraf.Metric) []string { - names := make([]string, 0, len(metrics)) +// filterMetrics filters a slice of metrics by name. +func filterMetrics(metrics []telegraf.Metric, name string) []telegraf.Metric { + var out []telegraf.Metric + for _, m := range metrics { + if m.Name() == name { + out = append(out, m) + } + } + return out +} + +// metricNameSet returns the set of unique metric names present in metrics. +func metricNameSet(metrics []telegraf.Metric) map[string]struct{} { + names := make(map[string]struct{}, len(metrics)) for _, m := range metrics { - names = append(names, m.Name()) + names[m.Name()] = struct{}{} } return names } diff --git a/plugins/inputs/system/system_notlinux.go b/plugins/inputs/system/system_notlinux.go new file mode 100644 index 0000000000000..8291659b09ccb --- /dev/null +++ b/plugins/inputs/system/system_notlinux.go @@ -0,0 +1,11 @@ +//go:build !linux + +package system + +import "github.com/influxdata/telegraf" + +func (s *System) Init() error { + return s.initCommon(crossPlatformCollectors) +} + +func (s *System) gatherPlatformInfo(_ telegraf.Accumulator) {} diff --git a/plugins/inputs/system/testdata/influx.conf b/plugins/inputs/system/testdata/influx.conf new file mode 100644 index 0000000000000..e042027db73db --- /dev/null +++ b/plugins/inputs/system/testdata/influx.conf @@ -0,0 +1,10 @@ +[agent] + interval = "1s" + flush_interval = "1s" + omit_hostname = false + +[[inputs.system]] + +[[outputs.file]] + files = ["stdout"] + data_format = "influx" diff --git a/plugins/inputs/system/testdata/prometheus.conf b/plugins/inputs/system/testdata/prometheus.conf new file mode 100644 index 0000000000000..9ffd390fa7b1b --- /dev/null +++ b/plugins/inputs/system/testdata/prometheus.conf @@ -0,0 +1,10 @@ +[agent] + interval = "1s" + flush_interval = "1s" + omit_hostname = false + +[[inputs.system]] + +[[outputs.file]] + files = ["stdout"] + data_format = "prometheus" From b399363b6036427a7ec560b4a0a2e424aaf9b042 Mon Sep 17 00:00:00 2001 From: beliys Date: Sat, 21 Mar 2026 13:05:03 +0200 Subject: [PATCH 06/12] fix linter --- plugins/inputs/system/README.md | 82 ++++++++++++------------ plugins/inputs/system/system.go | 8 +-- plugins/inputs/system/system_linux.go | 10 +++ plugins/inputs/system/system_notlinux.go | 4 +- 4 files changed, 55 insertions(+), 49 deletions(-) diff --git a/plugins/inputs/system/README.md b/plugins/inputs/system/README.md index d88e1b9252844..57ba0d0a82bb8 100644 --- a/plugins/inputs/system/README.md +++ b/plugins/inputs/system/README.md @@ -109,17 +109,17 @@ distributions provide every key; missing keys are set to empty strings internally (visible in Prometheus output, omitted in InfluxDB line protocol). Each measurement has a single field `info` (integer gauge, always `1`). -| Tag | Description | Example | -|--------------------|--------------------------------------------------|----------------------| -| `id` | Distribution identifier | `debian` | -| `id_like` | Space-separated list of related distribution IDs | `rhel centos fedora` | -| `name` | Human-readable distribution name | `Debian GNU/Linux` | -| `pretty_name` | Human-readable name including version | `Debian GNU/Linux 13 (trixie)` | -| `variant` | Variant of the distribution (if any) | `Server Edition` | -| `variant_id` | Machine-readable variant identifier | `server` | -| `version` | Version string | `13 (trixie)` | -| `version_codename` | Release codename | `trixie` | -| `version_id` | Machine-readable version identifier | `13` | +| Tag | Description | Example | +|--------------------|--------------------------------------------------|----------------------------------| +| `id` | Distribution identifier | `debian` | +| `id_like` | Space-separated list of related distribution IDs | `rhel centos fedora` | +| `name` | Human-readable distribution name | `Debian GNU/Linux` | +| `pretty_name` | Human-readable name including version | `Debian GNU/Linux 13 (trixie)` | +| `variant` | Variant of the distribution (if any) | `Server Edition` | +| `variant_id` | Machine-readable variant identifier | `server` | +| `version` | Version string | `13 (trixie)` | +| `version_codename` | Release codename | `trixie` | +| `version_id` | Machine-readable version identifier | `13` | [os-release]: https://www.freedesktop.org/software/systemd/man/os-release.html @@ -132,28 +132,28 @@ are set to empty strings internally (visible in Prometheus output, omitted in InfluxDB line protocol). Each measurement has a single field `info` (integer gauge, always `1`). -| Tag | Description | Example | -|---------------------|---------------------------------------|----------------------------------| -| `bios_date` | BIOS release date | `04/01/2014` | -| `bios_release` | BIOS major.minor release number | `0.0` | -| `bios_vendor` | BIOS vendor name | `SeaBIOS` | -| `bios_version` | BIOS version string | `1.16.3-debian-1.16.3-2` | -| `board_asset_tag` | Baseboard asset tag | `board-asset-tag` | -| `board_name` | Baseboard product name | `Standard PC (Q35 + ICH9, 2009)` | -| `board_serial` | Baseboard serial number *(root only)* | `board-serial-001` | -| `board_vendor` | Baseboard manufacturer | `QEMU` | -| `board_version` | Baseboard version | `pc-q35-10.0` | -| `chassis_asset_tag` | Chassis asset tag | `chassis-asset-tag` | -| `chassis_serial` | Chassis serial number *(root only)* | `chassis-serial-001` | -| `chassis_vendor` | Chassis manufacturer | `QEMU` | -| `chassis_version` | Chassis version | `pc-q35-10.0` | -| `product_family` | Product family | `QEMU Virtual Machine` | -| `product_name` | Product name | `Standard PC (Q35 + ICH9, 2009)` | -| `product_serial` | Product serial number *(root only)* | `product-serial-001` | -| `product_sku` | Product SKU number | `pc-q35-10.0` | -| `product_uuid` | Product UUID *(root only)* | `11111111-2222-3333-4444-555555555555` | -| `product_version` | Product version | `pc-q35-10.0` | -| `system_vendor` | System manufacturer | `QEMU` | +| Tag | Description | Example | +|---------------------|---------------------------------------|------------------------------------------| +| `bios_date` | BIOS release date | `04/01/2014` | +| `bios_release` | BIOS major.minor release number | `0.0` | +| `bios_vendor` | BIOS vendor name | `SeaBIOS` | +| `bios_version` | BIOS version string | `1.16.3-debian-1.16.3-2` | +| `board_asset_tag` | Baseboard asset tag | `board-asset-tag` | +| `board_name` | Baseboard product name | `Standard PC (Q35 + ICH9, 2009)` | +| `board_serial` | Baseboard serial number *(root only)* | `board-serial-001` | +| `board_vendor` | Baseboard manufacturer | `QEMU` | +| `board_version` | Baseboard version | `pc-q35-10.0` | +| `chassis_asset_tag` | Chassis asset tag | `chassis-asset-tag` | +| `chassis_serial` | Chassis serial number *(root only)* | `chassis-serial-001` | +| `chassis_vendor` | Chassis manufacturer | `QEMU` | +| `chassis_version` | Chassis version | `pc-q35-10.0` | +| `product_family` | Product family | `QEMU Virtual Machine` | +| `product_name` | Product name | `Standard PC (Q35 + ICH9, 2009)` | +| `product_serial` | Product serial number *(root only)* | `product-serial-001` | +| `product_sku` | Product SKU number | `pc-q35-10.0` | +| `product_uuid` | Product UUID *(root only)* | `11111111-2222-3333-4444-555555555555` | +| `product_version` | Product version | `pc-q35-10.0` | +| `system_vendor` | System manufacturer | `QEMU` | [smbios]: https://www.dmtf.org/standards/smbios @@ -162,14 +162,14 @@ Each measurement has a single field `info` (integer gauge, always `1`). Sourced from the `uname(2)` system call. Each measurement has a single field `info` (integer gauge, always `1`). -| Tag | Description | Example | -|--------------|------------------------------------------------|-------------------------------------------| -| `sysname` | Operating system name | `Linux` | -| `nodename` | Node hostname | `worker-01.example.com` | -| `release` | Kernel release string | `6.12.57+deb13-amd64` | -| `version` | Kernel version / build info | `#1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1` | -| `machine` | Hardware architecture | `x86_64` | -| `domainname` | NIS domain name (`(none)` when not configured) | `(none)` | +| Tag | Description | Example | +|--------------|------------------------------------------------|----------------------------------------------| +| `sysname` | Operating system name | `Linux` | +| `nodename` | Node hostname | `worker-01.example.com` | +| `release` | Kernel release string | `6.12.57+deb13-amd64` | +| `version` | Kernel version / build info | `#1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1` | +| `machine` | Hardware architecture | `x86_64` | +| `domainname` | NIS domain name (`(none)` when not configured) | `(none)` | ## Example Output diff --git a/plugins/inputs/system/system.go b/plugins/inputs/system/system.go index b0d936b62247d..c5acccb55be27 100644 --- a/plugins/inputs/system/system.go +++ b/plugins/inputs/system/system.go @@ -36,13 +36,7 @@ type System struct { collectNCPUs bool collectUptime bool - collectOS bool - collectDMI bool - collectUname bool - - osTags map[string]string - dmiTags map[string]string - unameTags map[string]string + platformData } func (*System) SampleConfig() string { diff --git a/plugins/inputs/system/system_linux.go b/plugins/inputs/system/system_linux.go index c7f95a9cf5adb..2dc975370344f 100644 --- a/plugins/inputs/system/system_linux.go +++ b/plugins/inputs/system/system_linux.go @@ -21,6 +21,16 @@ const ( defaultHostSys = "/sys" ) +type platformData struct { + collectOS bool + collectDMI bool + collectUname bool + + osTags map[string]string + dmiTags map[string]string + unameTags map[string]string +} + var linuxCollectors = []string{"os", "dmi", "uname"} var dmiFiles = []struct { diff --git a/plugins/inputs/system/system_notlinux.go b/plugins/inputs/system/system_notlinux.go index 8291659b09ccb..f1bcdad8ace48 100644 --- a/plugins/inputs/system/system_notlinux.go +++ b/plugins/inputs/system/system_notlinux.go @@ -4,8 +4,10 @@ package system import "github.com/influxdata/telegraf" +type platformData struct{} + func (s *System) Init() error { return s.initCommon(crossPlatformCollectors) } -func (s *System) gatherPlatformInfo(_ telegraf.Accumulator) {} +func (*System) gatherPlatformInfo(_ telegraf.Accumulator) {} From 07ba526ebae3cfaf311354a9581de251aa0be68e Mon Sep 17 00:00:00 2001 From: beliys Date: Sat, 21 Mar 2026 13:22:17 +0200 Subject: [PATCH 07/12] nolint:unused for non-Linux --- plugins/inputs/system/system.go | 2 +- plugins/inputs/system/system_linux.go | 2 +- plugins/inputs/system/system_notlinux.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/inputs/system/system.go b/plugins/inputs/system/system.go index c5acccb55be27..215f13ebd4f20 100644 --- a/plugins/inputs/system/system.go +++ b/plugins/inputs/system/system.go @@ -36,7 +36,7 @@ type System struct { collectNCPUs bool collectUptime bool - platformData + platformData //nolint:unused // for OS-specific usage } func (*System) SampleConfig() string { diff --git a/plugins/inputs/system/system_linux.go b/plugins/inputs/system/system_linux.go index 2dc975370344f..c9ee60cdec00c 100644 --- a/plugins/inputs/system/system_linux.go +++ b/plugins/inputs/system/system_linux.go @@ -21,7 +21,7 @@ const ( defaultHostSys = "/sys" ) -type platformData struct { +type platformData struct { //nolint:unused // used on Linux, needed for System struct collectOS bool collectDMI bool collectUname bool diff --git a/plugins/inputs/system/system_notlinux.go b/plugins/inputs/system/system_notlinux.go index f1bcdad8ace48..448af74981392 100644 --- a/plugins/inputs/system/system_notlinux.go +++ b/plugins/inputs/system/system_notlinux.go @@ -4,7 +4,7 @@ package system import "github.com/influxdata/telegraf" -type platformData struct{} +type platformData struct{} //nolint:unused // not used on non-Linux, needed for System struct func (s *System) Init() error { return s.initCommon(crossPlatformCollectors) From c7f1241e7ec94c1bb9cdb66a50e0e0aef6217915 Mon Sep 17 00:00:00 2001 From: beliys Date: Sat, 21 Mar 2026 13:41:05 +0200 Subject: [PATCH 08/12] fix linter --- plugins/inputs/system/system.go | 2 +- plugins/inputs/system/system_linux.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/inputs/system/system.go b/plugins/inputs/system/system.go index 215f13ebd4f20..d2f438fe81eef 100644 --- a/plugins/inputs/system/system.go +++ b/plugins/inputs/system/system.go @@ -36,7 +36,7 @@ type System struct { collectNCPUs bool collectUptime bool - platformData //nolint:unused // for OS-specific usage + platformData //nolint:unused,nolintlint // for OS-specific usage } func (*System) SampleConfig() string { diff --git a/plugins/inputs/system/system_linux.go b/plugins/inputs/system/system_linux.go index c9ee60cdec00c..2dc975370344f 100644 --- a/plugins/inputs/system/system_linux.go +++ b/plugins/inputs/system/system_linux.go @@ -21,7 +21,7 @@ const ( defaultHostSys = "/sys" ) -type platformData struct { //nolint:unused // used on Linux, needed for System struct +type platformData struct { collectOS bool collectDMI bool collectUname bool From 6bb47588be5f386df8e0b0d7db0e77397c118536 Mon Sep 17 00:00:00 2001 From: beliys Date: Fri, 3 Apr 2026 22:32:41 +0300 Subject: [PATCH 09/12] 1 step --- plugins/inputs/system/README.md | 159 +--- plugins/inputs/system/sample.conf | 23 +- plugins/inputs/system/system.go | 27 +- plugins/inputs/system/system_linux.go | 251 ------ plugins/inputs/system/system_linux_test.go | 822 ------------------ plugins/inputs/system/system_notlinux.go | 13 - plugins/inputs/system/testdata/influx.conf | 10 - .../inputs/system/testdata/prometheus.conf | 10 - 8 files changed, 26 insertions(+), 1289 deletions(-) delete mode 100644 plugins/inputs/system/system_linux.go delete mode 100644 plugins/inputs/system/system_linux_test.go delete mode 100644 plugins/inputs/system/system_notlinux.go delete mode 100644 plugins/inputs/system/testdata/influx.conf delete mode 100644 plugins/inputs/system/testdata/prometheus.conf diff --git a/plugins/inputs/system/README.md b/plugins/inputs/system/README.md index 57ba0d0a82bb8..21abbcac8ddb0 100644 --- a/plugins/inputs/system/README.md +++ b/plugins/inputs/system/README.md @@ -3,15 +3,10 @@ This plugin gathers general system statistics like system load, uptime or the number of users logged in. It is similar to the unix `uptime` command. -On Linux it also collects static node-identity metrics (OS release, DMI/SMBIOS, -and uname), similar to [prometheus-node-exporter][node-exporter]. - ⭐ Telegraf v0.1.6 🏷️ system 💻 all -[node-exporter]: https://github.com/prometheus/node_exporter - ## Global configuration options Plugins support additional global and plugin configuration settings for tasks @@ -23,27 +18,14 @@ plugin ordering. See [CONFIGURATION.md][CONFIGURATION.md] for more details. ## Configuration ```toml @sample.conf -# Read metrics about system load, uptime, users, and node identity +# Read metrics about system load & uptime [[inputs.system]] ## Metric groups to collect. ## Available options: - ## load - load averages (load1, load5, load15) - ## users - logged-in user counts (n_users, n_unique_users) - ## n_cpus - CPU counts (n_cpus, n_physical_cpus) - ## uptime - system uptime (uptime, uptime_format) - ## os - OS release info from /etc/os-release (Linux only) - ## dmi - DMI/SMBIOS hardware info from /sys/class/dmi (Linux only) - ## uname - kernel info from uname(2) (Linux only) - ## By default all groups available on the current platform are collected. - # collect = ["load", "users", "n_cpus", "uptime", "os", "dmi", "uname"] - - ## Path to the host /etc directory (used by the "os" collector). - ## Useful when running inside a container with the host filesystem mounted. - # host_etc = "/etc" - - ## Path to the host /sys directory (used by the "dmi" collector). - ## Useful when running inside a container with the host filesystem mounted. - # host_sys = "/sys" + ## load - system gauge metrics (load averages, cpu counts, user counts) + ## uptime - system uptime + ## By default all groups are collected. + # collect = ["load", "uptime"] ``` ### Permissions @@ -56,130 +38,31 @@ The `n_unique_users` shows the count of unique usernames logged in. This way if a user has multiple sessions open/started they would only get counted once. The same requirements for `n_users` apply. -### Container / privileged usage (Linux) - -When Telegraf runs inside a container but needs to inspect the **host** -filesystem, mount the host paths and point the plugin at them: - -```toml -[[inputs.system]] - host_etc = "/host/etc" - host_sys = "/host/sys" -``` - -Some DMI files under `/sys/class/dmi/id/` (e.g. `product_serial`, -`board_serial`, `product_uuid`) are readable only by `root`. When running as -an unprivileged user those fields will appear as empty strings in the metric -tags; no error is returned. - -On platforms where `/sys/class/dmi/id/` does not exist (ARM SBCs, -unprivileged containers, etc.) the `system_dmi` metric is silently skipped. - -> [!TIP] -> Any collector group can be disabled by removing it from the `collect` list. -> For example, to collect only load averages and uptime: -> -> ```toml -> collect = ["load", "uptime"] -> ``` - ## Metrics ### `system` -All fields are emitted in the `system` measurement. Each field is only present -when its collector group is enabled in `collect`. +All fields below belong to the `system` measurement. The `collect` option +controls which groups are gathered. | Field | Group | Type | Description | |-------------------|----------|---------|------------------------------------------------| | `load1` | `load` | float | 1-minute load average | | `load5` | `load` | float | 5-minute load average | | `load15` | `load` | float | 15-minute load average | -| `n_users` | `users` | integer | Number of logged-in user sessions | -| `n_unique_users` | `users` | integer | Number of unique logged-in usernames | -| `n_cpus` | `n_cpus` | integer | Number of logical CPUs | -| `n_physical_cpus` | `n_cpus` | integer | Number of physical CPUs | +| `n_users` | `load` | integer | Number of logged-in user sessions | +| `n_unique_users` | `load` | integer | Number of unique logged-in usernames | +| `n_cpus` | `load` | integer | Number of logical CPUs | +| `n_physical_cpus` | `load` | integer | Number of physical CPUs | | `uptime` | `uptime` | integer | System uptime in seconds | | `uptime_format` | `uptime` | string | Human-readable uptime (deprecated, use uptime) | -### `system_os` (Linux only) - -Sourced from `/etc/os-release` ([os-release(5)][os-release]). Not all -distributions provide every key; missing keys are set to empty strings -internally (visible in Prometheus output, omitted in InfluxDB line protocol). -Each measurement has a single field `info` (integer gauge, always `1`). - -| Tag | Description | Example | -|--------------------|--------------------------------------------------|----------------------------------| -| `id` | Distribution identifier | `debian` | -| `id_like` | Space-separated list of related distribution IDs | `rhel centos fedora` | -| `name` | Human-readable distribution name | `Debian GNU/Linux` | -| `pretty_name` | Human-readable name including version | `Debian GNU/Linux 13 (trixie)` | -| `variant` | Variant of the distribution (if any) | `Server Edition` | -| `variant_id` | Machine-readable variant identifier | `server` | -| `version` | Version string | `13 (trixie)` | -| `version_codename` | Release codename | `trixie` | -| `version_id` | Machine-readable version identifier | `13` | - -[os-release]: https://www.freedesktop.org/software/systemd/man/os-release.html - -### `system_dmi` (Linux only) - -Sourced from individual files under `/sys/class/dmi/id/` -([DMI/SMBIOS][smbios]). Tag names match the source file names, except -`system_vendor` which reads from `sys_vendor`. Absent or unreadable fields -are set to empty strings internally (visible in Prometheus output, omitted in -InfluxDB line protocol). -Each measurement has a single field `info` (integer gauge, always `1`). - -| Tag | Description | Example | -|---------------------|---------------------------------------|------------------------------------------| -| `bios_date` | BIOS release date | `04/01/2014` | -| `bios_release` | BIOS major.minor release number | `0.0` | -| `bios_vendor` | BIOS vendor name | `SeaBIOS` | -| `bios_version` | BIOS version string | `1.16.3-debian-1.16.3-2` | -| `board_asset_tag` | Baseboard asset tag | `board-asset-tag` | -| `board_name` | Baseboard product name | `Standard PC (Q35 + ICH9, 2009)` | -| `board_serial` | Baseboard serial number *(root only)* | `board-serial-001` | -| `board_vendor` | Baseboard manufacturer | `QEMU` | -| `board_version` | Baseboard version | `pc-q35-10.0` | -| `chassis_asset_tag` | Chassis asset tag | `chassis-asset-tag` | -| `chassis_serial` | Chassis serial number *(root only)* | `chassis-serial-001` | -| `chassis_vendor` | Chassis manufacturer | `QEMU` | -| `chassis_version` | Chassis version | `pc-q35-10.0` | -| `product_family` | Product family | `QEMU Virtual Machine` | -| `product_name` | Product name | `Standard PC (Q35 + ICH9, 2009)` | -| `product_serial` | Product serial number *(root only)* | `product-serial-001` | -| `product_sku` | Product SKU number | `pc-q35-10.0` | -| `product_uuid` | Product UUID *(root only)* | `11111111-2222-3333-4444-555555555555` | -| `product_version` | Product version | `pc-q35-10.0` | -| `system_vendor` | System manufacturer | `QEMU` | - -[smbios]: https://www.dmtf.org/standards/smbios - -### `system_uname` (Linux only) - -Sourced from the `uname(2)` system call. -Each measurement has a single field `info` (integer gauge, always `1`). - -| Tag | Description | Example | -|--------------|------------------------------------------------|----------------------------------------------| -| `sysname` | Operating system name | `Linux` | -| `nodename` | Node hostname | `worker-01.example.com` | -| `release` | Kernel release string | `6.12.57+deb13-amd64` | -| `version` | Kernel version / build info | `#1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1` | -| `machine` | Hardware architecture | `x86_64` | -| `domainname` | NIS domain name (`(none)` when not configured) | `(none)` | - ## Example Output ```text system,host=worker-01 load1=3.72,load5=2.4,load15=2.1,n_users=3i,n_unique_users=2i,n_cpus=4i,n_physical_cpus=2i 1748000000000000000 system,host=worker-01 uptime=1249632i 1748000000000000000 system,host=worker-01 uptime_format="14 days, 11:07" 1748000000000000000 -system_os,host=worker-01,id=debian,name=Debian\ GNU/Linux,pretty_name=Debian\ GNU/Linux\ 13\ (trixie),version=13\ (trixie),version_codename=trixie,version_id=13 info=1i 1748000000000000000 -system_dmi,bios_date=04/01/2014,bios_release=0.0,bios_vendor=SeaBIOS,bios_version=1.16.3-debian-1.16.3-2,chassis_vendor=QEMU,chassis_version=pc-q35-10.0,host=worker-01,product_name=Standard\ PC\ (Q35\ +\ ICH9\,\ 2009),product_version=pc-q35-10.0,system_vendor=QEMU info=1i 1748000000000000000 -system_uname,domainname=(none),host=worker-01,machine=x86_64,nodename=worker-01.example.com,release=6.12.57+deb13-amd64,sysname=Linux,version=#1\ SMP\ PREEMPT_DYNAMIC\ Debian\ 6.12.57-1\ (2025-11-05) info=1i 1748000000000000000 ``` ## Example Output (Prometheus) @@ -192,14 +75,14 @@ its own Prometheus metric by appending the field name to the measurement name. [prom-client]: ../../../plugins/outputs/prometheus_client/README.md ```text -# HELP system_load15 Telegraf collected metric -# TYPE system_load15 gauge -system_load15{host="worker-01"} 2.1 - # HELP system_load1 Telegraf collected metric # TYPE system_load1 gauge system_load1{host="worker-01"} 3.72 +# HELP system_load15 Telegraf collected metric +# TYPE system_load15 gauge +system_load15{host="worker-01"} 2.1 + # HELP system_load5 Telegraf collected metric # TYPE system_load5 gauge system_load5{host="worker-01"} 2.4 @@ -223,16 +106,4 @@ system_n_users{host="worker-01"} 3 # HELP system_uptime Telegraf collected metric # TYPE system_uptime counter system_uptime{host="worker-01"} 1249632 - -# HELP system_os_info Telegraf collected metric -# TYPE system_os_info gauge -system_os_info{host="worker-01",id="debian",id_like="",name="Debian GNU/Linux",pretty_name="Debian GNU/Linux 13 (trixie)",variant="",variant_id="",version="13 (trixie)",version_codename="trixie",version_id="13"} 1 - -# HELP system_dmi_info Telegraf collected metric -# TYPE system_dmi_info gauge -system_dmi_info{bios_date="04/01/2014",bios_release="0.0",bios_vendor="SeaBIOS",bios_version="1.16.3-debian-1.16.3-2",board_asset_tag="",board_name="",board_serial="",board_vendor="",board_version="",chassis_asset_tag="",chassis_serial="",chassis_vendor="QEMU",chassis_version="pc-q35-10.0",host="worker-01",product_family="",product_name="Standard PC (Q35 + ICH9, 2009)",product_serial="",product_sku="",product_uuid="",product_version="pc-q35-10.0",system_vendor="QEMU"} 1 - -# HELP system_uname_info Telegraf collected metric -# TYPE system_uname_info gauge -system_uname_info{domainname="(none)",host="worker-01",machine="x86_64",nodename="worker-01.example.com",release="6.12.57+deb13-amd64",sysname="Linux",version="#1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1 (2025-11-05)"} 1 ``` diff --git a/plugins/inputs/system/sample.conf b/plugins/inputs/system/sample.conf index 1ad4845bdbe8b..90d15b61dbd1f 100644 --- a/plugins/inputs/system/sample.conf +++ b/plugins/inputs/system/sample.conf @@ -1,21 +1,8 @@ -# Read metrics about system load, uptime, users, and node identity +# Read metrics about system load & uptime [[inputs.system]] ## Metric groups to collect. ## Available options: - ## load - load averages (load1, load5, load15) - ## users - logged-in user counts (n_users, n_unique_users) - ## n_cpus - CPU counts (n_cpus, n_physical_cpus) - ## uptime - system uptime (uptime, uptime_format) - ## os - OS release info from /etc/os-release (Linux only) - ## dmi - DMI/SMBIOS hardware info from /sys/class/dmi (Linux only) - ## uname - kernel info from uname(2) (Linux only) - ## By default all groups available on the current platform are collected. - # collect = ["load", "users", "n_cpus", "uptime", "os", "dmi", "uname"] - - ## Path to the host /etc directory (used by the "os" collector). - ## Useful when running inside a container with the host filesystem mounted. - # host_etc = "/etc" - - ## Path to the host /sys directory (used by the "dmi" collector). - ## Useful when running inside a container with the host filesystem mounted. - # host_sys = "/sys" + ## load - system gauge metrics (load averages, cpu counts, user counts) + ## uptime - system uptime + ## By default all groups are collected. + # collect = ["load", "uptime"] diff --git a/plugins/inputs/system/system.go b/plugins/inputs/system/system.go index d2f438fe81eef..c2fa01e03f421 100644 --- a/plugins/inputs/system/system.go +++ b/plugins/inputs/system/system.go @@ -22,38 +22,30 @@ import ( //go:embed sample.conf var sampleConfig string -var crossPlatformCollectors = []string{"load", "users", "n_cpus", "uptime"} +var availableCollectors = []string{"load", "uptime"} type System struct { Collect []string `toml:"collect"` - PathEtc string `toml:"host_etc"` - PathSys string `toml:"host_sys"` Log telegraf.Logger `toml:"-"` collectLoad bool - collectUsers bool - collectNCPUs bool collectUptime bool - - platformData //nolint:unused,nolintlint // for OS-specific usage } func (*System) SampleConfig() string { return sampleConfig } -func (s *System) initCommon(available []string) error { +func (s *System) Init() error { if len(s.Collect) == 0 { - s.Collect = available + s.Collect = availableCollectors } - if err := choice.CheckSlice(s.Collect, available); err != nil { + if err := choice.CheckSlice(s.Collect, availableCollectors); err != nil { return fmt.Errorf("config option 'collect': %w", err) } s.collectLoad = choice.Contains("load", s.Collect) - s.collectUsers = choice.Contains("users", s.Collect) - s.collectNCPUs = choice.Contains("n_cpus", s.Collect) s.collectUptime = choice.Contains("uptime", s.Collect) return nil @@ -61,9 +53,10 @@ func (s *System) initCommon(available []string) error { func (s *System) Gather(acc telegraf.Accumulator) error { now := time.Now() - fields := make(map[string]interface{}) if s.collectLoad { + fields := make(map[string]interface{}) + loadavg, err := load.Avg() if err != nil { if !strings.Contains(err.Error(), "not implemented") { @@ -74,9 +67,7 @@ func (s *System) Gather(acc telegraf.Accumulator) error { fields["load5"] = loadavg.Load5 fields["load15"] = loadavg.Load15 } - } - if s.collectNCPUs { numLogicalCPUs, err := cpu.Counts(true) if err != nil { return err @@ -87,9 +78,7 @@ func (s *System) Gather(acc telegraf.Accumulator) error { } fields["n_cpus"] = numLogicalCPUs fields["n_physical_cpus"] = numPhysicalCPUs - } - if s.collectUsers { users, err := host.Users() if err == nil { fields["n_users"] = len(users) @@ -99,9 +88,7 @@ func (s *System) Gather(acc telegraf.Accumulator) error { } else if os.IsPermission(err) { s.Log.Debug(err.Error()) } - } - if len(fields) > 0 { acc.AddGauge("system", fields, nil, now) } @@ -118,8 +105,6 @@ func (s *System) Gather(acc telegraf.Accumulator) error { }, nil, now) } - s.gatherPlatformInfo(acc) - return nil } diff --git a/plugins/inputs/system/system_linux.go b/plugins/inputs/system/system_linux.go deleted file mode 100644 index 2dc975370344f..0000000000000 --- a/plugins/inputs/system/system_linux.go +++ /dev/null @@ -1,251 +0,0 @@ -//go:build linux - -package system - -import ( - "bufio" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "golang.org/x/sys/unix" - - "github.com/influxdata/telegraf" - "github.com/influxdata/telegraf/internal/choice" -) - -const ( - defaultHostEtc = "/etc" - defaultHostSys = "/sys" -) - -type platformData struct { - collectOS bool - collectDMI bool - collectUname bool - - osTags map[string]string - dmiTags map[string]string - unameTags map[string]string -} - -var linuxCollectors = []string{"os", "dmi", "uname"} - -var dmiFiles = []struct { - tag string - filename string -}{ - {"bios_date", "bios_date"}, - {"bios_release", "bios_release"}, - {"bios_vendor", "bios_vendor"}, - {"bios_version", "bios_version"}, - {"board_asset_tag", "board_asset_tag"}, - {"board_name", "board_name"}, - {"board_serial", "board_serial"}, - {"board_vendor", "board_vendor"}, - {"board_version", "board_version"}, - {"chassis_asset_tag", "chassis_asset_tag"}, - {"chassis_serial", "chassis_serial"}, - {"chassis_vendor", "chassis_vendor"}, - {"chassis_version", "chassis_version"}, - {"product_family", "product_family"}, - {"product_name", "product_name"}, - {"product_serial", "product_serial"}, - {"product_sku", "product_sku"}, - {"product_uuid", "product_uuid"}, - {"product_version", "product_version"}, - // The kernel exposes this as "sys_vendor"; we surface it as - // "system_vendor" to match the prometheus-node-exporter label name. - {"system_vendor", "sys_vendor"}, -} - -var osReleaseKeys = []string{ - "ID", - "ID_LIKE", - "NAME", - "PRETTY_NAME", - "VARIANT", - "VARIANT_ID", - "VERSION", - "VERSION_CODENAME", - "VERSION_ID", -} - -func (s *System) Init() error { - if s.PathEtc == "" { - s.PathEtc = defaultHostEtc - } - if s.PathSys == "" { - s.PathSys = defaultHostSys - } - - available := make([]string, 0, len(crossPlatformCollectors)+len(linuxCollectors)) - available = append(available, crossPlatformCollectors...) - available = append(available, linuxCollectors...) - if err := s.initCommon(available); err != nil { - return err - } - - s.collectOS = choice.Contains("os", s.Collect) - s.collectDMI = choice.Contains("dmi", s.Collect) - s.collectUname = choice.Contains("uname", s.Collect) - - // --- cache os-release ------------------------------------------------ - if s.collectOS { - tags, err := s.initOSTags() - if err != nil { - s.Log.Warnf("Could not read os-release: %v; system_os will not be emitted", err) - } else { - s.osTags = tags - } - } - - // --- cache DMI ------------------------------------------------------- - if s.collectDMI { - s.dmiTags = s.initDMITags() - } - - // --- cache uname ----------------------------------------------------- - if s.collectUname { - tags, err := initUnameTags() - if err != nil { - s.Log.Warnf("Could not read uname info: %v; system_uname will not be emitted", err) - } else { - s.unameTags = tags - } - } - - return nil -} - -func (s *System) gatherPlatformInfo(acc telegraf.Accumulator) { - if s.osTags != nil { - acc.AddGauge("system_os", map[string]interface{}{"info": int64(1)}, s.osTags) - } - if s.dmiTags != nil { - acc.AddGauge("system_dmi", map[string]interface{}{"info": int64(1)}, s.dmiTags) - } - if s.unameTags != nil { - acc.AddGauge("system_uname", map[string]interface{}{"info": int64(1)}, s.unameTags) - } -} - -func (s *System) initOSTags() (map[string]string, error) { - info, err := s.readOSRelease() - if err != nil { - return nil, err - } - - tags := make(map[string]string, len(osReleaseKeys)) - for _, key := range osReleaseKeys { - tags[strings.ToLower(key)] = info[key] // missing key → "" - } - return tags, nil -} - -func (s *System) readOSRelease() (map[string]string, error) { - primary := filepath.Join(s.PathEtc, "os-release") - fallback := filepath.Join(s.PathEtc, "..", "usr", "lib", "os-release") - - f, err := os.Open(primary) - if err != nil { - if !os.IsNotExist(err) { - return nil, fmt.Errorf("opening %q: %w", primary, err) - } - s.Log.Debugf("Primary os-release not found at %q, trying fallback", primary) - f, err = os.Open(fallback) - if err != nil { - return nil, fmt.Errorf("opening os-release (tried %q and %q): %w", primary, fallback, err) - } - } - defer f.Close() - - return parseOSRelease(f) -} - -func parseOSRelease(r io.Reader) (map[string]string, error) { - result := make(map[string]string) - scanner := bufio.NewScanner(r) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - key, value, found := strings.Cut(line, "=") - if !found { - continue - } - result[strings.TrimSpace(key)] = unquoteOSReleaseValue(strings.TrimSpace(value)) - } - if err := scanner.Err(); err != nil { - return result, fmt.Errorf("reading os-release: %w", err) - } - return result, nil -} - -func unquoteOSReleaseValue(s string) string { - if len(s) >= 2 { - if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { - return s[1 : len(s)-1] - } - } - return s -} - -func (s *System) initDMITags() map[string]string { - dmiDir := filepath.Join(s.PathSys, "class", "dmi", "id") - - if _, err := os.Stat(dmiDir); os.IsNotExist(err) { - s.Log.Debugf("DMI directory %q does not exist, skipping system_dmi", dmiDir) - return nil - } - - tags := make(map[string]string, len(dmiFiles)) - for _, entry := range dmiFiles { - value, err := readFileTrimmed(filepath.Join(dmiDir, entry.filename)) - if err != nil { - s.Log.Debugf("Reading DMI file %q: %v", entry.filename, err) - value = "" - } - tags[entry.tag] = value - } - return tags -} - -func initUnameTags() (map[string]string, error) { - var utsname unix.Utsname - if err := unix.Uname(&utsname); err != nil { - return nil, fmt.Errorf("calling uname: %w", err) - } - - tags := map[string]string{ - "domainname": utsFieldToString(utsname.Domainname[:]), - "machine": utsFieldToString(utsname.Machine[:]), - "nodename": utsFieldToString(utsname.Nodename[:]), - "release": utsFieldToString(utsname.Release[:]), - "sysname": utsFieldToString(utsname.Sysname[:]), - "version": utsFieldToString(utsname.Version[:]), - } - return tags, nil -} - -func utsFieldToString[T byte | int8](field []T) string { - b := make([]byte, 0, len(field)) - for _, c := range field { - if c == 0 { - break - } - b = append(b, byte(c)) - } - return string(b) -} - -func readFileTrimmed(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - return strings.TrimSpace(string(data)), nil -} diff --git a/plugins/inputs/system/system_linux_test.go b/plugins/inputs/system/system_linux_test.go deleted file mode 100644 index 69146ce280643..0000000000000 --- a/plugins/inputs/system/system_linux_test.go +++ /dev/null @@ -1,822 +0,0 @@ -//go:build linux - -package system - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/influxdata/telegraf" - "github.com/influxdata/telegraf/metric" - "github.com/influxdata/telegraf/testutil" -) - -const sampleOSReleaseDebian = `# os-release(5) file for Debian -PRETTY_NAME="Debian GNU/Linux 13 (trixie)" -NAME="Debian GNU/Linux" -VERSION_ID="13" -VERSION="13 (trixie)" -VERSION_CODENAME=trixie -ID=debian -HOME_URL="https://www.debian.org/" -SUPPORT_URL="https://www.debian.org/support" -BUG_REPORT_URL="https://bugs.debian.org/" -` - -const sampleOSReleaseUbuntu = `NAME="Ubuntu" -VERSION="22.04.3 LTS (Jammy Jellyfish)" -ID=ubuntu -ID_LIKE=debian -PRETTY_NAME="Ubuntu 22.04.3 LTS" -VERSION_ID="22.04" -VERSION_CODENAME=jammy -` - -const sampleOSReleaseRocky = `NAME="Rocky Linux" -VERSION="9.3 (Blue Onyx)" -ID="rocky" -ID_LIKE="rhel centos fedora" -VERSION_ID="9.3" -PLATFORM_ID="platform:el9" -PRETTY_NAME="Rocky Linux 9.3 (Blue Onyx)" -` - -const sampleOSReleaseArch = `NAME="Arch Linux" -PRETTY_NAME="Arch Linux" -ID=arch -BUILD_ID=rolling -ANSI_COLOR="38;2;23;147;209" -HOME_URL="https://archlinux.org/" -` - -const sampleOSReleaseAlpine = `NAME="Alpine Linux" -ID=alpine -VERSION_ID=3.19.0 -PRETTY_NAME="Alpine Linux v3.19" -HOME_URL="https://alpinelinux.org/" -BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues" -` - -const sampleOSReleaseFedoraServer = `NAME="Fedora Linux" -VERSION="39 (Server Edition)" -ID=fedora -VERSION_ID=39 -VERSION_CODENAME="" -PRETTY_NAME="Fedora Linux 39 (Server Edition)" -VARIANT="Server Edition" -VARIANT_ID=server -` - -func TestParseOSRelease(t *testing.T) { - tests := []struct { - name string - input string - expected map[string]string - }{ - { - name: "debian", - input: sampleOSReleaseDebian, - expected: map[string]string{ - "PRETTY_NAME": "Debian GNU/Linux 13 (trixie)", - "NAME": "Debian GNU/Linux", - "VERSION_ID": "13", - "VERSION": "13 (trixie)", - "VERSION_CODENAME": "trixie", - "ID": "debian", - "HOME_URL": "https://www.debian.org/", - "SUPPORT_URL": "https://www.debian.org/support", - "BUG_REPORT_URL": "https://bugs.debian.org/", - }, - }, - { - name: "ubuntu with ID_LIKE", - input: sampleOSReleaseUbuntu, - expected: map[string]string{ - "NAME": "Ubuntu", - "VERSION": "22.04.3 LTS (Jammy Jellyfish)", - "ID": "ubuntu", - "ID_LIKE": "debian", - "PRETTY_NAME": "Ubuntu 22.04.3 LTS", - "VERSION_ID": "22.04", - "VERSION_CODENAME": "jammy", - }, - }, - { - name: "rocky linux / RHEL-like with double-quoted ID", - input: sampleOSReleaseRocky, - expected: map[string]string{ - "NAME": "Rocky Linux", - "VERSION": "9.3 (Blue Onyx)", - "ID": "rocky", - "ID_LIKE": "rhel centos fedora", - "VERSION_ID": "9.3", - "PLATFORM_ID": "platform:el9", - "PRETTY_NAME": "Rocky Linux 9.3 (Blue Onyx)", - }, - }, - { - name: "arch linux rolling (no VERSION keys)", - input: sampleOSReleaseArch, - expected: map[string]string{ - "NAME": "Arch Linux", - "PRETTY_NAME": "Arch Linux", - "ID": "arch", - "BUILD_ID": "rolling", - "ANSI_COLOR": "38;2;23;147;209", - "HOME_URL": "https://archlinux.org/", - }, - }, - { - name: "alpine linux minimal", - input: sampleOSReleaseAlpine, - expected: map[string]string{ - "NAME": "Alpine Linux", - "ID": "alpine", - "VERSION_ID": "3.19.0", - "PRETTY_NAME": "Alpine Linux v3.19", - "HOME_URL": "https://alpinelinux.org/", - "BUG_REPORT_URL": "https://gitlab.alpinelinux.org/alpine/aports/-/issues", - }, - }, - { - name: "fedora server with VARIANT", - input: sampleOSReleaseFedoraServer, - expected: map[string]string{ - "NAME": "Fedora Linux", - "VERSION": "39 (Server Edition)", - "ID": "fedora", - "VERSION_ID": "39", - "VERSION_CODENAME": "", - "PRETTY_NAME": "Fedora Linux 39 (Server Edition)", - "VARIANT": "Server Edition", - "VARIANT_ID": "server", - }, - }, - { - name: "unquoted values", - input: `ID=ubuntu -VERSION_ID=22.04 -`, - expected: map[string]string{ - "ID": "ubuntu", - "VERSION_ID": "22.04", - }, - }, - { - name: "single-quoted values", - input: `NAME='My Linux' -ID=mylinux -`, - expected: map[string]string{ - "NAME": "My Linux", - "ID": "mylinux", - }, - }, - { - name: "empty input", - input: "", - expected: map[string]string{}, - }, - { - name: "comments and blank lines are skipped", - input: `# comment - -ID=test -`, - expected: map[string]string{ - "ID": "test", - }, - }, - { - name: "value containing equals sign", - input: `SOME_KEY=val=ue=extra -`, - expected: map[string]string{ - "SOME_KEY": "val=ue=extra", - }, - }, - { - name: "value with empty double quotes", - input: `VERSION_CODENAME="" -`, - expected: map[string]string{ - "VERSION_CODENAME": "", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseOSRelease(strings.NewReader(tt.input)) - require.NoError(t, err) - require.Equal(t, tt.expected, result) - }) - } -} - -func TestUnquoteOSReleaseValue(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {`"hello world"`, "hello world"}, - {`'hello world'`, "hello world"}, - {`noquotes`, "noquotes"}, - {`""`, ""}, - {`''`, ""}, - {`"`, `"`}, - {`'`, `'`}, - {`"mismatched'`, `"mismatched'`}, - } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - require.Equal(t, tt.expected, unquoteOSReleaseValue(tt.input)) - }) - } -} - -func TestUtsFieldToString(t *testing.T) { - t.Run("int8 array", func(t *testing.T) { - field := [8]int8{'L', 'i', 'n', 'u', 'x', 0, 0, 0} - require.Equal(t, "Linux", utsFieldToString(field[:])) - }) - t.Run("byte array", func(t *testing.T) { - field := [8]byte{'L', 'i', 'n', 'u', 'x', 0, 0, 0} - require.Equal(t, "Linux", utsFieldToString(field[:])) - }) - t.Run("nul at start produces empty string", func(t *testing.T) { - field := [4]int8{0, 'a', 'b', 'c'} - require.Empty(t, utsFieldToString(field[:])) - }) - t.Run("no nul terminator fills whole array", func(t *testing.T) { - field := [4]int8{'a', 'b', 'c', 'd'} - require.Equal(t, "abcd", utsFieldToString(field[:])) - }) - t.Run("empty array", func(t *testing.T) { - var field [0]int8 - require.Empty(t, utsFieldToString(field[:])) - }) -} - -func TestSystemInitDefaults(t *testing.T) { - plugin := &System{ - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - require.Equal(t, defaultHostEtc, plugin.PathEtc) - require.Equal(t, defaultHostSys, plugin.PathSys) - require.Equal(t, []string{"load", "users", "n_cpus", "uptime", "os", "dmi", "uname"}, plugin.Collect) -} - -func TestSystemInitCustomPaths(t *testing.T) { - plugin := &System{ - PathEtc: "/custom/etc", - PathSys: "/custom/sys", - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - require.Equal(t, "/custom/etc", plugin.PathEtc) - require.Equal(t, "/custom/sys", plugin.PathSys) -} - -func TestSystemInitInvalidCollectOption(t *testing.T) { - plugin := &System{ - Collect: []string{"load", "bogus"}, - Log: testutil.Logger{}, - } - err := plugin.Init() - require.Error(t, err) - require.ErrorContains(t, err, "config option 'collect'") -} - -func TestSystemInitValidCollectSubset(t *testing.T) { - plugin := &System{ - Collect: []string{"uname"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - require.Equal(t, []string{"uname"}, plugin.Collect) -} - -// setupEtcDir creates a temporary etc directory with the given os-release -// content. Returns the path to the tmp root that serves as host_etc. -func setupEtcDir(t *testing.T, content string) string { - t.Helper() - td := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(td, "os-release"), []byte(content), 0o600)) - return td -} - -// setupDMIDir creates a fake /sys/class/dmi/id/ tree and returns the "sys" -// root path. -func setupDMIDir(t *testing.T, files map[string]string) string { - t.Helper() - dmiDir := filepath.Join(t.TempDir(), "class", "dmi", "id") - require.NoError(t, os.MkdirAll(dmiDir, 0o750)) - for name, content := range files { - require.NoError(t, os.WriteFile(filepath.Join(dmiDir, name), []byte(content+"\n"), 0o600)) - } - // Return three levels up: id → dmi → class → - return filepath.Dir(filepath.Dir(filepath.Dir(dmiDir))) -} - -func TestGatherOSInfoDebian(t *testing.T) { - etcDir := setupEtcDir(t, sampleOSReleaseDebian) - - plugin := &System{ - PathEtc: etcDir, - PathSys: etcDir, // unused for this test but must be set - Collect: []string{"os"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - expected := []telegraf.Metric{ - metric.New( - "system_os", - map[string]string{ - "id": "debian", - "id_like": "", - "name": "Debian GNU/Linux", - "pretty_name": "Debian GNU/Linux 13 (trixie)", - "variant": "", - "variant_id": "", - "version": "13 (trixie)", - "version_codename": "trixie", - "version_id": "13", - }, - map[string]interface{}{"info": int64(1)}, - time.Unix(0, 0), - telegraf.Gauge, - ), - } - - // Filter to only system_os metrics to avoid interference from system metrics. - var nodeOSMetrics []telegraf.Metric - for _, m := range acc.GetTelegrafMetrics() { - if m.Name() == "system_os" { - nodeOSMetrics = append(nodeOSMetrics, m) - } - } - testutil.RequireMetricsEqual(t, expected, nodeOSMetrics, testutil.IgnoreTime()) -} - -func TestGatherOSInfoArch(t *testing.T) { - // Arch Linux has no VERSION, VERSION_ID, VERSION_CODENAME, VARIANT keys. - etcDir := setupEtcDir(t, sampleOSReleaseArch) - - plugin := &System{ - PathEtc: etcDir, - PathSys: etcDir, - Collect: []string{"os"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_os") - require.Len(t, metrics, 1) - - tags := metrics[0].Tags() - require.Equal(t, "arch", tags["id"]) - require.Equal(t, "Arch Linux", tags["name"]) - // Missing keys must appear as empty-string tags. - require.Empty(t, tags["version"]) - require.Empty(t, tags["version_id"]) - require.Empty(t, tags["version_codename"]) - require.Empty(t, tags["variant"]) - require.Empty(t, tags["variant_id"]) - require.Empty(t, tags["id_like"]) - - _, ok := metrics[0].GetField("info") - require.True(t, ok) -} - -func TestGatherOSInfoAlpine(t *testing.T) { - etcDir := setupEtcDir(t, sampleOSReleaseAlpine) - - plugin := &System{ - PathEtc: etcDir, - PathSys: etcDir, - Collect: []string{"os"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_os") - require.Len(t, metrics, 1) - - tags := metrics[0].Tags() - require.Equal(t, "alpine", tags["id"]) - require.Equal(t, "3.19.0", tags["version_id"]) - require.Empty(t, tags["version"]) - require.Empty(t, tags["version_codename"]) - - _, ok := metrics[0].GetField("info") - require.True(t, ok) -} - -func TestGatherOSInfoFedoraVariant(t *testing.T) { - etcDir := setupEtcDir(t, sampleOSReleaseFedoraServer) - - plugin := &System{ - PathEtc: etcDir, - PathSys: etcDir, - Collect: []string{"os"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_os") - require.Len(t, metrics, 1) - - tags := metrics[0].Tags() - require.Equal(t, "fedora", tags["id"]) - require.Equal(t, "Server Edition", tags["variant"]) - require.Equal(t, "server", tags["variant_id"]) - require.Empty(t, tags["version_codename"]) - - _, ok := metrics[0].GetField("info") - require.True(t, ok) -} - -func TestGatherOSInfoFallbackToUsrLib(t *testing.T) { - // Simulate a system where /etc/os-release is absent but - // /usr/lib/os-release exists (common in some containers). - td := t.TempDir() - // Do NOT create td/os-release. - usrLib := filepath.Join(td, "..", "usr", "lib") - require.NoError(t, os.MkdirAll(usrLib, 0o750)) - require.NoError(t, os.WriteFile(filepath.Join(usrLib, "os-release"), []byte(sampleOSReleaseAlpine), 0o600)) - - plugin := &System{ - PathEtc: td, - PathSys: td, - Collect: []string{"os"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_os") - require.Len(t, metrics, 1) - require.Equal(t, "alpine", metrics[0].Tags()["id"]) -} - -func TestGatherOSInfoMissingBothFiles(t *testing.T) { - td := t.TempDir() - - plugin := &System{ - PathEtc: td, - PathSys: td, - Collect: []string{"os"}, - Log: testutil.Logger{}, - } - // Init succeeds (warns but does not fail) even when os-release is missing. - require.NoError(t, plugin.Init()) - // The osTags cache must be nil so that Gather skips the metric. - require.Nil(t, plugin.osTags) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - - // No system_os metric and no errors — the warning was already logged during Init. - require.Empty(t, filterMetrics(acc.GetTelegrafMetrics(), "system_os")) - require.Empty(t, acc.Errors) -} - -func TestGatherDMIInfo(t *testing.T) { - sysRoot := setupDMIDir(t, map[string]string{ - "bios_date": "04/01/2014", - "bios_release": "0.0", - "bios_vendor": "SeaBIOS", - "bios_version": "1.16.3-debian-1.16.3-2", - "board_asset_tag": "", - "board_name": "Standard PC", - "board_serial": "board-serial-001", - "board_vendor": "QEMU", - "board_version": "1.0", - "chassis_asset_tag": "", - "chassis_serial": "", - "chassis_vendor": "QEMU", - "chassis_version": "pc-q35-10.0", - "product_family": "", - "product_name": "Standard PC (Q35 + ICH9, 2009)", - "product_serial": "", - "product_sku": "", - "product_uuid": "11111111-2222-3333-4444-555555555555", - "product_version": "pc-q35-10.0", - "sys_vendor": "QEMU", - }) - - etcDir := setupEtcDir(t, sampleOSReleaseDebian) - - plugin := &System{ - PathEtc: etcDir, - PathSys: sysRoot, - Collect: []string{"dmi"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_dmi") - require.Len(t, metrics, 1) - require.Equal(t, "system_dmi", metrics[0].Name()) - - tags := metrics[0].Tags() - require.Equal(t, "04/01/2014", tags["bios_date"]) - require.Equal(t, "0.0", tags["bios_release"]) - require.Equal(t, "SeaBIOS", tags["bios_vendor"]) - require.Equal(t, "1.16.3-debian-1.16.3-2", tags["bios_version"]) - require.Equal(t, "Standard PC", tags["board_name"]) - require.Equal(t, "QEMU", tags["system_vendor"]) - require.Equal(t, "Standard PC (Q35 + ICH9, 2009)", tags["product_name"]) - require.Equal(t, "11111111-2222-3333-4444-555555555555", tags["product_uuid"]) - - value, ok := metrics[0].GetField("info") - require.True(t, ok) - require.Equal(t, int64(1), value) -} - -func TestGatherDMIInfoMissingFiles(t *testing.T) { - // Only provide a subset of DMI files; the rest should default to empty strings. - sysRoot := setupDMIDir(t, map[string]string{ - "bios_vendor": "TestVendor", - "product_name": "TestProduct", - "sys_vendor": "TestSystemVendor", - }) - - plugin := &System{ - PathSys: sysRoot, - PathEtc: sysRoot, - Collect: []string{"dmi"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_dmi") - require.Len(t, metrics, 1) - - tags := metrics[0].Tags() - require.Equal(t, "TestVendor", tags["bios_vendor"]) - require.Equal(t, "TestProduct", tags["product_name"]) - require.Equal(t, "TestSystemVendor", tags["system_vendor"]) - // Missing files must produce empty tag values, not absent tags. - require.Empty(t, tags["bios_date"]) - require.Empty(t, tags["chassis_vendor"]) - require.Empty(t, tags["product_uuid"]) - - _, ok := metrics[0].GetField("info") - require.True(t, ok) -} - -func TestGatherDMIInfoDirectoryMissing(t *testing.T) { - // Simulate ARM board or container where /sys/class/dmi/id/ is absent. - td := t.TempDir() - - plugin := &System{ - PathSys: td, - PathEtc: td, - Collect: []string{"dmi"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - - // No system_dmi metric should be emitted and no error should be accumulated. - require.Empty(t, acc.Errors) - require.Empty(t, filterMetrics(acc.GetTelegrafMetrics(), "system_dmi")) -} - -func TestGatherUnameInfo(t *testing.T) { - plugin := &System{ - PathEtc: t.TempDir(), - PathSys: t.TempDir(), - Collect: []string{"uname"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - metrics := filterMetrics(acc.GetTelegrafMetrics(), "system_uname") - require.Len(t, metrics, 1) - require.Equal(t, "system_uname", metrics[0].Name()) - - tags := metrics[0].Tags() - - // We cannot predict exact kernel values on the CI machine, but we can - // assert that the mandatory tags are present and non-empty. - for _, key := range []string{"sysname", "release", "machine", "nodename", "version"} { - v, ok := tags[key] - require.Truef(t, ok, "tag %q is missing", key) - require.NotEmptyf(t, v, "tag %q should not be empty", key) - } - // domainname may legitimately be "(none)" but the tag must exist. - _, ok := tags["domainname"] - require.True(t, ok, "tag \"domainname\" is missing") - - value, ok := metrics[0].GetField("info") - require.True(t, ok) - require.Equal(t, int64(1), value) -} - -func TestCollectOnlyOS(t *testing.T) { - etcDir := setupEtcDir(t, sampleOSReleaseDebian) - - plugin := &System{ - PathEtc: etcDir, - PathSys: t.TempDir(), - Collect: []string{"os"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - names := metricNameSet(acc.GetTelegrafMetrics()) - require.Contains(t, names, "system_os") - require.NotContains(t, names, "system_dmi") - require.NotContains(t, names, "system_uname") -} - -func TestCollectOnlyUname(t *testing.T) { - plugin := &System{ - PathEtc: t.TempDir(), - PathSys: t.TempDir(), - Collect: []string{"uname"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - names := metricNameSet(acc.GetTelegrafMetrics()) - require.Contains(t, names, "system_uname") - require.NotContains(t, names, "system_os") - require.NotContains(t, names, "system_dmi") -} - -func TestCollectOnlyDMI(t *testing.T) { - sysRoot := setupDMIDir(t, map[string]string{ - "sys_vendor": "TestVendor", - }) - - plugin := &System{ - PathEtc: t.TempDir(), - PathSys: sysRoot, - Collect: []string{"dmi"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - names := metricNameSet(acc.GetTelegrafMetrics()) - require.Contains(t, names, "system_dmi") - require.NotContains(t, names, "system_os") - require.NotContains(t, names, "system_uname") -} - -func TestGatherAllMetricGroups(t *testing.T) { - etcDir := setupEtcDir(t, sampleOSReleaseDebian) - - sysRoot := setupDMIDir(t, map[string]string{ - "sys_vendor": "QEMU", - "product_name": "Standard PC", - }) - - plugin := &System{ - PathEtc: etcDir, - PathSys: sysRoot, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - names := metricNameSet(acc.GetTelegrafMetrics()) - require.Contains(t, names, "system_os") - require.Contains(t, names, "system_dmi") - require.Contains(t, names, "system_uname") -} - -func TestGatherMultipleCollectRuns(t *testing.T) { - // Verify that repeated Gather() calls produce consistent results. - etcDir := setupEtcDir(t, sampleOSReleaseDebian) - - plugin := &System{ - PathEtc: etcDir, - PathSys: t.TempDir(), - Collect: []string{"os", "uname"}, - Log: testutil.Logger{}, - } - require.NoError(t, plugin.Init()) - - for i := 0; i < 3; i++ { - var acc testutil.Accumulator - require.NoError(t, plugin.Gather(&acc)) - require.Empty(t, acc.Errors) - - names := metricNameSet(acc.GetTelegrafMetrics()) - require.Contains(t, names, "system_os") - require.Contains(t, names, "system_uname") - require.NotContains(t, names, "system_dmi") - } -} - -func TestReadFileTrimmed(t *testing.T) { - td := t.TempDir() - - t.Run("normal value with newline", func(t *testing.T) { - p := filepath.Join(td, "normal") - require.NoError(t, os.WriteFile(p, []byte("SeaBIOS\n"), 0o600)) - v, err := readFileTrimmed(p) - require.NoError(t, err) - require.Equal(t, "SeaBIOS", v) - }) - - t.Run("value with extra whitespace", func(t *testing.T) { - p := filepath.Join(td, "whitespace") - require.NoError(t, os.WriteFile(p, []byte(" QEMU \n"), 0o600)) - v, err := readFileTrimmed(p) - require.NoError(t, err) - require.Equal(t, "QEMU", v) - }) - - t.Run("empty file", func(t *testing.T) { - p := filepath.Join(td, "empty") - require.NoError(t, os.WriteFile(p, []byte(""), 0o600)) - v, err := readFileTrimmed(p) - require.NoError(t, err) - require.Empty(t, v) - }) - - t.Run("missing file returns error", func(t *testing.T) { - _, err := readFileTrimmed(filepath.Join(td, "nonexistent")) - require.Error(t, err) - }) -} - -// filterMetrics filters a slice of metrics by name. -func filterMetrics(metrics []telegraf.Metric, name string) []telegraf.Metric { - var out []telegraf.Metric - for _, m := range metrics { - if m.Name() == name { - out = append(out, m) - } - } - return out -} - -// metricNameSet returns the set of unique metric names present in metrics. -func metricNameSet(metrics []telegraf.Metric) map[string]struct{} { - names := make(map[string]struct{}, len(metrics)) - for _, m := range metrics { - names[m.Name()] = struct{}{} - } - return names -} diff --git a/plugins/inputs/system/system_notlinux.go b/plugins/inputs/system/system_notlinux.go deleted file mode 100644 index 448af74981392..0000000000000 --- a/plugins/inputs/system/system_notlinux.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build !linux - -package system - -import "github.com/influxdata/telegraf" - -type platformData struct{} //nolint:unused // not used on non-Linux, needed for System struct - -func (s *System) Init() error { - return s.initCommon(crossPlatformCollectors) -} - -func (*System) gatherPlatformInfo(_ telegraf.Accumulator) {} diff --git a/plugins/inputs/system/testdata/influx.conf b/plugins/inputs/system/testdata/influx.conf deleted file mode 100644 index e042027db73db..0000000000000 --- a/plugins/inputs/system/testdata/influx.conf +++ /dev/null @@ -1,10 +0,0 @@ -[agent] - interval = "1s" - flush_interval = "1s" - omit_hostname = false - -[[inputs.system]] - -[[outputs.file]] - files = ["stdout"] - data_format = "influx" diff --git a/plugins/inputs/system/testdata/prometheus.conf b/plugins/inputs/system/testdata/prometheus.conf deleted file mode 100644 index 9ffd390fa7b1b..0000000000000 --- a/plugins/inputs/system/testdata/prometheus.conf +++ /dev/null @@ -1,10 +0,0 @@ -[agent] - interval = "1s" - flush_interval = "1s" - omit_hostname = false - -[[inputs.system]] - -[[outputs.file]] - files = ["stdout"] - data_format = "prometheus" From 27b75e9e6426e1b758718b9aa350ef1cb080ff69 Mon Sep 17 00:00:00 2001 From: bilkoua Date: Thu, 23 Apr 2026 12:15:00 +0300 Subject: [PATCH 10/12] feat(inputs.system): add include option for selective metric collection --- plugins/inputs/system/README.md | 105 ++++---- plugins/inputs/system/sample.conf | 24 +- plugins/inputs/system/system.go | 165 +++++++----- plugins/inputs/system/system_test.go | 238 ++++++++++++++++++ .../inputs/system/system_users_linux_test.go | 24 ++ .../inputs/system/system_users_other_test.go | 18 ++ 6 files changed, 458 insertions(+), 116 deletions(-) create mode 100644 plugins/inputs/system/system_users_linux_test.go create mode 100644 plugins/inputs/system/system_users_other_test.go diff --git a/plugins/inputs/system/README.md b/plugins/inputs/system/README.md index 21abbcac8ddb0..a5bf68fecc4f7 100644 --- a/plugins/inputs/system/README.md +++ b/plugins/inputs/system/README.md @@ -20,14 +20,36 @@ plugin ordering. See [CONFIGURATION.md][CONFIGURATION.md] for more details. ```toml @sample.conf # Read metrics about system load & uptime [[inputs.system]] - ## Metric groups to collect. + ## Metric groups to collect; the "include" option controls which fields + ## are gathered and how they are emitted. + ## ## Available options: - ## load - system gauge metrics (load averages, cpu counts, user counts) - ## uptime - system uptime - ## By default all groups are collected. - # collect = ["load", "uptime"] + ## load - 1/5/15-minute load averages (load1, load5, load15) + ## users - logged-in user counts (n_users, n_unique_users) + ## cpus - CPU counts with new field names (n_virtual_cpus, + ## n_physical_cpus) + ## deprecated-cpus - CPU counts with legacy field names (n_cpus, + ## n_physical_cpus); deprecated, use "cpus" instead + ## uptime - system uptime as a field in the main metric + ## deprecated-uptime - system uptime as separate counter and + ## uptime_format metrics; deprecated, use "uptime" + ## instead + ## + ## "cpus" and "deprecated-cpus" are mutually exclusive. + ## "uptime" and "deprecated-uptime" are mutually exclusive. + ## + ## By default all groups are collected with backward-compatible field names. + # include = ["load", "users", "deprecated-cpus", "deprecated-uptime"] ``` +> **Note:** `cpus` and `deprecated-cpus` are mutually exclusive. +> `uptime` and `deprecated-uptime` are mutually exclusive. +> +> **Migration note:** Switching from `deprecated-uptime` to `uptime` changes +> the Prometheus metric type of `system_uptime` from **counter** to **gauge**. +> If your dashboards or alerts use counter-specific functions such as `rate()` +> or `increase()` on `system_uptime`, update them before migrating. + ### Permissions The `n_users` field requires read access to `/var/run/utmp`, and may require the @@ -42,68 +64,41 @@ same requirements for `n_users` apply. ### `system` -All fields below belong to the `system` measurement. The `collect` option +All fields below belong to the `system` measurement. The `include` option controls which groups are gathered. -| Field | Group | Type | Description | -|-------------------|----------|---------|------------------------------------------------| -| `load1` | `load` | float | 1-minute load average | -| `load5` | `load` | float | 5-minute load average | -| `load15` | `load` | float | 15-minute load average | -| `n_users` | `load` | integer | Number of logged-in user sessions | -| `n_unique_users` | `load` | integer | Number of unique logged-in usernames | -| `n_cpus` | `load` | integer | Number of logical CPUs | -| `n_physical_cpus` | `load` | integer | Number of physical CPUs | -| `uptime` | `uptime` | integer | System uptime in seconds | -| `uptime_format` | `uptime` | string | Human-readable uptime (deprecated, use uptime) | +| Field | Include option | Type | Description | +|-------------------|----------------------------|---------|---------------------------------------------| +| `load1` | `load` | float | 1-minute load average | +| `load5` | `load` | float | 5-minute load average | +| `load15` | `load` | float | 15-minute load average | +| `n_users` | `users` | integer | Number of logged-in user sessions | +| `n_unique_users` | `users` | integer | Number of unique logged-in usernames | +| `n_virtual_cpus` | `cpus` | integer | Number of logical CPUs | +| `n_cpus` | `deprecated-cpus` | integer | Number of logical CPUs (deprecated name) | +| `n_physical_cpus` | `cpus` / `deprecated-cpus` | integer | Number of physical CPUs | +| `uptime` | `uptime` | integer | System uptime in seconds (gauge field) | +| `uptime` | `deprecated-uptime` | integer | System uptime in seconds (separate counter) | +| `uptime_format` | `deprecated-uptime` | string | Human-readable uptime (deprecated) | ## Example Output +### Default configuration + +With the default `include = ["load", "users", "deprecated-cpus", "deprecated-uptime"]`, +the output is backward-compatible with previous versions: + ```text system,host=worker-01 load1=3.72,load5=2.4,load15=2.1,n_users=3i,n_unique_users=2i,n_cpus=4i,n_physical_cpus=2i 1748000000000000000 system,host=worker-01 uptime=1249632i 1748000000000000000 system,host=worker-01 uptime_format="14 days, 11:07" 1748000000000000000 ``` -## Example Output (Prometheus) +### Recommended configuration -When using the [Prometheus output plugin][prom-output] or -[Prometheus client plugin][prom-client], Telegraf converts each field into -its own Prometheus metric by appending the field name to the measurement name. - -[prom-output]: ../../../plugins/outputs/prometheus_client/README.md -[prom-client]: ../../../plugins/outputs/prometheus_client/README.md +With `include = ["load", "users", "cpus", "uptime"]`, all fields are emitted +in a single metric with the new field names: ```text -# HELP system_load1 Telegraf collected metric -# TYPE system_load1 gauge -system_load1{host="worker-01"} 3.72 - -# HELP system_load15 Telegraf collected metric -# TYPE system_load15 gauge -system_load15{host="worker-01"} 2.1 - -# HELP system_load5 Telegraf collected metric -# TYPE system_load5 gauge -system_load5{host="worker-01"} 2.4 - -# HELP system_n_cpus Telegraf collected metric -# TYPE system_n_cpus gauge -system_n_cpus{host="worker-01"} 4 - -# HELP system_n_physical_cpus Telegraf collected metric -# TYPE system_n_physical_cpus gauge -system_n_physical_cpus{host="worker-01"} 2 - -# HELP system_n_unique_users Telegraf collected metric -# TYPE system_n_unique_users gauge -system_n_unique_users{host="worker-01"} 2 - -# HELP system_n_users Telegraf collected metric -# TYPE system_n_users gauge -system_n_users{host="worker-01"} 3 - -# HELP system_uptime Telegraf collected metric -# TYPE system_uptime counter -system_uptime{host="worker-01"} 1249632 +system,host=worker-01 load1=3.72,load5=2.4,load15=2.1,n_users=3i,n_unique_users=2i,n_virtual_cpus=4i,n_physical_cpus=2i,uptime=1249632i 1748000000000000000 ``` diff --git a/plugins/inputs/system/sample.conf b/plugins/inputs/system/sample.conf index 90d15b61dbd1f..0d9e13d0fea90 100644 --- a/plugins/inputs/system/sample.conf +++ b/plugins/inputs/system/sample.conf @@ -1,8 +1,22 @@ # Read metrics about system load & uptime [[inputs.system]] - ## Metric groups to collect. + ## Metric groups to collect; the "include" option controls which fields + ## are gathered and how they are emitted. + ## ## Available options: - ## load - system gauge metrics (load averages, cpu counts, user counts) - ## uptime - system uptime - ## By default all groups are collected. - # collect = ["load", "uptime"] + ## load - 1/5/15-minute load averages (load1, load5, load15) + ## users - logged-in user counts (n_users, n_unique_users) + ## cpus - CPU counts with new field names (n_virtual_cpus, + ## n_physical_cpus) + ## deprecated-cpus - CPU counts with legacy field names (n_cpus, + ## n_physical_cpus); deprecated, use "cpus" instead + ## uptime - system uptime as a field in the main metric + ## deprecated-uptime - system uptime as separate counter and + ## uptime_format metrics; deprecated, use "uptime" + ## instead + ## + ## "cpus" and "deprecated-cpus" are mutually exclusive. + ## "uptime" and "deprecated-uptime" are mutually exclusive. + ## + ## By default all groups are collected with backward-compatible field names. + # include = ["load", "users", "deprecated-cpus", "deprecated-uptime"] diff --git a/plugins/inputs/system/system.go b/plugins/inputs/system/system.go index c2fa01e03f421..66ccbb5f11d2a 100644 --- a/plugins/inputs/system/system.go +++ b/plugins/inputs/system/system.go @@ -5,6 +5,7 @@ import ( "bufio" "bytes" _ "embed" + "errors" "fmt" "os" "strings" @@ -15,22 +16,17 @@ import ( "github.com/shirou/gopsutil/v4/load" "github.com/influxdata/telegraf" - "github.com/influxdata/telegraf/internal/choice" + "github.com/influxdata/telegraf/config" "github.com/influxdata/telegraf/plugins/inputs" ) //go:embed sample.conf var sampleConfig string -var availableCollectors = []string{"load", "uptime"} - type System struct { - Collect []string `toml:"collect"` + Include []string `toml:"include"` Log telegraf.Logger `toml:"-"` - - collectLoad bool - collectUptime bool } func (*System) SampleConfig() string { @@ -38,71 +34,128 @@ func (*System) SampleConfig() string { } func (s *System) Init() error { - if len(s.Collect) == 0 { - s.Collect = availableCollectors + if len(s.Include) == 0 { + s.Include = []string{"load", "users", "deprecated-cpus", "deprecated-uptime"} } - if err := choice.CheckSlice(s.Collect, availableCollectors); err != nil { - return fmt.Errorf("config option 'collect': %w", err) + + enabled := make(map[string]bool, len(s.Include)) + deduped := make([]string, 0, len(s.Include)) + for _, incl := range s.Include { + if enabled[incl] { + continue + } + switch incl { + case "load", "users", "cpus", "uptime": + case "deprecated-cpus": + config.PrintOptionValueDeprecationNotice( + "inputs.system", + "include", + "deprecated-cpus", + telegraf.DeprecationInfo{ + Since: "1.39.0", + RemovalIn: "1.45.0", + Notice: "use 'cpus' instead", + }, + ) + case "deprecated-uptime": + config.PrintOptionValueDeprecationNotice( + "inputs.system", + "include", + "deprecated-uptime", + telegraf.DeprecationInfo{ + Since: "1.39.0", + RemovalIn: "1.45.0", + Notice: "use 'uptime' instead", + }, + ) + default: + return fmt.Errorf("invalid 'include' option %q", incl) + } + enabled[incl] = true + deduped = append(deduped, incl) } + s.Include = deduped - s.collectLoad = choice.Contains("load", s.Collect) - s.collectUptime = choice.Contains("uptime", s.Collect) + if enabled["cpus"] && enabled["deprecated-cpus"] { + return errors.New(`"cpus" and "deprecated-cpus" are mutually exclusive`) + } + if enabled["uptime"] && enabled["deprecated-uptime"] { + return errors.New(`"uptime" and "deprecated-uptime" are mutually exclusive`) + } return nil } func (s *System) Gather(acc telegraf.Accumulator) error { now := time.Now() - - if s.collectLoad { - fields := make(map[string]interface{}) - - loadavg, err := load.Avg() - if err != nil { - if !strings.Contains(err.Error(), "not implemented") { - return err + fields := make(map[string]interface{}, 8) + + for _, incl := range s.Include { + switch incl { + case "load": + loadavg, err := load.Avg() + if err != nil { + if !strings.Contains(err.Error(), "not implemented") { + acc.AddError(fmt.Errorf("reading load averages: %w", err)) + } + continue } - } else { fields["load1"] = loadavg.Load1 fields["load5"] = loadavg.Load5 fields["load15"] = loadavg.Load15 + case "users": + users, err := host.Users() + if err == nil { + fields["n_users"] = len(users) + fields["n_unique_users"] = findUniqueUsers(users) + } else if os.IsNotExist(err) { + s.Log.Debugf("Reading users: %s", err.Error()) + } else if os.IsPermission(err) { + s.Log.Debug(err.Error()) + } else { + s.Log.Warnf("Reading users: %s", err.Error()) + } + case "cpus", "deprecated-cpus": + numLogicalCPUs, err := cpu.Counts(true) + if err != nil { + acc.AddError(fmt.Errorf("reading logical CPU count: %w", err)) + continue + } + numPhysicalCPUs, err := cpu.Counts(false) + if err != nil { + acc.AddError(fmt.Errorf("reading physical CPU count: %w", err)) + continue + } + if incl == "cpus" { + fields["n_virtual_cpus"] = numLogicalCPUs + } else { + fields["n_cpus"] = numLogicalCPUs + } + fields["n_physical_cpus"] = numPhysicalCPUs + case "uptime": + uptime, err := host.Uptime() + if err != nil { + acc.AddError(fmt.Errorf("reading uptime: %w", err)) + continue + } + fields["uptime"] = uptime + case "deprecated-uptime": + uptime, err := host.Uptime() + if err != nil { + acc.AddError(fmt.Errorf("reading uptime: %w", err)) + continue + } + acc.AddCounter("system", map[string]interface{}{ + "uptime": uptime, + }, nil, now) + acc.AddFields("system", map[string]interface{}{ + "uptime_format": formatUptime(uptime), + }, nil, now) } - - numLogicalCPUs, err := cpu.Counts(true) - if err != nil { - return err - } - numPhysicalCPUs, err := cpu.Counts(false) - if err != nil { - return err - } - fields["n_cpus"] = numLogicalCPUs - fields["n_physical_cpus"] = numPhysicalCPUs - - users, err := host.Users() - if err == nil { - fields["n_users"] = len(users) - fields["n_unique_users"] = findUniqueUsers(users) - } else if os.IsNotExist(err) { - s.Log.Debugf("Reading users: %s", err.Error()) - } else if os.IsPermission(err) { - s.Log.Debug(err.Error()) - } - - acc.AddGauge("system", fields, nil, now) } - if s.collectUptime { - uptime, err := host.Uptime() - if err != nil { - return err - } - acc.AddCounter("system", map[string]interface{}{ - "uptime": uptime, - }, nil, now) - acc.AddFields("system", map[string]interface{}{ - "uptime_format": formatUptime(uptime), - }, nil, now) + if len(fields) > 0 { + acc.AddGauge("system", fields, nil, now) } return nil diff --git a/plugins/inputs/system/system_test.go b/plugins/inputs/system/system_test.go index 24fe747589da3..8de219e8634c0 100644 --- a/plugins/inputs/system/system_test.go +++ b/plugins/inputs/system/system_test.go @@ -2,9 +2,14 @@ package system import ( "testing" + "time" "github.com/shirou/gopsutil/v4/host" "github.com/stretchr/testify/require" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/metric" + "github.com/influxdata/telegraf/testutil" ) func TestUniqueUsers(t *testing.T) { @@ -62,3 +67,236 @@ func TestUniqueUsers(t *testing.T) { }) } } + +func TestInitAllValidOptions(t *testing.T) { + // cpus/deprecated-cpus and uptime/deprecated-uptime are mutually exclusive, + // so cover all six valid values across two configurations. + for _, include := range [][]string{ + {"load", "users", "cpus", "uptime"}, + {"load", "users", "deprecated-cpus", "deprecated-uptime"}, + } { + s := &System{Include: include, Log: &testutil.Logger{}} + require.NoError(t, s.Init()) + } +} + +func TestInitErrors(t *testing.T) { + tests := []struct { + name string + include []string + errMsg string + }{ + { + name: "invalid option", + include: []string{"invalid"}, + errMsg: `invalid 'include' option "invalid"`, + }, + { + name: "cpus mutually exclusive", + include: []string{"cpus", "deprecated-cpus"}, + errMsg: "mutually exclusive", + }, + { + name: "uptime mutually exclusive", + include: []string{"uptime", "deprecated-uptime"}, + errMsg: "mutually exclusive", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &System{ + Include: tt.include, + Log: &testutil.Logger{}, + } + require.ErrorContains(t, s.Init(), tt.errMsg) + }) + } +} + +func TestGather(t *testing.T) { + // host.Users() depends on /var/run/utmp which is not available on every + // runner. On Linux we mock it via HOST_VAR; on other platforms we probe + // at runtime and skip relevant cases when the call cannot be satisfied. + usersAvailable := setupUsers(t) + + tests := []struct { + name string + include []string + expected []telegraf.Metric + requireUsers bool + }{ + { + name: "default", + include: nil, + requireUsers: true, + expected: []telegraf.Metric{ + metric.New( + "system", + map[string]string{}, + map[string]interface{}{ + "load1": float64(0), + "load5": float64(0), + "load15": float64(0), + "n_users": 0, + "n_unique_users": 0, + "n_cpus": 0, + "n_physical_cpus": 0, + }, + time.Unix(0, 0), + telegraf.Gauge, + ), + metric.New( + "system", + map[string]string{}, + map[string]interface{}{"uptime": uint64(0)}, + time.Unix(0, 0), + telegraf.Counter, + ), + metric.New( + "system", + map[string]string{}, + map[string]interface{}{"uptime_format": string("")}, + time.Unix(0, 0), + telegraf.Untyped, + ), + }, + }, + { + name: "cpus", + include: []string{"cpus"}, + expected: []telegraf.Metric{ + metric.New( + "system", + map[string]string{}, + map[string]interface{}{ + "n_virtual_cpus": 0, + "n_physical_cpus": 0, + }, + time.Unix(0, 0), + telegraf.Gauge, + ), + }, + }, + { + name: "uptime as gauge field", + include: []string{"uptime"}, + expected: []telegraf.Metric{ + metric.New( + "system", + map[string]string{}, + map[string]interface{}{"uptime": uint64(0)}, + time.Unix(0, 0), + telegraf.Gauge, + ), + }, + }, + { + name: "all new options", + include: []string{"load", "users", "cpus", "uptime"}, + requireUsers: true, + expected: []telegraf.Metric{ + metric.New( + "system", + map[string]string{}, + map[string]interface{}{ + "load1": float64(0), + "load5": float64(0), + "load15": float64(0), + "n_users": 0, + "n_unique_users": 0, + "n_virtual_cpus": 0, + "n_physical_cpus": 0, + "uptime": uint64(0), + }, + time.Unix(0, 0), + telegraf.Gauge, + ), + }, + }, + { + name: "deprecated-uptime only", + include: []string{"deprecated-uptime"}, + expected: []telegraf.Metric{ + metric.New( + "system", + map[string]string{}, + map[string]interface{}{"uptime": uint64(0)}, + time.Unix(0, 0), + telegraf.Counter, + ), + metric.New( + "system", + map[string]string{}, + map[string]interface{}{"uptime_format": string("")}, + time.Unix(0, 0), + telegraf.Untyped, + ), + }, + }, + { + name: "users only", + include: []string{"users"}, + requireUsers: true, + expected: []telegraf.Metric{ + metric.New( + "system", + map[string]string{}, + map[string]interface{}{ + "n_users": 0, + "n_unique_users": 0, + }, + time.Unix(0, 0), + telegraf.Gauge, + ), + }, + }, + { + name: "duplicates are de-duplicated", + include: []string{"deprecated-uptime", "deprecated-uptime", "cpus", "cpus"}, + expected: []telegraf.Metric{ + metric.New( + "system", + map[string]string{}, + map[string]interface{}{ + "n_virtual_cpus": 0, + "n_physical_cpus": 0, + }, + time.Unix(0, 0), + telegraf.Gauge, + ), + metric.New( + "system", + map[string]string{}, + map[string]interface{}{"uptime": uint64(0)}, + time.Unix(0, 0), + telegraf.Counter, + ), + metric.New( + "system", + map[string]string{}, + map[string]interface{}{"uptime_format": string("")}, + time.Unix(0, 0), + telegraf.Untyped, + ), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.requireUsers && !usersAvailable { + t.Skip("host.Users() not mockable on this platform") + } + s := &System{ + Include: tt.include, + Log: &testutil.Logger{}, + } + require.NoError(t, s.Init()) + + var acc testutil.Accumulator + require.NoError(t, s.Gather(&acc)) + + actual := acc.GetTelegrafMetrics() + testutil.RequireMetricsStructureEqual(t, tt.expected, actual, testutil.IgnoreTime(), testutil.SortMetrics()) + }) + } +} diff --git a/plugins/inputs/system/system_users_linux_test.go b/plugins/inputs/system/system_users_linux_test.go new file mode 100644 index 0000000000000..315a30e3d8b7c --- /dev/null +++ b/plugins/inputs/system/system_users_linux_test.go @@ -0,0 +1,24 @@ +//go:build linux + +package system + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// setupUsers configures gopsutil to read from an empty synthetic utmp file +// so that host.Users() returns zero users deterministically. Returns true +// to indicate the call is mocked and always available. +func setupUsers(t *testing.T) bool { + t.Helper() + tmpDir := t.TempDir() + runDir := filepath.Join(tmpDir, "run") + require.NoError(t, os.MkdirAll(runDir, 0750)) + require.NoError(t, os.WriteFile(filepath.Join(runDir, "utmp"), nil, 0640)) + t.Setenv("HOST_VAR", tmpDir) + return true +} diff --git a/plugins/inputs/system/system_users_other_test.go b/plugins/inputs/system/system_users_other_test.go new file mode 100644 index 0000000000000..57b963c7e65df --- /dev/null +++ b/plugins/inputs/system/system_users_other_test.go @@ -0,0 +1,18 @@ +//go:build !linux + +package system + +import ( + "testing" + + "github.com/shirou/gopsutil/v4/host" +) + +// setupUsers cannot mock host.Users() on non-Linux platforms because gopsutil +// hardcodes the utmp path or returns ErrNotImplementedError. It probes the +// call at runtime and returns true only if users can actually be read. +func setupUsers(t *testing.T) bool { + t.Helper() + _, err := host.Users() + return err == nil +} From 3fdb4c0296aa785af2c487af719c51770ff1fb5d Mon Sep 17 00:00:00 2001 From: Oleksandr Bilko <5033681+bilkoua@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:41:44 +0300 Subject: [PATCH 11/12] Update plugins/inputs/system/sample.conf Co-authored-by: Sven Rebhan <36194019+srebhan@users.noreply.github.com> --- plugins/inputs/system/sample.conf | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/plugins/inputs/system/sample.conf b/plugins/inputs/system/sample.conf index 0d9e13d0fea90..74902a6d567c9 100644 --- a/plugins/inputs/system/sample.conf +++ b/plugins/inputs/system/sample.conf @@ -1,22 +1,10 @@ # Read metrics about system load & uptime [[inputs.system]] - ## Metric groups to collect; the "include" option controls which fields - ## are gathered and how they are emitted. - ## - ## Available options: - ## load - 1/5/15-minute load averages (load1, load5, load15) - ## users - logged-in user counts (n_users, n_unique_users) - ## cpus - CPU counts with new field names (n_virtual_cpus, - ## n_physical_cpus) - ## deprecated-cpus - CPU counts with legacy field names (n_cpus, - ## n_physical_cpus); deprecated, use "cpus" instead - ## uptime - system uptime as a field in the main metric - ## deprecated-uptime - system uptime as separate counter and - ## uptime_format metrics; deprecated, use "uptime" - ## instead - ## - ## "cpus" and "deprecated-cpus" are mutually exclusive. - ## "uptime" and "deprecated-uptime" are mutually exclusive. - ## - ## By default all groups are collected with backward-compatible field names. + ## Information to collect; available options are: + ## load - 1, 5 and 15-minute load averages + ## users - logged-in user counts + ## cpus - CPU counts of the system + ## deprecated-cpus - legacy layout of CPU counts; see README for details + ## uptime - system uptime + ## deprecated-uptime - legacy layout of system uptime; see README for details # include = ["load", "users", "deprecated-cpus", "deprecated-uptime"] From f9c7aa7883e81d48f3b2a9f1a18bb4b4dd2367e4 Mon Sep 17 00:00:00 2001 From: Oleksandr Bilko <5033681+bilkoua@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:42:16 +0300 Subject: [PATCH 12/12] Update plugins/inputs/system/system.go Co-authored-by: Sven Rebhan <36194019+srebhan@users.noreply.github.com> --- plugins/inputs/system/system.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/inputs/system/system.go b/plugins/inputs/system/system.go index 66ccbb5f11d2a..5caae55b5bc6a 100644 --- a/plugins/inputs/system/system.go +++ b/plugins/inputs/system/system.go @@ -24,8 +24,7 @@ import ( var sampleConfig string type System struct { - Include []string `toml:"include"` - + Include []string `toml:"include"` Log telegraf.Logger `toml:"-"` }