Skip to content

Commit

Permalink
Pick changes from elastic/beats#31643
Browse files Browse the repository at this point in the history
  • Loading branch information
kvch committed Jun 9, 2022
1 parent 7c661c0 commit 2b4aadc
Show file tree
Hide file tree
Showing 10 changed files with 2,446 additions and 1 deletion.
40 changes: 40 additions & 0 deletions metric/cpu/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,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
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 @@ -116,6 +131,13 @@ func (m *Monitor) FetchCores() ([]Metrics, error) {
previousSample: lastMetric,
isTotals: false,
}

// Only add CPUInfo metric if it's available
// 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 @@ -126,6 +148,7 @@ type Metrics struct {
previousSample CPU
currentSample CPU
count int
cpuInfo CPUInfo
isTotals bool
}

Expand Down Expand Up @@ -156,6 +179,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 @@ -165,6 +189,22 @@ 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 {
// Some platforms do not report those metrics, so metric.cpuInfo
// is empty, if that happens we do not add the empty metrics to the
// final event.
if metric.cpuInfo != (CPUInfo{}) {
// /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 metric/cpu/metrics_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,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 metric/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)
}
}
70 changes: 69 additions & 1 deletion metric/cpu/metrics_procfs_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"fmt"
"os"
"strconv"
"strings"

"github.com/elastic/elastic-agent-system-metrics/metric/system/resolve"
)
Expand All @@ -40,8 +41,75 @@ func Get(procfs resolve.Resolver) (CPUMetrics, error) {
return CPUMetrics{}, fmt.Errorf("error opening file %s: %w", path, err)
}

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
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
Loading

0 comments on commit 2b4aadc

Please sign in to comment.