Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

system/core: add CPU information for Linux hosts #31643

Merged
merged 13 commits into from
May 23, 2022
Merged
1 change: 1 addition & 0 deletions CHANGELOG.next.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ https://github.com/elastic/beats/compare/v8.2.0\...main[Check the HEAD diff]
- Add metadata for missing k8s resources/metricsets {pull}31590[31590]
- Fix `include_top_n` fields in system/process {pull}31595[31595]
- Upgrade Mongodb library in Beats to v5 {pull}31185[31185]
- system/core: add cpuinfo information for Linux hosts {pull}31643[31643]

*Packetbeat*

Expand Down
50 changes: 50 additions & 0 deletions metricbeat/docs/fields.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -59695,6 +59695,56 @@ type: long

--

*`system.core.model_number`*::
+
--
CPU model number. Only availabe on Linux


type: keyword

--

*`system.core.model_name`*::
+
--
CPU model name. Only availabe on Linux


type: keyword

--

*`system.core.mhz`*::
+
--
CPU core current clock. Only availabe on Linux


type: float

--

*`system.core.core_id`*::
+
--
CPU physical core ID. One core might might execute multiple threads, hence more than one `system.core.id` can share the same `system.core.core_id`. Only availabe on Linux


type: keyword

--

*`system.core.physical_id`*::
+
--
CPU core physical ID. Only availabe on Linux


type: keyword

--

[float]
=== cpu

Expand Down
37 changes: 36 additions & 1 deletion metricbeat/internal/metrics/cpu/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,26 @@ type MetricOpts struct {
NormalizedPercentages bool
}

// CPUInfo manages the CPU information from /proc/cpuinfo
// If a given value isn't available on a given platformn
// the value will be the type's zero-value
type CPUInfo struct {
ModelName string
ModelNumber string
Mhz float64
belimawr marked this conversation as resolved.
Show resolved Hide resolved
PhysicalID int
CoreID int
}

// CPUMetrics carries global and per-core CPU metrics
type CPUMetrics struct {
totals CPU

// list carries the same data, broken down by CPU
list []CPU

// CPUInfo carries some data from /proc/cpuinfo
CPUInfo []CPUInfo
}

// Total returns the total CPU time in ticks as scraped by the API
Expand Down Expand Up @@ -97,7 +112,6 @@ func (m *Monitor) Fetch() (Metrics, error) {
// FetchCores collects a new sample of CPU usage metrics per-core
// This will overwrite the currently stored samples.
func (m *Monitor) FetchCores() ([]Metrics, error) {

metric, err := Get(m.Hostfs)
if err != nil {
return nil, errors.Wrap(err, "Error fetching CPU metrics")
Expand All @@ -110,11 +124,19 @@ func (m *Monitor) FetchCores() ([]Metrics, error) {
if len(m.lastSample.list) > i {
lastMetric = m.lastSample.list[i]
}

coreMetrics[i] = Metrics{
currentSample: metric.list[i],
previousSample: lastMetric,
isTotals: false,
}

// Only add CPUInfo metric if it's available
// TODO: Remove this if statement once CPUInfo is supported
// by all systems
if len(metric.CPUInfo) != 0 {
coreMetrics[i].cpuInfo = metric.CPUInfo[i]
}
}
m.lastSample = metric
return coreMetrics, nil
Expand All @@ -125,6 +147,7 @@ type Metrics struct {
previousSample CPU
currentSample CPU
count int
cpuInfo CPUInfo
isTotals bool
}

Expand Down Expand Up @@ -155,6 +178,7 @@ func (metric Metrics) Format(opts MetricOpts) (mapstr.M, error) {
formattedMetrics.Put("total.norm.pct", createTotal(metric.previousSample, metric.currentSample, timeDelta, 1))
}

// /proc/stat metrics
reportOptMetric("user", metric.currentSample.User, metric.previousSample.User, normCPU)
reportOptMetric("system", metric.currentSample.Sys, metric.previousSample.Sys, normCPU)
reportOptMetric("idle", metric.currentSample.Idle, metric.previousSample.Idle, normCPU)
Expand All @@ -164,6 +188,17 @@ func (metric Metrics) Format(opts MetricOpts) (mapstr.M, error) {
reportOptMetric("softirq", metric.currentSample.SoftIrq, metric.previousSample.SoftIrq, normCPU)
reportOptMetric("steal", metric.currentSample.Stolen, metric.previousSample.Stolen, normCPU)

// Only add CPU info metrics if we're returning information by core
// (isTotals is false)
if !metric.isTotals {
// /proc/cpuinfo metrics
formattedMetrics["model_number"] = metric.cpuInfo.ModelNumber
formattedMetrics["model_name"] = metric.cpuInfo.ModelName
formattedMetrics["mhz"] = metric.cpuInfo.Mhz
formattedMetrics["core_id"] = metric.cpuInfo.CoreID
formattedMetrics["physical_id"] = metric.cpuInfo.PhysicalID
}

return formattedMetrics, nil
}

Expand Down
4 changes: 4 additions & 0 deletions metricbeat/internal/metrics/cpu/metrics_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,7 @@ func parseCPULine(line string) (CPU, error) {

return cpuData, errs.Err()
}

func scanCPUInfoFile(scanner *bufio.Scanner) ([]CPUInfo, error) {
return cpuinfoScanner(scanner)
}
97 changes: 97 additions & 0 deletions metricbeat/internal/metrics/cpu/metrics_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package cpu

import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strconv"
"testing"

"github.com/stretchr/testify/assert"
)

// TestScanCPUInfoFile tests the parsing of `/proc/cpuinfo` for different
// system/CPU configurations. The lscpu GitHub contains a nice set of
// test files: https://github.com/util-linux/util-linux/tree/master/tests/ts/lscpu/dumps
func TestScanCPUInfoFile(t *testing.T) {
testCases := []string{
"cpuinfo",
"cpuinfo-quad-socket",
// Source: https://github.com/util-linux/util-linux/blob/master/tests/ts/lscpu/dumps/armv7.tar.gz
"cpuinfo-armv7",
}
for _, tc := range testCases {
t.Run(tc, func(t *testing.T) {
sourceFd, err := os.Open(filepath.Join("testdata", tc))
if err != nil {
t.Fatalf("cannot open test file: %s", err)
}
defer sourceFd.Close()

scanner := bufio.NewScanner(sourceFd)
cpuInfo, err := scanCPUInfoFile(scanner)
if err != nil {
t.Fatalf("scanCPUInfoFile error: %s", err)
}

// Ignoring the error, because if there is any parsing error, generateGoldenFile
// will be false, making the test to run as expected
if generateGoldenFile, _ := strconv.ParseBool(os.Getenv("GENERATE")); generateGoldenFile {
t.Logf("generating golden files for test: %s", t.Name())
scanCPUInfoFileGenGoldenFile(t, cpuInfo, tc)
return
}

expectedFd, err := os.Open(filepath.Join("testdata", tc+".expected.json"))
if err != nil {
t.Fatalf("cannot open test expectation file: %s", err)
}
defer expectedFd.Close()

expected := []CPUInfo{}
if err := json.NewDecoder(expectedFd).Decode(&expected); err != nil {
t.Fatalf("cannot decode goldenfile data: %s", err)
}

assert.Equal(t, expected, cpuInfo)
})
}
}

func scanCPUInfoFileGenGoldenFile(t *testing.T, data []CPUInfo, name string) {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
t.Fatalf("cannot marshal data into JSON: %s", err)
}

// Add a line break at the end
jsonData = append(jsonData, '\n')

expectedFd, err := os.Create(filepath.Join("testdata", name+".expected.json"))
if err != nil {
t.Fatalf("cannot open/create test expectation file: %s", err)
}
defer expectedFd.Close()

if _, err := expectedFd.Write(jsonData); err != nil {
t.Fatalf("cannot write data to goldenfile: %s", err)
}
}
71 changes: 70 additions & 1 deletion metricbeat/internal/metrics/cpu/metrics_procfs_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ package cpu

import (
"bufio"
"fmt"
"os"
"strconv"
"strings"

"github.com/pkg/errors"

Expand All @@ -39,8 +41,75 @@ func Get(procfs resolve.Resolver) (CPUMetrics, error) {
return CPUMetrics{}, errors.Wrapf(err, "error opening file %s", path)
}

return scanStatFile(bufio.NewScanner(fd))
metrics, err := scanStatFile(bufio.NewScanner(fd))
if err != nil {
return CPUMetrics{}, fmt.Errorf("scanning stat file: %w", err)
}

cpuInfoPath := procfs.ResolveHostFS("/proc/cpuinfo")
cpuInfoFd, err := os.Open(cpuInfoPath)
if err != nil {
return CPUMetrics{}, fmt.Errorf("opening '%s': %w", cpuInfoPath, err)
}
defer cpuInfoFd.Close()

cpuInfo, err := scanCPUInfoFile(bufio.NewScanner(cpuInfoFd))
metrics.CPUInfo = cpuInfo

return metrics, err
}

func cpuinfoScanner(scanner *bufio.Scanner) ([]CPUInfo, error) {
cpuInfos := []CPUInfo{}
current := CPUInfo{}
// On my tests the order the cores appear on /proc/cpuinfo
// is the same as on /proc/stats, this means it matches our
// current 'system.core.id' metric. This information
// is also the same as the 'processor' line on /proc/cpuinfo.
coreID := 0
for scanner.Scan() {
line := scanner.Text()
split := strings.Split(line, ":")
if len(split) != 2 {
// A blank line its a separation between CPUs
// even the last CPU contains one blank line at the end
cpuInfos = append(cpuInfos, current)
current = CPUInfo{}
coreID++

continue
}

k, v := split[0], split[1]
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
switch k {
case "model":
current.ModelNumber = v
belimawr marked this conversation as resolved.
Show resolved Hide resolved
case "model name":
current.ModelName = v
case "physical id":
id, err := strconv.Atoi(v)
if err != nil {
return []CPUInfo{}, fmt.Errorf("parsing physical ID: %w", err)
}
current.PhysicalID = id
case "core id":
id, err := strconv.Atoi(v)
if err != nil {
return []CPUInfo{}, fmt.Errorf("parsing core ID: %w", err)
}
current.CoreID = id
case "cpu MHz":
mhz, err := strconv.ParseFloat(v, 64)
if err != nil {
return []CPUInfo{}, fmt.Errorf("parsing CPU %d Mhz: %w", coreID, err)
}
current.Mhz = mhz
}
}

return cpuInfos, nil
}

// statScanner iterates through a /proc/stat entry, reading both the global lines and per-CPU lines, each time calling lineReader, which implements the OS-specific code for parsing individual lines
Expand Down
3 changes: 2 additions & 1 deletion metricbeat/internal/metrics/cpu/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/elastic/beats/v7/libbeat/opt"
"github.com/elastic/elastic-agent-libs/mapstr"
Expand Down Expand Up @@ -54,7 +55,7 @@ func TestCoresMonitorSample(t *testing.T) {
evt := mapstr.M{}
metricOpts := MetricOpts{Percentages: true, Ticks: true}
evt, err := s.Format(metricOpts)
assert.NoError(t, err, "error in Format")
require.NoError(t, err, "error in Format")
testPopulatedEvent(evt, t, false)
}
}
Expand Down
Loading