Repository: prometheus/procfs (go)

Issue:
Add pressure stall information
I'd like to add support for `/proc/pressure/*` that the Linux kernel exposes (to in turn add support for that to the node exporter).

I started looking at it but I'm stumbling on a thing; currently `Proc` and its `path` helper always assumes that what we want is `/proc/<pid>/<path...>` however what I need access to is `/proc/pressure/{cpu,memory,io}` and I can't seem to find a good way to do that.

Did I miss something in the `Proc` API or should I just read the contents of those three files without leveraging the `Proc` struct and its methods?

Pull request description:
procfs: Add support for /proc/pressure/*
Closes #126

Patch:
diff --git a/fixtures.ttar b/fixtures.ttar
index 13c831e..d2256f2 100644
--- a/fixtures.ttar
+++ b/fixtures.ttar
@@ -404,6 +404,26 @@ XfrmOutStateInvalid             28765
 XfrmAcquireError                24532
 Mode: 644
 # ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+Directory: fixtures/pressure
+Mode: 755
+# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+Path: fixtures/pressure/cpu
+Lines: 1
+some avg10=0.10 avg60=2.00 avg300=3.85 total=15
+Mode: 644
+# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+Path: fixtures/pressure/io
+Lines: 2
+some avg10=0.10 avg60=2.00 avg300=3.85 total=15
+full avg10=0.20 avg60=3.00 avg300=4.95 total=25
+Mode: 644
+# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+Path: fixtures/pressure/memory
+Lines: 2
+some avg10=0.10 avg60=2.00 avg300=3.85 total=15
+full avg10=0.20 avg60=3.00 avg300=4.95 total=25
+Mode: 644
+# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 Path: fixtures/self
 SymlinkTo: 26231
 # ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -458,5 +478,5 @@ Mode: 644
 # ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 Path: fixtures/.unpacked
 Lines: 0
-Mode: 664
+Mode: 644
 # ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/proc_psi.go b/proc_psi.go
new file mode 100644
index 0000000..4f11cdb
--- /dev/null
+++ b/proc_psi.go
@@ -0,0 +1,110 @@
+// Copyright 2019 The Prometheus Authors
+// Licensed 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 procfs
+
+// The PSI / pressure interface is described at
+//   https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Documentation/accounting/psi.txt
+// Each resource (cpu, io, memory, ...) is exposed as a single file.
+// Each file may contain up to two lines, one for "some" pressure and one for "full" pressure.
+// Each line contains several averages (over n seconds) and a total in µs.
+//
+// Example io pressure file:
+// > some avg10=0.06 avg60=0.21 avg300=0.99 total=8537362
+// > full avg10=0.00 avg60=0.13 avg300=0.96 total=8183134
+
+import (
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"strings"
+)
+
+const lineFormat = "avg10=%f avg60=%f avg300=%f total=%d"
+
+// PSILine is a single line of values as returned by /proc/pressure/*
+// The Avg entries are averages over n seconds, as a percentage
+// The Total line is in microseconds
+type PSILine struct {
+	Avg10  float64
+	Avg60  float64
+	Avg300 float64
+	Total  uint64
+}
+
+// PSIStats represent pressure stall information from /proc/pressure/*
+// Some indicates the share of time in which at least some tasks are stalled
+// Full indicates the share of time in which all non-idle tasks are stalled simultaneously
+type PSIStats struct {
+	Some *PSILine
+	Full *PSILine
+}
+
+// NewPSIStatsForResource reads pressure stall information for the specified
+// resource. At time of writing this can be either "cpu", "memory" or "io".
+func NewPSIStatsForResource(resource string) (PSIStats, error) {
+	fs, err := NewFS(DefaultMountPoint)
+	if err != nil {
+		return PSIStats{}, err
+	}
+
+	return fs.NewPSIStatsForResource(resource)
+}
+
+// NewPSIStatsForResource reads pressure stall information from /proc/pressure/<resource>
+func (fs FS) NewPSIStatsForResource(resource string) (PSIStats, error) {
+	file, err := os.Open(fs.Path(fmt.Sprintf("%s/%s", "pressure", resource)))
+	if err != nil {
+		return PSIStats{}, fmt.Errorf("psi_stats: unavailable for %s", resource)
+	}
+
+	defer file.Close()
+	return parsePSIStats(resource, file)
+}
+
+// parsePSIStats parses the specified file for pressure stall information
+func parsePSIStats(resource string, file io.Reader) (PSIStats, error) {
+	psiStats := PSIStats{}
+	stats, err := ioutil.ReadAll(file)
+	if err != nil {
+		return psiStats, fmt.Errorf("psi_stats: unable to read data for %s", resource)
+	}
+
+	for _, l := range strings.Split(string(stats), "\n") {
+		prefix := strings.Split(l, " ")[0]
+		switch prefix {
+		case "some":
+			psi := PSILine{}
+			_, err := fmt.Sscanf(l, fmt.Sprintf("some %s", lineFormat), &psi.Avg10, &psi.Avg60, &psi.Avg300, &psi.Total)
+			if err != nil {
+				return PSIStats{}, err
+			}
+			psiStats.Some = &psi
+		case "full":
+			psi := PSILine{}
+			_, err := fmt.Sscanf(l, fmt.Sprintf("full %s", lineFormat), &psi.Avg10, &psi.Avg60, &psi.Avg300, &psi.Total)
+			if err != nil {
+				return PSIStats{}, err
+			}
+			psiStats.Full = &psi
+		default:
+			// If we encounter a line with an unknown prefix, ignore it and move on
+			// Should new measurement types be added in the future we'll simply ignore them instead
+			// of erroring on retrieval
+			continue
+		}
+	}
+
+	return psiStats, nil
+}


Test patch:
diff --git a/proc_psi_test.go b/proc_psi_test.go
new file mode 100644
index 0000000..4403834
--- /dev/null
+++ b/proc_psi_test.go
@@ -0,0 +1,149 @@
+// Copyright 2019 The Prometheus Authors
+// Licensed 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 procfs
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestPSIStats(t *testing.T) {
+	t.Run("fake", func(*testing.T) {
+		stats, err := FS("fixtures").NewPSIStatsForResource("fake")
+		if err == nil {
+			t.Fatal("fake resource does not have PSI statistics")
+		}
+
+		if stats.Some != nil || stats.Full != nil {
+			t.Error("a fake resource cannot have PSILine entries")
+		}
+	})
+
+	t.Run("cpu", func(t *testing.T) {
+		stats, err := FS("fixtures").NewPSIStatsForResource("cpu")
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if stats.Full != nil {
+			t.Fatal("cpu resource cannot have 'full' stats")
+		}
+
+		if stats.Some == nil {
+			t.Fatal("cpu resource should not have nil 'some' stats")
+		}
+
+		testCases := []struct {
+			name string
+			got  float64
+			want float64
+		}{
+			{"Avg10", stats.Some.Avg10, 0.1},
+			{"Avg60", stats.Some.Avg60, 2.0},
+			{"Avg300", stats.Some.Avg300, 3.85},
+			{"Total", float64(stats.Some.Total), 15.0},
+		}
+
+		for _, tc := range testCases {
+			t.Run(tc.name, func(t *testing.T) {
+				if tc.got != tc.want {
+					t.Errorf("got: %f, want: %f", tc.got, tc.want)
+				}
+			})
+		}
+	})
+
+	res := []string{"memory", "io"}
+
+	for _, resource := range res {
+		t.Run(resource, func(t *testing.T) {
+			stats, err := FS("fixtures").NewPSIStatsForResource(resource)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if stats.Full == nil {
+				t.Fatalf("%s resource must not have nil 'full' stats", resource)
+			}
+
+			if stats.Some == nil {
+				t.Fatalf("%s resource must not have nil 'some' stats", resource)
+			}
+
+			testCases := []struct {
+				name string
+				got  float64
+				want float64
+			}{
+				{"some/Avg10", stats.Some.Avg10, 0.1},
+				{"some/Avg60", stats.Some.Avg60, 2.0},
+				{"some/Avg300", stats.Some.Avg300, 3.85},
+				{"some/Total", float64(stats.Some.Total), 15.0},
+				{"full/Avg10", stats.Full.Avg10, 0.2},
+				{"full/Avg60", stats.Full.Avg60, 3.0},
+				{"full/Avg300", stats.Full.Avg300, 4.95},
+				{"full/Total", float64(stats.Full.Total), 25.0},
+			}
+
+			for _, tc := range testCases {
+				t.Run(tc.name, func(t *testing.T) {
+					if tc.got != tc.want {
+						t.Errorf("got: %f, want: %f", tc.got, tc.want)
+					}
+				})
+			}
+		})
+	}
+}
+
+// TestParsePSIStats tests the edge cases that we won't run into when running TestPSIStats
+func TestParsePSIStats(t *testing.T) {
+	t.Run("unknown measurement type", func(t *testing.T) {
+		raw := "nonesense haha test=fake"
+		_, err := parsePSIStats("fake", strings.NewReader(raw))
+		if err != nil {
+			t.Error("unknown measurement type must be ignored")
+		}
+	})
+
+	t.Run("malformed measurement", func(t *testing.T) {
+		t.Run("some", func(t *testing.T) {
+			raw := `some avg10=0.10 avg60=2.00 avg300=3.85 total=oops
+full avg10=0.20 avg60=3.00 avg300=teddy total=25`
+			stats, err := parsePSIStats("fake", strings.NewReader(raw))
+			if err == nil {
+				t.Error("a malformed line must result in a parse error")
+			}
+
+			if stats.Some != nil || stats.Full != nil {
+				t.Error("a parse error must result in a nil PSILine")
+			}
+		})
+		t.Run("full", func(t *testing.T) {
+			raw := `some avg10=0.10 avg60=2.00 avg300=3.85 total=1
+full avg10=0.20 avg60=3.00 avg300=test total=25`
+			stats, err := parsePSIStats("fake", strings.NewReader(raw))
+			t.Log(err)
+			t.Log(stats)
+			if err == nil {
+				t.Error("a malformed line must result in a parse error")
+			}
+
+			if stats.Some != nil || stats.Full != nil {
+				t.Error("a parse error must result in a nil PSILine")
+			}
+		})
+
+	})
+}